CultLib/scripts/verify-eve-browser-network.mjs
Meta Vens 89687fceb4 Let the browser sample read the provider's advertisement
Eve refuses to build a command intent for a surface that has not
advertised a command boundary and receipt schema, and the intent it
builds now carries the operation under operation.operationId. The
browser sample did neither: the click was refused inside the page and
the checkpoint saw only a 30 s timeout on the receipt.

The C# provider now publishes its gamecult.eve.provider_advertisement.v1
document with the surface's worldInteraction, and the page leases that
and hands it to the lowering as `provider`. The provider owns the
declaration; the page reads it rather than restating it. The command
sink reads intent.operation.operationId.

The checkpoint now forwards the page's console and pageerror output.
"CultMesh browser operation is required." took one run to find with it
and had been a bare timeout without it.

  scripts/verify-eve-getting-started.ps1 -EveRoot ..\Eve (Eve main)
  -> Eve getting-started verification passed: clean .NET and TypeScript
     artifacts, DOM and headless convergence, real Chromium and C#
     clients, Odin discovery, authority-bound routes, receipts,
     persistence, and route replacement.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 13:55:46 +02:00

339 lines
14 KiB
JavaScript

import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { createServer as createHttpServer } from "node:http";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { createServer as createNetServer } from "node:net";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { build } from "esbuild";
import { chromium } from "playwright-core";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const eveFlag = process.argv.indexOf("--eve-root");
const eveRoot = resolve(eveFlag >= 0 ? process.argv[eveFlag + 1] : join(repoRoot, "..", "Eve"));
const packageFeedFlag = process.argv.indexOf("--package-feed");
const packageFeed = packageFeedFlag >= 0 ? resolve(process.argv[packageFeedFlag + 1]) : undefined;
const cultLibVersionFlag = process.argv.indexOf("--cultlib-package-version");
const cultLibPackageVersion = cultLibVersionFlag >= 0 ? process.argv[cultLibVersionFlag + 1] : undefined;
const eveSurfaceVersionFlag = process.argv.indexOf("--eve-surface-package-version");
const eveSurfacePackageVersion = eveSurfaceVersionFlag >= 0 ? process.argv[eveSurfaceVersionFlag + 1] : undefined;
const nugetConfigFlag = process.argv.indexOf("--nuget-config");
const nugetConfig = nugetConfigFlag >= 0 ? resolve(process.argv[nugetConfigFlag + 1]) : undefined;
const sampleRoot = join(repoRoot, "samples", "eve-browser-network");
const workRoot = await mkdtemp(join(tmpdir(), "cultmesh-browser-network-"));
const statePath = join(workRoot, "counter.cc");
const decoyStatePath = join(workRoot, "decoy-counter.cc");
const bundlePath = join(workRoot, "bundle.js");
const token = "sample-session";
let provider;
let decoyProvider;
let replacementProvider;
let odin;
let headless;
let httpServer;
let browser;
try {
const tsc = join(repoRoot, "node_modules", "typescript", "bin", "tsc");
await run(process.execPath, [tsc, "-p", join(repoRoot, "packages", "cultnet-ts", "tsconfig.json"), "--pretty", "false"]);
await run(process.execPath, [tsc, "-p", join(repoRoot, "packages", "cultmesh-browser", "tsconfig.json"), "--pretty", "false"]);
await build({
entryPoints: [join(sampleRoot, "browser", "main.ts")],
outfile: bundlePath,
bundle: true,
format: "esm",
platform: "browser",
target: "es2022",
sourcemap: true,
alias: {
"@gamecult/eve-contracts": join(eveRoot, "packages", "eve-contracts", "src", "index.ts"),
"@gamecult/eve-browser-lowering": join(eveRoot, "packages", "eve-browser-lowering", "src", "index.ts"),
"cultmesh-browser": join(repoRoot, "packages", "cultmesh-browser", "src", "index.ts"),
"cultnet-ts/contracts": join(repoRoot, "packages", "cultnet-ts", "src", "contracts.ts"),
},
logLevel: "warning",
});
const bundle = await readFile(bundlePath, "utf8");
assert.doesNotMatch(bundle, /(?:from\s*["']node:|require\(["']node:)/u, "browser bundle contains a Node builtin import");
const dotnetBuildArguments = [
"build",
join(sampleRoot, "EveBrowserNetworkSample.csproj"),
"-m:1",
"--verbosity", "quiet",
"-p:NoWarn=1591%3BCS8632",
"-p:NuGetAudit=false",
"-p:RestoreIgnoreFailedSources=true",
];
if (nugetConfig) dotnetBuildArguments.push(`-p:RestoreConfigFile=${nugetConfig}`);
if (packageFeed) {
if (!cultLibPackageVersion || !eveSurfacePackageVersion) {
throw new Error("Package artifact builds require both CultLib and Eve surface package versions.");
}
dotnetBuildArguments.push(
"-p:UsePackageArtifacts=true",
`-p:PackageFeed=${packageFeed}`,
`-p:CultLibPackageVersion=${cultLibPackageVersion}`,
`-p:EveSurfacePackageVersion=${eveSurfacePackageVersion}`,
);
} else {
dotnetBuildArguments.push(`-p:EveRoot=${eveRoot}`, `-p:CultLibRoot=${repoRoot}`);
}
await run("dotnet", dotnetBuildArguments);
const providerPort = await freePort();
const decoyProviderPort = await freePort();
const replacementProviderPort = await freePort();
const odinPort = await freePort();
const httpPort = await freePort();
provider = await startProvider(providerPort);
const endpoint = await provider.waitFor("PROVIDER_READY ");
decoyProvider = await startProvider(decoyProviderPort, {
authorityRuntimeId: "sample.decoy-daemon",
routeGeneration: "sample-decoy-route-1",
statePath: decoyStatePath,
});
const decoyEndpoint = await decoyProvider.waitFor("PROVIDER_READY ");
odin = await startOdin(odinPort, endpoint, decoyEndpoint);
const odinEndpoint = await odin.waitFor("ODIN_READY ");
headless = startDotnet([
"headless", "--odin", odinEndpoint, "--verse-id", "sample.counter",
"--expected-count", "2", "--token", token,
]);
await headless.waitFor("HEADLESS_READY ");
httpServer = await serve(httpPort, sampleRoot, bundlePath);
browser = await chromium.launch({ executablePath: resolveChromiumExecutable(), headless: true });
let page = await browser.newPage();
// A command the lowering refuses is caught inside the page; without this
// the only symptom is a 30 s timeout on the receipt.
page.on("console", message => console.log(`[browser:${message.type()}] ${message.text()}`));
page.on("pageerror", error => console.log(`[browser:pageerror] ${error.message}`));
const url = `http://127.0.0.1:${httpPort}/?odin=${encodeURIComponent(odinEndpoint)}&token=${token}`;
await page.goto(url);
await page.waitForFunction(() => window.__sampleReady || window.__sampleError);
assert.equal(await page.evaluate(() => window.__sampleError), undefined);
assert.equal(await page.evaluate(() => window.__sampleCount), 0);
await page.locator("button").click();
await page.waitForFunction(() => window.__sampleReceipt?.count === 1 && window.__sampleCount === 1);
const firstReceipt = await page.evaluate(() => window.__sampleReceipt?.receiptId);
const firstHeadlessUpdate = JSON.parse(await headless.waitFor("HEADLESS_UPDATE_1 "));
assert.equal(firstHeadlessUpdate.count, 1);
assert.ok(firstHeadlessUpdate.receiptIds.includes(firstReceipt));
await page.locator("button").click();
await page.waitForFunction(receipt => window.__sampleReceipt?.receiptId === receipt, firstReceipt);
assert.equal(await page.evaluate(() => window.__sampleCount), 1, "duplicate idempotency key changed canonical state");
replacementProvider = await startProvider(replacementProviderPort);
const restartedEndpoint = await replacementProvider.waitFor("PROVIDER_READY ");
assert.notEqual(restartedEndpoint, endpoint);
await stop(odin.process);
odin = await startOdin(odinPort, restartedEndpoint, decoyEndpoint);
assert.equal(await odin.waitFor("ODIN_READY "), odinEndpoint);
await stop(provider.process);
provider = replacementProvider;
replacementProvider = undefined;
await page.waitForFunction(() =>
window.__sampleConnectionStates?.includes("reconnecting")
&& window.__sampleConnectionStates.at(-1) === "connected");
headless.sendLine("INVOKE headless-command-2");
const headlessReceipt = JSON.parse(await headless.waitFor("HEADLESS_RECEIPT "));
assert.equal(headlessReceipt.status, "accepted");
assert.equal(headlessReceipt.count, 2);
await page.waitForFunction(() => window.__sampleCount === 2);
const secondReceipt = headlessReceipt.receiptId;
const secondHeadlessUpdate = JSON.parse(await headless.waitFor("HEADLESS_UPDATE_2 "));
assert.equal(secondHeadlessUpdate.count, 2);
assert.ok(secondHeadlessUpdate.receiptIds.includes(firstReceipt));
assert.ok(secondHeadlessUpdate.receiptIds.includes(secondReceipt));
const networkBenchmark = JSON.parse(await headless.waitFor("HEADLESS_NETWORK_BENCHMARK ", 60_000));
assert.equal(networkBenchmark.operations, 10_000);
assert.ok(networkBenchmark.p99Milliseconds < 250);
assert.ok(networkBenchmark.managedHeapGrowth <= 8 * 1024 * 1024);
const connectionStates = await page.evaluate(() => window.__sampleConnectionStates);
assert.equal(connectionStates[0], "connected");
assert.equal(connectionStates.at(-1), "connected");
assert.ok(connectionStates.includes("reconnecting"));
assert.ok(!connectionStates.includes("disposed"));
console.log(JSON.stringify({
providerEndpoint: endpoint,
replacementProviderEndpoint: restartedEndpoint,
rejectedWrongAuthorityEndpoint: decoyEndpoint,
odinEndpoint,
browserRuntime: "chromium",
headlessRuntime: "csharp",
receiptId: firstReceipt,
secondReceiptId: secondReceipt,
count: 2,
routeRotationCount: 1,
retainedHeadlessLease: true,
networkBenchmark,
}));
} finally {
if (browser) await browser.close().catch(() => undefined);
if (headless) await stop(headless.process);
if (provider) await stop(provider.process);
if (decoyProvider) await stop(decoyProvider.process);
if (replacementProvider) await stop(replacementProvider.process);
if (odin) await stop(odin.process);
if (httpServer) await new Promise(resolve => httpServer.close(resolve));
await rm(workRoot, { recursive: true, force: true });
}
async function startProvider(port, options = {}) {
return startDotnet([
"provider", "--port", String(port),
"--state", options.statePath ?? statePath,
"--authority-runtime-id", options.authorityRuntimeId ?? "sample.counter-daemon",
"--route-generation", options.routeGeneration ?? "sample-counter-route-1",
"--token", token,
]);
}
async function startOdin(port, providerEndpoint, decoyEndpoint) {
return startDotnet([
"odin",
"--port", String(port),
"--provider-endpoint", providerEndpoint,
"--decoy-endpoint", decoyEndpoint,
"--decoy-authority-runtime-id", "sample.decoy-daemon",
"--decoy-route-generation", "sample-decoy-route-1",
"--token", token,
]);
}
function startDotnet(arguments_) {
const process = spawn("dotnet", [
"run", "--no-build", "--project", join(sampleRoot, "EveBrowserNetworkSample.csproj"), "--", ...arguments_,
], { cwd: repoRoot, stdio: ["pipe", "pipe", "pipe"] });
let output = "";
let error = "";
const waiters = [];
process.stdout.setEncoding("utf8");
process.stderr.setEncoding("utf8");
process.stdout.on("data", chunk => { output += chunk; settle(); });
process.stderr.on("data", chunk => { error += chunk; });
process.on("exit", code => {
for (const waiter of waiters.splice(0)) {
clearTimeout(waiter.timer);
waiter.reject(new Error(`dotnet sample exited ${code}\n${output}\n${error}`));
}
});
function settle() {
for (let index = waiters.length - 1; index >= 0; index--) {
const waiter = waiters[index];
const line = output.split(/\r?\n/u).find(value => value.startsWith(waiter.prefix));
if (!line) continue;
waiters.splice(index, 1);
clearTimeout(waiter.timer);
waiter.resolve(line.slice(waiter.prefix.length));
}
}
return {
process,
sendLine(line) {
process.stdin.write(`${line}\n`);
},
waitFor(prefix, timeoutMs = 30_000) {
const existing = output.split(/\r?\n/u).find(value => value.startsWith(prefix));
if (existing) return Promise.resolve(existing.slice(prefix.length));
return new Promise((resolve, reject) => {
const waiter = {
prefix,
resolve,
reject,
timer: setTimeout(() => {
const index = waiters.indexOf(waiter);
if (index >= 0) waiters.splice(index, 1);
reject(new Error(`Timed out waiting for '${prefix}'.\n${output}\n${error}`));
}, timeoutMs),
};
waiters.push(waiter);
});
},
};
}
function run(command, arguments_) {
return new Promise((resolvePromise, reject) => {
const process = spawn(command, arguments_, { cwd: repoRoot, stdio: "inherit" });
process.on("error", reject);
process.on("exit", code => code === 0 ? resolvePromise() : reject(new Error(`${command} exited ${code}`)));
});
}
async function serve(port, root, builtBundle) {
const index = await readFile(join(root, "browser", "index.html"));
const server = createHttpServer(async (request, response) => {
if (request.url?.startsWith("/bundle.js")) {
response.setHeader("Content-Type", "text/javascript; charset=utf-8");
response.end(await readFile(builtBundle));
return;
}
response.setHeader("Content-Type", "text/html; charset=utf-8");
response.end(index);
});
await new Promise((resolvePromise, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", resolvePromise);
});
return server;
}
async function freePort() {
const server = createNetServer();
await new Promise((resolvePromise, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolvePromise);
});
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
await new Promise(resolvePromise => server.close(resolvePromise));
return port;
}
async function stop(process) {
if (!process || process.exitCode !== null) return;
process.kill();
await Promise.race([
new Promise(resolvePromise => process.once("exit", resolvePromise)),
new Promise(resolvePromise => setTimeout(resolvePromise, 2_000)),
]);
if (process.exitCode === null) process.kill("SIGKILL");
}
function resolveChromiumExecutable() {
const candidates = [process.env.CHROME_PATH];
if (process.platform === "win32") {
for (const root of [process.env.ProgramFiles, process.env["ProgramFiles(x86)"], process.env.LOCALAPPDATA]) {
if (!root) continue;
candidates.push(
join(root, "Google", "Chrome", "Application", "chrome.exe"),
join(root, "Microsoft", "Edge", "Application", "msedge.exe"),
);
}
} else if (process.platform === "darwin") {
candidates.push(
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
);
} else {
candidates.push(
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/opt/google/chrome/chrome",
);
}
candidates.push(chromium.executablePath());
const executablePath = candidates.find(candidate => candidate && existsSync(candidate));
if (executablePath) return executablePath;
throw new Error(
"No Chromium-family browser was found. Set CHROME_PATH or run "
+ "'node node_modules/playwright-core/cli.js install chromium'.",
);
}