CultLib/scripts/verify-eve-browser-network.mjs
Libby 286a9af389
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 5m49s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 36s
npm: scope cultnet-ts, cultmesh-ts and cultmesh-browser under @gamecult
Unscoped npm names are unclaimed and open to dependency confusion; cultcache-ts was
already @gamecult/cultcache-ts. package.json names, intra-repo dependencies, package-lock,
import specifiers (src, tests, samples, docs/start, first-call docs test), the workspace
list in run-typescript-workspaces, the verify-eve module map and package READMEs now use
the scoped names. Directory names, workspace paths in CI and release tag prefixes are
unchanged. Historical cut docs and past CHANGELOG entries keep the old names.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 16:25:10 +00:00

364 lines
15 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, writeFile } 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", "cultcache-ts", "tsconfig.json"), "--pretty", "false"]);
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"]);
// esbuild strips types, so a package signature change would otherwise surface as a browser error. The page is
// type-checked against the same module map the bundle uses.
const moduleMap = {
"@gamecult/eve-contracts": join(eveRoot, "packages", "eve-contracts", "src", "index.ts"),
"@gamecult/eve-browser-lowering": join(eveRoot, "packages", "eve-browser-lowering", "src", "index.ts"),
"@gamecult/cultmesh-browser": join(repoRoot, "packages", "cultmesh-browser", "src", "index.ts"),
"@gamecult/cultnet-ts/contracts": join(repoRoot, "packages", "cultnet-ts", "src", "contracts.ts"),
};
const sampleTsconfig = join(workRoot, "tsconfig.json");
await writeFile(sampleTsconfig, JSON.stringify({
compilerOptions: {
target: "ES2022",
module: "ES2022",
moduleResolution: "Bundler",
lib: ["ES2022", "ESNext.Disposable", "DOM", "DOM.Iterable"],
strict: true,
noEmit: true,
skipLibCheck: true,
types: [],
paths: Object.fromEntries(Object.entries(moduleMap).map(([name, target]) => [name, [target]])),
},
files: [join(sampleRoot, "browser", "main.ts")],
}));
await run(process.execPath, [tsc, "-p", sampleTsconfig, "--pretty", "false"]);
await build({
entryPoints: [join(sampleRoot, "browser", "main.ts")],
outfile: bundlePath,
bundle: true,
format: "esm",
platform: "browser",
target: "es2022",
sourcemap: true,
alias: moduleMap,
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}`));
}
});
// A line is complete once its newline has arrived: a stdout chunk can end inside a line, and the 10,000-operation
// benchmark line was parsed as JSON half-written.
function completeLines() {
return output.split(/\r?\n/u).slice(0, -1);
}
function settle() {
for (let index = waiters.length - 1; index >= 0; index--) {
const waiter = waiters[index];
const line = completeLines().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 = completeLines().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'.",
);
}