cynarAI 5cd1bc906f ci: Product CI merge gate and Autopilot auto-merge (#84)
* ci: add Product CI merge gate and Autopilot auto-merge path

Aggregate lint, typecheck, builds, unit, Postgres journeys, and Web E2E
into a single Product CI check for branch protection. Soft-fail artifact
uploads and skip headless macOS screenshots so infra noise does not block
merge. Add autopilot/auto-merge workflow and MERGE_GATES.md.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(ci): keep autopilot auto-merge inert without label

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(ci): run autopilot auto-merge on self-hosted runner

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(ci): free stale E2E ports before Playwright bind on Linux

Self-hosted evoo-ci runners can retain listeners on harness ports
(3110 API, 5180 web, 4321 marketing, 3111 device control) after a
failed run, causing EADDRINUSE before the next job. Add a guarded
free-e2e-ports.sh that uses fuser on Linux only and never touches
live dev ports 3100/5173. Wire it into the Playwright workflow and
the E2E harness with clear logging.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(web): stabilize message More menu Escape in hover E2E

The desktop hover spec assumed Escape always closed the first page-level
menu within one keypress. On self-hosted Chromium the Copy menu could stay
open while the rail remained visible, failing getByRole('menu') count.

Wait for the Copy item to prove the menu opened, then retry Escape until
that item hides while asserting the More control stays on the hover rail.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(web): retry sign-up navigation in Playwright helpers

Late-suite specs can miss the sign-up heading when Vite is still
warming on self-hosted runners. Retry goto until the form renders
instead of failing once at the default timeout.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-09-24 00:57:10 +02:00

338 lines
12 KiB
TypeScript

import { execSync } from "node:child_process";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { loadRootEnv } from "@rakazo/core/node/load-root-env";
import { PostgreSqlContainer } from "@testcontainers/postgresql";
import type { createApp } from "../../../../apps/api/src/app.ts";
import { runProcess } from "./process.js";
loadRootEnv();
const integration = process.argv.includes("--integration");
const e2e = process.argv.includes("--e2e");
const sandboxArg = process.argv.find((arg) => arg.startsWith("--sandbox="));
const specArg = process.argv.find((arg) => arg.startsWith("--spec="));
const grepArg = process.argv.find((arg) => arg.startsWith("--grep="));
const runtimeArg = process.argv.find((arg) => arg.startsWith("--runtime="));
const sandboxProvider = sandboxArg?.slice("--sandbox=".length) ?? "fake";
const e2eSpec = specArg?.slice("--spec=".length);
const e2eGrep = grepArg?.slice("--grep=".length);
const agentRuntime = runtimeArg?.slice("--runtime=".length) ?? "scripted";
if (Number(integration) + Number(e2e) !== 1) {
throw new Error("Pass exactly one of --integration or --e2e");
}
if (!["fake", "e2b", "daytona", "box"].includes(sandboxProvider)) {
throw new Error('Sandbox must be "fake", "e2b", "daytona", or "box"');
}
if (integration && sandboxProvider !== "fake") {
throw new Error("Integration tests only support the fake sandbox");
}
if (agentRuntime !== "pi" && agentRuntime !== "scripted") {
throw new Error('Runtime must be "pi" or "scripted"');
}
if (sandboxProvider === "e2b" && !process.env.E2B_API_KEY) {
throw new Error("E2B_API_KEY is required when --sandbox=e2b");
}
if (sandboxProvider === "daytona" && !process.env.DAYTONA_API_KEY) {
throw new Error("DAYTONA_API_KEY is required when --sandbox=daytona");
}
if (sandboxProvider === "box" && !process.env.BOX_API_KEY) {
throw new Error("BOX_API_KEY is required when --sandbox=box");
}
async function main() {
const mode = integration ? "integration" : "e2e";
const reportDir = path.resolve("test-report", mode);
await mkdir(reportDir, { recursive: true });
const container = await new PostgreSqlContainer("postgres:16-alpine").start();
try {
const databaseUrl = container.getConnectionUri();
const apiPort = Number(process.env.API_PORT ?? 3110);
const webPort = Number(process.env.WEB_PORT ?? 5180);
const webOrigin = `http://127.0.0.1:${webPort}`;
process.env.DATABASE_URL = databaseUrl;
process.env.REALTIME_DATABASE_URL = databaseUrl;
process.env.VERIFY_DATABASE = "1";
process.env.WAKEUP_DRIVER = "memory";
process.env.SANDBOX_PROVIDER = sandboxProvider;
process.env.AGENT_RUNTIME = agentRuntime;
// Playwright/E2E force the offline cloud-agent emulator; clear Cursor keys so cards never hit a live VM.
process.env.CLOUD_AGENT_PROVIDER = "emulator";
delete process.env.CURSOR_API_KEY;
process.env.COMPOSIO_API_KEY = "";
process.env.BETTER_AUTH_SECRET = "test-secret-test-secret-32chars!";
process.env.ENCRYPTION_KEY = "test-encryption-key-test-encryption-key";
process.env.SANDBOX_SUPERVISOR_TOKEN = "test-supervisor-token-test-32chars";
process.env.SCREEN_PROXY_SECRET = "test-screen-proxy-secret-test-32chars";
process.env.BETTER_AUTH_URL = webOrigin;
process.env.WEB_ORIGIN = webOrigin;
process.env.API_PORT = String(apiPort);
process.env.API_URL = `http://127.0.0.1:${apiPort}`;
process.env.API_PROXY_TARGET = `http://127.0.0.1:${apiPort}`;
process.env.WEB_PORT = String(webPort);
process.env.PLAYWRIGHT_BASE_URL = webOrigin;
process.env.DATA_DIR = path.join(reportDir, "data");
process.env.SIGNUPS_ENABLED = "true";
process.env.SIGNUP_ALLOWLIST = "";
process.env.CI = "1";
execSync("pnpm --filter @rakazo/db generate", { stdio: "inherit", env: process.env });
execSync("pnpm --filter @rakazo/db exec prisma migrate deploy", {
stdio: "inherit",
env: process.env,
cwd: path.resolve("packages/db"),
});
if (e2e && process.platform === "linux") {
execSync("bash scripts/free-e2e-ports.sh", { stdio: "inherit", env: process.env });
}
if (integration) {
const suites = [
"packages/testkit/src/pi-offline.postgres.test.ts",
"packages/testkit/src/computer-approval.postgres.test.ts",
"packages/testkit/src/eval-history.postgres.test.ts",
"packages/testkit/src/eval-customer-support.postgres.test.ts",
"packages/testkit/src/journeys.test.ts",
"packages/testkit/src/authorization.test.ts",
"packages/testkit/src/attachments.test.ts",
"packages/testkit/src/voice.test.ts",
"packages/testkit/src/search.test.ts",
"packages/testkit/src/executor-lifecycle.test.ts",
"packages/testkit/src/connections.test.ts",
"packages/testkit/src/bot-secrets.test.ts",
"packages/db/src/space-membership.postgres.test.ts",
"packages/db/src/messaging.postgres.test.ts",
"packages/memory/src/commit.postgres.test.ts",
"packages/adapters/src/wakeup.postgres.test.ts",
"packages/adapters/src/realtime.postgres.test.ts",
"packages/adapters/src/job-reconciler.postgres.test.ts",
"packages/adapters/src/cloud-agent.postgres.test.ts",
];
// Each app reconciles all durable work in its database, including intentionally
// unfinished fixture runs. Clone the pristine migrated schema so one suite
// cannot execute another suite's backlog or wait for it during shutdown.
const template = container.getDatabase().replaceAll('"', '""');
const databaseCommand = async (statement: string) => {
const result = await container.exec([
"psql",
"-U",
container.getUsername(),
"-d",
"postgres",
"-v",
"ON_ERROR_STOP=1",
"-c",
statement,
]);
if (result.exitCode !== 0)
throw new Error("Isolated integration database operation failed");
};
for (const [index, suite] of suites.entries()) {
const database = `integration_${index}`;
await databaseCommand(`CREATE DATABASE "${database}" TEMPLATE "${template}"`);
const suiteUrl = new URL(databaseUrl);
suiteUrl.pathname = `/${database}`;
try {
await runProcess("pnpm", ["exec", "vitest", "run", suite], {
...process.env,
DATABASE_URL: suiteUrl.toString(),
REALTIME_DATABASE_URL: suiteUrl.toString(),
OPENROUTER_API_KEY: "",
MODEL_API_KEY: "",
});
} finally {
await databaseCommand(`DROP DATABASE IF EXISTS "${database}" WITH (FORCE)`);
}
}
await writeSummary(reportDir, {
ok: true,
mode,
sandbox: process.env.SANDBOX_PROVIDER,
runtime: process.env.AGENT_RUNTIME,
});
return;
}
const [
{ ComposioEmulator, EmailEmulator, PipedreamConnector, ThirdPartyConnectorEmulator },
{ createApp },
] = await Promise.all([import("@rakazo/adapters"), import("../../../../apps/api/src/app.ts")]);
const { serve } = await import("@hono/node-server");
const thirdParties = new ThirdPartyConnectorEmulator();
const pipedream = new PipedreamConnector(
{
clientId: "fake-client-id",
clientSecret: "fake-client-secret",
projectId: "fake-project-id",
environment: "development",
identitySecret: process.env.ENCRYPTION_KEY,
},
{ fetch: thirdParties.fetch, resolveHostname: thirdParties.resolveHostname },
);
const email = new EmailEmulator();
const handles = await createApp({
databaseUrl,
prisma: undefined,
composio: new ComposioEmulator(),
pipedream,
email,
remoteConnectors: {
fetch: thirdParties.fetch,
resolveHostname: thirdParties.resolveHostname,
},
integrationsCatalogUrl: "https://catalog.example.test/",
});
let activeRequests = 0;
const requestWaiters = new Set<() => void>();
const server = serve({
fetch: async (request) => {
if (new URL(request.url).pathname === "/__e2e/emails") {
return Response.json(email.sent, { headers: { "cache-control": "no-store" } });
}
activeRequests += 1;
try {
return await handles.app.fetch(request);
} finally {
activeRequests -= 1;
if (activeRequests === 0) {
for (const resolve of requestWaiters) resolve();
requestWaiters.clear();
}
}
},
port: apiPort,
hostname: "127.0.0.1",
});
await waitForHealth(`http://127.0.0.1:${apiPort}/health`, 15_000);
try {
execSync("rm -rf apps/web/node_modules/.vite node_modules/.vite", {
stdio: "inherit",
env: process.env,
});
try {
await runProcess(
"pnpm",
[
"--filter",
"@rakazo/web",
"exec",
"playwright",
"test",
...(e2eSpec ? [e2eSpec] : []),
...(e2eGrep ? ["--grep", e2eGrep] : []),
],
{
...process.env,
CI: "1",
// Pin English so e2e selectors match source messages regardless of runner locale.
VITE_DEFAULT_UI_LOCALE: "en",
},
);
} catch (error) {
const failedRuns = await handles.prisma.run.findMany({
where: { status: "failed" },
select: { id: true, error: true },
});
if (failedRuns.length) console.error("Failed agent runs:", failedRuns);
throw error;
}
await writeSummary(reportDir, {
ok: true,
mode,
sandbox: process.env.SANDBOX_PROVIDER,
runtime: process.env.AGENT_RUNTIME,
apiPort,
webPort,
});
} finally {
const cleanupErrors: unknown[] = [];
const computers = await managedComputers(handles).catch((error) => {
cleanupErrors.push(error);
return [];
});
await new Promise<void>((resolve) => server.close(() => resolve()));
if (activeRequests > 0) {
await new Promise<void>((resolve) => requestWaiters.add(resolve));
}
await handles.stop().catch(() => undefined);
for (let index = 0; index < computers.length; index += 4) {
const results = await Promise.allSettled(
computers.slice(index, index + 4).map((computer) =>
handles.sandbox.destroy(
{
id: computer.providerRef!,
botId: computer.homeKey,
kind: computer.kind as "e2b" | "daytona" | "box",
providerRef: computer.providerRef!,
},
{
operationId: "e2e-cleanup",
traceId: "e2e-cleanup",
spaceId: computer.spaceId,
userId: computer.userId,
signal: new AbortController().signal,
},
),
),
);
for (const result of results) {
if (result.status === "rejected") cleanupErrors.push(result.reason);
}
}
if (cleanupErrors.length) {
console.error(
new AggregateError(cleanupErrors, "Could not destroy every managed test sandbox"),
);
process.exitCode = 1;
}
}
} finally {
await container.stop().catch(() => undefined);
}
}
type AppHandles = Awaited<ReturnType<typeof createApp>>;
async function managedComputers(handles: AppHandles) {
if (!["e2b", "daytona", "box"].includes(sandboxProvider)) return [];
return handles.prisma.computer.findMany({
where: { providerRef: { not: null } },
select: { homeKey: true, kind: true, providerRef: true, userId: true, spaceId: true },
});
}
async function writeSummary(reportDir: string, summary: Record<string, unknown>) {
await writeFile(
path.join(reportDir, "summary.json"),
JSON.stringify({ ...summary, at: new Date().toISOString() }, null, 2),
);
}
async function waitForHealth(url: string, ms: number) {
const start = Date.now();
let last = "";
while (Date.now() - start < ms) {
try {
const res = await fetch(url);
if (res.ok) return;
last = `${res.status}`;
} catch (error) {
last = error instanceof Error ? error.message : String(error);
}
await new Promise((r) => setTimeout(r, 200));
}
throw new Error(`API health check failed for ${url}: ${last}`);
}
main().then(
() => process.exit(process.exitCode ?? 0),
(error) => {
console.error(error);
process.exit(1);
},
);