chore: import upstream snapshot with attribution
Build / Frontend Build (push) Has been cancelled
Build / Backend Build (push) Has been cancelled
Build / Docker Build (push) Has been cancelled
Build / Kubernetes Deploy Smoke Test (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:35:24 +08:00
commit 6d978fe483
567 changed files with 180611 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
Push-Location (Join-Path $PSScriptRoot "..")
try {
docker compose -f docker/docker-compose.e2e.yml down --remove-orphans
} finally {
Pop-Location
}
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env sh
set -eu
cd "$(dirname "$0")/.."
docker compose -f docker/docker-compose.e2e.yml down --remove-orphans
+10
View File
@@ -0,0 +1,10 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
Push-Location (Join-Path $PSScriptRoot "..")
try {
docker compose -f docker/docker-compose.e2e.yml up -d mysql
} finally {
Pop-Location
}
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env sh
set -eu
cd "$(dirname "$0")/.."
docker compose -f docker/docker-compose.e2e.yml up -d mysql
+50
View File
@@ -0,0 +1,50 @@
import mysql from "mysql2/promise";
import { env } from "../fixtures/env.js";
import { sleep, waitForTcpPort } from "./wait-for.js";
async function main() {
await waitForTcpPort(env.db.host, env.db.port, 120_000);
let connection: mysql.Connection | undefined;
let lastError: unknown;
for (let attempt = 1; attempt <= 30; attempt += 1) {
try {
connection = await mysql.createConnection({
host: env.db.host,
port: env.db.port,
user: env.db.user,
password: env.db.password,
multipleStatements: false
});
break;
} catch (error) {
lastError = error;
await sleep(2000);
}
}
if (!connection) {
throw new Error(
`failed to connect to MySQL: ${
lastError instanceof Error ? lastError.message : String(lastError)
}`
);
}
try {
await connection.query(`DROP DATABASE IF EXISTS \`${env.db.database}\``);
await connection.query(
`CREATE DATABASE \`${env.db.database}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`
);
console.log(`[e2e] reset database ${env.db.database}`);
} finally {
await connection.end();
}
}
main().catch((error: unknown) => {
console.error("[e2e] failed to reset database");
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
+47
View File
@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import { buildTargetRunOptions } from "./run-target.js";
const defaultOptions = buildTargetRunOptions(["172.16.1.12:39443"], {});
assert.equal(defaultOptions.frontendUrl, "https://172.16.1.12:39443");
assert.equal(defaultOptions.backendUrl, "https://172.16.1.12:39443/api/v1");
assert.deepEqual(defaultOptions.playwrightArgs, [
"playwright",
"test",
"--grep",
"@p0",
"--grep-invert",
"@local-only",
"--workers=1"
]);
assert.equal(defaultOptions.env.E2E_FRONTEND_URL, "https://172.16.1.12:39443");
assert.equal(defaultOptions.env.E2E_BACKEND_URL, "https://172.16.1.12:39443/api/v1");
assert.equal(defaultOptions.env.E2E_IGNORE_HTTPS_ERRORS, "true");
assert.equal(defaultOptions.env.E2E_HEADLESS, "false");
assert.equal(defaultOptions.env.E2E_DEBUG, "false");
assert.equal(defaultOptions.env.E2E_SLOW_MO_MS, "0");
assert.equal(defaultOptions.debug, false);
assert.equal(defaultOptions.slowMoMs, 0);
const httpOptions = buildTargetRunOptions(["http://172.16.1.12:39080/"]);
assert.equal(httpOptions.frontendUrl, "http://172.16.1.12:39080");
assert.equal(httpOptions.backendUrl, "http://172.16.1.12:39080/api/v1");
const headlessOptions = buildTargetRunOptions(["172.16.1.12:39443", "--headless"], {});
assert.equal(headlessOptions.env.E2E_HEADLESS, "true");
const headedOptions = buildTargetRunOptions(["172.16.1.12:39443", "--headless=false"], {});
assert.equal(headedOptions.env.E2E_HEADLESS, "false");
const ciOptions = buildTargetRunOptions(["172.16.1.12:39443"], { CI: "true" });
assert.equal(ciOptions.env.E2E_HEADLESS, "true");
const debugOptions = buildTargetRunOptions(["172.16.1.12:39443", "--debug"], {});
assert.equal(debugOptions.env.E2E_DEBUG, "true");
assert.equal(debugOptions.env.E2E_SLOW_MO_MS, "500");
assert.equal(debugOptions.env.E2E_HEADLESS, "false");
assert.equal(debugOptions.debug, true);
assert.equal(debugOptions.slowMoMs, 500);
assert.throws(() => buildTargetRunOptions([]), /Usage/);
assert.throws(() => buildTargetRunOptions(["https://a.example", "https://b.example"]), /only accepts one address/);
+117
View File
@@ -0,0 +1,117 @@
import { pathToFileURL } from "node:url";
import { e2eRoot } from "../fixtures/env.js";
import {
createLogger,
DEBUG_SLOW_MO_MS,
defaultHeadlessFromEnv,
parseDebugArgs,
parseHeadlessArgs,
runCommand,
suiteArgs,
writeFailureScreenshotSummary
} from "./runner-utils.js";
export interface TargetRunOptions {
frontendUrl: string;
backendUrl: string;
headless: boolean;
debug: boolean;
slowMoMs: number;
playwrightArgs: string[];
env: NodeJS.ProcessEnv;
}
function withoutTrailingSlash(value: string) {
return value.replace(/\/+$/, "");
}
function normalizeTargetAddress(rawAddress: string) {
const trimmed = rawAddress.trim();
if (!trimmed) {
throw new Error("Usage: npm run target -- <clawmanager-address>");
}
const withProtocol = /^[a-z][a-z\d+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
const url = new URL(withProtocol);
url.hash = "";
url.search = "";
return withoutTrailingSlash(url.toString());
}
export function buildTargetRunOptions(
args: string[] = process.argv.slice(2),
environment: NodeJS.ProcessEnv = process.env
): TargetRunOptions {
const parsedHeadless = parseHeadlessArgs(args, defaultHeadlessFromEnv(environment));
const parsedDebug = parseDebugArgs(parsedHeadless.args, false);
const remainingArgs = parsedDebug.args;
if (remainingArgs.length === 0) {
throw new Error("Usage: npm run target -- <clawmanager-address>");
}
if (remainingArgs.length > 1) {
throw new Error("target runner only accepts one address plus optional --headless/--headed/--debug");
}
const frontendUrl = normalizeTargetAddress(remainingArgs[0]);
const backendUrl = `${frontendUrl}/api/v1`;
const slowMoMs = parsedDebug.debug ? DEBUG_SLOW_MO_MS : 0;
return {
frontendUrl,
backendUrl,
headless: parsedHeadless.headless,
debug: parsedDebug.debug,
slowMoMs,
playwrightArgs: ["playwright", "test", ...suiteArgs("p0"), "--grep-invert", "@local-only", "--workers=1"],
env: {
...environment,
E2E_FRONTEND_URL: frontendUrl,
E2E_BACKEND_URL: backendUrl,
E2E_CI: "true",
E2E_HEADLESS: String(parsedHeadless.headless),
E2E_DEBUG: String(parsedDebug.debug),
E2E_SLOW_MO_MS: String(slowMoMs),
E2E_IGNORE_HTTPS_ERRORS: "true",
E2E_REMOTE_TARGET: "true"
}
};
}
async function main() {
const options = buildTargetRunOptions();
const logger = createLogger("target-run.log");
try {
logger.log(`target frontend: ${options.frontendUrl}`);
logger.log(`target backend: ${options.backendUrl}`);
logger.log(`target headless: ${options.headless}`);
logger.log(`target debug: ${options.debug} slowMoMs=${options.slowMoMs}`);
logger.log("running remote Playwright P0 suite");
try {
await runCommand(
"npx",
options.playwrightArgs,
{
cwd: e2eRoot,
label: "playwright",
env: options.env
},
logger.write
);
} catch (error) {
writeFailureScreenshotSummary(logger.write);
throw error;
}
logger.log("target e2e run completed successfully");
} finally {
await logger.close();
}
}
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
}
+56
View File
@@ -0,0 +1,56 @@
import assert from "node:assert/strict";
import { parseArgs } from "./run-with-log.js";
assert.deepEqual(parseArgs(["p0"], {}), {
suite: "p0",
ci: false,
headless: false,
debug: false,
slowMoMs: 0,
extraArgs: []
});
assert.deepEqual(parseArgs(["p1", "--headless", "--workers=1"], {}), {
suite: "p1",
ci: false,
headless: true,
debug: false,
slowMoMs: 0,
extraArgs: ["--workers=1"]
});
assert.deepEqual(parseArgs(["p2", "--headed", "--workers=1"], { CI: "true" }), {
suite: "p2",
ci: false,
headless: false,
debug: false,
slowMoMs: 0,
extraArgs: ["--workers=1"]
});
assert.deepEqual(parseArgs(["all", "--ci"], {}), {
suite: "all",
ci: true,
headless: true,
debug: false,
slowMoMs: 0,
extraArgs: []
});
assert.deepEqual(parseArgs(["p0", "--ci", "--headless=false"], {}), {
suite: "p0",
ci: true,
headless: false,
debug: false,
slowMoMs: 0,
extraArgs: []
});
assert.deepEqual(parseArgs(["p0", "--debug", "--workers=1"], {}), {
suite: "p0",
ci: false,
headless: false,
debug: true,
slowMoMs: 500,
extraArgs: ["--workers=1"]
});
+181
View File
@@ -0,0 +1,181 @@
import path from "node:path";
import { pathToFileURL } from "node:url";
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { env, e2eRoot, repoRoot } from "../fixtures/env.js";
import { waitForHttpStatus, waitForTcpPort } from "./wait-for.js";
import {
createLogger,
DEBUG_SLOW_MO_MS,
defaultHeadlessFromEnv,
parseDebugArgs,
parseHeadlessArgs,
runCommand,
startService,
stopService,
suiteArgs,
type Suite,
writeFailureScreenshotSummary
} from "./runner-utils.js";
const npmCmd = "npm";
const npxCmd = "npx";
const goCmd = "go";
function childEnv(extra: NodeJS.ProcessEnv = {}) {
return {
...process.env,
E2E_FRONTEND_URL: env.frontendUrl,
E2E_BACKEND_URL: env.backendUrl,
E2E_DB_HOST: env.db.host,
E2E_DB_PORT: String(env.db.port),
E2E_DB_USER: env.db.user,
E2E_DB_PASSWORD: env.db.password,
E2E_DB_NAME: env.db.database,
E2E_RUNTIME_AGENT_TOKEN: env.runtimeAgentToken,
E2E_WORKSPACE_ROOT: env.workspaceRoot,
E2E_OBJECT_STORAGE_FALLBACK: env.objectStorageFallback,
...extra
};
}
export function parseArgs(
args: string[] = process.argv.slice(2),
environment: NodeJS.ProcessEnv = process.env
): { suite: Suite; ci: boolean; headless: boolean; debug: boolean; slowMoMs: number; extraArgs: string[] } {
const [suiteArg = "p0", ...rest] = args;
if (!["p0", "p1", "p2", "all"].includes(suiteArg)) {
throw new Error(`unknown e2e suite: ${suiteArg}`);
}
const ci = rest.includes("--ci");
const filteredArgs = rest.filter((value) => value !== "--ci" && value !== "--");
const parsedHeadless = parseHeadlessArgs(
filteredArgs,
ci || defaultHeadlessFromEnv(environment)
);
const parsedDebug = parseDebugArgs(parsedHeadless.args, false);
return {
suite: suiteArg as Suite,
ci,
headless: parsedHeadless.headless,
debug: parsedDebug.debug,
slowMoMs: parsedDebug.debug ? DEBUG_SLOW_MO_MS : 0,
extraArgs: parsedDebug.args
};
}
function frontendHostAndPort() {
const url = new URL(env.frontendUrl);
return {
host: url.hostname,
port: url.port || (url.protocol === "https:" ? "443" : "80")
};
}
async function main() {
const { suite, ci, headless, debug, slowMoMs, extraArgs } = parseArgs();
const logger = createLogger();
let backend: ChildProcessWithoutNullStreams | undefined;
let frontend: ChildProcessWithoutNullStreams | undefined;
try {
logger.log(`starting e2e suite=${suite} ci=${ci} headless=${headless} debug=${debug} slowMoMs=${slowMoMs}`);
logger.log(`run log: ${logger.logPath}`);
logger.log("starting MySQL");
await runCommand(
"docker",
["compose", "-f", "docker/docker-compose.e2e.yml", "up", "-d", "mysql"],
{ cwd: e2eRoot, label: "docker" },
logger.write
);
await waitForTcpPort(env.db.host, env.db.port, 120_000);
logger.log("resetting e2e database");
await runCommand(npxCmd, ["tsx", "scripts/reset-db.ts"], { cwd: e2eRoot, label: "db" }, logger.write);
logger.log("starting backend");
backend = startService(
goCmd,
["run", "./cmd/server"],
{
cwd: path.join(repoRoot, "backend"),
label: "backend",
env: childEnv({
SERVER_ADDRESS: ":9001",
DB_HOST: env.db.host,
DB_PORT: String(env.db.port),
DB_USER: env.db.user,
DB_PASSWORD: env.db.password,
DB_NAME: env.db.database,
JWT_SECRET: "clawmanager-e2e-secret",
RUNTIME_SCHEDULER_ENABLED: "false",
RUNTIME_AGENT_REPORT_TOKEN: env.runtimeAgentToken,
RUNTIME_WORKSPACE_ROOT: env.workspaceRoot,
OBJECT_STORAGE_LOCAL_FALLBACK: env.objectStorageFallback,
SKILL_SCANNER_ENABLED: "false"
})
},
logger.write
);
await waitForTcpPort("127.0.0.1", 9001, 120_000);
logger.log("starting frontend");
const frontendTarget = frontendHostAndPort();
frontend = startService(
npmCmd,
["run", "dev", "--", "--host", frontendTarget.host, "--port", frontendTarget.port, "--strictPort"],
{ cwd: path.join(repoRoot, "frontend"), label: "frontend", env: childEnv() },
logger.write
);
await waitForHttpStatus(env.frontendUrl, 200, 120_000);
logger.log("running Playwright");
const args = ["playwright", "test", ...suiteArgs(suite)];
if (ci && !extraArgs.some((value) => value.startsWith("--workers"))) {
args.push("--workers=1");
}
args.push(...extraArgs);
try {
await runCommand(
npxCmd,
args,
{
cwd: e2eRoot,
label: "playwright",
env: childEnv({
CI: ci ? "true" : process.env.CI,
E2E_HEADLESS: String(headless),
E2E_DEBUG: String(debug),
E2E_SLOW_MO_MS: String(slowMoMs)
})
},
logger.write
);
} catch (error) {
writeFailureScreenshotSummary(logger.write);
throw error;
}
logger.log("e2e run completed successfully");
} finally {
logger.log("cleaning up e2e services");
await stopService(frontend, "frontend");
await stopService(backend, "backend");
await runCommand(
"docker",
["compose", "-f", "docker/docker-compose.e2e.yml", "down", "--remove-orphans"],
{ cwd: e2eRoot, label: "docker" },
logger.write
).catch((error: unknown) => {
logger.log(`docker cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
});
await logger.close();
}
}
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
}
+78
View File
@@ -0,0 +1,78 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import assert from "node:assert/strict";
import { findFailureScreenshots, parseDebugArgs, parseHeadlessArgs } from "./runner-utils.js";
assert.deepEqual(parseHeadlessArgs([], false), {
headless: false,
args: []
});
assert.deepEqual(parseHeadlessArgs([], true), {
headless: true,
args: []
});
assert.deepEqual(parseHeadlessArgs(["--headless", "--workers=1"], false), {
headless: true,
args: ["--workers=1"]
});
assert.deepEqual(parseHeadlessArgs(["--headless=false", "--workers=1"], true), {
headless: false,
args: ["--workers=1"]
});
assert.deepEqual(parseHeadlessArgs(["--headless", "false", "--workers=1"], true), {
headless: false,
args: ["--workers=1"]
});
assert.deepEqual(parseHeadlessArgs(["--headed", "--workers=1"], true), {
headless: false,
args: ["--workers=1"]
});
assert.throws(() => parseHeadlessArgs(["--headless=maybe"], false), /headless/);
assert.deepEqual(parseDebugArgs([], false), {
debug: false,
args: []
});
assert.deepEqual(parseDebugArgs(["--debug", "--workers=1"], false), {
debug: true,
args: ["--workers=1"]
});
assert.deepEqual(parseDebugArgs(["--debug=false", "--workers=1"], true), {
debug: false,
args: ["--workers=1"]
});
assert.deepEqual(parseDebugArgs(["--debug", "false", "--workers=1"], true), {
debug: false,
args: ["--workers=1"]
});
assert.deepEqual(parseDebugArgs(["--no-debug", "--workers=1"], true), {
debug: false,
args: ["--workers=1"]
});
assert.throws(() => parseDebugArgs(["--debug=maybe"], false), /debug/);
const screenshotRoot = fs.mkdtempSync(path.join(os.tmpdir(), "clawmanager-e2e-screenshots-"));
const nestedDir = path.join(screenshotRoot, "failed-test");
fs.mkdirSync(nestedDir, { recursive: true });
fs.writeFileSync(path.join(nestedDir, "test-failed-1.png"), "");
fs.writeFileSync(path.join(nestedDir, "trace.zip"), "");
fs.writeFileSync(path.join(screenshotRoot, "other.png"), "");
assert.deepEqual(
findFailureScreenshots(screenshotRoot)
.map((filePath) => path.basename(filePath))
.sort(),
["other.png", "test-failed-1.png"]
);
+278
View File
@@ -0,0 +1,278 @@
import fs from "node:fs";
import path from "node:path";
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { reportsDir, testResultsDir } from "../fixtures/env.js";
export type Suite = "p0" | "p1" | "p2" | "all";
export const DEBUG_SLOW_MO_MS = 500;
const isWindows = process.platform === "win32";
const truthyValues = new Set(["1", "true", "yes", "on"]);
const falsyValues = new Set(["0", "false", "no", "off"]);
export function defaultHeadlessFromEnv(environment: NodeJS.ProcessEnv = process.env) {
return environment.CI === "true" || environment.E2E_CI === "true";
}
function parseBooleanFlag(flag: string, value: string) {
const normalized = value.trim().toLowerCase();
if (truthyValues.has(normalized)) {
return true;
}
if (falsyValues.has(normalized)) {
return false;
}
throw new Error(`${flag} must be true or false`);
}
export function parseHeadlessArgs(args: string[], defaultHeadless: boolean) {
let headless = defaultHeadless;
const remainingArgs: string[] = [];
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--headless") {
const nextArg = args[index + 1];
const nextValue = nextArg?.toLowerCase();
if (nextValue && (truthyValues.has(nextValue) || falsyValues.has(nextValue))) {
headless = parseBooleanFlag("--headless", nextArg);
index += 1;
} else {
headless = true;
}
continue;
}
if (arg === "--headed" || arg === "--no-headless") {
headless = false;
continue;
}
if (arg.startsWith("--headless=")) {
headless = parseBooleanFlag("--headless", arg.slice("--headless=".length));
continue;
}
remainingArgs.push(arg);
}
return {
headless,
args: remainingArgs
};
}
export function parseDebugArgs(args: string[], defaultDebug: boolean) {
let debug = defaultDebug;
const remainingArgs: string[] = [];
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--debug") {
const nextArg = args[index + 1];
const nextValue = nextArg?.toLowerCase();
if (nextValue && (truthyValues.has(nextValue) || falsyValues.has(nextValue))) {
debug = parseBooleanFlag("--debug", nextArg);
index += 1;
} else {
debug = true;
}
continue;
}
if (arg === "--no-debug") {
debug = false;
continue;
}
if (arg.startsWith("--debug=")) {
debug = parseBooleanFlag("--debug", arg.slice("--debug=".length));
continue;
}
remainingArgs.push(arg);
}
return {
debug,
args: remainingArgs
};
}
export function findFailureScreenshots(rootDir = testResultsDir) {
if (!fs.existsSync(rootDir)) {
return [];
}
const screenshots: string[] = [];
const visit = (dir: string) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
visit(entryPath);
} else if (entry.isFile() && entry.name.toLowerCase().endsWith(".png")) {
screenshots.push(entryPath);
}
}
};
visit(rootDir);
return screenshots.sort((a, b) => a.localeCompare(b));
}
export function writeFailureScreenshotSummary(write: (line: string) => void) {
const screenshots = findFailureScreenshots();
if (screenshots.length === 0) {
write(`[e2e] no failure screenshots found in ${testResultsDir}\n`);
return;
}
write(`[e2e] failure screenshot evidence:\n`);
for (const screenshot of screenshots) {
write(`[e2e] ${screenshot}\n`);
}
}
function timestamp() {
return new Date().toISOString();
}
export function createLogger(fileName = "run.log") {
fs.mkdirSync(reportsDir, { recursive: true });
const logPath = path.join(reportsDir, fileName);
const stream = fs.createWriteStream(logPath, { flags: "w" });
const write = (line: string) => {
stream.write(line);
process.stdout.write(line);
};
const log = (message: string) => {
write(`[${timestamp()}] ${message}\n`);
};
return {
logPath,
log,
write,
close: () =>
new Promise<void>((resolve) => {
stream.end(resolve);
})
};
}
function pipeChild(
child: ChildProcessWithoutNullStreams,
label: string,
write: (line: string) => void
) {
child.stdout.on("data", (chunk: Buffer) => {
write(chunk.toString().replace(/^/gm, `[${label}] `));
});
child.stderr.on("data", (chunk: Buffer) => {
write(chunk.toString().replace(/^/gm, `[${label}] `));
});
}
function quoteWindowsArg(value: string) {
if (/^[A-Za-z0-9_./:=@-]+$/.test(value)) {
return value;
}
return `"${value.replace(/"/g, '\\"')}"`;
}
function spawnPortable(
command: string,
args: string[],
options: { cwd: string; env?: NodeJS.ProcessEnv }
): ChildProcessWithoutNullStreams {
if (!isWindows) {
return spawn(command, args, {
cwd: options.cwd,
env: options.env,
windowsHide: true
});
}
const commandLine = [command, ...args].map(quoteWindowsArg).join(" ");
return spawn("cmd.exe", ["/d", "/s", "/c", commandLine], {
cwd: options.cwd,
env: options.env,
windowsHide: true
});
}
export function runCommand(
command: string,
args: string[],
options: { cwd: string; env?: NodeJS.ProcessEnv; label: string },
write: (line: string) => void
): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawnPortable(command, args, options);
pipeChild(child, options.label, write);
child.on("error", reject);
child.on("exit", (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`${options.label} exited with code ${code}`));
}
});
});
}
export function startService(
command: string,
args: string[],
options: { cwd: string; env?: NodeJS.ProcessEnv; label: string },
write: (line: string) => void
) {
const child = spawnPortable(command, args, options);
pipeChild(child, options.label, write);
return child;
}
async function killWindowsProcessTree(pid: number) {
await new Promise<void>((resolve) => {
const child = spawn("taskkill.exe", ["/pid", String(pid), "/t", "/f"], {
windowsHide: true
});
child.on("exit", () => resolve());
child.on("error", () => resolve());
});
}
export async function stopService(
child: ChildProcessWithoutNullStreams | undefined,
label: string
) {
if (!child || child.killed) {
return;
}
await new Promise<void>((resolve) => {
child.once("exit", () => resolve());
if (isWindows && child.pid) {
void killWindowsProcessTree(child.pid).then(resolve);
} else {
child.kill();
}
setTimeout(() => {
if (!child.killed) {
child.kill("SIGKILL");
}
resolve();
}, 5000).unref();
});
console.log(`[e2e] stopped ${label}`);
}
export function suiteArgs(suite: Suite) {
switch (suite) {
case "p0":
return ["--grep", "@p0"];
case "p1":
return ["--grep", "@p1"];
case "p2":
return ["--grep", "@p2"];
case "all":
return [];
}
}
+70
View File
@@ -0,0 +1,70 @@
import net from "node:net";
export function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function waitForTcpPort(
host: string,
port: number,
timeoutMs = 60_000
): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
while (Date.now() < deadline) {
try {
await new Promise<void>((resolve, reject) => {
const socket = net.createConnection({ host, port });
socket.setTimeout(2000);
socket.once("connect", () => {
socket.end();
resolve();
});
socket.once("timeout", () => {
socket.destroy();
reject(new Error(`timeout waiting for ${host}:${port}`));
});
socket.once("error", reject);
});
return;
} catch (error) {
lastError = error;
await sleep(1000);
}
}
throw new Error(
`timed out waiting for ${host}:${port}: ${
lastError instanceof Error ? lastError.message : String(lastError)
}`
);
}
export async function waitForHttpStatus(
url: string,
expectedStatus = 200,
timeoutMs = 60_000
): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastStatus = 0;
let lastError = "";
while (Date.now() < deadline) {
try {
const response = await fetch(url);
lastStatus = response.status;
if (response.status === expectedStatus) {
return;
}
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
await sleep(1000);
}
throw new Error(
`timed out waiting for ${url}; last status=${lastStatus}; last error=${lastError}`
);
}