CultLib/scripts/verify-eve-browser-network.mjs

339 lines
14 KiB
JavaScript
Raw Permalink Normal View History

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"),
2026-08-18 03:41:47 +02:00
"-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));
2026-08-18 03:56:24 +02:00
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,
2026-08-18 03:56:24 +02:00
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'.",
);
}