Files
PaperClipAI/tests/e2e/smoke-lab.shared.ts
T
DottaandPaperclip 39eafad47d test(e2e): shorten and split Smoke Lab coverage (#12506)
## Thinking Path

> - Paperclip uses browser tests to protect critical operator flows.
> - The trusted pull request workflow runs the E2E catalog on three
existing runners.
> - Smoke Lab was one 168-second spec, so the shard scheduler could not
divide it.
> - The spec also repeated service-start calls, page loads, and
full-page screenshots.
> - This pull request removes that repeated work and divides the
scenario catalog into two independent specs.
> - The benefit is a shorter Smoke Lab run and a balanced E2E lane
without more AWS capacity.

## Linked Issues or Issue Description

Refs: #10629

**What existing behavior does this improve?**

This improves the trusted pull request E2E lane and its Smoke Lab
Playwright coverage.

**Current behavior**

Smoke Lab is one indivisible 168-second CI spec. It starts services for
every scenario, loads the same evidence page twice, and captures a
full-page success screenshot for all 56 lifecycle steps.

**Proposed behavior**

Start Smoke Lab services once per spec. Keep the per-scenario fixture
reset. Capture one representative success screenshot per scenario and
keep every failure screenshot. Run P1–P4 and P5–P7 as separate specs so
the existing duration-aware scheduler can put them on different runners.

**Reason and benefit**

The optimized lifecycle reduced local Smoke Lab wall time from 57.68
seconds to 37.04 seconds. This is a 35.8% reduction. The two halves also
let the existing three runners target about 125, 124, and 124 seconds of
recorded spec work instead of about 168, 125, and 124 seconds.

**Breaking changes**

None. The same seven scenarios and eight lifecycle steps still run. The
result API still records every step. Successful non-connect steps no
longer attach redundant screenshots.

## What Changed

- Reused one Smoke Lab service start within each spec while retaining
isolated fixture installation for every scenario.
- Removed the duplicate catalog evidence navigation.
- Reduced success screenshots from 56 to 7 while retaining screenshots
for every failed step.
- Split the shared lifecycle runner into P1–P4 and P5–P7 specs.
- Mark each successful split result as partial and keep dashboard health
amber until one run covers the full catalog.
- Updated the duration manifest and contributor docs for the split.

## Verification

- `pnpm -r typecheck` passed on Node.js 24.20.0.
- `pnpm build` passed on Node.js 24.20.0.
- `node --test scripts/__tests__/e2e-shard.test.mjs` passed 9 tests.
- `pnpm exec vitest run ui/src/pages/tools/smoke-lab-matrix.test.ts`
passed 8 tests.
- Both split specs passed together on Node.js 24.20.0 after the review
fixes: 2 passed in 35.7 seconds; shell wall time was 36.86 seconds.
- The pre-change Smoke Lab baseline passed with a 57.68-second shell
wall time. The optimized unsplit A/B run passed with a 37.04-second
shell wall time.
- The full local E2E catalog passed 44 tests and skipped 2 tests. One
existing `pipelines-tutorial-flow.spec.ts` assertion failed again when
run alone.
- The broad local unit run reproduced failures in untouched
workspace-runtime suites. Typecheck, build, shard tests, and all changed
browser coverage pass. CI remains the authoritative full-suite result.

## Risks

- The split duration weights use the measured local reduction and the
previous 168-second CI weight. They should be refreshed after two real
pull request runs.
- Service state is shared within each half. Fixture installation still
runs before every scenario to reset connection, policy, and catalog
state.
- Each half records passed execution with partial coverage. Dashboard
health recognizes the partial flag and stays amber because no single
runner covers the full catalog. A failed half still records failed/red.
- Fewer success screenshots reduce redundant artifacts. Every scenario
keeps its connect screenshot, and every failure still captures evidence.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5. The exact deployment ID and context window are
not exposed in this session. The model used agentic reasoning, code
editing, shell execution, browser testing, and GitHub tools.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-31 10:15:39 -05:00

471 lines
19 KiB
TypeScript

import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
import { ciSmokeLabScenarios, type SmokeLabLifecycleTool, type SmokeLabScenario, type SmokeRunStepPath } from "./smoke-lab.catalog";
type SmokeRunStepStatus = "pass" | "fail" | "skipped";
const SCREENSHOT_DIR = "test-results/smoke-lab";
type Json = Record<string, unknown>;
type Seed = { companyId: string; prefix: string };
type Scout = { id: string; name: string };
type SmokeRun = { id: string; status: string };
type SmokeRunStepResult = {
step: { id: string; status: SmokeRunStepStatus };
summary: Record<string, unknown>;
};
type ToolConnection = {
id: string;
name: string;
transport: "mcp_remote" | "local_stdio";
applicationId: string;
enabled: boolean;
status?: string;
config?: Record<string, unknown> | null;
};
type ToolCatalogEntry = {
id: string;
toolName: string;
name: string;
riskLevel?: string | null;
status?: string | null;
};
type FixtureInstall = {
connections: ToolConnection[];
catalog: ToolCatalogEntry[];
};
type TestCallResult = {
decision: "allowed" | "ask_first" | "off";
invocationId: string;
actionRequestId?: string;
error?: { reasonCode?: string | null; message: string };
};
type GatewaySession = {
sessionId: string;
token: string;
toolsUrl: string;
callUrl: string;
};
async function json<T = Json>(response: Awaited<ReturnType<APIRequestContext["get"]>>): Promise<T> {
const body = await response.text();
expect(response.ok(), `${response.url()} failed ${response.status()}: ${body}`).toBe(true);
return JSON.parse(body) as T;
}
async function expectError(response: Awaited<ReturnType<APIRequestContext["get"]>>, status: number) {
const body = await response.text();
expect(response.status(), `${response.url()} expected ${status}, got ${response.status()}: ${body}`).toBe(status);
return body;
}
async function newCompany(request: APIRequestContext, label: string): Promise<Seed> {
const body = await json<{ id: string; issuePrefix?: string; prefix?: string; urlKey?: string }>(
await request.post("/api/companies", { data: { name: `Smoke Lab ${label} ${Date.now()}` } }),
);
return { companyId: body.id, prefix: body.issuePrefix ?? body.prefix ?? body.urlKey ?? "E2E" };
}
async function createScout(request: APIRequestContext, companyId: string): Promise<Scout> {
const body = await json<{ id: string; name: string }>(
await request.post(`/api/companies/${companyId}/agents`, {
data: {
name: `Smoke Scout ${Date.now()}`,
role: "qa",
title: "Smoke Lab scout",
capabilities: "Runs deterministic Smoke Lab fixture calls.",
adapterType: "process",
adapterConfig: { command: "node", args: ["-e", "setTimeout(() => {}, 1000)"] },
},
}),
);
return { id: body.id, name: body.name };
}
async function enableSmokeLab(request: APIRequestContext) {
await json(await request.patch("/api/instance/settings/experimental", { data: { enableSmokeLab: true, enableApps: true } }));
}
async function createSmokeRun(request: APIRequestContext, companyId: string, scenarioCount: number) {
const result = await json<{ run: SmokeRun }>(
await request.post(`/api/companies/${companyId}/smoke-lab/runs`, {
data: {
trigger: "ci",
summary: {
catalog: "tests/e2e/smoke-lab.catalog.ts",
scenarioCount,
},
},
}),
);
return result.run;
}
async function updateSmokeRun(
request: APIRequestContext,
companyId: string,
runId: string,
status: "passed" | "failed",
summary: Json,
) {
await json(await request.patch(`/api/companies/${companyId}/smoke-lab/runs/${runId}`, {
data: { status, summary },
}));
}
async function recordStep(
request: APIRequestContext,
companyId: string,
runId: string,
input: {
path: SmokeRunStepPath;
scenarioStep: string;
status: SmokeRunStepStatus;
detail?: string | null;
screenshotPath?: string | null;
durationMs?: number | null;
},
): Promise<SmokeRunStepResult> {
return await json<SmokeRunStepResult>(
await request.post(`/api/companies/${companyId}/smoke-lab/runs/${runId}/steps`, {
data: {
path: input.path,
scenarioStep: input.scenarioStep,
status: input.status,
detail: input.detail ?? null,
screenshotArtifactRef: input.screenshotPath
? { kind: "playwright_screenshot", path: input.screenshotPath }
: null,
durationMs: input.durationMs ?? null,
},
}),
);
}
async function screenshot(page: Page, scenario: SmokeLabScenario, step: string) {
const path = `${SCREENSHOT_DIR}/${scenario.path.toLowerCase()}-${step}.png`;
await page.screenshot({ path, fullPage: true });
return path;
}
async function navigateForEvidence(page: Page, seed: Seed, connectionId: string, scenario: SmokeLabScenario) {
if (scenario.uiEntryPath === "advanced") {
await page.goto(`/${seed.prefix}/apps/advanced`);
await expect(page.getByRole("heading", { name: "Advanced setup" })).toBeVisible({ timeout: 20_000 });
return;
}
if (scenario.uiEntryPath === "review") {
await page.goto(`/${seed.prefix}/apps/${connectionId}/review`);
await expect(page.getByText(/Nothing is waiting for your OK|new actions? (need|to) review/i).first()).toBeVisible({ timeout: 20_000 });
return;
}
if (scenario.uiEntryPath === "activity") {
await page.goto(`/${seed.prefix}/apps/${connectionId}/activity`);
await expect(page.getByRole("heading", { name: "Recent activity" })).toBeVisible({ timeout: 20_000 });
return;
}
if (scenario.uiEntryPath === "attention") {
await page.goto(`/${seed.prefix}/apps/connections`);
await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible({ timeout: 20_000 });
return;
}
await page.goto(`/${seed.prefix}/apps/${connectionId}`);
await expect(page.getByRole("heading", { name: /Smoke Lab/i }).first()).toBeVisible({ timeout: 30_000 });
}
async function runRecordedStep(
page: Page,
request: APIRequestContext,
seed: Seed,
runId: string,
scenario: SmokeLabScenario,
step: string,
action: () => Promise<string | null | undefined>,
captureSuccessScreenshot = false,
) {
const start = Date.now();
try {
const screenshotHint = await action();
const screenshotPath = captureSuccessScreenshot ? await screenshot(page, scenario, step) : null;
await recordStep(request, seed.companyId, runId, {
path: scenario.path,
scenarioStep: step,
status: "pass",
detail: screenshotHint ?? null,
screenshotPath,
durationMs: Date.now() - start,
});
} catch (error) {
const screenshotPath = await screenshot(page, scenario, `${step}-failed`).catch(() => null);
await recordStep(request, seed.companyId, runId, {
path: scenario.path,
scenarioStep: step,
status: "fail",
detail: error instanceof Error ? error.message : String(error),
screenshotPath,
durationMs: Date.now() - start,
}).catch(() => undefined);
throw error;
}
}
async function startSmokeLabServices(request: APIRequestContext, companyId: string) {
await json(await request.post(`/api/companies/${companyId}/smoke-lab/services/start`));
}
async function installFixtures(request: APIRequestContext, companyId: string): Promise<FixtureInstall> {
return await json<FixtureInstall>(await request.post(`/api/companies/${companyId}/smoke-lab/install-fixtures`));
}
function connectionForScenario(fixtures: FixtureInstall, scenario: SmokeLabScenario): ToolConnection {
const preferStdio = scenario.transport === "local_stdio" || scenario.transport === "plugin";
const transport = preferStdio ? "local_stdio" : "mcp_remote";
const connection = fixtures.connections.find((candidate) => candidate.transport === transport);
if (!connection) throw new Error(`Missing ${transport} fixture connection for ${scenario.path}`);
return connection;
}
async function catalog(request: APIRequestContext, connectionId: string) {
return await json<{ catalog: ToolCatalogEntry[] }>(await request.get(`/api/tool-connections/${connectionId}/catalog`));
}
async function testCall(
request: APIRequestContext,
connectionId: string,
scout: Scout,
tool: SmokeLabLifecycleTool,
) {
return await json<TestCallResult>(
await request.post(`/api/tool-connections/${connectionId}/test-calls`, {
data: { agentId: scout.id, toolName: tool.name, parameters: tool.parameters },
}),
);
}
async function policy(
request: APIRequestContext,
companyId: string,
body: {
name: string;
policyType: "allow" | "block" | "require_approval";
priority: number;
selectors: Record<string, unknown>;
},
) {
return await json<{ id: string }>(await request.post(`/api/companies/${companyId}/tools/policies`, { data: body }));
}
async function approveActionRequest(request: APIRequestContext, companyId: string, actionRequestId: string) {
await json(await request.post(`/api/tool-gateway/action-requests/${actionRequestId}/approve`, {
data: { companyId },
}));
}
async function pollTestCall(
request: APIRequestContext,
connectionId: string,
actionRequestId: string,
expectedPhase: string,
) {
for (let i = 0; i < 40; i += 1) {
const status = await json<{ phase: string }>(
await request.get(`/api/tool-connections/${connectionId}/test-calls/${actionRequestId}`),
);
if (status.phase === expectedPhase) return status;
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(`Timed out waiting for test-call ${actionRequestId} phase ${expectedPhase}`);
}
async function expectAuditEvent(
request: APIRequestContext,
companyId: string,
options: { connectionId: string; agentId: string; search: string },
) {
const audit = await json<{ events: Array<Json> }>(
await request.get(
`/api/tool-gateway/audit?companyId=${companyId}&app=${options.connectionId}&agent=${options.agentId}&search=${encodeURIComponent(options.search)}&limit=50`,
),
);
expect(audit.events.length, `expected audit/activity row matching ${options.search}`).toBeGreaterThan(0);
}
async function createGatewaySession(request: APIRequestContext, companyId: string, scout: Scout): Promise<GatewaySession> {
const invoked = await json<{ id: string }>(await request.post(`/api/agents/${scout.id}/heartbeat/invoke`));
return await json<GatewaySession>(
await request.post("/api/tool-gateway/sessions", {
data: { companyId, agentId: scout.id, runId: invoked.id, ttlMs: 60_000 },
}),
);
}
async function gatewayFetch(request: APIRequestContext, path: string, token: string, data?: Json) {
const headers = { "x-paperclip-tool-gateway-token": token };
if (data) return await request.post(path, { headers, data });
return await request.get(path, { headers });
}
export const defineSmokeLabSuite = (label: string, scenarios: SmokeLabScenario[]) => test.describe.serial(`Smoke Lab scenario catalog mirror (${label})`, () => {
// The lifecycle test walks all CI-safe scenarios and records eight steps per
// scenario. Each scenario keeps one representative success screenshot, while
// every failed step still captures its own evidence. On a loaded CI runner
// the whole walk can take several minutes, so this ceiling keeps enough
// headroom for runner variance without slowing a healthy run.
test.setTimeout(360_000);
test(`records the ${label} CI-safe Smoke Lab lifecycle into the results API @smoke-lab`, async ({ page, request }) => {
const seed = await newCompany(request, "catalog");
const scout = await createScout(request, seed.companyId);
await enableSmokeLab(request);
const smokeRun = await createSmokeRun(request, seed.companyId, scenarios.length);
const failed: string[] = [];
const isPartialRun = scenarios.length < ciSmokeLabScenarios.length;
try {
await startSmokeLabServices(request, seed.companyId);
for (const scenario of scenarios) {
const fixtures = await installFixtures(request, seed.companyId);
const connection = connectionForScenario(fixtures, scenario);
await runRecordedStep(page, request, seed, smokeRun.id, scenario, "connect", async () => {
await navigateForEvidence(page, seed, connection.id, scenario);
return scenario.lifecycle.connect;
}, true);
await runRecordedStep(page, request, seed, smokeRun.id, scenario, "discover-catalog", async () => {
const discovered = await catalog(request, connection.id);
expect(discovered.catalog.map((entry) => entry.toolName)).toContain(scenario.lifecycle.allowedRead.name);
return `${scenario.lifecycle.discoverCatalog}: ${discovered.catalog.length} entries`;
});
await runRecordedStep(page, request, seed, smokeRun.id, scenario, "allowed-read", async () => {
const read = await testCall(request, connection.id, scout, scenario.lifecycle.allowedRead);
expect(read.decision).toBe("allowed");
expect(read.error).toBeUndefined();
await expectAuditEvent(request, seed.companyId, {
connectionId: connection.id,
agentId: scout.id,
search: scenario.lifecycle.allowedRead.name,
});
await page.goto(`/${seed.prefix}/apps/${connection.id}/activity`);
return `Allowed read ${scenario.lifecycle.allowedRead.name}`;
});
await runRecordedStep(page, request, seed, smokeRun.id, scenario, "ask-first-write-approved", async () => {
await policy(request, seed.companyId, {
name: `${scenario.path} require approval ${Date.now()}`,
policyType: "require_approval",
priority: 10,
selectors: { connectionId: connection.id, toolNames: [scenario.lifecycle.askFirstWrite.name] },
});
const pending = await testCall(request, connection.id, scout, scenario.lifecycle.askFirstWrite);
expect(pending.decision).toBe("ask_first");
expect(pending.actionRequestId).toBeTruthy();
await page.goto(`/${seed.prefix}/apps/${connection.id}/review`);
await approveActionRequest(request, seed.companyId, pending.actionRequestId!);
await pollTestCall(request, connection.id, pending.actionRequestId!, "done");
return `Approved ask-first call ${scenario.lifecycle.askFirstWrite.name}`;
});
await runRecordedStep(page, request, seed, smokeRun.id, scenario, "denied-blocked-call", async () => {
await policy(request, seed.companyId, {
name: `${scenario.path} block ${Date.now()}`,
policyType: "block",
priority: 1,
selectors: { connectionId: connection.id, toolNames: [scenario.lifecycle.deniedCall.name] },
});
const denied = await testCall(request, connection.id, scout, scenario.lifecycle.deniedCall);
expect(denied.decision).toBe("off");
expect(denied.error?.reasonCode).toBeTruthy();
await page.goto(`/${seed.prefix}/apps/${connection.id}/review`);
return `Blocked call ${scenario.lifecycle.deniedCall.name}: ${denied.error?.reasonCode}`;
});
await runRecordedStep(page, request, seed, smokeRun.id, scenario, "schema-change-quarantine", async () => {
if (connection.transport !== "mcp_remote") {
await page.goto(`/${seed.prefix}/apps/${connection.id}/activity`);
return "Non-HTTP path records governance/quarantine evidence through fixture metadata.";
}
await json<ToolConnection>(await request.patch(`/api/tool-connections/${connection.id}`, {
data: { config: { ...(connection.config ?? {}), quarantineNewEntries: true } },
}));
await policy(request, seed.companyId, {
name: `${scenario.path} allow schema flip ${Date.now()}`,
policyType: "allow",
priority: 5,
selectors: { connectionId: connection.id, toolNames: [scenario.lifecycle.schemaChangeQuarantine.name] },
});
const flipped = await testCall(request, connection.id, scout, scenario.lifecycle.schemaChangeQuarantine);
expect(flipped.decision).toBe("allowed");
expect(flipped.error).toBeUndefined();
const refresh = await json<{ quarantinedCount: number }>(
await request.post(`/api/tool-connections/${connection.id}/catalog/refresh`),
);
expect(refresh.quarantinedCount).toBeGreaterThan(0);
await page.goto(`/${seed.prefix}/apps/connections`);
return `Catalog refresh quarantined ${refresh.quarantinedCount} changed entries.`;
});
await runRecordedStep(page, request, seed, smokeRun.id, scenario, "revoke", async () => {
if (scenario.transport === "gateway_session") {
const session = await createGatewaySession(request, seed.companyId, scout);
const listed = await gatewayFetch(request, session.toolsUrl, session.token);
expect(listed.ok()).toBe(true);
await json(await request.post(`/api/tool-gateway/sessions/${session.sessionId}/revoke`, {
data: { companyId: seed.companyId },
}));
await expectError(await gatewayFetch(request, session.toolsUrl, session.token), 401);
await page.goto(`/${seed.prefix}/apps/${connection.id}/activity`);
return scenario.lifecycle.revoke;
}
const disabled = await json<ToolConnection>(await request.patch(`/api/tool-connections/${connection.id}`, {
data: { enabled: false },
}));
expect(disabled.enabled).toBe(false);
await page.goto(`/${seed.prefix}/apps/${connection.id}`);
await json<ToolConnection>(await request.patch(`/api/tool-connections/${connection.id}`, {
data: { enabled: true },
}));
return scenario.lifecycle.revoke;
});
await runRecordedStep(page, request, seed, smokeRun.id, scenario, "audit-evidence", async () => {
await expectAuditEvent(request, seed.companyId, {
connectionId: connection.id,
agentId: scout.id,
search: scenario.lifecycle.allowedRead.name,
});
await page.goto(`/${seed.prefix}/apps/${connection.id}/activity`);
return scenario.lifecycle.auditEvidence;
});
}
} catch (error) {
failed.push(error instanceof Error ? error.message : String(error));
throw error;
} finally {
await updateSmokeRun(request, seed.companyId, smokeRun.id, failed.length > 0 ? "failed" : "passed", {
catalog: "tests/e2e/smoke-lab.catalog.ts",
scenarioCount: scenarios.length,
catalogScenarioCount: ciSmokeLabScenarios.length,
partial: isPartialRun,
failed,
}).catch(() => undefined);
}
const completed = await json<{
run: SmokeRun;
steps: Array<{ path: string; scenarioStep: string; status: string; screenshotArtifactRef: Json | null }>;
}>(
await request.get(`/api/companies/${seed.companyId}/smoke-lab/runs/${smokeRun.id}`),
);
expect(completed.run.status).toBe("passed");
for (const scenario of scenarios) {
const steps = completed.steps.filter((step) => step.path === scenario.path);
expect(steps.length, `${scenario.path} should record lifecycle steps`).toBeGreaterThanOrEqual(8);
expect(steps.every((step) => step.status === "pass")).toBe(true);
const screenshotSteps = steps.filter((step) => step.screenshotArtifactRef?.kind === "playwright_screenshot");
expect(screenshotSteps.map((step) => step.scenarioStep)).toEqual(["connect"]);
}
});
});