mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-08 09:35:19 +02:00
## Thinking Path > - Paperclip is the control plane for companies that use AI agents. > - Apps give humans and agents controlled access to external services. > - The existing app detail flow split permissions, tests, setup, and activity across separate pages. > - The split made access rules harder to understand and made reconnect work hard to find. > - New write actions also defaulted to Ask first, which did not match the intended connection policy. > - This pull request combines permission control and action testing, removes the setup page, and moves connection activity into Audit. > - The benefit is one clear place to configure, test, reconnect, and review each app. ## Linked Issues or Issue Description **What existing behavior does this improve?** The installed app Permissions, Test, Setup, and Activity views. **Subsystem affected** Cross-cutting. This change updates the React UI, shared app defaults, server permission behavior, tests, smoke scripts, and connection documentation. **Current behavior** App access and action testing use separate pages. The app detail view also links to a setup page after installation. Connection activity uses a separate tab. New write actions default to Ask first. **Proposed behavior** Permissions uses the connection access language from the initial flow. It includes searchable Read and Write sections, a three-state permission control, and a Test dialog for each action. Reconnect appears below a Needs attention header on Permissions and Review. Old Setup and Test links redirect to Permissions. Old Activity links redirect to the filtered company Audit feed. New write actions default to Allowed. **Reason and benefit** A person can understand and test app access without moving between several pages. Reconnect work stays visible where the person reviews the connection. Audit events use one consistent feed and filter model. New connections have the intended default policy. **Breaking changes** The Setup, Test, and app Activity tabs are removed. Existing deep links redirect to their replacement pages. Existing saved action permissions do not change. Only defaults for new write actions change. **Additional context** This builds on the managed app connection work in #12728. A search found no duplicate open pull request or issue. ## What Changed - Combined action testing with Permissions. - Added searchable Read and Write action groups. - Added Off, Ask first, and Allowed controls with tooltips. - Added an action Test dialog with agent selection, arguments, and formatted results. - Removed the installed-app Setup and Activity tabs. - Added reconnect guidance to Permissions and Review when a connection needs attention. - Routed connection activity into the company Audit feed and preserved the Apps & tools filter in streamlined Audit. - Moved connection removal to the Connectors-page management menu. - Made new write actions default to Allowed across connection creation paths. - Updated regression tests, browser suites, smoke scripts, and connection documentation. ## Verification - `pnpm check:token-gates` - `pnpm exec vitest run packages/shared/src/app-definitions.test.ts server/src/__tests__/generic-mcp-connection.test.ts server/src/__tests__/tool-access-service.test.ts ui/src/components/AppConnectionSidebar.test.tsx ui/src/pages/apps/AppDetail.test.tsx ui/src/pages/apps/AppNotConnected.test.tsx ui/src/pages/apps/AppsConnect.test.tsx ui/src/pages/apps/Browse.test.tsx ui/src/pages/apps/Connections.test.tsx ui/src/pages/apps/composio-services.test.ts ui/src/pages/audit/AuditFeed.test.tsx ui/src/pages/tools/PasteConfigTab.test.tsx` (517 tests passed) - `pnpm exec vitest run ui/src/pages/apps/app-detail/TestPanel.test.tsx ui/src/pages/audit/AuditHub.test.tsx ui/src/pages/audit/AuditFeed.test.tsx ui/src/pages/apps/AppDetail.test.tsx ui/src/pages/apps/Browse.test.tsx` (96 tests passed) - Targeted Playwright verification for connection removal, rename on Permissions, inline action testing, and Smoke Lab Audit evidence (5 flows passed) - `pnpm -r typecheck` - `pnpm build` - `pnpm test:run` completed with 5,755 passing tests and 20 unrelated macOS harness failures. The failures use `/tmp` versus `/private/tmp`, invalid ports above 65535, and workspace fixtures outside this change. ## Risks - Low migration risk. This change has no database migration. - Old app-detail URLs depend on redirect compatibility. - New connections grant write actions by default. Finalization remains configure-authorized and audited, Ask first and Off remain available per action, and existing connections keep their saved policy. > 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, exact model ID `gpt-5`. The client does not expose the context-window size. The model used reasoning, repository tools, code execution, and browser verification. ## 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>
353 lines
19 KiB
TypeScript
353 lines
19 KiB
TypeScript
/**
|
|
* Smoke Lab agent-driven browser runner (PAP-13350 / plan §D4 item 1).
|
|
*
|
|
* This is the reference implementation the `smoke-lab-browser-runner` runbook
|
|
* points at: a QA agent drives a REAL browser through the P1-P7 Smoke Lab
|
|
* lifecycle against a live local_trusted instance, typing demo credentials into
|
|
* the fake OAuth consent page, and posts every step + a viewable screenshot to
|
|
* the S1 results API so the run lands in the Smoke Lab UI + dashboard card.
|
|
*
|
|
* It deliberately imports the S4 catalog as the single source of truth for the
|
|
* scenario/step list (do NOT fork the step list).
|
|
*
|
|
* Run:
|
|
* node --experimental-strip-types tests/e2e/smoke-lab-browser-runner.mts
|
|
* Env (all optional except a reachable BASE):
|
|
* SMOKE_BASE=http://127.0.0.1:3211 target local_trusted, non-prod instance
|
|
* SMOKE_COMPANY_ID=<uuid> reuse a company (else creates one)
|
|
* SMOKE_SHOT_DIR=/tmp/pap13350-shots screenshot output dir
|
|
* SMOKE_ONLY=P1,P3 restrict to a subset of catalog paths
|
|
* (budget-bounding for the daily D5 routine;
|
|
* selects scenarios, does NOT fork the steps)
|
|
* SMOKE_TRIGGER=manual|ci|routine smoke_run trigger (default "manual")
|
|
* SMOKE_CHROMIUM_PATH=/path/to/chromium optional browser executable override
|
|
* SMOKE_FORCE_FAIL=<detail> inject one synthetic failing step on the
|
|
* first scenario — the canonical self-test for
|
|
* the §D5 routine's file-on-failure wiring.
|
|
*/
|
|
import { promises as fs } from "node:fs";
|
|
import { chromium } from "playwright";
|
|
import { ciSmokeLabScenarios } from "./smoke-lab.catalog.ts";
|
|
|
|
const BASE = (process.env.SMOKE_BASE ?? "http://127.0.0.1:3211").replace(/\/$/, "");
|
|
const SHOT_DIR = process.env.SMOKE_SHOT_DIR ?? "/tmp/pap13350-shots";
|
|
const CHROMIUM_PATH = process.env.SMOKE_CHROMIUM_PATH?.trim() || null;
|
|
const DEMO_EMAIL = "smoke@paperclip.test";
|
|
const DEMO_PASSWORD = "smoke-password";
|
|
const ONLY = (process.env.SMOKE_ONLY ?? "")
|
|
.split(",")
|
|
.map((s) => s.trim().toUpperCase())
|
|
.filter(Boolean);
|
|
const TRIGGER = process.env.SMOKE_TRIGGER ?? "manual";
|
|
|
|
type Json = Record<string, any>;
|
|
|
|
function assert(cond: any, msg: string): asserts cond {
|
|
if (!cond) throw new Error(`ASSERT FAILED: ${msg}`);
|
|
}
|
|
|
|
async function api(method: string, path: string, body?: Json): Promise<any> {
|
|
const res = await fetch(`${BASE}${path}`, {
|
|
method,
|
|
headers: { "content-type": "application/json", origin: BASE },
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
});
|
|
const text = await res.text();
|
|
if (!res.ok) throw new Error(`${method} ${path} -> ${res.status}: ${text.slice(0, 300)}`);
|
|
return text ? JSON.parse(text) : {};
|
|
}
|
|
|
|
async function uploadShot(companyId: string, file: string): Promise<string> {
|
|
const buf = await fs.readFile(file);
|
|
const form = new FormData();
|
|
form.append("file", new Blob([buf], { type: "image/png" }), file.split("/").pop());
|
|
form.append("namespace", "smoke-lab");
|
|
const res = await fetch(`${BASE}/api/companies/${companyId}/assets/images`, {
|
|
method: "POST",
|
|
headers: { origin: BASE },
|
|
body: form as any,
|
|
});
|
|
const text = await res.text();
|
|
if (!res.ok) throw new Error(`asset upload failed ${res.status}: ${text.slice(0, 200)}`);
|
|
const j = text ? JSON.parse(text) : {};
|
|
return `${BASE}${j.contentPath}`;
|
|
}
|
|
|
|
async function main() {
|
|
await fs.mkdir(SHOT_DIR, { recursive: true });
|
|
|
|
// Company + scout
|
|
let companyId = process.env.SMOKE_COMPANY_ID ?? "";
|
|
let prefix = "";
|
|
if (!companyId) {
|
|
const co = await api("POST", "/api/companies", { name: `Smoke Lab Browser Run ${Date.now()}` });
|
|
companyId = co.id;
|
|
prefix = co.issuePrefix ?? co.urlKey ?? "SMO";
|
|
} else {
|
|
const list = await api("GET", "/api/companies");
|
|
const co = (Array.isArray(list) ? list : list.companies ?? []).find((c: Json) => c.id === companyId);
|
|
prefix = co?.issuePrefix ?? co?.urlKey ?? "SMO";
|
|
}
|
|
await api("PATCH", "/api/instance/settings/experimental", { enableSmokeLab: true });
|
|
const scout = await api("POST", `/api/companies/${companyId}/agents`, {
|
|
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)"] },
|
|
});
|
|
console.log(`company=${companyId} prefix=${prefix} scout=${scout.id}`);
|
|
|
|
const scenarios = ONLY.length
|
|
? ciSmokeLabScenarios.filter((s) => ONLY.includes(s.path))
|
|
: ciSmokeLabScenarios;
|
|
assert(scenarios.length, `SMOKE_ONLY=${ONLY.join(",")} matched no catalog paths`);
|
|
|
|
const run = (await api("POST", `/api/companies/${companyId}/smoke-lab/runs`, {
|
|
trigger: TRIGGER,
|
|
summary: { catalog: "tests/e2e/smoke-lab.catalog.ts", driver: "agent-browser (real chromium)", track: "D4 agent-driven browser", scenarioCount: scenarios.length, only: ONLY.length ? ONLY : undefined },
|
|
})).run;
|
|
console.log(`smoke_run=${run.id}`);
|
|
|
|
const browser = await chromium.launch({
|
|
...(CHROMIUM_PATH ? { executablePath: CHROMIUM_PATH } : {}),
|
|
headless: true,
|
|
});
|
|
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
|
|
const page = await ctx.newPage();
|
|
const failed: string[] = [];
|
|
|
|
const shot = async (scenario: Json, step: string) => {
|
|
const file = `${SHOT_DIR}/${scenario.path.toLowerCase()}-${step}.png`;
|
|
await page.screenshot({ path: file, fullPage: true });
|
|
return await uploadShot(companyId, file);
|
|
};
|
|
const record = async (scenario: Json, step: string, status: string, detail: string | null, url: string | null, ms: number) => {
|
|
await api("POST", `/api/companies/${companyId}/smoke-lab/runs/${run.id}/steps`, {
|
|
path: scenario.path, scenarioStep: step, status, detail,
|
|
screenshotArtifactRef: url ? { kind: "agent_browser_screenshot", url } : null,
|
|
durationMs: ms,
|
|
});
|
|
};
|
|
const doStep = async (scenario: Json, step: string, action: () => Promise<string | null | undefined>) => {
|
|
const t0 = Date.now();
|
|
try {
|
|
const hint = await action();
|
|
const url = await shot(scenario, step);
|
|
await record(scenario, step, "pass", hint ?? null, url, Date.now() - t0);
|
|
console.log(` ${scenario.path}/${step} PASS`);
|
|
} catch (e: any) {
|
|
let url: string | null = null;
|
|
try { url = await shot(scenario, `${step}-failed`); } catch {}
|
|
await record(scenario, step, "fail", e?.message ?? String(e), url, Date.now() - t0).catch(() => {});
|
|
failed.push(`${scenario.path}/${step}: ${e?.message ?? e}`);
|
|
console.log(` ${scenario.path}/${step} FAIL: ${e?.message ?? e}`);
|
|
}
|
|
};
|
|
|
|
const gotoUI = async (scenario: Json, connId: string) => {
|
|
const p = scenario.uiEntryPath;
|
|
if (p === "advanced") await page.goto(`${BASE}/${prefix}/apps/advanced`, { waitUntil: "networkidle" });
|
|
else if (p === "review") await page.goto(`${BASE}/${prefix}/apps/${connId}/review`, { waitUntil: "networkidle" });
|
|
else if (p === "activity") await page.goto(`${BASE}/${prefix}/activity?action=tool_`, { waitUntil: "networkidle" });
|
|
else if (p === "attention") await page.goto(`${BASE}/${prefix}/apps/attention`, { waitUntil: "networkidle" });
|
|
else await page.goto(`${BASE}/${prefix}/apps/${connId}`, { waitUntil: "networkidle" });
|
|
};
|
|
|
|
const auditHit = async (connId: string, search: string) => {
|
|
const a = await api("GET", `/api/tool-gateway/audit?companyId=${companyId}&app=${connId}&agent=${scout.id}&search=${encodeURIComponent(search)}&limit=50`);
|
|
assert((a.events?.length ?? 0) > 0, `audit row for ${search}`);
|
|
};
|
|
|
|
for (const scenario of scenarios) {
|
|
console.log(`--- ${scenario.path}: ${scenario.title} ---`);
|
|
// Fixtures (board UI action; idempotent). Start services then install.
|
|
const services = (await api("POST", `/api/companies/${companyId}/smoke-lab/services/start`)).services;
|
|
const oauthUrl = services.find((s: Json) => s.id === "fake-oauth")?.url as string;
|
|
const fx = await api("POST", `/api/companies/${companyId}/smoke-lab/install-fixtures`);
|
|
const preferStdio = scenario.transport === "local_stdio" || scenario.transport === "plugin";
|
|
const wantTransport = preferStdio ? "local_stdio" : "mcp_remote";
|
|
const conn = fx.connections.find((c: Json) => c.transport === wantTransport);
|
|
assert(conn, `${wantTransport} connection for ${scenario.path}`);
|
|
|
|
// connect
|
|
await doStep(scenario, "connect", async () => {
|
|
if (scenario.authMode === "oauth") {
|
|
// REAL BROWSER: fake OAuth login + consent page, type demo credentials.
|
|
const authorize = `${oauthUrl}?client_id=smoke-client&redirect_uri=${encodeURIComponent("http://127.0.0.1/callback")}&scope=${encodeURIComponent("smoke:openid smoke:profile smoke:email")}&state=smoke-state&response_type=code`;
|
|
await page.goto(authorize, { waitUntil: "domcontentloaded" });
|
|
await page.getByRole("heading", { name: /Smoke OAuth/i }).waitFor({ timeout: 15_000 });
|
|
await page.fill('input[name="email"]', DEMO_EMAIL);
|
|
await page.fill('input[name="password"]', DEMO_PASSWORD);
|
|
// Screenshot the filled consent page as evidence before submit.
|
|
const consentUrl = await shot(scenario, "connect-consent");
|
|
await record(scenario, "connect-consent", "pass", `Typed demo creds ${DEMO_EMAIL} into fake OAuth consent page`, consentUrl, 0);
|
|
// Submit consent; capture the 302 to prove the fake provider accepted the
|
|
// demo creds (redirect target is a dead loopback callback that never loads).
|
|
const [resp] = await Promise.all([
|
|
page.waitForResponse((r: any) => r.url().includes("/smoke-lab/oauth/authorize") && r.request().method() === "POST", { timeout: 12_000 }),
|
|
page.click('button[type="submit"]'),
|
|
]);
|
|
const loc = resp.headers()["location"] ?? "";
|
|
assert(resp.status() === 302 && /[?&]code=/.test(loc), `OAuth consent accepted creds → 302 with code (status ${resp.status()}, location ${loc.slice(0, 80)})`);
|
|
// The 302 target is a dead loopback callback; let that failed navigation
|
|
// commit (chrome-error page) before navigating into the Paperclip UI.
|
|
await page.waitForLoadState("domcontentloaded").catch(() => {});
|
|
await page.waitForTimeout(800);
|
|
await gotoUI(scenario, conn.id);
|
|
return `${scenario.lifecycle.connect} (typed demo creds into consent page; provider issued code via 302)`;
|
|
}
|
|
await gotoUI(scenario, conn.id);
|
|
return scenario.lifecycle.connect;
|
|
});
|
|
|
|
// discover-catalog
|
|
await doStep(scenario, "discover-catalog", async () => {
|
|
const cat = await api("GET", `/api/tool-connections/${conn.id}/catalog`);
|
|
const names = (cat.catalog ?? []).map((e: Json) => e.toolName);
|
|
assert(names.includes(scenario.lifecycle.allowedRead.name), `catalog has ${scenario.lifecycle.allowedRead.name}`);
|
|
await gotoUI(scenario, conn.id);
|
|
return `${scenario.lifecycle.discoverCatalog}: ${names.length} entries`;
|
|
});
|
|
|
|
// allowed-read
|
|
await doStep(scenario, "allowed-read", async () => {
|
|
const read = await api("POST", `/api/tool-connections/${conn.id}/test-calls`, {
|
|
agentId: scout.id, toolName: scenario.lifecycle.allowedRead.name, parameters: scenario.lifecycle.allowedRead.parameters,
|
|
});
|
|
assert(read.decision === "allowed", `allowed-read decision=${read.decision}`);
|
|
assert(!read.error, "allowed-read no error");
|
|
await auditHit(conn.id, scenario.lifecycle.allowedRead.name);
|
|
await page.goto(`${BASE}/${prefix}/activity?action=tool_`, { waitUntil: "networkidle" });
|
|
return `Allowed read ${scenario.lifecycle.allowedRead.name}`;
|
|
});
|
|
|
|
// ask-first-write-approved
|
|
await doStep(scenario, "ask-first-write-approved", async () => {
|
|
await api("POST", `/api/companies/${companyId}/tools/policies`, {
|
|
name: `${scenario.path} require approval ${Date.now()}`, policyType: "require_approval", priority: 10,
|
|
selectors: { connectionId: conn.id, toolNames: [scenario.lifecycle.askFirstWrite.name] },
|
|
});
|
|
const pending = await api("POST", `/api/tool-connections/${conn.id}/test-calls`, {
|
|
agentId: scout.id, toolName: scenario.lifecycle.askFirstWrite.name, parameters: scenario.lifecycle.askFirstWrite.parameters,
|
|
});
|
|
assert(pending.decision === "ask_first", `ask-first decision=${pending.decision}`);
|
|
assert(pending.actionRequestId, "ask-first has actionRequestId");
|
|
await page.goto(`${BASE}/${prefix}/apps/${conn.id}/review`, { waitUntil: "networkidle" });
|
|
await api("POST", `/api/tool-gateway/action-requests/${pending.actionRequestId}/approve`, { companyId });
|
|
let approved = false;
|
|
for (let i = 0; i < 40; i++) {
|
|
const st = await api("GET", `/api/tool-connections/${conn.id}/test-calls/${pending.actionRequestId}`);
|
|
if (st.phase === "done") {
|
|
approved = true;
|
|
break;
|
|
}
|
|
await new Promise((r) => setTimeout(r, 250));
|
|
}
|
|
assert(approved, `Timed out waiting for test-call ${pending.actionRequestId} phase done`);
|
|
return `Approved ask-first call ${scenario.lifecycle.askFirstWrite.name}`;
|
|
});
|
|
|
|
// denied-blocked-call
|
|
await doStep(scenario, "denied-blocked-call", async () => {
|
|
await api("POST", `/api/companies/${companyId}/tools/policies`, {
|
|
name: `${scenario.path} block ${Date.now()}`, policyType: "block", priority: 1,
|
|
selectors: { connectionId: conn.id, toolNames: [scenario.lifecycle.deniedCall.name] },
|
|
});
|
|
const denied = await api("POST", `/api/tool-connections/${conn.id}/test-calls`, {
|
|
agentId: scout.id, toolName: scenario.lifecycle.deniedCall.name, parameters: scenario.lifecycle.deniedCall.parameters,
|
|
});
|
|
assert(denied.decision === "off", `denied decision=${denied.decision}`);
|
|
assert(denied.error?.reasonCode, "denied has reasonCode");
|
|
await page.goto(`${BASE}/${prefix}/apps/${conn.id}/review`, { waitUntil: "networkidle" });
|
|
return `Blocked call ${scenario.lifecycle.deniedCall.name}: ${denied.error?.reasonCode}`;
|
|
});
|
|
|
|
// schema-change-quarantine
|
|
await doStep(scenario, "schema-change-quarantine", async () => {
|
|
if (conn.transport !== "mcp_remote") {
|
|
await page.goto(`${BASE}/${prefix}/activity?action=tool_`, { waitUntil: "networkidle" });
|
|
return "Non-HTTP path records governance/quarantine evidence through fixture metadata.";
|
|
}
|
|
await api("PATCH", `/api/tool-connections/${conn.id}`, { config: { ...(conn.config ?? {}), quarantineNewEntries: true } });
|
|
await api("POST", `/api/companies/${companyId}/tools/policies`, {
|
|
name: `${scenario.path} allow schema flip ${Date.now()}`, policyType: "allow", priority: 5,
|
|
selectors: { connectionId: conn.id, toolNames: [scenario.lifecycle.schemaChangeQuarantine.name] },
|
|
});
|
|
const flipped = await api("POST", `/api/tool-connections/${conn.id}/test-calls`, {
|
|
agentId: scout.id, toolName: scenario.lifecycle.schemaChangeQuarantine.name, parameters: scenario.lifecycle.schemaChangeQuarantine.parameters,
|
|
});
|
|
assert(flipped.decision === "allowed", `schema-flip decision=${flipped.decision}`);
|
|
const refresh = await api("POST", `/api/tool-connections/${conn.id}/catalog/refresh`);
|
|
assert((refresh.quarantinedCount ?? 0) > 0, `quarantinedCount=${refresh.quarantinedCount}`);
|
|
await page.goto(`${BASE}/${prefix}/apps/attention`, { waitUntil: "networkidle" });
|
|
return `Catalog refresh quarantined ${refresh.quarantinedCount} changed entries.`;
|
|
});
|
|
|
|
// revoke
|
|
await doStep(scenario, "revoke", async () => {
|
|
if (scenario.transport === "gateway_session") {
|
|
const invoked = await api("POST", `/api/agents/${scout.id}/heartbeat/invoke`);
|
|
const session = await api("POST", "/api/tool-gateway/sessions", { companyId, agentId: scout.id, runId: invoked.id, ttlMs: 60_000 });
|
|
const listRes = await fetch(`${BASE}${new URL(session.toolsUrl, BASE).pathname}`, { headers: { "x-paperclip-tool-gateway-token": session.token } });
|
|
assert(listRes.ok, "gateway tools list ok pre-revoke");
|
|
await api("POST", `/api/tool-gateway/sessions/${session.sessionId}/revoke`, { companyId });
|
|
const after = await fetch(`${BASE}${new URL(session.toolsUrl, BASE).pathname}`, { headers: { "x-paperclip-tool-gateway-token": session.token } });
|
|
assert(after.status === 401, `revoked token cut off (got ${after.status})`);
|
|
await page.goto(`${BASE}/${prefix}/activity?action=tool_`, { waitUntil: "networkidle" });
|
|
return scenario.lifecycle.revoke;
|
|
}
|
|
const disabled = await api("PATCH", `/api/tool-connections/${conn.id}`, { enabled: false });
|
|
assert(disabled.enabled === false, "connection disabled");
|
|
await page.goto(`${BASE}/${prefix}/apps/${conn.id}`, { waitUntil: "networkidle" });
|
|
await api("PATCH", `/api/tool-connections/${conn.id}`, { enabled: true });
|
|
return scenario.lifecycle.revoke;
|
|
});
|
|
|
|
// audit-evidence
|
|
await doStep(scenario, "audit-evidence", async () => {
|
|
await auditHit(conn.id, scenario.lifecycle.allowedRead.name);
|
|
await page.goto(`${BASE}/${prefix}/activity?action=tool_`, { waitUntil: "networkidle" });
|
|
return scenario.lifecycle.auditEvidence;
|
|
});
|
|
}
|
|
|
|
// Optional injected failure — the canonical self-test for the §D5 routine's
|
|
// file-on-failure wiring. Records a real fail step + screenshot so the wrapper
|
|
// sees exit 2 + a `<path>-<step>-failed.png` to attach.
|
|
if (process.env.SMOKE_FORCE_FAIL) {
|
|
const target = scenarios[0];
|
|
await doStep(target, "forced-failure", async () => {
|
|
throw new Error(`SMOKE_FORCE_FAIL: ${process.env.SMOKE_FORCE_FAIL}`);
|
|
});
|
|
}
|
|
|
|
// Finalize run + capture the run in the live Smoke Lab UI.
|
|
await api("PATCH", `/api/companies/${companyId}/smoke-lab/runs/${run.id}`, {
|
|
status: failed.length ? "failed" : "passed",
|
|
summary: { catalog: "tests/e2e/smoke-lab.catalog.ts", driver: "agent-browser (real chromium)", scenarioCount: scenarios.length, only: ONLY.length ? ONLY : undefined, failed },
|
|
});
|
|
await page.goto(`${BASE}/${prefix}/apps/advanced/smoke-lab`, { waitUntil: "networkidle" });
|
|
await page.waitForTimeout(1500);
|
|
const tabFile = `${SHOT_DIR}/zz-smoke-lab-tab.png`;
|
|
await page.screenshot({ path: tabFile, fullPage: true });
|
|
await page.goto(`${BASE}/${prefix}/dashboard`, { waitUntil: "networkidle" });
|
|
await page.waitForTimeout(1500);
|
|
const dashFile = `${SHOT_DIR}/zz-dashboard-card.png`;
|
|
await page.screenshot({ path: dashFile, fullPage: true });
|
|
|
|
await browser.close();
|
|
|
|
const finalRun = await api("GET", `/api/companies/${companyId}/smoke-lab/runs/${run.id}`);
|
|
const steps = finalRun.steps ?? [];
|
|
const byPath: Record<string, number> = {};
|
|
for (const s of steps) byPath[s.path] = (byPath[s.path] ?? 0) + 1;
|
|
console.log("\n=== RESULT ===");
|
|
console.log(`companyId=${companyId} prefix=${prefix} runId=${run.id} status=${finalRun.run?.status}`);
|
|
console.log(`steps=${steps.length} perPath=${JSON.stringify(byPath)} failed=${failed.length}`);
|
|
console.log(`withScreenshot=${steps.filter((s: Json) => s.screenshotArtifactRef?.url).length}/${steps.length}`);
|
|
console.log(`tabShot=${tabFile} dashShot=${dashFile}`);
|
|
await fs.writeFile(`${SHOT_DIR}/result.json`, JSON.stringify({ companyId, prefix, runId: run.id, status: finalRun.run?.status, steps: steps.length, byPath, failed }, null, 2));
|
|
if (failed.length) process.exitCode = 2;
|
|
}
|
|
|
|
main().catch((e) => { console.error("RUNNER ERROR", e); process.exit(1); });
|