mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
Accept artifact-only publication for experimental Claws so ClawHub can attest, retry, and serve the exact stored bytes. Preserve exact actor, owner, and digest identity across staged retries and validate current release state before reuse. Add durable contract documentation and real-stack publish, poll, download, and retry proof.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
622 lines
20 KiB
TypeScript
622 lines
20 KiB
TypeScript
#!/usr/bin/env bun
|
|
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
|
import { generateKeyPairSync } from "node:crypto";
|
|
import {
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
renameSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { createServer } from "node:net";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import {
|
|
buildLocalAuthTrendingSnapshotArgs,
|
|
resolveLocalAuthDeployment,
|
|
resolveLocalAuthRunnerConfig,
|
|
} from "./playwright-local-auth-config";
|
|
|
|
const DEFAULT_DEV_AUTH_CONVEX_DEPLOYMENT = "anonymous:anonymous-agent";
|
|
const DEFAULT_PLAYWRIGHT_PORT = 4173;
|
|
const DEFAULT_E2E_WORKER_TOKEN = "local-e2e-worker-token";
|
|
const DEFAULT_START_TIMEOUT_MS = 300_000;
|
|
const DEFAULT_REACHABILITY_REQUEST_TIMEOUT_MS = 5_000;
|
|
const STOP_TIMEOUT_MS = 30_000;
|
|
const FUNCTION_READY_TIMEOUT_MS = 120_000;
|
|
const POLL_MS = 500;
|
|
const LOCAL_CONVEX_STATE_DIR = ".convex/local/default";
|
|
const LOCAL_ENV_FILE = ".env.local";
|
|
const START_TIMEOUT_MS = readPositiveIntegerEnv(
|
|
"PLAYWRIGHT_WEB_SERVER_TIMEOUT_MS",
|
|
DEFAULT_START_TIMEOUT_MS,
|
|
);
|
|
const REACHABILITY_REQUEST_TIMEOUT_MS = readPositiveIntegerEnv(
|
|
"PLAYWRIGHT_WEB_SERVER_REQUEST_TIMEOUT_MS",
|
|
DEFAULT_REACHABILITY_REQUEST_TIMEOUT_MS,
|
|
);
|
|
|
|
type LocalDeploymentConfig = {
|
|
adminKey: string;
|
|
deploymentName: string;
|
|
};
|
|
|
|
const managedChildren = new Set<ChildProcess>();
|
|
const tempDir = mkdtempSync(join(tmpdir(), "clawhub-pw-local-auth-"));
|
|
const envFile = join(tempDir, ".env.local");
|
|
const emailCaptureFile = join(tempDir, "emails.jsonl");
|
|
const localConvexStateBackupDir = join(tempDir, "convex-local-default.backup");
|
|
const localEnvBackupFile = join(tempDir, ".env.local.backup");
|
|
let backedUpLocalConvexState = false;
|
|
let backedUpLocalEnvFile = false;
|
|
let isolatedLocalState = false;
|
|
let activeConvexUrl: string | null = null;
|
|
let activePreviewUrl: string | null = null;
|
|
|
|
function sleep(ms: number) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function readPositiveIntegerEnv(name: string, fallback: number) {
|
|
const raw = process.env[name];
|
|
if (!raw) return fallback;
|
|
const value = Number(raw);
|
|
if (!Number.isInteger(value) || value <= 0) {
|
|
throw new Error(`${name} must be a positive integer number of milliseconds.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
async function checkReachable(url: string) {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), REACHABILITY_REQUEST_TIMEOUT_MS);
|
|
try {
|
|
const response = await fetch(url, {
|
|
method: "GET",
|
|
signal: controller.signal,
|
|
});
|
|
return {
|
|
detail: `HTTP ${response.status}`,
|
|
reachable: response.status < 500,
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof Error && error.name === "AbortError") {
|
|
return {
|
|
detail: `request timed out after ${REACHABILITY_REQUEST_TIMEOUT_MS}ms`,
|
|
reachable: false,
|
|
};
|
|
}
|
|
return {
|
|
detail: error instanceof Error ? error.message : String(error),
|
|
reachable: false,
|
|
};
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
async function isReachable(url: string) {
|
|
return (await checkReachable(url)).reachable;
|
|
}
|
|
|
|
async function waitUntilReachable(url: string, label: string, child?: ChildProcess) {
|
|
const startedAt = Date.now();
|
|
let lastDetail = "not checked";
|
|
while (Date.now() - startedAt < START_TIMEOUT_MS) {
|
|
const result = await checkReachable(url);
|
|
lastDetail = result.detail;
|
|
if (result.reachable) return;
|
|
if (child && (child.exitCode !== null || child.signalCode !== null)) {
|
|
throw new Error(
|
|
`${label} exited before it became reachable at ${url} (exit=${child.exitCode ?? "null"}, signal=${child.signalCode ?? "null"}, last=${lastDetail}).`,
|
|
);
|
|
}
|
|
await sleep(POLL_MS);
|
|
}
|
|
throw new Error(`${label} did not become reachable at ${url} (last=${lastDetail}).`);
|
|
}
|
|
|
|
async function waitUntilUnreachable(url: string, label: string) {
|
|
const startedAt = Date.now();
|
|
while (Date.now() - startedAt < STOP_TIMEOUT_MS) {
|
|
if (!(await isReachable(url))) return;
|
|
await sleep(POLL_MS);
|
|
}
|
|
throw new Error(`${label} did not stop serving at ${url}.`);
|
|
}
|
|
|
|
function canListen(port: number) {
|
|
return new Promise<boolean>((resolve) => {
|
|
const server = createServer();
|
|
server.once("error", () => resolve(false));
|
|
server.once("listening", () => {
|
|
server.close(() => resolve(true));
|
|
});
|
|
server.listen(port, "127.0.0.1");
|
|
});
|
|
}
|
|
|
|
async function resolveAppPort() {
|
|
const requested = Number(process.env.PLAYWRIGHT_PORT ?? DEFAULT_PLAYWRIGHT_PORT);
|
|
if (!Number.isInteger(requested) || requested <= 0) {
|
|
throw new Error(`Invalid PLAYWRIGHT_PORT: ${process.env.PLAYWRIGHT_PORT}`);
|
|
}
|
|
|
|
if (process.env.PLAYWRIGHT_PORT) {
|
|
if (!(await canListen(requested))) {
|
|
throw new Error(`PLAYWRIGHT_PORT ${requested} is already in use.`);
|
|
}
|
|
return requested;
|
|
}
|
|
|
|
for (let port = requested; port < requested + 50; port += 1) {
|
|
if (await canListen(port)) return port;
|
|
}
|
|
throw new Error(`No available preview port found starting at ${requested}.`);
|
|
}
|
|
|
|
function buildAuthKeys() {
|
|
const { publicKey, privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
|
const privatePem = privateKey.export({ type: "pkcs8", format: "pem" });
|
|
const publicJwk = publicKey.export({ format: "jwk" });
|
|
return {
|
|
JWT_PRIVATE_KEY: privatePem.trimEnd().replace(/\n/g, " "),
|
|
JWKS: JSON.stringify({ keys: [{ use: "sig", ...publicJwk }] }),
|
|
};
|
|
}
|
|
|
|
function readLocalDeploymentConfig(): LocalDeploymentConfig | null {
|
|
try {
|
|
const raw = readFileSync(".convex/local/default/config.json", "utf8");
|
|
const parsed = JSON.parse(raw) as { adminKey?: unknown; deploymentName?: unknown };
|
|
return typeof parsed.adminKey === "string" &&
|
|
parsed.adminKey &&
|
|
typeof parsed.deploymentName === "string" &&
|
|
parsed.deploymentName
|
|
? { adminKey: parsed.adminKey, deploymentName: parsed.deploymentName }
|
|
: null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function readLocalDeployment() {
|
|
const config = readLocalDeploymentConfig();
|
|
if (!config) return null;
|
|
return config.deploymentName.startsWith("anonymous-")
|
|
? `anonymous:${config.deploymentName}`
|
|
: `local:${config.deploymentName}`;
|
|
}
|
|
|
|
function devAuthDeploymentMarker(deployment: string) {
|
|
return deployment.startsWith("anonymous-") ? `anonymous:${deployment}` : deployment;
|
|
}
|
|
|
|
function getLocalUrlPort(url: string, label: string) {
|
|
const parsed = new URL(url);
|
|
const isLocalhost =
|
|
parsed.hostname === "127.0.0.1" ||
|
|
parsed.hostname === "localhost" ||
|
|
parsed.hostname === "::1" ||
|
|
parsed.hostname === "[::1]";
|
|
if (!isLocalhost) {
|
|
throw new Error(`${label} must be a localhost URL for the local-auth runner: ${url}`);
|
|
}
|
|
|
|
const port = Number(parsed.port);
|
|
if (!Number.isInteger(port) || port <= 0) {
|
|
throw new Error(`${label} must include an explicit port: ${url}`);
|
|
}
|
|
return port;
|
|
}
|
|
|
|
function spawnManaged(command: string, args: string[], env: NodeJS.ProcessEnv) {
|
|
const child = spawn(command, args, {
|
|
cwd: process.cwd(),
|
|
detached: process.platform !== "win32",
|
|
env,
|
|
stdio: "inherit",
|
|
});
|
|
managedChildren.add(child);
|
|
child.once("exit", () => managedChildren.delete(child));
|
|
return child;
|
|
}
|
|
|
|
function signalManagedChild(child: ChildProcess, signal: NodeJS.Signals) {
|
|
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
|
|
try {
|
|
if (process.platform !== "win32" && child.pid) {
|
|
process.kill(-child.pid, signal);
|
|
return;
|
|
}
|
|
child.kill(signal);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ESRCH") {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
function waitForChildExit(child: ChildProcess, timeoutMs = 5_000) {
|
|
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve();
|
|
|
|
return new Promise<void>((resolve) => {
|
|
const timeout = setTimeout(resolve, timeoutMs);
|
|
child.once("exit", () => {
|
|
clearTimeout(timeout);
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
async function stopManagedChildren() {
|
|
const children = Array.from(managedChildren);
|
|
for (const child of children) signalManagedChild(child, "SIGTERM");
|
|
await Promise.all(children.map((child) => waitForChildExit(child)));
|
|
for (const child of children) signalManagedChild(child, "SIGKILL");
|
|
await Promise.all(children.map((child) => waitForChildExit(child)));
|
|
}
|
|
|
|
async function stopManagedChild(child: ChildProcess) {
|
|
signalManagedChild(child, "SIGTERM");
|
|
await waitForChildExit(child);
|
|
signalManagedChild(child, "SIGKILL");
|
|
await waitForChildExit(child);
|
|
}
|
|
|
|
function isolateLocalState() {
|
|
isolatedLocalState = true;
|
|
|
|
if (existsSync(LOCAL_CONVEX_STATE_DIR)) {
|
|
renameSync(LOCAL_CONVEX_STATE_DIR, localConvexStateBackupDir);
|
|
backedUpLocalConvexState = true;
|
|
}
|
|
|
|
if (existsSync(LOCAL_ENV_FILE)) {
|
|
renameSync(LOCAL_ENV_FILE, localEnvBackupFile);
|
|
backedUpLocalEnvFile = true;
|
|
}
|
|
}
|
|
|
|
function restoreLocalState() {
|
|
if (!isolatedLocalState) return;
|
|
|
|
rmSync(LOCAL_CONVEX_STATE_DIR, { force: true, recursive: true });
|
|
if (backedUpLocalConvexState) {
|
|
mkdirSync(".convex/local", { recursive: true });
|
|
renameSync(localConvexStateBackupDir, LOCAL_CONVEX_STATE_DIR);
|
|
}
|
|
|
|
rmSync(LOCAL_ENV_FILE, { force: true });
|
|
if (backedUpLocalEnvFile) {
|
|
renameSync(localEnvBackupFile, LOCAL_ENV_FILE);
|
|
}
|
|
}
|
|
|
|
async function cleanup() {
|
|
try {
|
|
await stopManagedChildren();
|
|
await Promise.all([
|
|
activePreviewUrl
|
|
? waitUntilUnreachable(activePreviewUrl, "Preview server")
|
|
: Promise.resolve(),
|
|
activeConvexUrl ? waitUntilUnreachable(activeConvexUrl, "Local Convex") : Promise.resolve(),
|
|
]);
|
|
} finally {
|
|
restoreLocalState();
|
|
rmSync(tempDir, { force: true, recursive: true });
|
|
}
|
|
}
|
|
|
|
function runRequired(command: string, args: string[], env: NodeJS.ProcessEnv) {
|
|
const result = spawnSync(command, args, {
|
|
cwd: process.cwd(),
|
|
env,
|
|
stdio: "inherit",
|
|
});
|
|
if ((result.status ?? 1) !== 0) {
|
|
throw new Error(`${command} ${args.join(" ")} failed with exit code ${result.status ?? 1}.`);
|
|
}
|
|
}
|
|
|
|
function runBuffered(command: string, args: string[], env: NodeJS.ProcessEnv) {
|
|
const result = spawnSync(command, args, {
|
|
cwd: process.cwd(),
|
|
encoding: "utf8",
|
|
env,
|
|
maxBuffer: 10 * 1024 * 1024,
|
|
});
|
|
return {
|
|
output: `${result.stdout ?? ""}${result.stderr ?? ""}`,
|
|
status: result.status ?? 1,
|
|
};
|
|
}
|
|
|
|
async function startLocalConvex(args: string[], env: NodeJS.ProcessEnv, convexUrl: string) {
|
|
const maxAttempts = 3;
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
const child = spawnManaged("bunx", args, env);
|
|
try {
|
|
await waitUntilReachable(convexUrl, "Local Convex", child);
|
|
return;
|
|
} catch (error) {
|
|
await stopManagedChild(child);
|
|
rmSync(LOCAL_CONVEX_STATE_DIR, { force: true, recursive: true });
|
|
if (attempt >= maxAttempts) throw error;
|
|
console.log(
|
|
`Local Convex did not start cleanly on attempt ${attempt}; retrying with fresh isolated state...`,
|
|
);
|
|
await sleep(2_000 * attempt);
|
|
}
|
|
}
|
|
}
|
|
|
|
function isFunctionUnavailableOutput(output: string) {
|
|
return (
|
|
output.includes("Could not find function for") &&
|
|
output.includes("Did you forget to run `npx convex dev`")
|
|
);
|
|
}
|
|
|
|
function isLocalConvexModuleStillPreparingOutput(output: string) {
|
|
return (
|
|
output.includes("InvalidModules") &&
|
|
((output.includes("ENOENT: no such file or directory") && output.includes("/modules/")) ||
|
|
(output.includes("Cannot find module") && output.includes("/modules/_deps/node/")))
|
|
);
|
|
}
|
|
|
|
function isFunctionReadinessRetryableOutput(output: string) {
|
|
return (
|
|
isFunctionUnavailableOutput(output) ||
|
|
isLocalConvexModuleStillPreparingOutput(output) ||
|
|
output.includes("Function execution timed out (maximum duration: 1s)")
|
|
);
|
|
}
|
|
|
|
async function setLocalConvexEnv(
|
|
convexUrl: string,
|
|
changes: Array<{ name: string; value: string }>,
|
|
) {
|
|
const config = readLocalDeploymentConfig();
|
|
if (!config) {
|
|
throw new Error(
|
|
"Local Convex config was not found at .convex/local/default/config.json after startup.",
|
|
);
|
|
}
|
|
|
|
const response = await fetch(new URL("/api/update_environment_variables", convexUrl), {
|
|
body: JSON.stringify({ changes }),
|
|
headers: {
|
|
Authorization: `Convex ${config.adminKey}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
method: "POST",
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`Failed to configure local Convex environment: ${response.status} ${await response.text()}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function runConvexFunctionWhenReady(
|
|
functionName: string,
|
|
args: Record<string, unknown>,
|
|
env: NodeJS.ProcessEnv,
|
|
options: { push?: boolean } = {},
|
|
) {
|
|
const startedAt = Date.now();
|
|
while (true) {
|
|
const result = runBuffered(
|
|
"bunx",
|
|
[
|
|
"convex",
|
|
"run",
|
|
...(options.push ? ["--push"] : []),
|
|
"--typecheck",
|
|
"disable",
|
|
"--codegen",
|
|
"disable",
|
|
functionName,
|
|
JSON.stringify(args),
|
|
],
|
|
env,
|
|
);
|
|
|
|
if (result.status === 0) {
|
|
if (result.output) process.stdout.write(result.output);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
!isFunctionReadinessRetryableOutput(result.output) ||
|
|
Date.now() - startedAt >= FUNCTION_READY_TIMEOUT_MS
|
|
) {
|
|
if (result.output) process.stdout.write(result.output);
|
|
throw new Error(`Convex function ${functionName} failed with exit code ${result.status}.`);
|
|
}
|
|
|
|
console.log(`Convex function ${functionName} is not ready yet; retrying...`);
|
|
await sleep(POLL_MS);
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
if (!existsSync("node_modules/.bin/vite")) {
|
|
console.log("Installing dependencies for the Playwright local-auth e2e runner...");
|
|
runRequired("bun", ["install", "--frozen-lockfile"], process.env);
|
|
}
|
|
|
|
const runnerConfig = resolveLocalAuthRunnerConfig(process.env, process.argv.slice(2));
|
|
const appPort = await resolveAppPort();
|
|
const appUrl = `http://127.0.0.1:${appPort}`;
|
|
const previewReadyUrl = new URL("/robots.txt", appUrl).toString();
|
|
const convexUrl = runnerConfig.convexUrl;
|
|
const convexSiteUrl = runnerConfig.convexSiteUrl;
|
|
activePreviewUrl = previewReadyUrl;
|
|
const convexCloudPort = String(getLocalUrlPort(convexUrl, "PLAYWRIGHT_LOCAL_AUTH_CONVEX_URL"));
|
|
const convexSitePort = String(
|
|
getLocalUrlPort(convexSiteUrl, "PLAYWRIGHT_LOCAL_AUTH_CONVEX_SITE_URL"),
|
|
);
|
|
if (await isReachable(convexUrl)) {
|
|
throw new Error(
|
|
`Local Convex is already reachable at ${convexUrl}. Stop the running local Convex process before running this e2e so it can use isolated disposable state.`,
|
|
);
|
|
}
|
|
|
|
isolateLocalState();
|
|
|
|
const authKeys = buildAuthKeys();
|
|
const deployment = resolveLocalAuthDeployment(
|
|
runnerConfig.convexDeployment,
|
|
readLocalDeployment(),
|
|
);
|
|
const e2eEnv: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
AUTH_GITHUB_ID: process.env.AUTH_GITHUB_ID ?? "local-dev",
|
|
AUTH_GITHUB_SECRET: process.env.AUTH_GITHUB_SECRET ?? "local-dev",
|
|
CLAWHUB_DISABLE_CRONS: "1",
|
|
CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "test",
|
|
CLAWHUB_EMAIL_CAPTURE_FILE: process.env.CLAWHUB_EMAIL_CAPTURE_FILE ?? emailCaptureFile,
|
|
CONVEX_AGENT_MODE: process.env.CONVEX_AGENT_MODE ?? "anonymous",
|
|
CONVEX_SITE_URL: convexSiteUrl,
|
|
DEV_AUTH_ENABLED: "1",
|
|
JWKS: authKeys.JWKS,
|
|
JWT_PRIVATE_KEY: authKeys.JWT_PRIVATE_KEY,
|
|
SECURITY_SCAN_DEFAULT_VT_WAIT_MS: "0",
|
|
SECURITY_SCAN_WORKER_TOKEN: process.env.SECURITY_SCAN_WORKER_TOKEN ?? DEFAULT_E2E_WORKER_TOKEN,
|
|
SITE_URL: appUrl,
|
|
VITE_CONVEX_SITE_URL: convexSiteUrl,
|
|
VITE_CONVEX_URL: convexUrl,
|
|
VITE_ENABLE_DEV_AUTH: "1",
|
|
VITE_SITE_URL: appUrl,
|
|
};
|
|
if (deployment) {
|
|
e2eEnv.CONVEX_DEPLOYMENT = deployment;
|
|
e2eEnv.DEV_AUTH_CONVEX_DEPLOYMENT = devAuthDeploymentMarker(deployment);
|
|
}
|
|
|
|
writeFileSync(
|
|
envFile,
|
|
[
|
|
`AUTH_GITHUB_ID=${e2eEnv.AUTH_GITHUB_ID}`,
|
|
`AUTH_GITHUB_SECRET=${e2eEnv.AUTH_GITHUB_SECRET}`,
|
|
"CLAWHUB_DISABLE_CRONS=1",
|
|
"CLAWHUB_EXPERIMENTAL_CLAWS=1",
|
|
"CLAWHUB_SKILLS_SH_ROLLOUT_MODE=test",
|
|
`CLAWHUB_EMAIL_CAPTURE_FILE=${e2eEnv.CLAWHUB_EMAIL_CAPTURE_FILE}`,
|
|
...(deployment ? [`CONVEX_DEPLOYMENT=${deployment}`] : []),
|
|
`CONVEX_SITE_URL=${convexSiteUrl}`,
|
|
...(deployment ? [`DEV_AUTH_CONVEX_DEPLOYMENT=${devAuthDeploymentMarker(deployment)}`] : []),
|
|
"DEV_AUTH_ENABLED=1",
|
|
`JWKS=${authKeys.JWKS}`,
|
|
`JWT_PRIVATE_KEY=${authKeys.JWT_PRIVATE_KEY}`,
|
|
"SECURITY_SCAN_DEFAULT_VT_WAIT_MS=0",
|
|
`SECURITY_SCAN_WORKER_TOKEN=${e2eEnv.SECURITY_SCAN_WORKER_TOKEN}`,
|
|
`SITE_URL=${appUrl}`,
|
|
`VITE_CONVEX_SITE_URL=${convexSiteUrl}`,
|
|
`VITE_CONVEX_URL=${convexUrl}`,
|
|
"VITE_ENABLE_DEV_AUTH=1",
|
|
`VITE_SITE_URL=${appUrl}`,
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
|
|
console.log(`Starting local Convex at ${convexUrl} with isolated e2e state.`);
|
|
await startLocalConvex(
|
|
[
|
|
"convex",
|
|
"dev",
|
|
"--env-file",
|
|
envFile,
|
|
"--typecheck",
|
|
"disable",
|
|
"--codegen",
|
|
"disable",
|
|
"--local-cloud-port",
|
|
convexCloudPort,
|
|
"--local-site-port",
|
|
convexSitePort,
|
|
],
|
|
e2eEnv,
|
|
convexUrl,
|
|
);
|
|
activeConvexUrl = convexUrl;
|
|
|
|
console.log("Configuring local Convex environment for local-auth Playwright e2e.");
|
|
const localAuthDeployment =
|
|
readLocalDeployment() ?? deployment ?? DEFAULT_DEV_AUTH_CONVEX_DEPLOYMENT;
|
|
e2eEnv.CONVEX_DEPLOYMENT = localAuthDeployment;
|
|
e2eEnv.DEV_AUTH_CONVEX_DEPLOYMENT = localAuthDeployment;
|
|
await setLocalConvexEnv(convexUrl, [
|
|
{ name: "AUTH_GITHUB_ID", value: e2eEnv.AUTH_GITHUB_ID ?? "local-dev" },
|
|
{ name: "AUTH_GITHUB_SECRET", value: e2eEnv.AUTH_GITHUB_SECRET ?? "local-dev" },
|
|
{ name: "CLAWHUB_DISABLE_CRONS", value: "1" },
|
|
{ name: "CLAWHUB_EXPERIMENTAL_CLAWS", value: "1" },
|
|
{ name: "CLAWHUB_SKILLS_SH_ROLLOUT_MODE", value: "test" },
|
|
{ name: "CLAWHUB_EMAIL_CAPTURE_FILE", value: e2eEnv.CLAWHUB_EMAIL_CAPTURE_FILE ?? "" },
|
|
{ name: "CLAWHUB_STAGED_PREPUBLICATION_PUBLISHES", value: "1" },
|
|
{ name: "DEV_AUTH_CONVEX_DEPLOYMENT", value: localAuthDeployment },
|
|
{ name: "DEV_AUTH_ENABLED", value: "1" },
|
|
{ name: "JWKS", value: authKeys.JWKS },
|
|
{ name: "JWT_PRIVATE_KEY", value: authKeys.JWT_PRIVATE_KEY },
|
|
{ name: "SECURITY_SCAN_DEFAULT_VT_WAIT_MS", value: "0" },
|
|
{
|
|
name: "SECURITY_SCAN_WORKER_TOKEN",
|
|
value: e2eEnv.SECURITY_SCAN_WORKER_TOKEN ?? DEFAULT_E2E_WORKER_TOKEN,
|
|
},
|
|
{ name: "SITE_URL", value: appUrl },
|
|
]);
|
|
|
|
console.log("Waiting for local Convex functions.");
|
|
await runConvexFunctionWhenReady("appMeta:getDeploymentInfo", {}, e2eEnv);
|
|
|
|
console.log("Materializing an empty canonical Trending snapshot for the isolated runtime.");
|
|
const trendingSnapshot = buildLocalAuthTrendingSnapshotArgs(Date.now());
|
|
await runConvexFunctionWhenReady(
|
|
"canonicalTrending:startSnapshotInternal",
|
|
trendingSnapshot.start,
|
|
e2eEnv,
|
|
);
|
|
await runConvexFunctionWhenReady(
|
|
"canonicalTrending:finalizeSnapshotInternal",
|
|
trendingSnapshot.finalize,
|
|
e2eEnv,
|
|
);
|
|
|
|
console.log("Building ClawHub for local-auth Playwright e2e.");
|
|
runRequired("bun", ["run", "build"], e2eEnv);
|
|
|
|
console.log(`Starting preview server at ${appUrl}.`);
|
|
const previewProcess = spawnManaged(
|
|
"bun",
|
|
["run", "preview", "--", "--host", "127.0.0.1", "--port", String(appPort)],
|
|
e2eEnv,
|
|
);
|
|
await waitUntilReachable(previewReadyUrl, "Preview server", previewProcess);
|
|
|
|
runRequired("bunx", ["playwright", "test", ...runnerConfig.playwrightArgs], {
|
|
...e2eEnv,
|
|
PLAYWRIGHT_BASE_URL: appUrl,
|
|
PLAYWRIGHT_WORKERS: "1",
|
|
});
|
|
}
|
|
|
|
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
|
process.once(signal, () => {
|
|
void cleanup().finally(() => {
|
|
process.exit(signal === "SIGINT" ? 130 : 143);
|
|
});
|
|
});
|
|
}
|
|
|
|
try {
|
|
await main();
|
|
} finally {
|
|
await cleanup();
|
|
}
|