2026-08-18 00:05:57 +02:00
|
|
|
import assert from "node:assert/strict";
|
|
|
|
|
import { spawn } from "node:child_process";
|
2026-08-18 03:17:52 +02:00
|
|
|
import { existsSync } from "node:fs";
|
2026-08-18 00:05:57 +02:00
|
|
|
import { createServer as createHttpServer } from "node:http";
|
2026-10-11 20:37:29 +02:00
|
|
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
2026-08-18 00:05:57 +02:00
|
|
|
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"));
|
2026-08-18 17:39:59 +02:00
|
|
|
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;
|
2026-08-18 00:05:57 +02:00
|
|
|
const sampleRoot = join(repoRoot, "samples", "eve-browser-network");
|
|
|
|
|
const workRoot = await mkdtemp(join(tmpdir(), "cultmesh-browser-network-"));
|
|
|
|
|
const statePath = join(workRoot, "counter.cc");
|
2026-08-18 17:21:58 +02:00
|
|
|
const decoyStatePath = join(workRoot, "decoy-counter.cc");
|
2026-08-18 00:05:57 +02:00
|
|
|
const bundlePath = join(workRoot, "bundle.js");
|
|
|
|
|
const token = "sample-session";
|
|
|
|
|
let provider;
|
2026-08-18 17:21:58 +02:00
|
|
|
let decoyProvider;
|
2026-08-18 03:34:59 +02:00
|
|
|
let replacementProvider;
|
2026-08-18 00:34:44 +02:00
|
|
|
let odin;
|
2026-08-18 00:05:57 +02:00
|
|
|
let headless;
|
|
|
|
|
let httpServer;
|
|
|
|
|
let browser;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const tsc = join(repoRoot, "node_modules", "typescript", "bin", "tsc");
|
2026-10-11 20:37:29 +02:00
|
|
|
await run(process.execPath, [tsc, "-p", join(repoRoot, "packages", "cultcache-ts", "tsconfig.json"), "--pretty", "false"]);
|
2026-08-18 00:05:57 +02:00
|
|
|
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"]);
|
2026-10-11 20:37:29 +02:00
|
|
|
// 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"),
|
|
|
|
|
"cultmesh-browser": join(repoRoot, "packages", "cultmesh-browser", "src", "index.ts"),
|
|
|
|
|
"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"]);
|
2026-08-18 00:05:57 +02:00
|
|
|
await build({
|
|
|
|
|
entryPoints: [join(sampleRoot, "browser", "main.ts")],
|
|
|
|
|
outfile: bundlePath,
|
|
|
|
|
bundle: true,
|
|
|
|
|
format: "esm",
|
|
|
|
|
platform: "browser",
|
|
|
|
|
target: "es2022",
|
|
|
|
|
sourcemap: true,
|
2026-10-11 20:37:29 +02:00
|
|
|
alias: moduleMap,
|
2026-08-18 00:05:57 +02:00
|
|
|
logLevel: "warning",
|
|
|
|
|
});
|
|
|
|
|
const bundle = await readFile(bundlePath, "utf8");
|
|
|
|
|
assert.doesNotMatch(bundle, /(?:from\s*["']node:|require\(["']node:)/u, "browser bundle contains a Node builtin import");
|
|
|
|
|
|
2026-08-18 17:39:59 +02:00
|
|
|
const dotnetBuildArguments = [
|
2026-08-18 03:17:52 +02:00
|
|
|
"build",
|
|
|
|
|
join(sampleRoot, "EveBrowserNetworkSample.csproj"),
|
2026-08-18 03:41:47 +02:00
|
|
|
"-m:1",
|
2026-08-18 03:17:52 +02:00
|
|
|
"--verbosity", "quiet",
|
|
|
|
|
"-p:NoWarn=1591%3BCS8632",
|
2026-08-18 17:39:59 +02:00
|
|
|
"-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);
|
2026-08-18 00:05:57 +02:00
|
|
|
const providerPort = await freePort();
|
2026-08-18 17:21:58 +02:00
|
|
|
const decoyProviderPort = await freePort();
|
2026-08-18 00:34:44 +02:00
|
|
|
const replacementProviderPort = await freePort();
|
|
|
|
|
const odinPort = await freePort();
|
2026-08-18 00:05:57 +02:00
|
|
|
const httpPort = await freePort();
|
|
|
|
|
provider = await startProvider(providerPort);
|
|
|
|
|
const endpoint = await provider.waitFor("PROVIDER_READY ");
|
2026-08-18 17:21:58 +02:00
|
|
|
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);
|
2026-08-18 00:34:44 +02:00
|
|
|
const odinEndpoint = await odin.waitFor("ODIN_READY ");
|
2026-08-18 03:34:59 +02:00
|
|
|
headless = startDotnet([
|
|
|
|
|
"headless", "--odin", odinEndpoint, "--verse-id", "sample.counter",
|
|
|
|
|
"--expected-count", "2", "--token", token,
|
|
|
|
|
]);
|
2026-08-18 00:05:57 +02:00
|
|
|
await headless.waitFor("HEADLESS_READY ");
|
|
|
|
|
httpServer = await serve(httpPort, sampleRoot, bundlePath);
|
|
|
|
|
|
2026-08-18 03:17:52 +02:00
|
|
|
browser = await chromium.launch({ executablePath: resolveChromiumExecutable(), headless: true });
|
2026-08-18 00:05:57 +02:00
|
|
|
let page = await browser.newPage();
|
2026-09-10 13:45:07 +02:00
|
|
|
// 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}`));
|
2026-08-18 00:34:44 +02:00
|
|
|
const url = `http://127.0.0.1:${httpPort}/?odin=${encodeURIComponent(odinEndpoint)}&token=${token}`;
|
2026-08-18 00:05:57 +02:00
|
|
|
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);
|
2026-08-18 03:34:59 +02:00
|
|
|
const firstHeadlessUpdate = JSON.parse(await headless.waitFor("HEADLESS_UPDATE_1 "));
|
|
|
|
|
assert.equal(firstHeadlessUpdate.count, 1);
|
|
|
|
|
assert.ok(firstHeadlessUpdate.receiptIds.includes(firstReceipt));
|
2026-08-18 00:05:57 +02:00
|
|
|
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");
|
|
|
|
|
|
2026-08-18 03:34:59 +02:00
|
|
|
replacementProvider = await startProvider(replacementProviderPort);
|
|
|
|
|
const restartedEndpoint = await replacementProvider.waitFor("PROVIDER_READY ");
|
2026-08-18 00:34:44 +02:00
|
|
|
assert.notEqual(restartedEndpoint, endpoint);
|
2026-08-18 03:34:59 +02:00
|
|
|
await stop(odin.process);
|
2026-08-18 17:21:58 +02:00
|
|
|
odin = await startOdin(odinPort, restartedEndpoint, decoyEndpoint);
|
2026-08-18 00:34:44 +02:00
|
|
|
assert.equal(await odin.waitFor("ODIN_READY "), odinEndpoint);
|
2026-08-18 03:34:59 +02:00
|
|
|
await stop(provider.process);
|
|
|
|
|
provider = replacementProvider;
|
|
|
|
|
replacementProvider = undefined;
|
|
|
|
|
await page.waitForFunction(() =>
|
|
|
|
|
window.__sampleConnectionStates?.includes("reconnecting")
|
|
|
|
|
&& window.__sampleConnectionStates.at(-1) === "connected");
|
2026-08-18 03:49:33 +02:00
|
|
|
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;
|
2026-08-18 03:34:59 +02:00
|
|
|
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);
|
2026-08-18 00:34:44 +02:00
|
|
|
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"));
|
2026-08-18 00:05:57 +02:00
|
|
|
|
|
|
|
|
console.log(JSON.stringify({
|
|
|
|
|
providerEndpoint: endpoint,
|
2026-08-18 00:34:44 +02:00
|
|
|
replacementProviderEndpoint: restartedEndpoint,
|
2026-08-18 17:21:58 +02:00
|
|
|
rejectedWrongAuthorityEndpoint: decoyEndpoint,
|
2026-08-18 00:34:44 +02:00
|
|
|
odinEndpoint,
|
2026-08-18 00:05:57 +02:00
|
|
|
browserRuntime: "chromium",
|
|
|
|
|
headlessRuntime: "csharp",
|
|
|
|
|
receiptId: firstReceipt,
|
2026-08-18 03:34:59 +02:00
|
|
|
secondReceiptId: secondReceipt,
|
2026-08-18 00:34:44 +02:00
|
|
|
count: 2,
|
|
|
|
|
routeRotationCount: 1,
|
2026-08-18 03:34:59 +02:00
|
|
|
retainedHeadlessLease: true,
|
2026-08-18 03:56:24 +02:00
|
|
|
networkBenchmark,
|
2026-08-18 00:05:57 +02:00
|
|
|
}));
|
|
|
|
|
} finally {
|
|
|
|
|
if (browser) await browser.close().catch(() => undefined);
|
|
|
|
|
if (headless) await stop(headless.process);
|
|
|
|
|
if (provider) await stop(provider.process);
|
2026-08-18 17:21:58 +02:00
|
|
|
if (decoyProvider) await stop(decoyProvider.process);
|
2026-08-18 03:34:59 +02:00
|
|
|
if (replacementProvider) await stop(replacementProvider.process);
|
2026-08-18 00:34:44 +02:00
|
|
|
if (odin) await stop(odin.process);
|
2026-08-18 00:05:57 +02:00
|
|
|
if (httpServer) await new Promise(resolve => httpServer.close(resolve));
|
|
|
|
|
await rm(workRoot, { recursive: true, force: true });
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 17:21:58 +02:00
|
|
|
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,
|
|
|
|
|
]);
|
2026-08-18 00:05:57 +02:00
|
|
|
}
|
|
|
|
|
|
2026-08-18 17:21:58 +02:00
|
|
|
async function startOdin(port, providerEndpoint, decoyEndpoint) {
|
2026-08-18 00:34:44 +02:00
|
|
|
return startDotnet([
|
|
|
|
|
"odin",
|
|
|
|
|
"--port", String(port),
|
|
|
|
|
"--provider-endpoint", providerEndpoint,
|
2026-08-18 17:21:58 +02:00
|
|
|
"--decoy-endpoint", decoyEndpoint,
|
|
|
|
|
"--decoy-authority-runtime-id", "sample.decoy-daemon",
|
|
|
|
|
"--decoy-route-generation", "sample-decoy-route-1",
|
2026-08-18 00:34:44 +02:00
|
|
|
"--token", token,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 00:05:57 +02:00
|
|
|
function startDotnet(arguments_) {
|
|
|
|
|
const process = spawn("dotnet", [
|
|
|
|
|
"run", "--no-build", "--project", join(sampleRoot, "EveBrowserNetworkSample.csproj"), "--", ...arguments_,
|
2026-08-18 03:49:33 +02:00
|
|
|
], { cwd: repoRoot, stdio: ["pipe", "pipe", "pipe"] });
|
2026-08-18 00:05:57 +02:00
|
|
|
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}`));
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-10-11 20:51:35 +02:00
|
|
|
// 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);
|
|
|
|
|
}
|
2026-08-18 00:05:57 +02:00
|
|
|
function settle() {
|
|
|
|
|
for (let index = waiters.length - 1; index >= 0; index--) {
|
|
|
|
|
const waiter = waiters[index];
|
2026-10-11 20:51:35 +02:00
|
|
|
const line = completeLines().find(value => value.startsWith(waiter.prefix));
|
2026-08-18 00:05:57 +02:00
|
|
|
if (!line) continue;
|
|
|
|
|
waiters.splice(index, 1);
|
|
|
|
|
clearTimeout(waiter.timer);
|
|
|
|
|
waiter.resolve(line.slice(waiter.prefix.length));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
process,
|
2026-08-18 03:49:33 +02:00
|
|
|
sendLine(line) {
|
|
|
|
|
process.stdin.write(`${line}\n`);
|
|
|
|
|
},
|
2026-08-18 00:05:57 +02:00
|
|
|
waitFor(prefix, timeoutMs = 30_000) {
|
2026-10-11 20:51:35 +02:00
|
|
|
const existing = completeLines().find(value => value.startsWith(prefix));
|
2026-08-18 00:05:57 +02:00
|
|
|
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");
|
|
|
|
|
}
|
2026-08-18 03:17:52 +02:00
|
|
|
|
|
|
|
|
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'.",
|
|
|
|
|
);
|
|
|
|
|
}
|