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 sampleRoot = join(repoRoot, "samples", "eve-browser-network"); const workRoot = await mkdtemp(join(tmpdir(), "cultmesh-browser-network-")); const statePath = join(workRoot, "counter.cc"); const bundlePath = join(workRoot, "bundle.js"); const token = "sample-session"; let provider; 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"), }, logLevel: "warning", }); const bundle = await readFile(bundlePath, "utf8"); assert.doesNotMatch(bundle, /(?:from\s*["']node:|require\(["']node:)/u, "browser bundle contains a Node builtin import"); await run("dotnet", [ "build", join(sampleRoot, "EveBrowserNetworkSample.csproj"), "--verbosity", "quiet", "-p:NoWarn=1591%3BCS8632", ]); const providerPort = 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 "); odin = await startOdin(odinPort, endpoint); const odinEndpoint = await odin.waitFor("ODIN_READY "); headless = startDotnet(["headless", "--endpoint", endpoint, "--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(); 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); assert.equal(JSON.parse(await headless.waitFor("HEADLESS_UPDATE ")).count, 1); 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"); await stop(provider.process); await stop(odin.process); provider = await startProvider(replacementProviderPort); const restartedEndpoint = await provider.waitFor("PROVIDER_READY "); assert.notEqual(restartedEndpoint, endpoint); odin = await startOdin(odinPort, restartedEndpoint); assert.equal(await odin.waitFor("ODIN_READY "), odinEndpoint); headless = startDotnet(["headless", "--endpoint", restartedEndpoint, "--token", token]); await headless.waitFor("HEADLESS_READY "); await page.evaluate(() => { window.__sampleCommandId = "browser-click-2"; }); await page.locator("button").click(); await page.waitForFunction(() => window.__sampleReceipt?.count === 2 && window.__sampleCount === 2); assert.equal(JSON.parse(await headless.waitFor("HEADLESS_UPDATE ")).count, 2); 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, odinEndpoint, browserRuntime: "chromium", headlessRuntime: "csharp", receiptId: firstReceipt, count: 2, routeRotationCount: 1, })); } finally { if (browser) await browser.close().catch(() => undefined); if (headless) await stop(headless.process); if (provider) await stop(provider.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) { return startDotnet(["provider", "--port", String(port), "--state", statePath, "--token", token]); } async function startOdin(port, providerEndpoint) { return startDotnet([ "odin", "--port", String(port), "--provider-endpoint", providerEndpoint, "--token", token, ]); } function startDotnet(arguments_) { const process = spawn("dotnet", [ "run", "--no-build", "--project", join(sampleRoot, "EveBrowserNetworkSample.csproj"), "--", ...arguments_, ], { cwd: repoRoot, stdio: ["ignore", "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, 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'.", ); }