chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
docker compose -f docker/docker-compose.e2e.yml down --remove-orphans
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
docker compose -f docker/docker-compose.e2e.yml up -d mysql
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/);
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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"]
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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"]
|
||||
);
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user