198 lines
8.1 KiB
JavaScript
198 lines
8.1 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { spawn } from "node:child_process";
|
|
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 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"]);
|
|
const providerPort = await freePort();
|
|
const httpPort = await freePort();
|
|
provider = await startProvider(providerPort);
|
|
const endpoint = await provider.waitFor("PROVIDER_READY ");
|
|
headless = startDotnet(["headless", "--endpoint", endpoint, "--token", token]);
|
|
await headless.waitFor("HEADLESS_READY ");
|
|
httpServer = await serve(httpPort, sampleRoot, bundlePath);
|
|
|
|
const executablePath = process.env.CHROME_PATH || "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
|
|
browser = await chromium.launch({ executablePath, headless: true });
|
|
let page = await browser.newPage();
|
|
const url = `http://127.0.0.1:${httpPort}/?endpoint=${encodeURIComponent(endpoint)}&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 page.close();
|
|
await stop(provider.process);
|
|
provider = await startProvider(providerPort);
|
|
const restartedEndpoint = await provider.waitFor("PROVIDER_READY ");
|
|
assert.equal(restartedEndpoint, endpoint);
|
|
page = await browser.newPage();
|
|
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), 1, "provider restart lost durable counter state");
|
|
|
|
console.log(JSON.stringify({
|
|
providerEndpoint: endpoint,
|
|
browserRuntime: "chromium",
|
|
headlessRuntime: "csharp",
|
|
receiptId: firstReceipt,
|
|
count: 1,
|
|
restartCount: 1,
|
|
}));
|
|
} finally {
|
|
if (browser) await browser.close().catch(() => undefined);
|
|
if (headless) await stop(headless.process);
|
|
if (provider) await stop(provider.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]);
|
|
}
|
|
|
|
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");
|
|
}
|