mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
Merge current master and decision admission fixes from work-folders base
Co-Authored-By: Paperclip <noreply@paperclip.ing> * codex/work-folders-base-refresh: (25 commits) fix: retain saved messages while decisions are pending fix(server): stop reporting expected managed-cloud transients to Sentry (#13323) fix: promote review tasks when continuations start (#13318) fix: require explicit native completion reviews (#13314) fix(onboarding): make chief-of-staff hiring reliable (#13317) fix: fence native startup against cancellation (#13316) chore(skills): allow verified PR merges (#13313) feat: add experimental persistent agent chat (#13284) chore(lockfile): refresh pnpm-lock.yaml (#13279) fix(ui): improve mobile task spacing (#13304) fix(connections): repair and simplify Google Workspace setup (#13289) fix(ui): stabilize steered chat activity presentation (#13246) fix(ui): make feed cards fully clickable (#13294) ci: activate shared PR dependency cache restores (#13302) test: remove cold executable reads from runner integrity deadlines (#13301) ci: reuse dependency caches without per-PR uploads (#13300) fix(ui): hide profile feedback flag on Cloud (#13292) feat(ui): refine dashboard cards, charts, and recent lists (#13269) fix: protect starting runs during overlapping deployments (#13285) fix: reliably interrupt and resume legacy message queues (#13275) ... # Conflicts: # packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs
This commit is contained in:
commit
bb80a847c4
385 files changed
+114493
-3058
No files matched your search
@@ -154,3 +154,21 @@ pnpm test:e2e:runner -- --list
|
||||
|
||||
Then run the narrowest paid cell that exercises the fixture. A full matrix is a
|
||||
manual or scheduled campaign, not a PR requirement.
|
||||
|
||||
|
||||
## Persistent chat fixtures
|
||||
|
||||
`chat-cases.ts` defines the six-case `agent-chat` suite; `chat-flow.ts` drives the
|
||||
production composer, plan revision/approval controls, questions, reset command,
|
||||
and project cards. Keep its 24 local cells intentional. `expectedRunCount`
|
||||
counts provider turns, including cancelled and handed-off task runs, but excludes
|
||||
synthetic `/new` runs. Assertions must inspect all company runs because ordinary
|
||||
issue lists exclude the source conversation. `assertChatHandoff` rejects missing
|
||||
projects/plans, chat children, wrong assignees, and execution before plan commit.
|
||||
|
||||
Retained `api-state.json`, `chat-handoff.json`, and plan-revision evidence
|
||||
include persisted comments, session generations, run context and logs, project
|
||||
workspaces, task documents, and ordering. They pass through the normal sanitizer.
|
||||
Screenshots are allowlisted to the exact disposable agent chat. Cleanup cancels
|
||||
all active runs in the isolated company, including handed-off work; usage from
|
||||
failed and cancelled runs must not disappear from campaign totals.
|
||||
@@ -72,7 +72,7 @@ pnpm test:e2e:runner -- --suite daytona-warm-continuity
|
||||
pnpm test:e2e:runner -- --all
|
||||
```
|
||||
|
||||
The catalog contains four suites. `core-compatibility` (**Core Runner
|
||||
The catalog contains five suites. `core-compatibility` (**Core Runner
|
||||
Compatibility**) is seven major runner profiles × local/Daytona × three
|
||||
workflows: 42 cells. Its cases are:
|
||||
|
||||
@@ -120,7 +120,44 @@ runner instance, PID, and process-start identity. Each turn is bounded to ten
|
||||
minutes, the cell to thirty minutes, and cleanup explicitly deletes the
|
||||
sandbox rather than waiting for Daytona's idle timeout.
|
||||
|
||||
The complete catalog is 68 cells (45 local and 23 Daytona) and 120 expected
|
||||
`agent-chat` (**Persistent Agent Chat**) adds six workflows on `legacy-codex`,
|
||||
`legacy-claude`, `runner-codex`, and `runner-acpx-claude`: **24 local cells**.
|
||||
They cover continuity across server restart, fresh context after `/new`,
|
||||
Stop/reset/resume, draft/revise/approve/plan handoff, clarification with existing
|
||||
project reuse, and a new project with two repository URLs. Each cell opens the
|
||||
production chat surface and resolves the backing issue through the chat API.
|
||||
The source conversation must settle to `in_review` / `waiting`; handed-off
|
||||
execution tasks must finish with their initial Plan and output documents.
|
||||
Reset runs are retained separately from the 68 expected provider turns in this
|
||||
suite. Cancelled turns and execution-task runs remain included in billing and
|
||||
cleanup. The production chat directive is injected normally; fixtures do not
|
||||
replace it with completion instructions. Daytona is excluded.
|
||||
|
||||
```bash
|
||||
# Run these after deterministic checks, with the required provider keys set.
|
||||
pnpm test:e2e:runner -- --id agent-chat.legacy-codex.local.continuity-restart
|
||||
pnpm test:e2e:runner -- --id agent-chat.legacy-claude.local.continuity-restart
|
||||
pnpm test:e2e:runner -- --suite agent-chat
|
||||
```
|
||||
|
||||
The regular browser suite has deterministic process providers in
|
||||
`tests/e2e/fixtures/agent-chat.mjs`. It exercises the real queue, APIs, database,
|
||||
MCP project tools, and shared task UI without provider billing. Only upstream
|
||||
GitHub discovery is simulated, scoped to a fixture-only credential; repository
|
||||
permissions and mutations remain real. Run it with:
|
||||
|
||||
```bash
|
||||
pnpm --filter @paperclipai/ui build
|
||||
pnpm test:e2e tests/e2e/agent-chat.spec.ts
|
||||
# Against a dedicated authenticated test instance configured per that suite:
|
||||
pnpm test:e2e:multiuser-authenticated --grep 'agent chats'
|
||||
```
|
||||
|
||||
Both suites save and restore experimental settings. Browser E2E always starts a
|
||||
throwaway instance; never point the authenticated suite at the running demo.
|
||||
Missing provider credentials fail paid preflight and are not passing coverage.
|
||||
|
||||
The complete catalog is 92 cells (69 local and 23 Daytona) and 188 expected
|
||||
paid agent turns. Follow-up steps remain ordered within their cell; all other
|
||||
cells are independent. Narrow selectors are strongly recommended while
|
||||
developing fixtures.
|
||||
@@ -219,6 +256,12 @@ usage is labeled `unavailable` or `unpriced`; it is never presented as zero
|
||||
cost. The CI report job stages the same portable site at
|
||||
`normalized/index.html` inside the access-controlled merged report artifact.
|
||||
|
||||
The trusted publisher discovers display-only entries for selected execution IDs
|
||||
absent from its local catalog, so branch-only suites remain visible in the
|
||||
dashboard, filters, gallery, and summary image. It validates execution identity
|
||||
and escapes display text without loading target-branch executable code. Unknown
|
||||
suite cardinality is not treated as proof of full-suite coverage.
|
||||
|
||||
Permanent publication uses two explicit bundles. Both retain only normalized
|
||||
result PNG files with the explicit `public-runner-fixture` publication marker,
|
||||
including marked `failure.png` captures, so every campaign dashboard has its
|
||||
@@ -237,6 +280,10 @@ retains allowlisted inert per-attempt evidence (`.json`, `.log`, `.md`, and
|
||||
redaction. The GitHub Pages bundle is regenerated separately with the same
|
||||
declared-screenshot boundary.
|
||||
|
||||
Publication fails if any declared public screenshot is missing from the bundle.
|
||||
The evidence packager explicitly retains `chat-plan-draft.png` and
|
||||
`chat-plan-revised.png`; arbitrary chat-prefixed files remain excluded.
|
||||
|
||||
Both public bundles exclude video, archives, raw/unallowlisted logs, SVG or
|
||||
other active content, generated Playwright/blob/HTML report trees, and
|
||||
undeclared PNG files, and per-attempt XML. The root `junit.xml` remains public
|
||||
@@ -272,6 +319,12 @@ artifacts. Each cell name links to its exact section in the campaign report.
|
||||
The public campaign links become available after the history publisher
|
||||
finishes. The artifact links remain available for 30 days.
|
||||
|
||||
For a development branch that adds a suite, the trusted default-branch dashboard
|
||||
may not yet include that suite's interactive cards. Its published `summary.md`
|
||||
and `normalized-results.json` still contain every selected cell. Use those files,
|
||||
the GitHub job summary, or `html/index.html` in the merged Playwright artifact
|
||||
to inspect branch-only results; an absent dashboard card is not passing coverage.
|
||||
|
||||
### Iterate on a published dashboard without rerunning paid tests
|
||||
|
||||
Download and extract the `github-pages` artifact from an existing workflow run,
|
||||
@@ -369,7 +422,7 @@ Set `RUNNER_E2E_AWS_ENABLED=true` to route paid cells to the repository-scoped
|
||||
ephemeral AWS RunsOn fleet selected by
|
||||
`runs-on/fleet=paperclip-public-pr-x64/env=public-ci`. Any other value uses the
|
||||
proven GitHub-hosted `ubuntu-latest` target. Set `RUNNER_E2E_MAX_PARALLEL` to an
|
||||
integer from 1–100 on AWS (default 100); use at least 68 to run the current
|
||||
integer from 1–100 on AWS (default 100); use at least 92 to run the current
|
||||
complete catalog in one wave. The fallback runner retains its 1–57 limit and
|
||||
default of 32. Multi-turn steps are sequential inside their cell while
|
||||
independent cells overlap. Artifacts and merged HTML/JUnit/normalized reports
|
||||
|
||||
@@ -74,6 +74,16 @@ report, history, and Pages jobs receive none of these secrets.
|
||||
Each full-stack matrix cell receives only its selected profile credential, plus
|
||||
Daytona only for Daytona cells. Secret-bearing and OIDC jobs use frozen installs
|
||||
without a shared dependency cache.
|
||||
On disposable GitHub Linux runners with Ubuntu's unprivileged-user-namespace
|
||||
restriction, the authorized default-branch workflow provisions an AppArmor profile before provider credentials are exposed. The profile is attached to the exact
|
||||
lockfile-pinned Codex executable. It grants `userns` so Codex can construct its
|
||||
filesystem sandbox; it does not disable the kernel restriction or Codex's
|
||||
workspace policy. Setup fails before invoking a model if the noninteractive
|
||||
profile load fails. Target-controlled tests only probe the existing sandbox and never invoke sudo or load host policy. This host-only profile disappears with the ephemeral runner.
|
||||
See [Ubuntu's namespace restriction documentation](https://documentation.ubuntu.com/security/security-features/privilege-restriction/apparmor/).
|
||||
Local developer machines are never modified by this setup. Legacy Codex fixtures
|
||||
disable optional shell-environment snapshots to avoid persisting credentials;
|
||||
the secret scanner retains its existing rejection rules.
|
||||
The Paperclip server process also receives none; the browser posts each value
|
||||
once to the encrypted company secret API and agents/environments retain only
|
||||
secret references.
|
||||
|
||||
@@ -41,10 +41,10 @@ describe("runner E2E catalog", () => {
|
||||
expect(localIntegrityTasks).toHaveLength(2);
|
||||
expect(openRouterBreadthTasks).toHaveLength(3);
|
||||
expect(runnerSuites.map((suite) => suite.expectedMatrixSize)).toEqual([
|
||||
42, 14, 10, 2,
|
||||
24, 42, 14, 10, 2,
|
||||
]);
|
||||
expect(validateRunnerCatalog()).toHaveLength(68);
|
||||
expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(68);
|
||||
expect(validateRunnerCatalog()).toHaveLength(92);
|
||||
expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(92);
|
||||
expect(
|
||||
runnerMatrix.filter((entry) => entry.suite.id === "core-compatibility"),
|
||||
).toHaveLength(42);
|
||||
@@ -68,7 +68,7 @@ describe("runner E2E catalog", () => {
|
||||
(total, execution) => total + execution.task.expectedRunCount,
|
||||
0,
|
||||
),
|
||||
).toBe(120);
|
||||
).toBe(188);
|
||||
expect(
|
||||
runnerTasks.find((task) => task.id === "plan-revise-accept")
|
||||
?.attemptTimeoutMs,
|
||||
@@ -354,7 +354,14 @@ describe("runner E2E catalog", () => {
|
||||
},
|
||||
executionId: execution!.id,
|
||||
}),
|
||||
).toMatchObject({ adapterConfig: { engine: "cli" } });
|
||||
).toMatchObject({
|
||||
adapterConfig: {
|
||||
engine: "cli",
|
||||
...(profileId === "legacy-codex"
|
||||
? { extraArgs: ["-c", "features.shell_snapshot=false"] }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -497,6 +504,7 @@ describe("runner E2E selectors", () => {
|
||||
"local",
|
||||
]);
|
||||
expect(selectRunnerExecutions(options).map((entry) => entry.id)).toEqual([
|
||||
...runnerMatrix.filter(entry => entry.suite.id === "agent-chat" && ["legacy-codex", "runner-codex"].includes(entry.profile.id)).map(entry => entry.id),
|
||||
"core-compatibility.legacy-codex.local.message-marker",
|
||||
"core-compatibility.legacy-codex.local.plan-revise-accept",
|
||||
"core-compatibility.legacy-codex.local.ask-question",
|
||||
@@ -551,10 +559,10 @@ describe("runner E2E selectors", () => {
|
||||
const jobs = buildMatrixJobs(
|
||||
selectRunnerExecutions(parseRunnerSelectors(["--all"])),
|
||||
);
|
||||
expect(jobs).toHaveLength(68);
|
||||
expect(jobs).toHaveLength(92);
|
||||
expect(jobs.filter((job) => job.needsDaytona)).toHaveLength(23);
|
||||
expect(jobs.filter((job) => !job.needsDaytona)).toHaveLength(45);
|
||||
expect(new Set(jobs.map((job) => job.executionId)).size).toBe(68);
|
||||
expect(jobs.filter((job) => !job.needsDaytona)).toHaveLength(69);
|
||||
expect(new Set(jobs.map((job) => job.executionId)).size).toBe(92);
|
||||
expect(
|
||||
jobs.find(
|
||||
(job) =>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { chatTasks } from "./chat-cases.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createAgentSchema } from "../../packages/shared/src/validators/agent.js";
|
||||
import { createEnvironmentSchema } from "../../packages/shared/src/validators/environment.js";
|
||||
@@ -30,6 +31,7 @@ const SELECTABLE_GROUPS = [
|
||||
"warm",
|
||||
"core",
|
||||
"breadth",
|
||||
"chat",
|
||||
] as const;
|
||||
const SAMPLE_UUID = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
@@ -68,8 +70,9 @@ function commonAgent(
|
||||
"AGENTS.md": [
|
||||
"You are running a paid Paperclip end-to-end acceptance fixture.",
|
||||
"Follow the assigned task and its Paperclip work mode literally.",
|
||||
"For standard and ask tasks, publish the requested visible answer and mark the task done.",
|
||||
"For planning tasks, publish or revise the canonical Plan document and its revision-bound request_confirmation, then wait. Only implement after that exact plan is accepted.",
|
||||
"In ongoing agent chats, follow the injected production chat directive; keep the conversation available after replying. The completion and implementation instructions below apply only to ordinary execution tasks.",
|
||||
"For ordinary standard and ask tasks, publish the requested visible answer and mark the task done.",
|
||||
"For ordinary planning tasks, publish or revise the canonical Plan document and its revision-bound request_confirmation, then wait. Only implement after that exact plan is accepted.",
|
||||
"Invoke assigned tools only through the runtime's real tool-call channel. Never print XML, DSML, JSON, or other tool-call markup as assistant text.",
|
||||
"Legacy adapters must use the public Paperclip API and the injected PAPERCLIP_API_URL, PAPERCLIP_API_KEY, PAPERCLIP_TASK_ID, and PAPERCLIP_RUN_ID values for comments, documents, interactions, and status changes.",
|
||||
...(adapterType === "paperclip_runner"
|
||||
@@ -205,7 +208,13 @@ export const runnerProfiles: readonly RunnerProfileFixture[] = [
|
||||
credential: "OPENAI_API_KEY",
|
||||
// Keep this fixture on the classic adapter/CLI lane. ACP execution is
|
||||
// covered independently by the native runner ACPX profiles below.
|
||||
extraConfig: { engine: "cli" },
|
||||
extraConfig: {
|
||||
engine: "cli",
|
||||
// Shell snapshots serialize inherited environment values into CODEX_HOME.
|
||||
// These disposable runs carry short-lived API credentials; keep that
|
||||
// optional optimization off rather than exempting leaked files from scans.
|
||||
extraArgs: ["-c", "features.shell_snapshot=false"],
|
||||
},
|
||||
}),
|
||||
legacyProfile({
|
||||
id: "legacy-claude",
|
||||
@@ -874,6 +883,14 @@ export const connectionReviewSuite: RunnerSuiteFixture = {
|
||||
};
|
||||
|
||||
export const runnerSuites: readonly RunnerSuiteFixture[] = [
|
||||
{
|
||||
id: "agent-chat", label: "Persistent Agent Chat",
|
||||
description: "Task-backed conversations, session resets, and project plan handoff.",
|
||||
groups: ["chat"],
|
||||
profiles: runnerProfiles.filter(profile => ["legacy-codex", "legacy-claude", "runner-codex", "runner-acpx-claude"].includes(profile.id)),
|
||||
environments: [localEnvironment], tasks: chatTasks, expectedMatrixSize: 24,
|
||||
definitionMetadata: { version: 1, resetRunsCountedSeparately: true },
|
||||
},
|
||||
...(process.env.PAPERCLIP_RUNNER_E2E_CONNECTION_REVIEWS === "1" ? [connectionReviewSuite] : []),
|
||||
{
|
||||
id: "core-compatibility",
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { RunnerTaskFixture } from "./types.js";
|
||||
|
||||
// Markers cross the rich-text composer and Markdown persistence boundary.
|
||||
// Alphanumeric text has identical visible and stored representations.
|
||||
export function chatMarker(
|
||||
prefix: "CHAT" | "DRAFT" | "OLDCONTEXT",
|
||||
nonce: string,
|
||||
) {
|
||||
return `${prefix}${nonce.replace(/[^a-zA-Z0-9]/g, "")}`;
|
||||
}
|
||||
|
||||
export const CHAT_CASES = [
|
||||
["continuity-restart", "Conversation continuity across restart", 3],
|
||||
["new-session", "Fresh context within preserved history", 2],
|
||||
["stop-new-resume", "Stop, reset, and resume", 3],
|
||||
["plan-handoff", "Draft, revise, approve, and hand off a plan", 4],
|
||||
["clarify-reuse", "Clarify and reuse an existing project", 3],
|
||||
["multi-repository", "Create a project with multiple repository URLs", 2],
|
||||
] as const;
|
||||
export type ChatCase = (typeof CHAT_CASES)[number][0];
|
||||
export const chatTasks: readonly RunnerTaskFixture[] = CHAT_CASES.map(
|
||||
([id, label, expectedRunCount]) => ({
|
||||
id,
|
||||
label,
|
||||
groups: ["chat"],
|
||||
flow: "agent_chat",
|
||||
workMode: "standard",
|
||||
expectedRunCount, // Provider turns, including cancelled turns and handed-off work; reset runs are separate.
|
||||
attemptTimeoutMs: { local: 15 * 60_000, daytona: 15 * 60_000 },
|
||||
expectedTerminalState: { issue: "in_review", run: "succeeded" },
|
||||
buildTitle: (nonce) => `Chat acceptance ${id} ${nonce}`,
|
||||
buildPrompt: (nonce) => `Let's discuss ${nonce}.`,
|
||||
buildVisibleMarker: (nonce) => chatMarker("CHAT", nonce),
|
||||
buildMatchers: () => [{ kind: "issue_status", expected: "in_review" }],
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,356 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { AskUserQuestionsPayload } from "../../packages/shared/src/types/issue.js";
|
||||
import {
|
||||
assertChatHandoff,
|
||||
assertChatTaskHandoff,
|
||||
chatQuestionPresentation,
|
||||
chatRunFailure,
|
||||
chatTaskCompletionFailure,
|
||||
createChatIdleFailureDetector,
|
||||
collectChatRunEvidence,
|
||||
readRunningChatLog,
|
||||
readChatOutputDocument,
|
||||
isChatClarificationReply,
|
||||
assertChatExecutionOutput,
|
||||
isResetRun,
|
||||
type ChatIssue,
|
||||
type ChatRun,
|
||||
} from "./chat-flow.js";
|
||||
import type { RunnerApi } from "./api.js";
|
||||
import { chatMarker } from "./chat-cases.js";
|
||||
import { runnerMatrix } from "./catalog.js";
|
||||
import { isPublicRunnerScreenshotRoute } from "./screenshot-policy.js";
|
||||
import { classifyFailure, shouldRetryFailure } from "./failure-classifier.js";
|
||||
|
||||
const source: ChatIssue = {
|
||||
id: "chat",
|
||||
companyId: "co",
|
||||
title: "Chat",
|
||||
status: "in_review",
|
||||
assigneeAgentId: "agent",
|
||||
};
|
||||
const task: ChatIssue = {
|
||||
...source,
|
||||
id: "work",
|
||||
parentId: null,
|
||||
projectId: "project",
|
||||
};
|
||||
const plan = {
|
||||
body: "# Relevant plan",
|
||||
latestRevisionId: "revision",
|
||||
updatedAt: "2026-09-11T10:00:00Z",
|
||||
};
|
||||
const run: ChatRun = {
|
||||
id: "run",
|
||||
companyId: "co",
|
||||
agentId: "agent",
|
||||
status: "succeeded",
|
||||
startedAt: "2026-09-11T10:00:01Z",
|
||||
};
|
||||
describe("chat acceptance contracts", () => {
|
||||
it("accepts concrete information requests without requiring question punctuation", () => {
|
||||
expect(isChatClarificationReply("What is the club name?")).toBe(true);
|
||||
expect(
|
||||
isChatClarificationReply(
|
||||
"Before assigning the welcome-note work, please share:\n\n1. Club name and intended readers.\n2. Format, length, and tone.\n3. Required details, sender, and deadline.",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isChatClarificationReply("Tell me the intended audience and format."),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isChatClarificationReply(
|
||||
"Thanks — before assigning the welcome-note drafting work, I need a compact brief covering:\n\n- Club and audience: club name and intended readers.\n- Purpose: welcome or next steps.\n- Required content: dates, links, and contacts.\n- Voice: tone and sender.\n- Delivery constraints: format, length, and deadline.\n- Examples or policies: existing notes and approval requirements.",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(isChatClarificationReply("I'll need your details about the audience and format.")).toBe(true);
|
||||
expect(isChatClarificationReply("We need some information about the club and intended readers.")).toBe(true);
|
||||
expect(isChatClarificationReply("I needed a compact brief before I assigned the work.")).toBe(false);
|
||||
expect(isChatClarificationReply("I need a compact brief:")).toBe(false);
|
||||
expect(isChatClarificationReply("I need information.")).toBe(false);
|
||||
expect(isChatClarificationReply("I need to create the task and write the note.")).toBe(false);
|
||||
expect(isChatClarificationReply("Please share:")).toBe(false);
|
||||
expect(isChatClarificationReply("Please share.")).toBe(false);
|
||||
expect(
|
||||
isChatClarificationReply("Asked the user clarifying questions about their club."),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isChatClarificationReply("I created the task and started writing the welcome note."),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects superseded plan requirements in executed output, independently of plan history", () => {
|
||||
expect(() => assertChatExecutionOutput("Welcome CHAT123.", "CHAT123", "DRAFT123")).not.toThrow();
|
||||
expect(() => assertChatExecutionOutput("Welcome DRAFT123 and CHAT123.", "CHAT123", "DRAFT123")).toThrow();
|
||||
expect(() => assertChatExecutionOutput("Welcome DRAFT123.", "CHAT123", "DRAFT123")).toThrow();
|
||||
});
|
||||
|
||||
it("keeps chat markers literal across rich-text and Markdown boundaries", () => {
|
||||
for (const prefix of ["CHAT", "DRAFT", "OLDCONTEXT"] as const) {
|
||||
expect(chatMarker(prefix, "abc123-1")).toBe(`${prefix}abc1231`);
|
||||
expect(chatMarker(prefix, "abc_123-1")).toMatch(/^[a-zA-Z0-9]+$/);
|
||||
}
|
||||
expect(chatMarker("OLDCONTEXT", "one-1")).not.toBe(
|
||||
chatMarker("CHAT", "one-1"),
|
||||
);
|
||||
expect(chatMarker("CHAT", "one-1")).not.toBe(chatMarker("CHAT", "two-1"));
|
||||
});
|
||||
it("has exactly six workflows on the four chosen local profiles", () => {
|
||||
const matrix = runnerMatrix.filter(
|
||||
(cell) => cell.suite.id === "agent-chat",
|
||||
);
|
||||
expect(matrix).toHaveLength(24);
|
||||
expect(new Set(matrix.map((cell) => cell.profile.id))).toEqual(
|
||||
new Set([
|
||||
"legacy-codex",
|
||||
"legacy-claude",
|
||||
"runner-codex",
|
||||
"runner-acpx-claude",
|
||||
]),
|
||||
);
|
||||
expect(new Set(matrix.map((cell) => cell.task.id)).size).toBe(6);
|
||||
expect(
|
||||
matrix.every(
|
||||
(cell) =>
|
||||
cell.environment.id === "local" &&
|
||||
cell.task.expectedTerminalState.issue === "in_review",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
it("rejects missing plan, chat children, wrong assignments, and execution before the plan", () => {
|
||||
expect(() => assertChatHandoff(task, plan, [run], source)).not.toThrow();
|
||||
for (const invalid of [
|
||||
{ ...task, parentId: "chat" },
|
||||
{ ...task, projectId: null },
|
||||
{ ...task, assigneeAgentId: "other" },
|
||||
])
|
||||
expect(() => assertChatHandoff(invalid, plan, [run], source)).toThrow();
|
||||
expect(() =>
|
||||
assertChatHandoff(task, { ...plan, body: "" }, [run], source),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
assertChatHandoff(
|
||||
task,
|
||||
{ ...plan, updatedAt: "2026-09-11T10:00:02Z" },
|
||||
[run],
|
||||
source,
|
||||
),
|
||||
).toThrow();
|
||||
expect(() => assertChatHandoff(task, plan, [], source)).toThrow();
|
||||
});
|
||||
it("requires a plan for plan handoff, while direct requests need only normal task assignment", () => {
|
||||
expect(() => assertChatTaskHandoff(task, [run], source)).not.toThrow();
|
||||
expect(() =>
|
||||
assertChatHandoff(task, { ...plan, body: "" }, [run], source),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
assertChatTaskHandoff({ ...task, projectId: null }, [run], source),
|
||||
).toThrow();
|
||||
});
|
||||
it.each(["project-description", "welcome-note", "output"])("finds committed %s output without accepting a copied plan or a claim", async (key) => {
|
||||
const output = {
|
||||
...plan,
|
||||
id: "description-doc",
|
||||
issueId: "work",
|
||||
key,
|
||||
body: "A completed description with CHAT123.",
|
||||
createdByAgentId: "agent",
|
||||
};
|
||||
const get = vi.fn(async (path: string) => {
|
||||
if (path === "/api/issues/work/documents")
|
||||
return [{ key: "plan" }, { key }];
|
||||
if (path === `/api/issues/work/documents/${key}`)
|
||||
return output;
|
||||
throw new Error(`Unexpected document read: ${path}`);
|
||||
});
|
||||
const api = { get } as Pick<RunnerApi, "get">;
|
||||
await expect(readChatOutputDocument(api, "work", "CHAT123")).resolves.toBe(
|
||||
output,
|
||||
);
|
||||
await expect(readChatOutputDocument(api, "work", "WRONG123")).rejects.toThrow(
|
||||
"no non-plan output document",
|
||||
);
|
||||
get.mockImplementation(async () => [{ key: "plan" }]);
|
||||
await expect(readChatOutputDocument(api, "work", "CHAT123")).rejects.toThrow(
|
||||
"document keys: plan",
|
||||
);
|
||||
});
|
||||
it("uses durable free-text labels, multi-selection, and the supplied submit label", () => {
|
||||
const payload: AskUserQuestionsPayload = {
|
||||
version: 1,
|
||||
submitLabel: "Send brief",
|
||||
questions: [
|
||||
{
|
||||
id: "audience",
|
||||
prompt: "Who is it for?",
|
||||
selectionMode: "multi",
|
||||
required: true,
|
||||
options: [
|
||||
{ id: "members", label: "New members" },
|
||||
{
|
||||
id: "custom",
|
||||
label: "Another audience or occasion",
|
||||
freeText: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const presentation = chatQuestionPresentation(payload);
|
||||
expect(presentation.submitLabel).toBe("Send brief");
|
||||
expect(presentation.questions[0]).toMatchObject({
|
||||
answerMode: "multi_select",
|
||||
customAnswer: { enabled: true, label: "Another audience or occasion" },
|
||||
});
|
||||
const nativePayload: AskUserQuestionsPayload = {
|
||||
...payload,
|
||||
questionSet: {
|
||||
schema: "paperclip.question_set.v1",
|
||||
submitLabel: "Continue",
|
||||
questions: [
|
||||
{
|
||||
id: "audience",
|
||||
prompt: "Who is it for?",
|
||||
required: true,
|
||||
answerMode: "text",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(chatQuestionPresentation(nativePayload)).toBe(
|
||||
nativePayload.questionSet,
|
||||
);
|
||||
});
|
||||
it("retains reset events without requesting a provider log, and does not hide missing real logs", async () => {
|
||||
const get = vi.fn().mockResolvedValue([{ type: "session_reset" }]);
|
||||
const reset = { ...run, resultJson: { conversationReset: true } };
|
||||
await expect(collectChatRunEvidence({ get }, reset)).resolves.toEqual({
|
||||
runId: run.id,
|
||||
log: null,
|
||||
events: [{ type: "session_reset" }],
|
||||
});
|
||||
expect(get.mock.calls).toEqual([
|
||||
[`/api/heartbeat-runs/${run.id}/events?limit=1000`],
|
||||
]);
|
||||
get.mockRejectedValue(new Error("Run log not found"));
|
||||
await expect(collectChatRunEvidence({ get }, run)).rejects.toThrow(
|
||||
"Run log not found",
|
||||
);
|
||||
});
|
||||
it("waits for a newly running provider's log file without swallowing server failures", async () => {
|
||||
const get = vi.fn().mockResolvedValue({ status: () => 404 });
|
||||
const api = { request: { get } } as unknown as Pick<RunnerApi, "request">;
|
||||
await expect(readRunningChatLog(api, "starting")).resolves.toBeUndefined();
|
||||
get.mockResolvedValue({
|
||||
status: () => 200,
|
||||
ok: () => true,
|
||||
json: async () => ({ content: "streamed reply" }),
|
||||
});
|
||||
await expect(readRunningChatLog(api, "running")).resolves.toBe(
|
||||
"streamed reply",
|
||||
);
|
||||
get.mockResolvedValue({ status: () => 500, ok: () => false });
|
||||
await expect(readRunningChatLog(api, "broken")).rejects.toThrow(
|
||||
"log returned 500",
|
||||
);
|
||||
});
|
||||
it("fails terminal execution errors without preempting active retries or recovery", () => {
|
||||
const failed = {
|
||||
...run,
|
||||
status: "failed",
|
||||
error: "provider rejected request",
|
||||
};
|
||||
expect(chatTaskCompletionFailure(task, [failed])).toContain(
|
||||
"provider rejected request",
|
||||
);
|
||||
expect(
|
||||
chatTaskCompletionFailure(task, [failed, { ...run, status: "queued" }]),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
chatTaskCompletionFailure({ ...task, scheduledRetry: { id: "retry" } }, [
|
||||
failed,
|
||||
]),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
chatTaskCompletionFailure(
|
||||
{ ...task, activeRecoveryAction: { id: "recovery" } },
|
||||
[failed],
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
it("fails stable contradictory idle states promptly without paid retries or transient false alarms", () => {
|
||||
const detect = createChatIdleFailureDetector(3);
|
||||
const settled = {
|
||||
resolved: true,
|
||||
status: "blocked",
|
||||
conversationState: "waiting",
|
||||
providerRunCount: 3,
|
||||
activeRuns: [] as string[],
|
||||
};
|
||||
expect(detect(settled)).toBeUndefined();
|
||||
expect(detect({ ...settled, activeRuns: ["running"] })).toBeUndefined();
|
||||
expect(detect(settled)).toBeUndefined();
|
||||
const failure = detect(settled);
|
||||
expect(failure).toContain("chat_idle_state_invariant");
|
||||
expect(classifyFailure(failure)).toBe("candidate_failure");
|
||||
expect(shouldRetryFailure(classifyFailure(failure))).toBe(false);
|
||||
expect(detect({ ...settled, status: "in_review" })).toBeUndefined();
|
||||
expect(detect(settled)).toBeUndefined();
|
||||
expect(detect({ ...settled, providerRunCount: 2 })).toBeUndefined();
|
||||
expect(detect({ ...settled, status: "in_progress" })).toBeUndefined();
|
||||
expect(detect(settled)).toBeUndefined();
|
||||
});
|
||||
it("fails promptly on terminal provider failures while permitting only expected cancellations", () => {
|
||||
expect(chatRunFailure([run])).toBeUndefined();
|
||||
expect(chatRunFailure([{ ...run, status: "running" }])).toBeUndefined();
|
||||
expect(
|
||||
chatRunFailure([
|
||||
{
|
||||
...run,
|
||||
status: "failed",
|
||||
errorCode: "permission_denied",
|
||||
error: "sandbox unavailable",
|
||||
},
|
||||
]),
|
||||
).toContain("run run failed (permission_denied): sandbox unavailable");
|
||||
expect(chatRunFailure([{ ...run, status: "cancelled" }])).toContain(
|
||||
"cancelled",
|
||||
);
|
||||
expect(
|
||||
chatRunFailure([{ ...run, status: "cancelled" }], true),
|
||||
).toBeUndefined();
|
||||
});
|
||||
it("separates reset control runs from provider runs without treating failures as resets", () => {
|
||||
expect(isResetRun(run)).toBe(false);
|
||||
expect(isResetRun({ ...run, status: "failed" })).toBe(false);
|
||||
expect(
|
||||
isResetRun({ ...run, contextSnapshot: { conversationReset: true } }),
|
||||
).toBe(true);
|
||||
});
|
||||
it("only publishes screenshots of the exact disposable chat", () => {
|
||||
const target = {
|
||||
issuePrefix: "E2E",
|
||||
issueId: "chat",
|
||||
issueIdentifier: null,
|
||||
chatAgentId: "fixture-agent",
|
||||
};
|
||||
expect(
|
||||
isPublicRunnerScreenshotRoute(
|
||||
"http://127.0.0.1:3199/E2E/chats/fixture-agent",
|
||||
target,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPublicRunnerScreenshotRoute(
|
||||
"http://127.0.0.1:3199/E2E/chats/another-agent",
|
||||
target,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isPublicRunnerScreenshotRoute(
|
||||
"https://example.com/E2E/chats/fixture-agent",
|
||||
target,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,837 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { pollUntil, type RunnerApi } from "./api.js";
|
||||
import type {
|
||||
AskUserQuestionsPayload,
|
||||
PaperclipQuestionSetPayload,
|
||||
} from "../../packages/shared/src/types/issue.js";
|
||||
import type { LiveFixtureValues } from "./live-fixtures.js";
|
||||
import type { MatrixExecution } from "./types.js";
|
||||
import { chatMarker } from "./chat-cases.js";
|
||||
|
||||
// Public API observations only: this driver never fabricates provider results or writes DB state.
|
||||
export interface ChatIssue {
|
||||
id: string;
|
||||
companyId: string;
|
||||
title: string;
|
||||
status: string;
|
||||
identifier?: string;
|
||||
conversationState?: string;
|
||||
conversationSessionGeneration?: number;
|
||||
conversationBoundaryCommentId?: string;
|
||||
parentId?: string | null;
|
||||
projectId?: string | null;
|
||||
assigneeAgentId?: string | null;
|
||||
scheduledRetry?: unknown;
|
||||
activeRecoveryAction?: unknown;
|
||||
}
|
||||
export interface ChatRun {
|
||||
id: string;
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
status: string;
|
||||
error?: string | null;
|
||||
errorCode?: string | null;
|
||||
runtimeMode?: string;
|
||||
contextSnapshot?: Record<string, unknown>;
|
||||
resultJson?: Record<string, unknown>;
|
||||
sessionIdBefore?: string | null;
|
||||
sessionIdAfter?: string | null;
|
||||
startedAt?: string;
|
||||
}
|
||||
type Comment = {
|
||||
id: string;
|
||||
body: string;
|
||||
authorAgentId?: string;
|
||||
createdByRunId?: string;
|
||||
conversationSessionGeneration?: number;
|
||||
};
|
||||
type Plan = { body: string; latestRevisionId: string; updatedAt: string };
|
||||
type ChatOutputDocument = Plan & { id: string; issueId: string; key: string };
|
||||
|
||||
/** Clarification may request information imperatively rather than end in a question mark. */
|
||||
export function isChatClarificationReply(body: string): boolean {
|
||||
if (body.includes("?")) return true;
|
||||
const request = body.match(
|
||||
/\b(?:please\s+(?:share|provide|clarify|confirm)|tell me|let me know)\b([\s\S]*)/i,
|
||||
) ?? body.match(
|
||||
/\b(?:I|we)(?:'ll|\s+will)?\s+need\s+(?:(?:a|some|the|your|more|following|compact|short|few|additional)\s+){0,4}(?:brief|details|information|context|clarification)\b([\s\S]*)/i,
|
||||
);
|
||||
return Boolean(request && /[\p{L}\p{N}]/u.test(request[1]));
|
||||
}
|
||||
|
||||
export function assertChatExecutionOutput(
|
||||
body: string,
|
||||
marker: string,
|
||||
supersededMarker?: string,
|
||||
): void {
|
||||
expect(body).toContain(marker);
|
||||
if (supersededMarker) expect(body).not.toContain(supersededMarker);
|
||||
}
|
||||
|
||||
/** A requested output document may have a descriptive key; a copied plan is not output. */
|
||||
export async function readChatOutputDocument(
|
||||
api: Pick<RunnerApi, "get">,
|
||||
issueId: string,
|
||||
marker: string,
|
||||
): Promise<ChatOutputDocument> {
|
||||
const summaries = await api.get<Array<{ key: string }>>(
|
||||
`/api/issues/${issueId}/documents`,
|
||||
);
|
||||
const documents = await Promise.all(
|
||||
summaries
|
||||
.filter((document) => document.key !== "plan")
|
||||
.map((document) =>
|
||||
api.get<ChatOutputDocument>(
|
||||
`/api/issues/${issueId}/documents/${encodeURIComponent(document.key)}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
const output = documents.find(
|
||||
(document) => document.issueId === issueId && document.body.includes(marker),
|
||||
);
|
||||
if (!output)
|
||||
throw new Error(
|
||||
`Execution task ${issueId} has no non-plan output document containing ${marker}; document keys: ${summaries.map((document) => document.key).join(", ") || "none"}`,
|
||||
);
|
||||
expect(output.id).toBeTruthy();
|
||||
expect(output.latestRevisionId).toBeTruthy();
|
||||
return output;
|
||||
}
|
||||
|
||||
export const isResetRun = (run: ChatRun) =>
|
||||
run.contextSnapshot?.conversationReset === true ||
|
||||
run.resultJson?.conversationReset === true;
|
||||
export function chatRunFailure(
|
||||
runs: ChatRun[],
|
||||
allowCancelled = false,
|
||||
): string | undefined {
|
||||
const failed = runs.find(
|
||||
(run) =>
|
||||
["failed", "timed_out"].includes(run.status) ||
|
||||
(!allowCancelled && run.status === "cancelled"),
|
||||
);
|
||||
return failed
|
||||
? `run ${failed.id} ${failed.status}${failed.errorCode ? ` (${failed.errorCode})` : ""}${failed.error ? `: ${failed.error}` : ""}`
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function chatTaskCompletionFailure(
|
||||
task: ChatIssue,
|
||||
runs: ChatRun[],
|
||||
): string | undefined {
|
||||
if (
|
||||
task.scheduledRetry ||
|
||||
task.activeRecoveryAction ||
|
||||
runs.some((run) => ["queued", "running"].includes(run.status))
|
||||
)
|
||||
return undefined;
|
||||
return chatRunFailure(runs);
|
||||
}
|
||||
|
||||
/** Require two settled observations so an in-flight finalization is not a failure. */
|
||||
export function createChatIdleFailureDetector(minimumProviderRuns: number) {
|
||||
let priorInconsistentState: string | undefined;
|
||||
return (state: {
|
||||
resolved: boolean;
|
||||
status?: string;
|
||||
conversationState?: string;
|
||||
providerRunCount: number;
|
||||
activeRuns: string[];
|
||||
}): string | undefined => {
|
||||
const inconsistentState =
|
||||
state.resolved &&
|
||||
state.providerRunCount >= minimumProviderRuns &&
|
||||
state.activeRuns.length === 0 &&
|
||||
state.conversationState === "waiting" &&
|
||||
["blocked", "done", "cancelled"].includes(state.status ?? "")
|
||||
? `${state.status}:${state.providerRunCount}`
|
||||
: undefined;
|
||||
const stable =
|
||||
inconsistentState !== undefined &&
|
||||
inconsistentState === priorInconsistentState;
|
||||
priorInconsistentState = inconsistentState;
|
||||
return stable
|
||||
? `chat_idle_state_invariant: conversation is ${state.status} while waiting after ${state.providerRunCount} settled provider runs`
|
||||
: undefined;
|
||||
};
|
||||
}
|
||||
|
||||
/** Match the shared question form's durable/native presentation, including custom labels. */
|
||||
export function chatQuestionPresentation(
|
||||
payload: AskUserQuestionsPayload,
|
||||
): PaperclipQuestionSetPayload {
|
||||
if (payload.questionSet) return payload.questionSet;
|
||||
return {
|
||||
schema: "paperclip.question_set.v1",
|
||||
...(payload.submitLabel ? { submitLabel: payload.submitLabel } : {}),
|
||||
questions: payload.questions.map((question) => {
|
||||
const freeText = question.options.find((option) => option.freeText);
|
||||
return {
|
||||
id: question.id,
|
||||
prompt: question.prompt,
|
||||
required: question.required === true,
|
||||
answerMode:
|
||||
question.selectionMode === "multi" ? "multi_select" : "single_select",
|
||||
...(freeText
|
||||
? { customAnswer: { enabled: true as const, label: freeText.label } }
|
||||
: {}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function assertChatTaskHandoff(
|
||||
task: ChatIssue,
|
||||
runs: ChatRun[],
|
||||
source: ChatIssue,
|
||||
) {
|
||||
expect(task.parentId).toBeNull();
|
||||
expect(task.projectId).toBeTruthy();
|
||||
expect(task.assigneeAgentId).toBe(source.assigneeAgentId);
|
||||
expect(runs.length).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
/** A running row can precede creation of its log file. Only that expected 404 is retryable. */
|
||||
export async function readRunningChatLog(
|
||||
api: Pick<RunnerApi, "request">,
|
||||
runId: string,
|
||||
): Promise<string | undefined> {
|
||||
const response = await api.request.get(
|
||||
`/api/heartbeat-runs/${runId}/log?limitBytes=65536`,
|
||||
);
|
||||
if (response.status() === 404) return undefined;
|
||||
if (!response.ok())
|
||||
throw new Error(`Run ${runId} log returned ${response.status()}`);
|
||||
return ((await response.json()) as { content?: string }).content;
|
||||
}
|
||||
|
||||
/** Synthetic reset runs have durable events but never start a provider log. */
|
||||
export async function collectChatRunEvidence(
|
||||
api: Pick<RunnerApi, "get">,
|
||||
run: ChatRun,
|
||||
) {
|
||||
return {
|
||||
runId: run.id,
|
||||
log: isResetRun(run)
|
||||
? null
|
||||
: await api.get(`/api/heartbeat-runs/${run.id}/log?limitBytes=1048576`),
|
||||
events: await api.get(`/api/heartbeat-runs/${run.id}/events?limit=1000`),
|
||||
};
|
||||
}
|
||||
|
||||
export function assertChatHandoff(
|
||||
task: ChatIssue,
|
||||
plan: Plan,
|
||||
runs: ChatRun[],
|
||||
source: ChatIssue,
|
||||
) {
|
||||
assertChatTaskHandoff(task, runs, source);
|
||||
expect(plan.body.trim()).not.toBe("");
|
||||
for (const run of runs) {
|
||||
expect(Date.parse(plan.updatedAt)).toBeLessThanOrEqual(
|
||||
Date.parse(run.startedAt!),
|
||||
);
|
||||
}
|
||||
}
|
||||
export async function sendChatMessage(page: Page, message: string) {
|
||||
const composer = page.getByTestId("task-chat-composer-input").last();
|
||||
await composer
|
||||
.locator('[contenteditable="true"], textarea')
|
||||
.first()
|
||||
.fill(message);
|
||||
await page.getByTestId("task-chat-composer-send").last().click();
|
||||
}
|
||||
|
||||
export async function runChatFlow(input: {
|
||||
page: Page;
|
||||
api: RunnerApi;
|
||||
fixtures: LiveFixtureValues;
|
||||
execution: MatrixExecution;
|
||||
nonce: string;
|
||||
restart: () => Promise<void>;
|
||||
observe: (issue: ChatIssue, runs: ChatRun[]) => void;
|
||||
capture: (id: string, label: string, file: string) => Promise<void>;
|
||||
evidence: (name: string, data: unknown) => Promise<void>;
|
||||
}) {
|
||||
const { page, api, fixtures: f, execution, nonce } = input;
|
||||
const chatPath = `/api/companies/${f.company.id}/chats/${f.agent.id}`;
|
||||
const route = `/${f.company.issuePrefix}/chats/${f.agent.id}`;
|
||||
const marker = execution.task.buildVisibleMarker(nonce);
|
||||
const draftMarker = chatMarker("DRAFT", nonce);
|
||||
const caseId = execution.task.id;
|
||||
let issue: ChatIssue;
|
||||
let runs: ChatRun[] = [];
|
||||
const settings = await api.get<Record<string, unknown>>(
|
||||
"/api/instance/settings/experimental",
|
||||
);
|
||||
const allRuns = async () => {
|
||||
const rows = await api.get<ChatRun[]>(
|
||||
`/api/companies/${f.company.id}/heartbeat-runs?limit=100`,
|
||||
);
|
||||
return Promise.all(
|
||||
rows.map((row) => api.get<ChatRun>(`/api/heartbeat-runs/${row.id}`)),
|
||||
);
|
||||
};
|
||||
const tasks = () =>
|
||||
api.get<ChatIssue[]>(`/api/companies/${f.company.id}/issues`);
|
||||
const comments = async () =>
|
||||
(
|
||||
await api.get<Array<Comment & { createdAt: string }>>(
|
||||
`/api/issues/${issue.id}/comments?order=asc`,
|
||||
)
|
||||
).sort(
|
||||
(a, b) =>
|
||||
a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id),
|
||||
);
|
||||
const idle = async (minimumProviderRuns: number) => {
|
||||
const inconsistentIdle = createChatIdleFailureDetector(minimumProviderRuns);
|
||||
await pollUntil({
|
||||
label: "chat turn settles to waiting",
|
||||
deadlineAt: Date.now() + 240_000,
|
||||
intervalMs: 1000,
|
||||
load: async () => {
|
||||
const resolved = await api.get<ChatIssue | null>(chatPath);
|
||||
if (resolved) issue = resolved;
|
||||
runs = await allRuns();
|
||||
if (resolved) input.observe(resolved, runs);
|
||||
return {
|
||||
resolved: Boolean(resolved),
|
||||
status: resolved?.status,
|
||||
conversationState: resolved?.conversationState,
|
||||
providerRunCount: runs.filter((run) => !isResetRun(run)).length,
|
||||
activeRuns: runs
|
||||
.filter((run) => ["queued", "running"].includes(run.status))
|
||||
.map((run) => run.id),
|
||||
failure: chatRunFailure(runs, caseId === "stop-new-resume"),
|
||||
};
|
||||
},
|
||||
reject: (state) => state.failure ?? inconsistentIdle(state),
|
||||
accept: (state) =>
|
||||
!state.failure &&
|
||||
state.resolved &&
|
||||
state.providerRunCount >= minimumProviderRuns &&
|
||||
state.activeRuns.length === 0 &&
|
||||
state.status === "in_review" &&
|
||||
state.conversationState === "waiting",
|
||||
});
|
||||
};
|
||||
const turn = async (text: string, count: number) => {
|
||||
await sendChatMessage(page, text);
|
||||
await idle(count);
|
||||
};
|
||||
const noTasks = async () => expect(await tasks()).toHaveLength(0);
|
||||
try {
|
||||
await api.patch("/api/instance/settings/experimental", {
|
||||
enableAgentChat: true,
|
||||
enableClassicTaskInterface: false,
|
||||
});
|
||||
expect(await api.get(chatPath)).toBeNull();
|
||||
// Cold Vite startup can keep unrelated assets loading after the chat is
|
||||
// interactive. The composer assertion below verifies actual UI readiness.
|
||||
await page.goto(route, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||
await expect(page.getByTestId("task-chat-composer-input")).toBeVisible();
|
||||
expect(await api.get(chatPath)).toBeNull();
|
||||
expect(await allRuns()).toHaveLength(0);
|
||||
|
||||
if (
|
||||
["continuity-restart", "new-session", "stop-new-resume"].includes(caseId)
|
||||
) {
|
||||
const secret = chatMarker("OLDCONTEXT", nonce);
|
||||
await turn(
|
||||
`For this conversation only, remember the phrase ${secret}. Just acknowledge briefly; no project or task is needed.`,
|
||||
1,
|
||||
);
|
||||
const initialId = issue!.id;
|
||||
const before = runs.filter((run) => !isResetRun(run))[0]!;
|
||||
await noTasks();
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||
if (caseId === "continuity-restart") {
|
||||
await turn(
|
||||
"What phrase did I just ask you to remember? Reply with the phrase only.",
|
||||
2,
|
||||
);
|
||||
expect(
|
||||
(await comments()).filter((c) => c.authorAgentId).at(-1)?.body,
|
||||
).toContain(secret);
|
||||
const count = runs.length;
|
||||
await input.restart();
|
||||
// Re-enter the canonical route after the server replaces its browser
|
||||
// transport; reloading the stale document can target a detached page.
|
||||
await page.goto(route, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||
await expect(page.getByTestId("task-chat-composer-input")).toBeVisible();
|
||||
await idle(2);
|
||||
expect(runs).toHaveLength(count);
|
||||
await turn(
|
||||
`We are done discussing it. Reply with ${marker} only; no further work.`,
|
||||
3,
|
||||
);
|
||||
} else {
|
||||
let cancelledId: string | undefined;
|
||||
if (caseId === "stop-new-resume") {
|
||||
await sendChatMessage(
|
||||
page,
|
||||
"Explain the history of gardening at length here, in 100 numbered paragraphs. This is discussion only; do not create work.",
|
||||
);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
runs = await allRuns();
|
||||
const active = runs.find(
|
||||
(run) => run.status === "running" && run.id !== before.id,
|
||||
);
|
||||
if (!active) return false;
|
||||
const events = await api.get<Array<Record<string, unknown>>>(
|
||||
`/api/heartbeat-runs/${active.id}/events?limit=1000`,
|
||||
);
|
||||
const log = await readRunningChatLog(api, active.id);
|
||||
if (!(events.length || log?.length)) return false;
|
||||
cancelledId = active.id;
|
||||
return true;
|
||||
},
|
||||
{ timeout: 120_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
await page.getByTestId("task-chat-composer-stop").click();
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await api.get<ChatRun>(`/api/heartbeat-runs/${cancelledId}`))
|
||||
.status,
|
||||
)
|
||||
.toBe("cancelled");
|
||||
}
|
||||
const oldComments = await comments();
|
||||
await sendChatMessage(page, "/new");
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await api.get<ChatIssue>(chatPath))
|
||||
.conversationSessionGeneration,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(1);
|
||||
await expect
|
||||
.poll(async () => (await allRuns()).some(isResetRun))
|
||||
.toBe(true);
|
||||
await turn(
|
||||
`Without reading older history or files, if you have a remembered phrase in your current context return it; otherwise reply exactly ${marker}. Do not look it up.`,
|
||||
caseId === "new-session" ? 2 : 3,
|
||||
);
|
||||
const fresh = runs
|
||||
.filter((run) => !isResetRun(run) && run.status === "succeeded")
|
||||
.sort((a, b) => Date.parse(a.startedAt!) - Date.parse(b.startedAt!))
|
||||
.at(-1)!;
|
||||
expect(fresh.contextSnapshot?.conversationSessionGeneration).toBe(1);
|
||||
expect(fresh.sessionIdBefore).toBeFalsy();
|
||||
expect(
|
||||
String(fresh.contextSnapshot?.paperclipTaskMarkdown ?? ""),
|
||||
).not.toContain(secret);
|
||||
if (before.sessionIdAfter && fresh.sessionIdAfter)
|
||||
expect(fresh.sessionIdAfter).not.toBe(before.sessionIdAfter);
|
||||
const replies = (await comments()).filter(
|
||||
(c) => c.createdByRunId === fresh.id && c.authorAgentId,
|
||||
);
|
||||
expect(replies.map((c) => c.body).join("\n")).toContain(marker);
|
||||
expect(replies.map((c) => c.body).join("\n")).not.toContain(secret);
|
||||
const reset = runs.filter(isResetRun);
|
||||
expect(reset).toHaveLength(1);
|
||||
expect(
|
||||
(await comments()).filter(
|
||||
(c) => c.authorAgentId && c.createdByRunId === reset[0]!.id,
|
||||
),
|
||||
).toHaveLength(0);
|
||||
expect(
|
||||
(await comments()).filter((c) =>
|
||||
oldComments.some((old) => old.id === c.id),
|
||||
),
|
||||
).toHaveLength(oldComments.length);
|
||||
if (cancelledId)
|
||||
expect(
|
||||
(await comments()).filter((c) => c.createdByRunId === cancelledId),
|
||||
).toEqual(
|
||||
oldComments.filter((c) => c.createdByRunId === cancelledId),
|
||||
);
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||
await expect(
|
||||
page.getByText("New session", { exact: true }),
|
||||
).toHaveCount(1);
|
||||
}
|
||||
expect(issue!.id).toBe(initialId);
|
||||
await noTasks();
|
||||
} else {
|
||||
let existingProject: { id: string; name: string } | undefined;
|
||||
let acceptedPlan: Plan | undefined;
|
||||
let repositoryCatalogBefore:
|
||||
{ repositories: Array<{ id: string; url: string }> } | undefined;
|
||||
const expectedRepositoryUrls = [
|
||||
"https://github.com/octocat/Hello-World",
|
||||
"https://github.com/octocat/Spoon-Knife",
|
||||
];
|
||||
if (caseId === "clarify-reuse") {
|
||||
existingProject = await api.post(
|
||||
`/api/companies/${f.company.id}/projects`,
|
||||
{
|
||||
name: `Garden ${nonce}`,
|
||||
description: "Garden club welcome notes and event announcements.",
|
||||
},
|
||||
);
|
||||
await turn(
|
||||
"I need a welcome note for our club. Help me clarify what information you need before assigning the work.",
|
||||
1,
|
||||
);
|
||||
await noTasks();
|
||||
const questions = await api.get<
|
||||
Array<{
|
||||
status: string;
|
||||
kind: string;
|
||||
payload: AskUserQuestionsPayload;
|
||||
}>
|
||||
>(`/api/issues/${issue!.id}/interactions`);
|
||||
const pendingInteraction = questions.find(
|
||||
(row) =>
|
||||
row.status === "pending" && row.kind === "ask_user_questions",
|
||||
);
|
||||
const questionSet = pendingInteraction
|
||||
? chatQuestionPresentation(pendingInteraction.payload)
|
||||
: undefined;
|
||||
const pendingQuestions = questionSet?.questions;
|
||||
expect(
|
||||
Boolean(pendingQuestions?.length) ||
|
||||
(await comments()).some(
|
||||
(c) => c.authorAgentId && isChatClarificationReply(c.body),
|
||||
),
|
||||
).toBe(true);
|
||||
const clarification = `It is the garden club; use the existing Garden ${nonce} project. Make one assigned task for yourself to write a two-sentence welcome note. Include ${marker} in that note, save it as a Paperclip document attached to that execution task, and finish that execution task. Please get it started now.`;
|
||||
if (pendingQuestions?.length) {
|
||||
for (const [index, question] of pendingQuestions.entries()) {
|
||||
const textInput = page
|
||||
.getByTestId("question-text-answer-composer")
|
||||
.last();
|
||||
if (await textInput.isVisible()) {
|
||||
await textInput
|
||||
.locator('[contenteditable="true"],textarea')
|
||||
.first()
|
||||
.fill(clarification);
|
||||
} else {
|
||||
await page
|
||||
.getByRole(
|
||||
question.answerMode === "multi_select" ? "checkbox" : "radio",
|
||||
{
|
||||
name: question.customAnswer?.label ?? "Other",
|
||||
exact: true,
|
||||
},
|
||||
)
|
||||
.last()
|
||||
.click();
|
||||
await page
|
||||
.getByTestId("question-other-answer-composer")
|
||||
.last()
|
||||
.locator('[contenteditable="true"],textarea')
|
||||
.first()
|
||||
.fill(clarification);
|
||||
}
|
||||
await page
|
||||
.getByRole("button", {
|
||||
name:
|
||||
index === pendingQuestions.length - 1
|
||||
? (questionSet?.submitLabel ?? "Submit answers")
|
||||
: "Next",
|
||||
exact: true,
|
||||
})
|
||||
.last()
|
||||
.click();
|
||||
}
|
||||
await idle(3);
|
||||
} else await turn(clarification, 3);
|
||||
} else if (caseId === "plan-handoff") {
|
||||
await page.getByTestId("task-chat-composer-mode").click();
|
||||
await page
|
||||
.getByTestId("task-chat-composer-mode-menu")
|
||||
.getByText("Plan mode", { exact: true })
|
||||
.click();
|
||||
await turn(
|
||||
`Let's plan a two-sentence garden club welcome note. The finished welcome note itself must contain the exact phrase ${draftMarker}. Write a plan in the plan panel that includes this requirement, and present it for approval. When I approve the final revision, create a suitable repository-free project and an assigned task for yourself, copy the plan into that task, and have it save the note as a Paperclip document attached to that execution task and finish. Do not create the project or task before approval.`,
|
||||
1,
|
||||
);
|
||||
const draft = await api.get<Plan>(
|
||||
`/api/issues/${issue!.id}/documents/plan`,
|
||||
);
|
||||
expect(draft.body).toContain(draftMarker);
|
||||
await noTasks();
|
||||
await input.capture(
|
||||
"chat-plan-draft",
|
||||
"Draft plan in the conversation",
|
||||
"chat-plan-draft.png",
|
||||
);
|
||||
const initialInteractions = await api.get<
|
||||
Array<{
|
||||
status: string;
|
||||
kind: string;
|
||||
payload?: {
|
||||
target?: { revisionId?: string };
|
||||
rejectLabel?: string;
|
||||
};
|
||||
}>
|
||||
>(`/api/issues/${issue!.id}/interactions`);
|
||||
const initialApproval = initialInteractions.find(
|
||||
(row) =>
|
||||
row.status === "pending" &&
|
||||
row.kind === "request_confirmation" &&
|
||||
row.payload?.target?.revisionId === draft.latestRevisionId,
|
||||
);
|
||||
expect(
|
||||
initialApproval,
|
||||
"draft has a revision-bound approval",
|
||||
).toBeTruthy();
|
||||
const reviseButton = page
|
||||
.getByRole("button", {
|
||||
name: initialApproval!.payload?.rejectLabel ?? "Reject",
|
||||
exact: true,
|
||||
})
|
||||
.last();
|
||||
await reviseButton.click();
|
||||
await page
|
||||
.getByTestId("plan-revision-composer")
|
||||
.last()
|
||||
.locator('[contenteditable="true"],textarea')
|
||||
.first()
|
||||
.fill(
|
||||
`Revise the plan: the finished welcome note itself must contain the exact phrase ${marker} instead of ${draftMarker}. Include that requirement in the revised plan. The execution task should save that welcome note as a Paperclip document attached to that task. Present this revised plan for approval; wait for that approval before handing it off as agreed.`,
|
||||
);
|
||||
await reviseButton.click();
|
||||
await idle(2);
|
||||
const revised = await api.get<Plan>(
|
||||
`/api/issues/${issue!.id}/documents/plan`,
|
||||
);
|
||||
expect(revised.body).toContain(marker);
|
||||
// A revision-history section may quote the superseded requirement.
|
||||
// The executed output below must use only the accepted requirement.
|
||||
expect(revised.latestRevisionId).not.toBe(draft.latestRevisionId);
|
||||
acceptedPlan = revised;
|
||||
await noTasks();
|
||||
const interactions = await api.get<
|
||||
Array<{
|
||||
id: string;
|
||||
status: string;
|
||||
kind: string;
|
||||
payload?: {
|
||||
target?: { revisionId?: string };
|
||||
acceptLabel?: string;
|
||||
};
|
||||
}>
|
||||
>(`/api/issues/${issue!.id}/interactions`);
|
||||
const approval = interactions.find(
|
||||
(row) =>
|
||||
row.status === "pending" &&
|
||||
row.kind === "request_confirmation" &&
|
||||
row.payload?.target?.revisionId === revised.latestRevisionId,
|
||||
);
|
||||
expect(approval, "approval targets the revised plan").toBeTruthy();
|
||||
await input.capture(
|
||||
"chat-plan-revised",
|
||||
"Revised plan before handoff",
|
||||
"chat-plan-revised.png",
|
||||
);
|
||||
await page
|
||||
.getByRole("button", {
|
||||
name: approval!.payload?.acceptLabel ?? "Approve",
|
||||
exact: true,
|
||||
})
|
||||
.last()
|
||||
.click();
|
||||
await idle(4);
|
||||
await input.evidence("chat-plan-revisions.json", {
|
||||
draft,
|
||||
revised,
|
||||
approval,
|
||||
source: await api.get(`/api/issues/${issue!.id}/documents/plan`),
|
||||
});
|
||||
} else {
|
||||
repositoryCatalogBefore = await api.get(
|
||||
`/api/companies/${f.company.id}/project-repositories`,
|
||||
);
|
||||
expect(
|
||||
repositoryCatalogBefore!.repositories.filter((repository) =>
|
||||
expectedRepositoryUrls.includes(repository.url),
|
||||
),
|
||||
).toHaveLength(0);
|
||||
await turn(
|
||||
`Create a project called Repository Discussion ${nonce} for work spanning https://github.com/octocat/Hello-World and https://github.com/octocat/Spoon-Knife. These existing public repositories are not in our catalog; register both URLs. Then make one assigned task for yourself to write a two-sentence description of the intended project as a Paperclip document attached to that execution task, containing ${marker}, and complete that task. No code changes or remote repository creation are needed.`,
|
||||
2,
|
||||
);
|
||||
}
|
||||
const children = await tasks();
|
||||
expect(children).toHaveLength(1);
|
||||
const child = children[0]!;
|
||||
await pollUntil({
|
||||
label: `execution task ${child.id} completes`,
|
||||
deadlineAt: Date.now() + 240_000,
|
||||
intervalMs: 1000,
|
||||
load: async () => ({
|
||||
task: await api.get<ChatIssue>(`/api/issues/${child.id}`),
|
||||
runs: await api.get<ChatRun[]>(`/api/issues/${child.id}/runs`),
|
||||
}),
|
||||
accept: (state) => state.task.status === "done",
|
||||
reject: (state) => chatTaskCompletionFailure(state.task, state.runs),
|
||||
});
|
||||
runs = await allRuns();
|
||||
input.observe(issue!, runs);
|
||||
const plan =
|
||||
caseId === "plan-handoff"
|
||||
? await api.get<Plan>(`/api/issues/${child.id}/documents/plan`)
|
||||
: null;
|
||||
const taskRuns = runs.filter(
|
||||
(run) => run.contextSnapshot?.issueId === child.id,
|
||||
);
|
||||
if (plan) {
|
||||
assertChatHandoff(child, plan, taskRuns, issue!);
|
||||
expect(plan.body).toContain(marker);
|
||||
const sourcePlan = await api.get<Plan>(
|
||||
`/api/issues/${issue!.id}/documents/plan`,
|
||||
);
|
||||
expect(sourcePlan.body).toBe(acceptedPlan!.body);
|
||||
expect(sourcePlan.latestRevisionId).toBe(
|
||||
acceptedPlan!.latestRevisionId,
|
||||
);
|
||||
} else assertChatTaskHandoff(child, taskRuns, issue!);
|
||||
const output = await readChatOutputDocument(api, child.id, marker);
|
||||
assertChatExecutionOutput(
|
||||
output.body,
|
||||
marker,
|
||||
caseId === "plan-handoff" ? draftMarker : undefined,
|
||||
);
|
||||
await input.evidence("chat-execution-output.json", {
|
||||
taskId: child.id,
|
||||
document: output,
|
||||
revisions: await api.get(
|
||||
`/api/issues/${child.id}/documents/${encodeURIComponent(output.key)}/revisions`,
|
||||
),
|
||||
executionRunIds: taskRuns.map((run) => run.id),
|
||||
});
|
||||
expect(
|
||||
(await comments())
|
||||
.filter((c) => c.authorAgentId)
|
||||
.map((c) => c.body)
|
||||
.join("\n"),
|
||||
).toMatch(new RegExp(`${child.id}|${child.identifier}`));
|
||||
const projects = await api.get<
|
||||
Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
workspaces: Array<{ id: string; name: string; repoUrl?: string }>;
|
||||
}>
|
||||
>(`/api/companies/${f.company.id}/projects`);
|
||||
expect(projects).toHaveLength(1);
|
||||
if (existingProject) {
|
||||
expect(child.projectId).toBe(existingProject.id);
|
||||
await expect(
|
||||
page.getByRole("article", { name: /Project created:/ }),
|
||||
).toHaveCount(0);
|
||||
} else {
|
||||
await expect(
|
||||
page.getByRole("article", { name: /Project created:/ }),
|
||||
).toHaveCount(1);
|
||||
if (caseId === "multi-repository") {
|
||||
expect(projects[0]!.workspaces.map((w) => w.repoUrl).sort()).toEqual(
|
||||
[...expectedRepositoryUrls].sort(),
|
||||
);
|
||||
// URL registration is persisted as project workspaces; the discovery
|
||||
// catalog continues to reflect authorized GitHub connections only.
|
||||
expect(
|
||||
projects[0]!.workspaces.every((workspace) => Boolean(workspace.id)),
|
||||
).toBe(true);
|
||||
expect(
|
||||
new Set(projects[0]!.workspaces.map((workspace) => workspace.id))
|
||||
.size,
|
||||
).toBe(2);
|
||||
const persistedProject = await api.get<{
|
||||
workspaces: Array<{ id: string; repoUrl?: string }>;
|
||||
}>(`/api/projects/${projects[0]!.id}`);
|
||||
expect(
|
||||
persistedProject.workspaces.map(({ id, repoUrl }) => ({
|
||||
id,
|
||||
repoUrl,
|
||||
})),
|
||||
).toEqual(
|
||||
projects[0]!.workspaces.map(({ id, repoUrl }) => ({ id, repoUrl })),
|
||||
);
|
||||
await input.evidence("chat-repository-registration.json", {
|
||||
catalogBefore: repositoryCatalogBefore,
|
||||
projectId: projects[0]!.id,
|
||||
registeredWorkspaces: persistedProject.workspaces,
|
||||
});
|
||||
const projectCard = page.getByRole("article", {
|
||||
name: /Project created:/,
|
||||
});
|
||||
// Repository labels may be customized; verify the actual destinations.
|
||||
for (const repositoryUrl of expectedRepositoryUrls) {
|
||||
await expect(
|
||||
projectCard.locator(`a[href="${repositoryUrl}"]`),
|
||||
).toBeVisible();
|
||||
}
|
||||
} else
|
||||
expect(projects[0]!.workspaces.filter((w) => w.repoUrl)).toHaveLength(
|
||||
0,
|
||||
);
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||
await expect(
|
||||
page.getByRole("article", { name: /Project created:/ }),
|
||||
).toHaveCount(1);
|
||||
}
|
||||
await input.evidence("chat-handoff.json", {
|
||||
source: issue!,
|
||||
task: child,
|
||||
plan,
|
||||
output,
|
||||
projects,
|
||||
});
|
||||
}
|
||||
await idle(execution.task.expectedRunCount);
|
||||
expect(runs.filter((run) => !isResetRun(run))).toHaveLength(
|
||||
execution.task.expectedRunCount,
|
||||
);
|
||||
for (const run of runs.filter((run) => !isResetRun(run))) {
|
||||
expect(run.runtimeMode).toBe(execution.profile.expectedRuntimeMode);
|
||||
expect(run.status).toBe(
|
||||
caseId === "stop-new-resume" && run.status === "cancelled"
|
||||
? "cancelled"
|
||||
: "succeeded",
|
||||
);
|
||||
}
|
||||
await input.evidence("api-state.json", {
|
||||
issue: issue!,
|
||||
runs,
|
||||
runGroups: {
|
||||
resets: runs.filter(isResetRun).map((run) => run.id),
|
||||
cancelled: runs
|
||||
.filter((run) => run.status === "cancelled")
|
||||
.map((run) => run.id),
|
||||
conversation: runs
|
||||
.filter(
|
||||
(run) =>
|
||||
!isResetRun(run) && run.contextSnapshot?.issueId === issue!.id,
|
||||
)
|
||||
.map((run) => run.id),
|
||||
handoff: runs
|
||||
.filter((run) => run.contextSnapshot?.issueId !== issue!.id)
|
||||
.map((run) => run.id),
|
||||
},
|
||||
comments: await comments(),
|
||||
activity: await api.get(`/api/issues/${issue!.id}/activity`),
|
||||
runEvidence: await Promise.all(
|
||||
runs.map((run) => collectChatRunEvidence(api, run)),
|
||||
),
|
||||
});
|
||||
await input.capture(
|
||||
"final-state",
|
||||
"Chat waiting after its verified workflow",
|
||||
"final-state.png",
|
||||
);
|
||||
return { issue: issue!, runs };
|
||||
} finally {
|
||||
await api.patch("/api/instance/settings/experimental", {
|
||||
enableAgentChat: settings.enableAgentChat,
|
||||
enableClassicTaskInterface: settings.enableClassicTaskInterface,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../..");
|
||||
|
||||
describe("Codex CI sandbox trust boundary", () => {
|
||||
it("keeps privileged policy changes out of target-controlled tests", async () => {
|
||||
const source = await readFile(
|
||||
path.join(root, "tests/runner-e2e/codex-ci-sandbox.ts"),
|
||||
"utf8",
|
||||
);
|
||||
expect(source).not.toMatch(
|
||||
/execFileSync\(["']sudo|apparmor_parser|flags=\(unconfined\)/,
|
||||
);
|
||||
expect(source).toMatch(/"sandbox",\s*"--permission-profile",\s*"paperclip-e2e-probe"/);
|
||||
expect(source).toContain(
|
||||
"permissions.paperclip-e2e-probe.network.enabled=false",
|
||||
);
|
||||
expect(source).toContain(
|
||||
'permissions.paperclip-e2e-probe.filesystem={":root"="read"}',
|
||||
);
|
||||
expect(source).toContain("Codex sandbox preflight failed");
|
||||
expect(source).not.toContain("...process.env");
|
||||
});
|
||||
|
||||
it("provisions the exact executable in trusted CI before provider credentials", async () => {
|
||||
const workflow = await readFile(
|
||||
path.join(root, ".github/workflows/runner-full-stack-e2e.yml"),
|
||||
"utf8",
|
||||
);
|
||||
const setup = workflow.indexOf(
|
||||
"- name: Provision Codex sandbox on the disposable trusted runner",
|
||||
);
|
||||
const paid = workflow.indexOf("- name: Run paid cell");
|
||||
expect(setup).toBeGreaterThan(
|
||||
workflow.indexOf(
|
||||
"- name: Reauthorize paid execution before provider access",
|
||||
),
|
||||
);
|
||||
expect(paid).toBeGreaterThan(setup);
|
||||
const step = workflow.slice(setup, paid);
|
||||
expect(step).toContain('binary.startsWith(root + "/node_modules/.pnpm/")');
|
||||
expect(step).toContain("binary.endsWith(suffix)");
|
||||
expect(step).toContain('"-n", "apparmor_parser", "-r", profilePath');
|
||||
expect(step).toContain('flag:"wx"');
|
||||
expect(step).not.toContain("secrets.");
|
||||
expect(step).not.toContain("node scripts/");
|
||||
expect(step).not.toContain("sysctl -w");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, realpath } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
|
||||
/** Probe the existing Linux sandbox before spending provider credentials.
|
||||
* Host policy is provisioned by the trusted workflow, never by target test code.
|
||||
*/
|
||||
export async function prepareCodexCiSandbox(
|
||||
repositoryRoot: string,
|
||||
temporaryRoot: string,
|
||||
) {
|
||||
if (process.platform !== "linux" || process.env.GITHUB_ACTIONS !== "true")
|
||||
return;
|
||||
const runnerRequire = createRequire(
|
||||
path.join(repositoryRoot, "packages/paperclip-runner/package.json"),
|
||||
);
|
||||
const acpRequire = createRequire(
|
||||
runnerRequire.resolve("@agentclientprotocol/codex-acp/package.json"),
|
||||
);
|
||||
const codexRequire = createRequire(
|
||||
acpRequire.resolve("@openai/codex/package.json"),
|
||||
);
|
||||
const arch =
|
||||
process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : null;
|
||||
if (!arch) throw new Error("Unsupported Codex CI architecture");
|
||||
const platformPackage = codexRequire.resolve(
|
||||
`@openai/codex-linux-${arch}/package.json`,
|
||||
);
|
||||
const triple =
|
||||
arch === "x64" ? "x86_64-unknown-linux-musl" : "aarch64-unknown-linux-musl";
|
||||
const binary = await realpath(
|
||||
path.join(path.dirname(platformPackage), "vendor", triple, "bin", "codex"),
|
||||
);
|
||||
const probeHome = path.join(temporaryRoot, "codex-sandbox-probe");
|
||||
await mkdir(probeHome, { mode: 0o700 });
|
||||
try {
|
||||
execFileSync(
|
||||
binary,
|
||||
[
|
||||
"sandbox",
|
||||
"--permission-profile",
|
||||
"paperclip-e2e-probe",
|
||||
"-c",
|
||||
'permissions.paperclip-e2e-probe.filesystem={":root"="read"}',
|
||||
"-c",
|
||||
"permissions.paperclip-e2e-probe.network.enabled=false",
|
||||
"-C",
|
||||
temporaryRoot,
|
||||
"--",
|
||||
"/bin/true",
|
||||
],
|
||||
{
|
||||
cwd: temporaryRoot,
|
||||
env: { PATH: process.env.PATH, CODEX_HOME: probeHome },
|
||||
timeout: 15_000,
|
||||
stdio: "pipe",
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
"Codex sandbox preflight failed. The trusted CI runner must provision its user-namespace policy before paid tests run.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import {
|
||||
discoverReportCatalog,
|
||||
type ReportExecution,
|
||||
} from "./report-catalog.js";
|
||||
import type {
|
||||
MatrixExecution,
|
||||
RunnerE2ECampaign,
|
||||
RunnerE2EHistoryIndex,
|
||||
RunnerE2EResult,
|
||||
@@ -22,7 +25,7 @@ export interface RunnerDashboardInput {
|
||||
title: string;
|
||||
generatedAt: string;
|
||||
expected: readonly string[];
|
||||
catalog: readonly MatrixExecution[];
|
||||
catalog: readonly ReportExecution[];
|
||||
entries: readonly RunnerDashboardEntry[];
|
||||
campaign?: RunnerE2ECampaign;
|
||||
history?: RunnerE2EHistoryIndex;
|
||||
@@ -135,7 +138,7 @@ function resolveScreenshots(
|
||||
}
|
||||
|
||||
function renderCase(
|
||||
execution: MatrixExecution,
|
||||
execution: ReportExecution,
|
||||
expected: ReadonlySet<string>,
|
||||
entryById: ReadonlyMap<string, RunnerDashboardEntry>,
|
||||
) {
|
||||
@@ -151,7 +154,11 @@ function renderCase(
|
||||
const label = state.replace("-", " ");
|
||||
const detail =
|
||||
entry?.errors.join("; ") ||
|
||||
(entry?.valid ? "All invariants passed" : "Not selected");
|
||||
(entry?.valid
|
||||
? "All invariants passed"
|
||||
: selected
|
||||
? "No result artifact was uploaded"
|
||||
: "Not selected");
|
||||
const screenshots = resolveScreenshots(entry);
|
||||
const billing = entry ? summarizeExecutionBilling(entry.result) : null;
|
||||
const matcherResults = entry?.result.matcherResults ?? [];
|
||||
@@ -508,7 +515,7 @@ function renderHistory(history: RunnerE2EHistoryIndex | undefined) {
|
||||
}
|
||||
|
||||
function renderSuiteMatrix(input: {
|
||||
suiteCatalog: readonly MatrixExecution[];
|
||||
suiteCatalog: readonly ReportExecution[];
|
||||
expected: ReadonlySet<string>;
|
||||
entryById: ReadonlyMap<string, RunnerDashboardEntry>;
|
||||
summary?: RunnerE2ESuiteSummary;
|
||||
@@ -580,6 +587,11 @@ function renderSuiteMatrix(input: {
|
||||
}
|
||||
|
||||
export function renderRunnerE2EDashboard(input: RunnerDashboardInput) {
|
||||
const catalog = discoverReportCatalog({
|
||||
catalog: input.catalog,
|
||||
expected: input.expected,
|
||||
results: input.entries.map((entry) => entry.result),
|
||||
});
|
||||
const expected = new Set(input.expected);
|
||||
const entryById = new Map(
|
||||
input.entries.map((entry) => [entry.result.executionId, entry]),
|
||||
@@ -602,13 +614,13 @@ export function renderRunnerE2EDashboard(input: RunnerDashboardInput) {
|
||||
);
|
||||
const suites = [
|
||||
...new Map(
|
||||
input.catalog.map((execution) => [execution.suite.id, execution.suite]),
|
||||
catalog.map((execution) => [execution.suite.id, execution.suite]),
|
||||
).values(),
|
||||
];
|
||||
const suiteSections = suites
|
||||
.map((suite) =>
|
||||
renderSuiteMatrix({
|
||||
suiteCatalog: input.catalog.filter(
|
||||
suiteCatalog: catalog.filter(
|
||||
(execution) => execution.suite.id === suite.id,
|
||||
),
|
||||
expected,
|
||||
@@ -625,15 +637,12 @@ export function renderRunnerE2EDashboard(input: RunnerDashboardInput) {
|
||||
);
|
||||
const filterProfiles = [
|
||||
...new Map(
|
||||
input.catalog.map((execution) => [
|
||||
execution.profile.id,
|
||||
execution.profile,
|
||||
]),
|
||||
catalog.map((execution) => [execution.profile.id, execution.profile]),
|
||||
).values(),
|
||||
];
|
||||
const filterEnvironments = [
|
||||
...new Map(
|
||||
input.catalog.map((execution) => [
|
||||
catalog.map((execution) => [
|
||||
execution.environment.id,
|
||||
execution.environment,
|
||||
]),
|
||||
@@ -1034,7 +1043,7 @@ export function renderRunnerE2EDashboard(input: RunnerDashboardInput) {
|
||||
</section>
|
||||
${suiteSections}
|
||||
${historySection}
|
||||
<footer><span>Generated ${html(input.generatedAt)}</span><span>${input.catalog.length} catalog executions · Declared screenshots and sanitized structured evidence published</span></footer>
|
||||
<footer><span>Generated ${html(input.generatedAt)}</span><span>${catalog.length} catalog executions · Declared screenshots and sanitized structured evidence published</span></footer>
|
||||
</main>
|
||||
<dialog class="gallery-dialog" data-gallery-dialog aria-labelledby="gallery-title">
|
||||
<div class="gallery-shell">
|
||||
|
||||
@@ -38,10 +38,7 @@ describe("runner E2E Daytona image contract", () => {
|
||||
"COPY packages/paperclip-runner ./packages/paperclip-runner",
|
||||
);
|
||||
expect(dockerfile).toContain(
|
||||
"COPY packages/paperclip-eval-kernel/src ./packages/paperclip-eval-kernel/src",
|
||||
);
|
||||
expect(dockerfile).toContain(
|
||||
"COPY packages/paperclip-runner/src ./packages/paperclip-runner/src",
|
||||
"COPY packages ./packages",
|
||||
);
|
||||
expect(dockerfile).toContain(
|
||||
"/opt/paperclip-runner/provider-pack/provider-pack.json",
|
||||
@@ -137,20 +134,21 @@ describe("runner E2E Daytona image contract", () => {
|
||||
workflow.indexOf(`--format '{{json .Image}}'`),
|
||||
);
|
||||
const providerInstall = dockerfile.indexOf(
|
||||
"RUN pnpm install --frozen-lockfile --filter '@paperclipai/paperclip-runner...'",
|
||||
"pnpm install --frozen-lockfile --filter '@paperclipai/paperclip-runner...'",
|
||||
);
|
||||
const runnerSourceCopy = dockerfile.indexOf(
|
||||
"COPY packages/paperclip-runner/src ./packages/paperclip-runner/src",
|
||||
"COPY packages ./packages",
|
||||
);
|
||||
const providerRevisionArg = dockerfile.indexOf(
|
||||
"ARG PAPERCLIP_RUNNER_SOURCE_REVISION",
|
||||
);
|
||||
const cliInstall = dockerfile.indexOf("RUN npm install -g");
|
||||
const cliInstall = dockerfile.indexOf("npm install -g");
|
||||
const finalMetadataArgs = dockerfile.lastIndexOf(
|
||||
"ARG PAPERCLIP_RUNNER_CONTENT_ID",
|
||||
);
|
||||
expect(providerInstall).toBeGreaterThan(0);
|
||||
expect(providerInstall).toBeLessThan(runnerSourceCopy);
|
||||
expect(runnerSourceCopy).toBeGreaterThan(0);
|
||||
expect(runnerSourceCopy).toBeLessThan(providerInstall);
|
||||
expect(providerInstall).toBeLessThan(providerRevisionArg);
|
||||
expect(cliInstall).toBeGreaterThan(0);
|
||||
expect(cliInstall).toBeLessThan(finalMetadataArgs);
|
||||
|
||||
@@ -38,6 +38,8 @@ const ALLOWED_ROOT_FILES = new Set([
|
||||
"result.json",
|
||||
"final-state.png",
|
||||
"failure.png",
|
||||
"chat-plan-draft.png",
|
||||
"chat-plan-revised.png",
|
||||
"server.log",
|
||||
"playwright.log",
|
||||
"junit.xml",
|
||||
|
||||
@@ -4,6 +4,14 @@ const TRANSIENT =
|
||||
/(?:\b429\b|\b5\d\d\b|rate.?limit|ECONN(?:RESET|REFUSED)|socket hang up|network (?:error|interruption|timeout)|service unavailable|(?:provider|server|bootstrap|browser|webserver|health|daytona|sandbox|ingress|preview|connection|harness).*(?:temporar|timed? out|timeout|closed|failed|unavailable|interrupt|reset|refused|create|start|connect)|(?:timed? out|timeout).*(?:provider|server|bootstrap|browser|webserver|health|daytona|sandbox|ingress|preview|connection|harness))/i;
|
||||
const PERMANENT =
|
||||
/(?:missing (?:credential|fixture secret)|invalid.*(?:credential|api key)|unauthorized|forbidden|qualification|model.*(?:unsupported|incompatible)|artifact.*incompatible|runner_remote_.*(?:incompatible|unavailable)|immutable image digest)/i;
|
||||
// These controller checkpoint failures are reproducible protocol defects, even
|
||||
// when their outer error is described as a provider transport failure.
|
||||
const NON_RETRYABLE_SESSION_CLOSE =
|
||||
/(?:native_session_close_unrecoverable|runner did not durably suspend before checkpoint)/i;
|
||||
// Recovery cannot repair a provider session-open rejection that explicitly
|
||||
// declares itself non-retryable. Keep generic transport/start failures transient.
|
||||
const NON_RETRYABLE_ACPX_SESSION_OPEN =
|
||||
/native_session_recovery_failed[\s\S]*ACPX sidecar command session\.open was rejected\s*\([^)]*\bretryable\s*=\s*false\b/i;
|
||||
const CANDIDATE =
|
||||
/(?:matcher|expected.*observed|marker|issue status|run status|runtime mode|wrong output|missing output)/i;
|
||||
|
||||
@@ -20,6 +28,12 @@ export function classifyFailure(error: unknown): FailureClass {
|
||||
: "cleanup_failure";
|
||||
}
|
||||
if (PERMANENT.test(message)) return "permanent_infrastructure";
|
||||
if (
|
||||
/chat_idle_state_invariant/.test(message) ||
|
||||
NON_RETRYABLE_SESSION_CLOSE.test(message) ||
|
||||
NON_RETRYABLE_ACPX_SESSION_OPEN.test(message)
|
||||
)
|
||||
return "candidate_failure";
|
||||
if (TRANSIENT.test(message)) return "transient_infrastructure";
|
||||
if (CANDIDATE.test(message)) return "candidate_failure";
|
||||
return "candidate_failure";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { validateRetainedRunnerResult } from "./result-validation.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { execFile } from "node:child_process";
|
||||
import path from "node:path";
|
||||
@@ -313,10 +314,14 @@ export async function createBundleManifest(
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(campaignId)) {
|
||||
throw new Error("Campaign ID is unsafe for immutable object storage");
|
||||
}
|
||||
const retainedPaths = await relativeFiles(root, root, allowPublicSummary, publicScreenshots);
|
||||
const retained = new Set(retainedPaths);
|
||||
const missingScreenshots = [...publicScreenshots].filter((relative) => !retained.has(relative));
|
||||
if (missingScreenshots.length > 0) {
|
||||
throw new Error(`Declared public screenshots are missing from the historical bundle: ${missingScreenshots.sort().join(", ")}`);
|
||||
}
|
||||
const files = await Promise.all(
|
||||
(await relativeFiles(root, root, allowPublicSummary, publicScreenshots))
|
||||
.sort()
|
||||
.map(async (relative) => {
|
||||
retainedPaths.sort().map(async (relative) => {
|
||||
const absolute = path.join(root, ...relative.split("/"));
|
||||
if (
|
||||
relative === "public-images/campaign-summary.png" ||
|
||||
@@ -513,6 +518,7 @@ async function main() {
|
||||
const campaign = JSON.parse(
|
||||
await readFile(path.join(reportRoot, "normalized-results.json"), "utf8"),
|
||||
) as RunnerE2ECampaign;
|
||||
for (const result of campaign.results ?? []) validateRetainedRunnerResult(result);
|
||||
if (campaign.schema !== "paperclip.runner-e2e.campaign/v2") {
|
||||
throw new Error("Historical publishing requires a v2 normalized campaign");
|
||||
}
|
||||
|
||||
@@ -195,8 +195,8 @@ describe("runner E2E campaign history", () => {
|
||||
expect(index).toContain("Runner E2E campaigns");
|
||||
expect(index).toContain("complete-green");
|
||||
expect(index).toContain("complete-red");
|
||||
expect(index).toContain("68/68 passed");
|
||||
expect(index).toContain("67/68 passed");
|
||||
expect(index).toContain("92/92 passed");
|
||||
expect(index).toContain("91/92 passed");
|
||||
expect(index).toContain("Open report →");
|
||||
expect(index).toContain(
|
||||
"campaigns/complete-red/public-images/campaign-summary.png",
|
||||
@@ -501,9 +501,25 @@ describe("historical publication security", () => {
|
||||
).rejects.toThrow("does not match its raster file type");
|
||||
});
|
||||
|
||||
it("refuses a public bundle when any declared screenshot is absent", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "runner-missing-screenshot-"));
|
||||
temporaryDirectories.push(root);
|
||||
const directory = path.join(root, "evidence", "agent-chat.runner-codex.local.plan-handoff", "attempt-1");
|
||||
await mkdir(directory, { recursive: true });
|
||||
const base = "evidence/agent-chat.runner-codex.local.plan-handoff/attempt-1";
|
||||
const declared = new Set([`${base}/final-state.png`, `${base}/chat-plan-draft.png`]);
|
||||
const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64");
|
||||
await writeFile(path.join(directory, "final-state.png"), png);
|
||||
await expect(createBundleManifest(root, "campaign-1", false, declared))
|
||||
.rejects.toThrow(`Declared public screenshots are missing from the historical bundle: ${base}/chat-plan-draft.png`);
|
||||
await writeFile(path.join(directory, "chat-plan-draft.png"), png);
|
||||
const manifest = await createBundleManifest(root, "campaign-1", false, declared);
|
||||
expect(new Set(manifest.files.map((file) => file.path))).toEqual(declared);
|
||||
});
|
||||
|
||||
it("requires trusted-fixture opt-in and rejects unsafe screenshot paths", () => {
|
||||
const execution = runnerMatrix[0]!;
|
||||
const campaign = buildRunnerCampaign({
|
||||
expect(() => buildRunnerCampaign({
|
||||
campaignId: "unsafe-screenshot",
|
||||
generatedAt: "2026-08-28T00:01:00.000Z",
|
||||
expected: [execution.id],
|
||||
@@ -520,10 +536,7 @@ describe("historical publication security", () => {
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(() => publicScreenshotPaths(campaign)).toThrow(
|
||||
"Cannot publish unsafe screenshot path",
|
||||
);
|
||||
})).toThrow("Invalid retained runner result field: result.screenshots[0].file");
|
||||
|
||||
const failedCampaign = buildRunnerCampaign({
|
||||
campaignId: "failed-screenshot",
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import { validateRetainedRunnerResult } from "./result-validation.js";
|
||||
import {
|
||||
discoverReportCatalog,
|
||||
parseReportExecutionId,
|
||||
type ReportExecution,
|
||||
} from "./report-catalog.js";
|
||||
import { runnerMatrix, runnerSuites } from "./catalog.js";
|
||||
import {
|
||||
aggregateCampaignBilling,
|
||||
@@ -20,11 +26,17 @@ export function canonicalExecutionId(id: string) {
|
||||
}
|
||||
|
||||
export function upgradeRunnerResult(result: RunnerE2EResult): RunnerE2EResult {
|
||||
validateRetainedRunnerResult(result);
|
||||
const executionId = canonicalExecutionId(result.executionId);
|
||||
const execution = runnerMatrix.find(
|
||||
(candidate) => candidate.id === executionId,
|
||||
);
|
||||
if (!execution) return result;
|
||||
if (!execution)
|
||||
return {
|
||||
...result,
|
||||
executionId,
|
||||
suiteId: result.suiteId ?? parseReportExecutionId(executionId).suiteId,
|
||||
};
|
||||
return {
|
||||
...result,
|
||||
executionId,
|
||||
@@ -49,25 +61,39 @@ export function buildRunnerCampaign(input: {
|
||||
expected: readonly string[];
|
||||
results: readonly RunnerE2EResult[];
|
||||
eventName?: string | null;
|
||||
catalog?: readonly ReportExecution[];
|
||||
}): RunnerE2ECampaign {
|
||||
const expected = input.expected.map(canonicalExecutionId);
|
||||
const results = input.results.map((result) => ({
|
||||
...upgradeRunnerResult(result),
|
||||
billing: result.billing ?? summarizeExecutionBilling(result),
|
||||
}));
|
||||
const knownCatalog = input.catalog ?? runnerMatrix;
|
||||
const catalog = discoverReportCatalog({
|
||||
catalog: knownCatalog,
|
||||
expected,
|
||||
results,
|
||||
});
|
||||
const discoveredSuites = [
|
||||
...new Map(
|
||||
catalog.map((execution) => [execution.suite.id, execution.suite]),
|
||||
).values(),
|
||||
];
|
||||
const resultSource = results.find((result) => result.source)?.source;
|
||||
const source = {
|
||||
...resolveRunnerE2ESource(resultSource),
|
||||
eventName: input.eventName ?? process.env.GITHUB_EVENT_NAME ?? null,
|
||||
};
|
||||
const suites = runnerSuites
|
||||
const suites = discoveredSuites
|
||||
.map((suite) => {
|
||||
const suiteExpected = expected.filter((id) =>
|
||||
id.startsWith(`${suite.id}.`),
|
||||
);
|
||||
if (suiteExpected.length === 0) return null;
|
||||
const suiteResults = results.filter(
|
||||
(result) => result.suiteId === suite.id,
|
||||
(result) =>
|
||||
expected.includes(result.executionId) &&
|
||||
result.executionId.startsWith(`${suite.id}.`),
|
||||
);
|
||||
const passed = suiteResults.filter(
|
||||
(result) => result.status === "passed" && result.cleanup === "passed",
|
||||
@@ -79,7 +105,7 @@ export function buildRunnerCampaign(input: {
|
||||
suiteId: suite.id,
|
||||
suiteDefinitionHash:
|
||||
suiteResults[0]?.suiteDefinitionHash ??
|
||||
runnerMatrix.find((execution) => execution.suite.id === suite.id)!
|
||||
catalog.find((execution) => execution.suite.id === suite.id)!
|
||||
.suiteDefinitionHash,
|
||||
expected: suite.expectedMatrixSize,
|
||||
selected: suiteExpected.length,
|
||||
@@ -93,7 +119,21 @@ export function buildRunnerCampaign(input: {
|
||||
cleanupPassed: suiteResults.every(
|
||||
(result) => result.cleanup === "passed",
|
||||
),
|
||||
complete: suiteExpected.length === suite.expectedMatrixSize,
|
||||
complete:
|
||||
suiteExpected.length === suite.expectedMatrixSize &&
|
||||
knownCatalog.filter((execution) => execution.suite.id === suite.id)
|
||||
.length === suiteExpected.length &&
|
||||
knownCatalog
|
||||
.filter((execution) => execution.suite.id === suite.id)
|
||||
.every(
|
||||
(execution) =>
|
||||
suiteExpected.includes(execution.id) &&
|
||||
suiteResults.every(
|
||||
(result) =>
|
||||
result.suiteDefinitionHash ===
|
||||
execution.suiteDefinitionHash,
|
||||
),
|
||||
),
|
||||
durationMs: suiteResults.reduce(
|
||||
(total, result) => total + result.durationMs,
|
||||
0,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { prepareCodexCiSandbox } from "./codex-ci-sandbox.js";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
assertSecretFree,
|
||||
findSecretLeakInDirectory,
|
||||
isEphemeralCodexRuntimeAuthFile,
|
||||
isEphemeralPostgresPidFile,
|
||||
normalizedSecrets,
|
||||
sanitizeJson,
|
||||
} from "./redaction.js";
|
||||
@@ -645,6 +647,9 @@ async function runAttempt(input: {
|
||||
temporaryRoot,
|
||||
process.env.PATH,
|
||||
);
|
||||
if (execution.environment.id === "local" && execution.profile.id === "runner-codex") {
|
||||
await prepareCodexCiSandbox(repositoryRoot, temporaryRoot);
|
||||
}
|
||||
const agentJwtSecret = secret(48);
|
||||
const decisionSigningSecret = secret(48);
|
||||
const toolActionSigningSecret = secret(48);
|
||||
@@ -785,6 +790,8 @@ async function runAttempt(input: {
|
||||
const leak = await findSecretLeakInDirectory(directory, credentials, {
|
||||
includeShapes: false,
|
||||
ignoreFile: (file) => expectedEphemeralCredentials.has(file),
|
||||
allowDisappearedFile: (file) =>
|
||||
label === "Paperclip home" && isEphemeralPostgresPidFile(paperclipHome, file),
|
||||
});
|
||||
if (!leak) break;
|
||||
const isManagedCodexRuntimeAuth =
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import path from "node:path";
|
||||
import { defineConfig } from "@playwright/test";
|
||||
import { runnerE2EWebServerCommand } from "./web-server-command.js";
|
||||
import {
|
||||
runnerE2EWebServerCommand,
|
||||
runnerE2EWebServerGracefulShutdown,
|
||||
} from "./web-server-command.js";
|
||||
|
||||
function required(name: string) {
|
||||
const value = process.env[name]?.trim();
|
||||
@@ -69,6 +72,7 @@ export default defineConfig({
|
||||
// blob reports. The wrapper inherits the test process and strips provider
|
||||
// keys before spawning the real Paperclip process.
|
||||
command: runnerE2EWebServerCommand(repositoryRoot),
|
||||
gracefulShutdown: runnerE2EWebServerGracefulShutdown,
|
||||
url: `${baseURL}/api/health`,
|
||||
reuseExistingServer: false,
|
||||
timeout: 180_000,
|
||||
|
||||
@@ -90,3 +90,28 @@ export async function reserveRunnerE2EServerPort(
|
||||
`Failed to reserve a conflict-free Paperclip/Vite port pair after ${maxAttempts} attempts`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Hold an OS-assigned database port until the isolated server is ready to spawn.
|
||||
* Using ephemeral allocation avoids concurrent cells scanning the same default
|
||||
* Postgres fallback range. The child cannot inherit this socket, so an unrelated
|
||||
* process can still claim it in the short close-to-spawn interval.
|
||||
*/
|
||||
export async function reserveRunnerE2EDatabasePort(
|
||||
serverPort: number,
|
||||
options: { maxAttempts?: number; openPort?: OpenLoopbackPort } = {},
|
||||
): Promise<LoopbackPortReservation> {
|
||||
const openPort = options.openPort ?? openLoopbackPort;
|
||||
const maxAttempts = options.maxAttempts ?? 32;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
const reservation = await openPort(0);
|
||||
if (
|
||||
!runnerE2EServerPortConflictsWithDatabase(serverPort, reservation.port)
|
||||
) {
|
||||
return reservation;
|
||||
}
|
||||
await reservation.close();
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to reserve an isolated database port after ${maxAttempts} attempts`,
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { discoverReportCatalog } from "./report-catalog.js";
|
||||
import path from "node:path";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { chromium } from "@playwright/test";
|
||||
@@ -24,8 +25,13 @@ function durationLabel(durationMs: number) {
|
||||
}
|
||||
|
||||
export function renderPublicCampaignSummary(campaign: RunnerE2ECampaign) {
|
||||
const catalog = discoverReportCatalog({
|
||||
catalog: runnerMatrix,
|
||||
expected: campaign.expected,
|
||||
results: campaign.results,
|
||||
});
|
||||
const catalogById = new Map(
|
||||
runnerMatrix.map((execution) => [execution.id, execution]),
|
||||
catalog.map((execution) => [execution.id, execution]),
|
||||
);
|
||||
const expectedIds = [
|
||||
...new Set(
|
||||
@@ -53,10 +59,7 @@ export function renderPublicCampaignSummary(campaign: RunnerE2ECampaign) {
|
||||
);
|
||||
const suites = [
|
||||
...new Map(
|
||||
runnerMatrix.map((execution) => [
|
||||
execution.suite.id,
|
||||
execution.suite.label,
|
||||
]),
|
||||
catalog.map((execution) => [execution.suite.id, execution.suite.label]),
|
||||
),
|
||||
].map(([suiteId, label]) => {
|
||||
const selectedIds = expectedIds.filter(
|
||||
@@ -111,12 +114,12 @@ export function renderPublicCampaignSummary(campaign: RunnerE2ECampaign) {
|
||||
<p class="eyebrow">Runner full-stack E2E</p>
|
||||
<h1>Campaign summary</h1>
|
||||
<section class="metrics">
|
||||
<div class="metric"><strong>${passed}/${expectedIds.length}</strong><span>Known executions passed</span></div>
|
||||
<div class="metric"><strong>${passed}/${expectedIds.length}</strong><span>Selected executions passed</span></div>
|
||||
<div class="metric"><strong>${expectedIds.length - passed}</strong><span>Failed or incomplete</span></div>
|
||||
<div class="metric"><strong>${html(durationLabel(durationMs))}</strong><span>Total test time</span></div>
|
||||
</section>
|
||||
<section class="suites">${rows}</section>
|
||||
<footer>Generated from fixed catalog labels and sanitized numeric/status fields. Provider output is never rendered here.</footer>
|
||||
<footer>Generated from trusted catalog labels, validated execution identities, and sanitized numeric/status fields. Provider output is never rendered here.</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
@@ -152,12 +152,18 @@ export function assertSecretFree(
|
||||
if (leak) throw new Error(`Secret leak in ${label}: ${leak}`);
|
||||
}
|
||||
|
||||
export function isEphemeralPostgresPidFile(paperclipHome: string, file: string): boolean {
|
||||
const relative = path.relative(paperclipHome, file).split(path.sep).join("/");
|
||||
return /^instances\/[^/]+\/db\/postmaster\.pid$/.test(relative);
|
||||
}
|
||||
|
||||
export async function findSecretLeakInDirectory(
|
||||
root: string,
|
||||
secrets: readonly string[],
|
||||
options: {
|
||||
includeShapes?: boolean;
|
||||
ignoreFile?: (file: string) => boolean;
|
||||
allowDisappearedFile?: (file: string) => boolean;
|
||||
} = {},
|
||||
): Promise<{ file: string; reason: string } | null> {
|
||||
const overlap = Math.max(
|
||||
@@ -179,14 +185,22 @@ export async function findSecretLeakInDirectory(
|
||||
} else if (entry.isFile()) {
|
||||
if (options.ignoreFile?.(file)) continue;
|
||||
let carry = Buffer.alloc(0);
|
||||
for await (const chunk of createReadStream(file)) {
|
||||
const data = Buffer.concat([
|
||||
carry,
|
||||
Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk),
|
||||
]);
|
||||
const reason = findSecretLeak(data, secrets, options);
|
||||
if (reason) return { file, reason };
|
||||
carry = data.subarray(Math.max(0, data.length - overlap));
|
||||
try {
|
||||
for await (const chunk of createReadStream(file)) {
|
||||
const data = Buffer.concat([
|
||||
carry,
|
||||
Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk),
|
||||
]);
|
||||
const reason = findSecretLeak(data, secrets, options);
|
||||
if (reason) return { file, reason };
|
||||
carry = data.subarray(Math.max(0, data.length - overlap));
|
||||
}
|
||||
} catch (error) {
|
||||
// PostgreSQL removes its PID file on shutdown, possibly after readdir.
|
||||
// Existing contents are still scanned; only the caller's exact
|
||||
// transient paths may disappear. Evidence and other I/O errors fail.
|
||||
if ((error as NodeJS.ErrnoException)?.code !== "ENOENT" ||
|
||||
!options.allowDisappearedFile?.(file)) throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import { renderPublicCampaignSummary } from "./public-summary-image.js";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { runnerMatrix } from "./catalog.js";
|
||||
import { discoverReportCatalog } from "./report-catalog.js";
|
||||
import { buildRunnerCampaign } from "./history.js";
|
||||
import { renderRunnerE2EDashboard } from "./dashboard.js";
|
||||
import { regenerateRunnerDashboard } from "./dashboard-regenerate.js";
|
||||
import type { RunnerE2EResult } from "./types.js";
|
||||
|
||||
const temporary: string[] = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporary.splice(0).map((dir) => rm(dir, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
const id = "branch-only-chat.branch-provider.local.conversation";
|
||||
const missingId = "branch-only-chat.branch-provider.local.reset";
|
||||
function result(): RunnerE2EResult {
|
||||
return {
|
||||
schema: "paperclip.runner-e2e.result/v2",
|
||||
executionId: id,
|
||||
suiteId: "branch-only-chat",
|
||||
suiteDefinitionHash: "branch-definition",
|
||||
profileId: "branch-provider",
|
||||
environmentId: "local",
|
||||
caseId: "conversation",
|
||||
provider: '<img src=x onerror="alert(1)">',
|
||||
model: "branch-model",
|
||||
runtimeMode: "native",
|
||||
attempt: 1,
|
||||
status: "passed",
|
||||
cleanup: "passed",
|
||||
durationMs: 1000,
|
||||
startedAt: "2026-09-11T21:00:00.000Z",
|
||||
finishedAt: "2026-09-11T21:00:01.000Z",
|
||||
screenshots: [
|
||||
{
|
||||
id: "final-state",
|
||||
label: '<script>alert("label")</script>',
|
||||
file: "final-state.png",
|
||||
publication: "public-runner-fixture",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
function campaign(results: RunnerE2EResult[] = [result()]) {
|
||||
return buildRunnerCampaign({
|
||||
campaignId: "branch-campaign",
|
||||
generatedAt: "2026-09-11T21:01:00.000Z",
|
||||
expected: [id, missingId],
|
||||
results,
|
||||
});
|
||||
}
|
||||
|
||||
describe("trusted report catalog discovery", () => {
|
||||
it("aggregates unknown suites and renders their cases, missing results, and screenshot gallery", () => {
|
||||
const summary = campaign();
|
||||
expect(summary.suites).toEqual([
|
||||
expect.objectContaining({
|
||||
suiteId: "branch-only-chat",
|
||||
selected: 2,
|
||||
executed: 1,
|
||||
passed: 1,
|
||||
failed: 1,
|
||||
complete: false,
|
||||
}),
|
||||
]);
|
||||
const page = renderRunnerE2EDashboard({
|
||||
title: "Report",
|
||||
generatedAt: summary.generatedAt,
|
||||
expected: summary.expected,
|
||||
catalog: [],
|
||||
campaign: summary,
|
||||
entries: [
|
||||
{
|
||||
result: result(),
|
||||
valid: true,
|
||||
errors: [],
|
||||
evidenceBaseHref: `evidence/${id}/attempt-1`,
|
||||
evidenceFiles: ["final-state.png"],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(page).toContain('id="suite-branch-only-chat"');
|
||||
expect(page).toContain('class="suite-summary"');
|
||||
expect(page).toContain(`data-execution-id="${id}"`);
|
||||
expect(page).toContain(`data-execution-id="${missingId}"`);
|
||||
expect(page).toContain('class="case case-missing"');
|
||||
expect(page).toContain("No result artifact was uploaded");
|
||||
expect(page).toContain(
|
||||
`data-gallery-href="evidence/${id}/attempt-1/final-state.png"`,
|
||||
);
|
||||
expect(page).toContain("View gallery · 1");
|
||||
expect(page).toContain("<img src=x onerror="alert(1)">");
|
||||
expect(page).not.toContain("<img src=x");
|
||||
expect(page).not.toContain('<script>alert("label")</script>');
|
||||
});
|
||||
|
||||
it("renders selected branch suites in the public summary image without provider content", () => {
|
||||
const page = renderPublicCampaignSummary(campaign());
|
||||
expect(page).toContain("<strong>1/2</strong>");
|
||||
expect(page).toContain("<span>branch-only-chat</span>");
|
||||
expect(page).not.toContain("<strong>0/0</strong>");
|
||||
expect(page).not.toContain("onerror");
|
||||
expect(page).not.toContain("branch-model");
|
||||
});
|
||||
|
||||
it("retains ordinary catalog metadata and marks no-results branch selections failed", () => {
|
||||
const catalog = discoverReportCatalog({
|
||||
catalog: runnerMatrix,
|
||||
expected: [runnerMatrix[0]!.id],
|
||||
results: [],
|
||||
});
|
||||
expect(catalog).toEqual(runnerMatrix);
|
||||
expect(catalog[0]).toBe(runnerMatrix[0]);
|
||||
expect(campaign([])).toMatchObject({
|
||||
selected: 2,
|
||||
executed: 0,
|
||||
passed: 0,
|
||||
failed: 2,
|
||||
suites: [
|
||||
expect.objectContaining({ suiteId: "branch-only-chat", failed: 2 }),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses known profile metadata in a new suite and rejects forged identities", () => {
|
||||
const known = runnerMatrix[0]!;
|
||||
const knownProfileId = `branch-only-chat.${known.profile.id}.${known.environment.id}.conversation`;
|
||||
expect(
|
||||
discoverReportCatalog({
|
||||
catalog: runnerMatrix,
|
||||
expected: [knownProfileId],
|
||||
results: [],
|
||||
}).at(-1)?.profile,
|
||||
).toBe(known.profile);
|
||||
expect(() =>
|
||||
discoverReportCatalog({
|
||||
catalog: [],
|
||||
expected: [id],
|
||||
results: [{ ...result(), suiteId: "forged-suite" }],
|
||||
}),
|
||||
).toThrow("Result identity does not match");
|
||||
expect(() =>
|
||||
discoverReportCatalog({
|
||||
catalog: [],
|
||||
expected: ["../../private"],
|
||||
results: [],
|
||||
}),
|
||||
).toThrow("Invalid report execution identity");
|
||||
expect(() =>
|
||||
discoverReportCatalog({ catalog: [], expected: [id, id], results: [] }),
|
||||
).toThrow("must be unique");
|
||||
});
|
||||
|
||||
it("regenerates branch-only cards and gallery from persisted JSON using trusted code", async () => {
|
||||
const bundle = await mkdtemp(
|
||||
path.join(os.tmpdir(), "branch-report-regenerate-"),
|
||||
);
|
||||
temporary.push(bundle);
|
||||
const evidence = path.join(bundle, "evidence", id, "attempt-1");
|
||||
await mkdir(evidence, { recursive: true });
|
||||
await writeFile(path.join(evidence, "final-state.png"), "fixture");
|
||||
await writeFile(
|
||||
path.join(bundle, "normalized-results.json"),
|
||||
JSON.stringify({ ...campaign(), suites: [] }),
|
||||
);
|
||||
await regenerateRunnerDashboard({ bundle, historyFile: null });
|
||||
const page = await readFile(path.join(bundle, "index.html"), "utf8");
|
||||
expect(page).toContain(`data-execution-id="${id}"`);
|
||||
expect(page).toContain(`data-execution-id="${missingId}"`);
|
||||
expect(page).toContain(
|
||||
`data-gallery-href="evidence/${id}/attempt-1/final-state.png"`,
|
||||
);
|
||||
const normalized = JSON.parse(
|
||||
await readFile(path.join(bundle, "normalized-results.json"), "utf8"),
|
||||
);
|
||||
expect(normalized.suites).toEqual([
|
||||
expect.objectContaining({
|
||||
suiteId: "branch-only-chat",
|
||||
selected: 2,
|
||||
passed: 1,
|
||||
failed: 1,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("generates an explicit failed result with the correct unknown identity when no artifact exists", async () => {
|
||||
const root = await mkdtemp(
|
||||
path.join(os.tmpdir(), "branch-report-missing-"),
|
||||
);
|
||||
temporary.push(root);
|
||||
const out = path.join(root, "merged");
|
||||
const repo = path.resolve(import.meta.dirname, "../..");
|
||||
await promisify(execFile)(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(repo, "cli/node_modules/tsx/dist/cli.mjs"),
|
||||
path.join(repo, "tests/runner-e2e/report.ts"),
|
||||
],
|
||||
{
|
||||
cwd: repo,
|
||||
env: {
|
||||
...process.env,
|
||||
PAPERCLIP_RUNNER_E2E_REPORT_ROOT: root,
|
||||
PAPERCLIP_RUNNER_E2E_REPORT_OUT: out,
|
||||
PAPERCLIP_RUNNER_E2E_EXPECTED_IDS: JSON.stringify([missingId]),
|
||||
},
|
||||
},
|
||||
).catch((error: { code?: number }) => {
|
||||
if (error.code !== 1) throw error;
|
||||
});
|
||||
const normalized = JSON.parse(
|
||||
await readFile(path.join(out, "normalized-results.json"), "utf8"),
|
||||
);
|
||||
expect(normalized.results).toEqual([
|
||||
expect.objectContaining({
|
||||
executionId: missingId,
|
||||
suiteId: "branch-only-chat",
|
||||
profileId: "branch-provider",
|
||||
caseId: "reset",
|
||||
attempt: 0,
|
||||
status: "failed",
|
||||
}),
|
||||
]);
|
||||
expect(normalized.suites).toEqual([
|
||||
expect.objectContaining({
|
||||
suiteId: "branch-only-chat",
|
||||
failed: 1,
|
||||
executed: 0,
|
||||
}),
|
||||
]);
|
||||
expect(await readFile(path.join(out, "index.html"), "utf8")).toContain(
|
||||
`data-execution-id="${missingId}"`,
|
||||
);
|
||||
});
|
||||
it.each([
|
||||
"1/../../../escape",
|
||||
"<img src=x onerror=alert(1)>",
|
||||
-1,
|
||||
1.5,
|
||||
Infinity,
|
||||
])("rejects malformed attempts before report staging: %s", (attempt) => {
|
||||
expect(() =>
|
||||
campaign([{ ...result(), attempt } as RunnerE2EResult]),
|
||||
).toThrow("result.attempt");
|
||||
});
|
||||
|
||||
it("validates nested timing and billing fields before rendering", () => {
|
||||
const malicious = {
|
||||
...result(),
|
||||
turnTimings: [{ turn: "<script>alert(1)</script>" }],
|
||||
} as unknown as RunnerE2EResult;
|
||||
expect(() => campaign([malicious])).toThrow("result.turnTimings[0].turn");
|
||||
const measured = campaign().results[0]!;
|
||||
expect(() =>
|
||||
campaign([
|
||||
{
|
||||
...measured,
|
||||
billing: {
|
||||
...measured.billing!,
|
||||
llm: {
|
||||
...measured.billing!.llm,
|
||||
runCount: "<img>" as unknown as number,
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toThrow("result.billing.llm.runCount");
|
||||
});
|
||||
|
||||
it("does not certify a same-sized replacement matrix or changed suite definition", () => {
|
||||
const known = runnerMatrix.filter(
|
||||
(entry) => entry.suite.id === runnerMatrix[0]!.suite.id,
|
||||
);
|
||||
const receipts = known.map((entry) => ({
|
||||
...result(),
|
||||
executionId: entry.id,
|
||||
suiteId: entry.suite.id,
|
||||
suiteDefinitionHash: entry.suiteDefinitionHash,
|
||||
profileId: entry.profile.id,
|
||||
environmentId: entry.environment.id,
|
||||
caseId: entry.task.id,
|
||||
}));
|
||||
const build = (results: RunnerE2EResult[]) =>
|
||||
buildRunnerCampaign({
|
||||
campaignId: "matrix-proof",
|
||||
generatedAt: result().finishedAt,
|
||||
expected: results.map((item) => item.executionId),
|
||||
results,
|
||||
});
|
||||
expect(build(receipts).suites[0]!.complete).toBe(true);
|
||||
const replacement = receipts.map((item, index) =>
|
||||
index
|
||||
? item
|
||||
: {
|
||||
...item,
|
||||
caseId: "replacement",
|
||||
executionId: item.executionId.replace(/[^.]+$/, "replacement"),
|
||||
},
|
||||
);
|
||||
expect(build(replacement).suites[0]!.complete).toBe(false);
|
||||
expect(
|
||||
build(
|
||||
receipts.map((item) => ({
|
||||
...item,
|
||||
suiteDefinitionHash: "different-definition",
|
||||
})),
|
||||
).suites[0]!.complete,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { validateRetainedRunnerResult } from "./result-validation.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { MatrixExecution, RunnerE2EResult } from "./types.js";
|
||||
|
||||
/** Display-only metadata: never load or reconstruct executable branch fixtures. */
|
||||
export interface ReportExecution {
|
||||
id: string;
|
||||
suiteDefinitionHash: string;
|
||||
suite: Pick<
|
||||
MatrixExecution["suite"],
|
||||
"id" | "label" | "description" | "expectedMatrixSize"
|
||||
>;
|
||||
profile: Pick<
|
||||
MatrixExecution["profile"],
|
||||
"id" | "label" | "generation" | "provider" | "model" | "expectedRuntimeMode"
|
||||
>;
|
||||
environment: Pick<
|
||||
MatrixExecution["environment"],
|
||||
"id" | "label" | "provider" | "expectedExecutionTarget"
|
||||
>;
|
||||
task: Pick<MatrixExecution["task"], "id" | "label">;
|
||||
}
|
||||
|
||||
export function parseReportExecutionId(id: string) {
|
||||
// These identities also become evidence paths. Reject path traversal and any
|
||||
// syntax other than the four catalog identity segments, even in imported JSON.
|
||||
if (typeof id !== "string")
|
||||
throw new Error("Report execution identity must be a string");
|
||||
const match =
|
||||
/^([a-z0-9][a-z0-9_-]*)\.([a-z0-9][a-z0-9_-]*)\.(local|daytona)\.([a-z0-9][a-z0-9_-]*)$/.exec(
|
||||
id,
|
||||
);
|
||||
if (!match || id.length > 512)
|
||||
throw new Error(
|
||||
`Invalid report execution identity: ${String(id).slice(0, 512)}`,
|
||||
);
|
||||
return {
|
||||
suiteId: match[1]!,
|
||||
profileId: match[2]!,
|
||||
environmentId: match[3]! as "local" | "daytona",
|
||||
caseId: match[4]!,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateReportResultIdentity(result: RunnerE2EResult) {
|
||||
validateRetainedRunnerResult(result);
|
||||
const identity = parseReportExecutionId(result.executionId);
|
||||
if (
|
||||
(result.suiteId !== undefined && result.suiteId !== identity.suiteId) ||
|
||||
result.profileId !== identity.profileId ||
|
||||
result.environmentId !== identity.environmentId ||
|
||||
result.caseId !== identity.caseId
|
||||
) {
|
||||
throw new Error(
|
||||
`Result identity does not match execution ID: ${result.executionId}`,
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
export function discoverReportCatalog(input: {
|
||||
catalog: readonly ReportExecution[];
|
||||
expected: readonly string[];
|
||||
results: readonly RunnerE2EResult[];
|
||||
}): ReportExecution[] {
|
||||
const expected = new Set(input.expected);
|
||||
if (expected.size !== input.expected.length)
|
||||
throw new Error("Report expected identities must be unique");
|
||||
const identities = new Map(
|
||||
input.expected.map((id) => [id, parseReportExecutionId(id)]),
|
||||
);
|
||||
const results = new Map<string, RunnerE2EResult>();
|
||||
for (const result of input.results) {
|
||||
validateReportResultIdentity(result);
|
||||
if (expected.has(result.executionId))
|
||||
results.set(result.executionId, result);
|
||||
}
|
||||
const catalog = [...input.catalog];
|
||||
const knownIds = new Set(catalog.map((execution) => execution.id));
|
||||
const knownSuites = new Map(
|
||||
catalog.map((execution) => [execution.suite.id, execution.suite]),
|
||||
);
|
||||
for (const [id, identity] of identities) {
|
||||
if (knownIds.has(id)) continue;
|
||||
const result = results.get(id);
|
||||
const suiteIds = input.expected
|
||||
.filter(
|
||||
(candidate) => identities.get(candidate)?.suiteId === identity.suiteId,
|
||||
)
|
||||
.sort();
|
||||
const suite = knownSuites.get(identity.suiteId) ?? {
|
||||
id: identity.suiteId,
|
||||
label: identity.suiteId,
|
||||
description:
|
||||
"Suite discovered from retained campaign identities; full suite size is not known to this publisher.",
|
||||
expectedMatrixSize: suiteIds.length,
|
||||
};
|
||||
const knownProfile = input.catalog.find(
|
||||
(execution) => execution.profile.id === identity.profileId,
|
||||
)?.profile;
|
||||
const knownEnvironment = input.catalog.find(
|
||||
(execution) => execution.environment.id === identity.environmentId,
|
||||
)?.environment;
|
||||
const runtimeMode = result?.runtimeMode === "legacy" ? "legacy" : "native";
|
||||
catalog.push({
|
||||
id,
|
||||
suite,
|
||||
suiteDefinitionHash:
|
||||
result?.suiteDefinitionHash ??
|
||||
`selection-${createHash("sha256").update(JSON.stringify(suiteIds)).digest("hex")}`,
|
||||
profile: knownProfile ?? {
|
||||
id: identity.profileId,
|
||||
label: identity.profileId,
|
||||
generation: runtimeMode,
|
||||
expectedRuntimeMode: runtimeMode,
|
||||
provider:
|
||||
typeof result?.provider === "string" ? result.provider : "unknown",
|
||||
model: typeof result?.model === "string" ? result.model : "unknown",
|
||||
},
|
||||
environment: knownEnvironment ?? {
|
||||
id: identity.environmentId,
|
||||
label: identity.environmentId,
|
||||
provider: identity.environmentId,
|
||||
expectedExecutionTarget: {
|
||||
kind: identity.environmentId === "local" ? "local" : "remote",
|
||||
},
|
||||
},
|
||||
task: { id: identity.caseId, label: identity.caseId },
|
||||
});
|
||||
}
|
||||
return catalog;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { discoverReportCatalog } from "./report-catalog.js";
|
||||
import path from "node:path";
|
||||
import {
|
||||
copyFile,
|
||||
@@ -188,6 +189,13 @@ async function main() {
|
||||
]);
|
||||
}
|
||||
|
||||
const reportCatalog = discoverReportCatalog({
|
||||
catalog: runnerMatrix,
|
||||
expected,
|
||||
results: [...candidates.values()].flatMap((entries) =>
|
||||
entries.map((entry) => entry.result),
|
||||
),
|
||||
});
|
||||
const selected: AggregatedResult[] = [];
|
||||
for (const executionId of expected) {
|
||||
const attempts = (candidates.get(executionId) ?? []).sort((left, right) => {
|
||||
@@ -205,9 +213,9 @@ async function main() {
|
||||
});
|
||||
if (attempts.length === 0) {
|
||||
const now = new Date().toISOString();
|
||||
const execution = runnerMatrix.find(
|
||||
const execution = reportCatalog.find(
|
||||
(candidate) => candidate.id === executionId,
|
||||
);
|
||||
)!;
|
||||
const missing: RunnerE2EResult = {
|
||||
schema: "paperclip.runner-e2e.result/v2",
|
||||
executionId,
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import type { RunnerE2EResult } from "./types.js";
|
||||
|
||||
type Rule = (value: unknown, at: string) => void;
|
||||
const invalid = (at: string): never => {
|
||||
throw new Error(`Invalid retained runner result field: ${at}`);
|
||||
};
|
||||
const string: Rule = (value, at) => {
|
||||
if (typeof value !== "string") invalid(at);
|
||||
};
|
||||
const number: Rule = (value, at) => {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
|
||||
invalid(at);
|
||||
};
|
||||
const integer: Rule = (value, at) => {
|
||||
number(value, at);
|
||||
if (!Number.isSafeInteger(value)) invalid(at);
|
||||
};
|
||||
const boolean: Rule = (value, at) => {
|
||||
if (typeof value !== "boolean") invalid(at);
|
||||
};
|
||||
const object: Rule = (value, at) => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) invalid(at);
|
||||
};
|
||||
const optional =
|
||||
(rule: Rule): Rule =>
|
||||
(value, at) => {
|
||||
if (value !== undefined) rule(value, at);
|
||||
};
|
||||
const nullable =
|
||||
(rule: Rule): Rule =>
|
||||
(value, at) => {
|
||||
if (value !== null) rule(value, at);
|
||||
};
|
||||
const oneOf =
|
||||
(...values: string[]): Rule =>
|
||||
(value, at) => {
|
||||
if (typeof value !== "string" || !values.includes(value)) invalid(at);
|
||||
};
|
||||
const array =
|
||||
(rule: Rule): Rule =>
|
||||
(value, at) => {
|
||||
if (!Array.isArray(value)) invalid(at);
|
||||
(value as unknown[]).forEach((item, index) =>
|
||||
rule(item, `${at}[${index}]`),
|
||||
);
|
||||
};
|
||||
const shape =
|
||||
(fields: Record<string, Rule>): Rule =>
|
||||
(value, at) => {
|
||||
object(value, at);
|
||||
for (const [key, rule] of Object.entries(fields))
|
||||
rule((value as Record<string, unknown>)[key], `${at}.${key}`);
|
||||
};
|
||||
const date: Rule = (value, at) => {
|
||||
string(value, at);
|
||||
if (!Number.isFinite(Date.parse(value as string))) invalid(at);
|
||||
};
|
||||
const url: Rule = (value, at) => {
|
||||
string(value, at);
|
||||
try {
|
||||
const parsed = new URL(value as string);
|
||||
if (
|
||||
!["https:", "http:"].includes(parsed.protocol) ||
|
||||
parsed.username ||
|
||||
parsed.password
|
||||
)
|
||||
invalid(at);
|
||||
} catch {
|
||||
invalid(at);
|
||||
}
|
||||
};
|
||||
const relativeFile: Rule = (value, at) => {
|
||||
string(value, at);
|
||||
if (
|
||||
!/^[A-Za-z0-9_.\-/]+$/.test(value as string) ||
|
||||
(value as string).startsWith("/") ||
|
||||
(value as string)
|
||||
.split("/")
|
||||
.some((part) => !part || part === "." || part === "..")
|
||||
)
|
||||
invalid(at);
|
||||
};
|
||||
const runtime = shape({
|
||||
provider: oneOf("local", "daytona"),
|
||||
agentRunDurationMs: number,
|
||||
leaseDurationMs: nullable(number),
|
||||
leaseCount: integer,
|
||||
cpuCores: optional(number),
|
||||
memoryGiB: optional(number),
|
||||
diskGiB: optional(number),
|
||||
estimatedListCostUsd: optional(number),
|
||||
costStatus: oneOf("estimated", "unavailable", "not_metered"),
|
||||
costSource: oneOf(
|
||||
"daytona_public_list_price",
|
||||
"provider_cost_unavailable",
|
||||
"local_not_metered",
|
||||
),
|
||||
pricingAsOf: optional(string),
|
||||
pricingUrl: optional(url),
|
||||
});
|
||||
const llm = shape({
|
||||
runCount: integer,
|
||||
runsWithTokenUsage: integer,
|
||||
runsWithReportedCost: integer,
|
||||
inputTokens: number,
|
||||
outputTokens: number,
|
||||
cachedInputTokens: number,
|
||||
totalTokens: number,
|
||||
reportedCostUsd: number,
|
||||
costStatus: oneOf("reported", "partial", "unpriced", "unavailable"),
|
||||
});
|
||||
const billing = shape({
|
||||
llm,
|
||||
runtime,
|
||||
reportedCostUsd: number,
|
||||
estimatedRuntimeCostUsd: number,
|
||||
observedAndEstimatedCostUsd: number,
|
||||
complete: boolean,
|
||||
});
|
||||
const matcher: Rule = (value, at) => {
|
||||
object(value, at);
|
||||
const item = value as Record<string, unknown>;
|
||||
string(item.kind, `${at}.kind`);
|
||||
const kinds: Record<string, Rule> = {
|
||||
message_exact: shape({ expected: string }),
|
||||
message_contains: shape({ expected: string }),
|
||||
message_occurrences: shape({ expected: string, count: integer }),
|
||||
message_regex: shape({ pattern: string, flags: optional(string) }),
|
||||
message_ordered: shape({ expected: array(string) }),
|
||||
issue_status: shape({ expected: string }),
|
||||
run_status: shape({ expected: string }),
|
||||
runtime_mode: shape({ expected: oneOf("legacy", "native") }),
|
||||
environment: shape({ expected: oneOf("local", "daytona") }),
|
||||
file_exists: shape({ path: string }),
|
||||
file_exact: shape({ path: string, expected: string }),
|
||||
file_contains: shape({ path: string, expected: string }),
|
||||
artifact_exists: shape({ name: string, mimeType: optional(string) }),
|
||||
json_path: shape({ path: string }),
|
||||
json_schema: shape({ schema: object }),
|
||||
};
|
||||
const rule = kinds[item.kind as string];
|
||||
if (!rule) invalid(`${at}.kind`);
|
||||
rule(value, at);
|
||||
};
|
||||
const fields = {
|
||||
schema: oneOf(
|
||||
"paperclip.runner-e2e.result/v1",
|
||||
"paperclip.runner-e2e.result/v2",
|
||||
),
|
||||
executionId: string,
|
||||
suiteId: optional(string),
|
||||
suiteDefinitionHash: optional(string),
|
||||
source: optional(
|
||||
shape({
|
||||
sha: nullable(string),
|
||||
ref: nullable(string),
|
||||
workflowRunUrl: nullable(url),
|
||||
}),
|
||||
),
|
||||
rankingSnapshot: optional(
|
||||
shape({
|
||||
snapshotId: string,
|
||||
capturedAt: date,
|
||||
sourceUrl: url,
|
||||
rank: integer,
|
||||
canonicalModelId: string,
|
||||
}),
|
||||
),
|
||||
attempt: integer,
|
||||
status: oneOf("passed", "failed"),
|
||||
failureClass: optional(
|
||||
oneOf(
|
||||
"candidate_failure",
|
||||
"provider_variance",
|
||||
"transient_infrastructure",
|
||||
"permanent_infrastructure",
|
||||
"secret_leak",
|
||||
"cleanup_failure",
|
||||
),
|
||||
),
|
||||
error: optional(string),
|
||||
profileId: string,
|
||||
environmentId: oneOf("local", "daytona"),
|
||||
caseId: string,
|
||||
provider: string,
|
||||
model: string,
|
||||
runtimeMode: oneOf("legacy", "native"),
|
||||
issueId: optional(string),
|
||||
issueIdentifier: optional(nullable(string)),
|
||||
runIds: optional(array(string)),
|
||||
turnTimings: optional(
|
||||
array(
|
||||
shape({
|
||||
turn: integer,
|
||||
submittedAt: date,
|
||||
runStartedAt: nullable(date),
|
||||
runFinishedAt: nullable(date),
|
||||
schedulerLatencyMs: nullable(number),
|
||||
runDurationMs: nullable(number),
|
||||
responseLatencyMs: nullable(number),
|
||||
runId: string,
|
||||
leaseAcquisitionOutcome: oneOf(
|
||||
"created",
|
||||
"resumed",
|
||||
"replacement",
|
||||
"unknown",
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
startedAt: date,
|
||||
finishedAt: date,
|
||||
durationMs: number,
|
||||
// Provider usage is intentionally opaque. Billing reads only finite numbers;
|
||||
// the dashboard shows opaque diagnostics through escaped JSON.
|
||||
usage: optional(nullable(object)),
|
||||
runtimeUsage: optional(runtime),
|
||||
billing: optional(billing),
|
||||
matcherResults: optional(
|
||||
array(shape({ matcher, passed: boolean, detail: string })),
|
||||
),
|
||||
screenshots: optional(
|
||||
array(
|
||||
shape({
|
||||
id: string,
|
||||
label: string,
|
||||
file: relativeFile,
|
||||
publication: optional(oneOf("public-runner-fixture")),
|
||||
}),
|
||||
),
|
||||
),
|
||||
cleanup: oneOf("not_started", "passed", "failed"),
|
||||
} satisfies Record<keyof RunnerE2EResult, Rule>;
|
||||
const result = shape(fields);
|
||||
|
||||
/** Validate every typed result field before upgrading, path construction or HTML rendering. */
|
||||
export function validateRetainedRunnerResult(
|
||||
value: unknown,
|
||||
): asserts value is RunnerE2EResult {
|
||||
result(value, "result");
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { runChatFlow } from "./chat-flow.js";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
@@ -628,6 +629,7 @@ for (const execution of executions) {
|
||||
|
||||
const isReviewedFixtureScreenshotRoute = () =>
|
||||
isPublicRunnerScreenshotRoute(page.url(), {
|
||||
chatAgentId: execution.task.flow === "agent_chat" ? fixtures?.agent.id : undefined,
|
||||
issuePrefix: fixtures?.company.issuePrefix,
|
||||
issueId: issue?.id,
|
||||
issueIdentifier: issue?.identifier,
|
||||
@@ -670,10 +672,10 @@ for (const execution of executions) {
|
||||
};
|
||||
|
||||
const cancelActiveRunsForCleanup = async () => {
|
||||
if (!issue) return;
|
||||
const cleanupIssueId = issue.id;
|
||||
if (!issue && !(execution.task.flow === "agent_chat" && fixtures)) return;
|
||||
const cleanupIssueId = issue?.id;
|
||||
const runs = await api.get<RunRecord[]>(
|
||||
`/api/issues/${cleanupIssueId}/runs`,
|
||||
execution.task.flow === "agent_chat" && fixtures ? `/api/companies/${fixtures.company.id}/heartbeat-runs?limit=100` : `/api/issues/${cleanupIssueId}/runs`,
|
||||
);
|
||||
const activeRunIds = [
|
||||
...new Set(
|
||||
@@ -694,7 +696,7 @@ for (const execution of executions) {
|
||||
await pollUntil({
|
||||
label: `cleanup cancellation for issue ${cleanupIssueId}`,
|
||||
deadlineAt: Date.now() + 45_000,
|
||||
load: () => api.get<RunRecord[]>(`/api/issues/${cleanupIssueId}/runs`),
|
||||
load: () => api.get<RunRecord[]>(execution.task.flow === "agent_chat" && fixtures ? `/api/companies/${fixtures.company.id}/heartbeat-runs?limit=100` : `/api/issues/${cleanupIssueId}/runs`),
|
||||
accept: (currentRuns) =>
|
||||
currentRuns
|
||||
.filter((run) => activeIds.has(run.id))
|
||||
@@ -704,7 +706,16 @@ for (const execution of executions) {
|
||||
};
|
||||
|
||||
const captureFailureApiState = async () => {
|
||||
if (!fixtures || !issue) return;
|
||||
if (!fixtures) return;
|
||||
if (execution.task.flow === "agent_chat" && !issue) {
|
||||
const companyRuns = await api.get<RunRecord[]>(`/api/companies/${fixtures.company.id}/heartbeat-runs?limit=100`);
|
||||
selectedRuns = await Promise.all(companyRuns.map(run => api.get<RunRecord>(`/api/heartbeat-runs/${run.id}`)));
|
||||
// Settings are already restored on failure, so chat resolution may be
|
||||
// gated. Direct task access still permits evidence and usage capture.
|
||||
const sourceId = selectedRuns.map(run => record(run.contextSnapshot).issueId).find(id => typeof id === "string");
|
||||
if (typeof sourceId === "string") issue = await api.get<IssueRecord>(`/api/issues/${sourceId}`);
|
||||
}
|
||||
if (!issue) return;
|
||||
const capture = async <T>(operation: () => Promise<T>) =>
|
||||
operation().catch((error) => ({
|
||||
evidenceCaptureError:
|
||||
@@ -715,7 +726,9 @@ for (const execution of executions) {
|
||||
capture(() => api.get<IssueRecord>(`/api/issues/${issue!.id}`)),
|
||||
capture(() =>
|
||||
api.get<RunRecord[]>(
|
||||
`/api/companies/${fixtures!.company.id}/heartbeat-runs?agentId=${fixtures!.agent.id}&limit=20`,
|
||||
execution.task.flow === "agent_chat"
|
||||
? `/api/companies/${fixtures!.company.id}/heartbeat-runs?limit=100`
|
||||
: `/api/companies/${fixtures!.company.id}/heartbeat-runs?agentId=${fixtures!.agent.id}&limit=20`,
|
||||
),
|
||||
),
|
||||
capture(() =>
|
||||
@@ -730,7 +743,7 @@ for (const execution of executions) {
|
||||
),
|
||||
]);
|
||||
const taskRuns = Array.isArray(listedRuns)
|
||||
? matchingRuns(listedRuns, "id" in currentIssue ? currentIssue : issue)
|
||||
? execution.task.flow === "agent_chat" ? listedRuns : matchingRuns(listedRuns, "id" in currentIssue ? currentIssue : issue)
|
||||
: [];
|
||||
const detailedRuns = await Promise.all(
|
||||
taskRuns.map((candidate) =>
|
||||
@@ -801,7 +814,9 @@ for (const execution of executions) {
|
||||
// Each isolated campaign selects its generation explicitly; the app's
|
||||
// default can change without changing which runtime this cell exercises.
|
||||
const enableNativeRunner = execution.profile.generation === "native";
|
||||
await api.patch("/api/instance/settings/experimental", {
|
||||
const experimental = await api.patch<{
|
||||
enableNativeRunner: boolean;
|
||||
}>("/api/instance/settings/experimental", {
|
||||
enableNativeRunner,
|
||||
...(execution.task.flow === "warm_three_turn"
|
||||
? { enableIsolatedWorkspaces: true }
|
||||
@@ -811,6 +826,7 @@ for (const execution of executions) {
|
||||
? { enableRunnerPreviewIngress: true }
|
||||
: {}),
|
||||
});
|
||||
expect(experimental.enableNativeRunner).toBe(enableNativeRunner);
|
||||
|
||||
const configuredExperimental = await api.get<{
|
||||
enableNativeRunner: boolean;
|
||||
@@ -850,6 +866,17 @@ for (const execution of executions) {
|
||||
secrets,
|
||||
);
|
||||
|
||||
if (execution.task.flow === "agent_chat") {
|
||||
const chat = await runChatFlow({
|
||||
page, api, fixtures, execution, nonce,
|
||||
restart: () => restartIsolatedPaperclipServer({ api, requestId: `chat-${nonce}`, deadlineAt: startedAtMs + deadlineMs }),
|
||||
observe: (chatIssue, chatRuns) => { issue = chatIssue; selectedRuns = chatRuns; },
|
||||
capture: captureScreenshot,
|
||||
evidence: (name, data) => writeSanitizedJson(snapshotsDir, name, data, secrets),
|
||||
});
|
||||
issue = chat.issue; selectedRuns = chat.runs;
|
||||
matcherResults = [{ matcher: { kind: "issue_status", expected: "in_review" }, passed: true, detail: "Chat workflow and durable handoff/session assertions passed" }];
|
||||
} else {
|
||||
const issuePrefix = fixtures.company.issuePrefix;
|
||||
if (!issuePrefix)
|
||||
throw new Error(
|
||||
@@ -915,7 +942,9 @@ for (const execution of executions) {
|
||||
const [currentIssue, runs, comments, interactions] = await Promise.all([
|
||||
api.get<IssueRecord>(`/api/issues/${issue!.id}`),
|
||||
api.get<RunRecord[]>(
|
||||
`/api/companies/${fixtures!.company.id}/heartbeat-runs?agentId=${fixtures!.agent.id}&limit=20`,
|
||||
execution.task.flow === "agent_chat"
|
||||
? `/api/companies/${fixtures!.company.id}/heartbeat-runs?limit=100`
|
||||
: `/api/companies/${fixtures!.company.id}/heartbeat-runs?agentId=${fixtures!.agent.id}&limit=20`,
|
||||
),
|
||||
api.get<CommentRecord[]>(
|
||||
`/api/issues/${issue!.id}/comments?order=asc`,
|
||||
@@ -2381,6 +2410,7 @@ for (const execution of executions) {
|
||||
`Runtime invariant failure: ${invariantFailures.join("; ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
primaryError = error;
|
||||
try {
|
||||
@@ -2443,6 +2473,11 @@ for (const execution of executions) {
|
||||
});
|
||||
try {
|
||||
await cancelActiveRunsForCleanup();
|
||||
if (execution.task.flow === "agent_chat") {
|
||||
const companyRuns = await api.get<RunRecord[]>(`/api/companies/${fixtures.company.id}/heartbeat-runs?limit=100`);
|
||||
selectedRuns = await Promise.all(companyRuns.map(run => api.get<RunRecord>(`/api/heartbeat-runs/${run.id}`)));
|
||||
await writeSanitizedJson(snapshotsDir, "chat-final-run-ledger.json", selectedRuns, secrets);
|
||||
}
|
||||
await fixtures.teardown();
|
||||
cleanup = "passed";
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const PUBLIC_RUNNER_SCREENSHOT_MARKER = "public-runner-fixture" as const;
|
||||
|
||||
export interface PublicRunnerScreenshotTarget {
|
||||
chatAgentId?: string;
|
||||
issuePrefix: string | null | undefined;
|
||||
issueId: string | null | undefined;
|
||||
issueIdentifier: string | null | undefined;
|
||||
@@ -15,10 +16,12 @@ export function isPublicRunnerScreenshotRoute(
|
||||
const candidate = new URL(url);
|
||||
const issueReference = target.issueIdentifier ?? target.issueId;
|
||||
const expectedPath = `/${encodeURIComponent(target.issuePrefix)}/issues/${encodeURIComponent(issueReference)}`;
|
||||
const chatPath = target.chatAgentId ? `/${encodeURIComponent(target.issuePrefix)}/chats/${encodeURIComponent(target.chatAgentId)}` : null;
|
||||
return (
|
||||
candidate.protocol === "http:" &&
|
||||
candidate.hostname === "127.0.0.1" &&
|
||||
(candidate.pathname === expectedPath ||
|
||||
(candidate.pathname === chatPath ||
|
||||
candidate.pathname === expectedPath ||
|
||||
candidate.pathname === `${expectedPath}/`)
|
||||
);
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { createServer } from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { paperclipConfigSchema } from "../../packages/shared/src/config-schema.js";
|
||||
import {
|
||||
reserveRunnerE2EDatabasePort,
|
||||
type LoopbackPortReservation,
|
||||
} from "./ports.js";
|
||||
import { prepareRunnerE2EServerConfig } from "./server-config.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
const reservations: LoopbackPortReservation[] = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
reservations.splice(0).map((reservation) => reservation.close()),
|
||||
);
|
||||
await Promise.all(
|
||||
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe("isolated paid E2E database ports", () => {
|
||||
it("keeps parallel database reservations distinct and held until server spawn", async () => {
|
||||
reservations.push(
|
||||
...(await Promise.all(
|
||||
Array.from({ length: 8 }, () => reserveRunnerE2EDatabasePort(3100)),
|
||||
)),
|
||||
);
|
||||
expect(new Set(reservations.map(({ port }) => port)).size).toBe(8);
|
||||
const contender = createServer();
|
||||
const error = await new Promise<NodeJS.ErrnoException>((resolve) => {
|
||||
contender.once("error", resolve);
|
||||
contender.listen(reservations[0].port, "127.0.0.1");
|
||||
});
|
||||
expect(error.code).toBe("EADDRINUSE");
|
||||
});
|
||||
|
||||
it("rejects the HTTP/HMR ports and retains only the selected reservation", async () => {
|
||||
const closed: number[] = [];
|
||||
const ports = [3100, 13100, 41000];
|
||||
const selected = await reserveRunnerE2EDatabasePort(3100, {
|
||||
openPort: async () => {
|
||||
const port = ports.shift()!;
|
||||
return {
|
||||
port,
|
||||
close: async () => {
|
||||
closed.push(port);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
expect(selected.port).toBe(41000);
|
||||
expect(closed).toEqual([3100, 13100]);
|
||||
await selected.close();
|
||||
expect(closed).toEqual([3100, 13100, 41000]);
|
||||
});
|
||||
|
||||
it("writes a valid private config and preserves its database on restart", async () => {
|
||||
const temporaryRoot = await mkdtemp(
|
||||
path.join(os.tmpdir(), "runner-e2e-config-test-"),
|
||||
);
|
||||
roots.push(temporaryRoot);
|
||||
const configPath = path.join(
|
||||
temporaryRoot,
|
||||
"paperclip-home",
|
||||
"instances",
|
||||
"fixture",
|
||||
"config.json",
|
||||
);
|
||||
const input = { temporaryRoot, configPath, serverPort: 3100 };
|
||||
const reservation = await prepareRunnerE2EServerConfig(input);
|
||||
expect(reservation).not.toBeNull();
|
||||
reservations.push(reservation!);
|
||||
const encoded = await readFile(configPath, "utf8");
|
||||
const config = paperclipConfigSchema.parse(JSON.parse(encoded));
|
||||
expect(config.database.embeddedPostgresPort).toBe(reservation!.port);
|
||||
expect(config.database.mode).toBe("embedded-postgres");
|
||||
for (const directory of [
|
||||
config.database.embeddedPostgresDataDir,
|
||||
config.database.backup.dir,
|
||||
config.logging.logDir,
|
||||
config.storage.localDisk.baseDir,
|
||||
config.secrets.localEncrypted.keyFilePath,
|
||||
])
|
||||
expect(directory.startsWith(`${temporaryRoot}/`)).toBe(true);
|
||||
expect(config.secrets.strictMode).toBe(true);
|
||||
expect(await prepareRunnerE2EServerConfig(input)).toBeNull();
|
||||
expect(await readFile(configPath, "utf8")).toBe(encoded);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { paperclipConfigSchema } from "../../packages/shared/src/config-schema.js";
|
||||
import { reserveRunnerE2EDatabasePort } from "./ports.js";
|
||||
|
||||
/** Seed only the disposable fixture. Onboarding preserves this validated config
|
||||
* and still creates the normal secrets and database. Restarts keep the same DB.
|
||||
*/
|
||||
export async function prepareRunnerE2EServerConfig(input: {
|
||||
temporaryRoot: string;
|
||||
configPath: string;
|
||||
serverPort: number;
|
||||
}) {
|
||||
try {
|
||||
await readFile(input.configPath, "utf8");
|
||||
return null;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
const databaseReservation = await reserveRunnerE2EDatabasePort(
|
||||
input.serverPort,
|
||||
);
|
||||
try {
|
||||
const instanceRoot = path.dirname(input.configPath);
|
||||
const config = paperclipConfigSchema.parse({
|
||||
$meta: {
|
||||
version: 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
source: "onboard",
|
||||
},
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: path.join(instanceRoot, "db"),
|
||||
embeddedPostgresPort: databaseReservation.port,
|
||||
backup: {
|
||||
enabled: false,
|
||||
dir: path.join(input.temporaryRoot, "backups"),
|
||||
},
|
||||
},
|
||||
logging: { mode: "file", logDir: path.join(instanceRoot, "logs") },
|
||||
server: {
|
||||
deploymentMode: "local_trusted",
|
||||
exposure: "private",
|
||||
bind: "loopback",
|
||||
host: "127.0.0.1",
|
||||
port: input.serverPort,
|
||||
serveUi: true,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: { baseDir: path.join(input.temporaryRoot, "storage") },
|
||||
},
|
||||
secrets: {
|
||||
provider: "local_encrypted",
|
||||
strictMode: true,
|
||||
localEncrypted: {
|
||||
keyFilePath: path.join(instanceRoot, "secrets", "master.key"),
|
||||
},
|
||||
},
|
||||
});
|
||||
await mkdir(instanceRoot, { recursive: true, mode: 0o700 });
|
||||
await writeFile(input.configPath, `${JSON.stringify(config, null, 2)}\n`, {
|
||||
flag: "wx",
|
||||
mode: 0o600,
|
||||
});
|
||||
return databaseReservation;
|
||||
} catch (error) {
|
||||
await databaseReservation.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { prepareRunnerE2EServerConfig } from "./server-config.js";
|
||||
import {
|
||||
assertIsolatedServerEnvironment,
|
||||
buildPaperclipServerEnvironment,
|
||||
@@ -347,6 +348,13 @@ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
|
||||
}
|
||||
|
||||
async function supervise() {
|
||||
const databaseReservation = await prepareRunnerE2EServerConfig({
|
||||
temporaryRoot,
|
||||
configPath,
|
||||
serverPort: Number(port),
|
||||
});
|
||||
// Postgres needs the socket itself; release immediately before child spawn.
|
||||
await databaseReservation?.close();
|
||||
startServer();
|
||||
let lastRestartRequestId: string | null = null;
|
||||
while (!shutdownRequested()) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, unlinkSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
findSecretLeakInJsonValues,
|
||||
findSecretLeakInDirectory,
|
||||
isEphemeralCodexRuntimeAuthFile,
|
||||
isEphemeralPostgresPidFile,
|
||||
redactText,
|
||||
sanitizeJson,
|
||||
} from "./redaction.js";
|
||||
@@ -683,6 +685,35 @@ describe("runner E2E run observations", () => {
|
||||
});
|
||||
|
||||
describe("runner E2E failure policy", () => {
|
||||
it.each([
|
||||
"native_session_close_unrecoverable: provider transport failed",
|
||||
"Provider connection closed: runner did not durably suspend before checkpoint",
|
||||
"native_session_close_unrecoverable: provider transport timed out; runner did not durably suspend before checkpoint",
|
||||
])("does not retry controller session-close defects: %s", (message) => {
|
||||
const failureClass = classifyFailure(new Error(message));
|
||||
expect(failureClass).toBe("candidate_failure");
|
||||
expect(shouldRetryFailure(failureClass)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'native_session_recovery_failed: Error: PRP command run.attach failed: {"result":{"code":"command_execution_failed","message":"failed to start ACPX provider: ACPX sidecar command session.open was rejected (retryable=false, classification=unclassified)"},"status":"failed"}',
|
||||
"native_session_recovery_failed: provider transport failed\nACPX sidecar command session.open was rejected (retryable = false, classification=session_not_found)",
|
||||
])("does not retry explicit non-retryable ACPX recovery rejection: %s", (message) => {
|
||||
const failureClass = classifyFailure(new Error(message));
|
||||
expect(failureClass).toBe("candidate_failure");
|
||||
expect(shouldRetryFailure(failureClass)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'native_session_recovery_failed: PRP run.attach failed: {"message":"failed to start ACPX provider: ACPX sidecar command session.open was rejected (retryable=true, classification=network)","status":"failed"}',
|
||||
'native_session_recovery_failed: PRP run.attach failed: {"message":"failed to start ACPX provider: ACPX sidecar command session.open was rejected","status":"failed"}',
|
||||
"native_session_recovery_failed: failed to start ACPX provider: ECONNRESET",
|
||||
])("retains transient ACPX recovery retries: %s", (message) => {
|
||||
const failureClass = classifyFailure(new Error(message));
|
||||
expect(failureClass).toBe("transient_infrastructure");
|
||||
expect(shouldRetryFailure(failureClass)).toBe(true);
|
||||
});
|
||||
|
||||
it("retries only transient infrastructure failures", () => {
|
||||
expect(
|
||||
classifyFailure(new Error("Daytona preview connection timed out")),
|
||||
@@ -930,6 +961,74 @@ describe("runner E2E evidence redaction", () => {
|
||||
).resolves.toMatchObject({ reason: "exact secret value" });
|
||||
});
|
||||
|
||||
it("tolerates only the embedded PostgreSQL PID disappearing during shutdown and keeps scanning", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "runner-e2e-postgres-pid-race-"));
|
||||
cleanupDirectories.push(root);
|
||||
const pidFile = path.join(root, "instances", "test", "db", "postmaster.pid");
|
||||
const persistedFile = path.join(path.dirname(pidFile), "z-persisted.bin");
|
||||
await mkdir(path.dirname(pidFile), { recursive: true });
|
||||
await writeFile(pidFile, "1234\n");
|
||||
await writeFile(persistedFile, secret);
|
||||
await expect(findSecretLeakInDirectory(root, [secret], {
|
||||
ignoreFile: (file) => {
|
||||
if (file === pidFile) unlinkSync(file); // Removed after directory enumeration.
|
||||
return false;
|
||||
},
|
||||
allowDisappearedFile: (file) => isEphemeralPostgresPidFile(root, file),
|
||||
})).resolves.toEqual({ file: persistedFile, reason: "exact secret value" });
|
||||
expect(isEphemeralPostgresPidFile(root, path.join(root, "workspace", "postmaster.pid"))).toBe(false);
|
||||
expect(isEphemeralPostgresPidFile(root, path.join(root, "instances", "test", "db", "records.bin"))).toBe(false);
|
||||
});
|
||||
|
||||
it("still detects secrets in an existing PostgreSQL PID file", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "runner-e2e-postgres-pid-secret-"));
|
||||
cleanupDirectories.push(root);
|
||||
const pidFile = path.join(root, "instances", "test", "db", "postmaster.pid");
|
||||
await mkdir(path.dirname(pidFile), { recursive: true });
|
||||
await writeFile(pidFile, secret);
|
||||
await expect(findSecretLeakInDirectory(root, [secret], {
|
||||
allowDisappearedFile: (file) => isEphemeralPostgresPidFile(root, file),
|
||||
})).resolves.toEqual({ file: pidFile, reason: "exact secret value" });
|
||||
});
|
||||
|
||||
it("fails when required persisted state disappears during scanning", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "runner-e2e-required-file-race-"));
|
||||
cleanupDirectories.push(root);
|
||||
const requiredFile = path.join(root, "records.json");
|
||||
await writeFile(requiredFile, "{}");
|
||||
await expect(findSecretLeakInDirectory(root, [secret], {
|
||||
ignoreFile: (file) => { unlinkSync(file); return false; },
|
||||
allowDisappearedFile: (file) => isEphemeralPostgresPidFile(root, file),
|
||||
})).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("does not suppress other I/O failures for the ephemeral PID path", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "runner-e2e-postgres-pid-io-"));
|
||||
cleanupDirectories.push(root);
|
||||
const pidFile = path.join(root, "instances", "test", "db", "postmaster.pid");
|
||||
await mkdir(path.dirname(pidFile), { recursive: true });
|
||||
await writeFile(pidFile, "1234\n");
|
||||
await expect(findSecretLeakInDirectory(root, [secret], {
|
||||
ignoreFile: (file) => { unlinkSync(file); mkdirSync(file); return false; },
|
||||
allowDisappearedFile: (file) => isEphemeralPostgresPidFile(root, file),
|
||||
})).rejects.toMatchObject({ code: "EISDIR" });
|
||||
});
|
||||
|
||||
it("still reports missing mandatory pass evidence", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "runner-e2e-required-evidence-"));
|
||||
cleanupDirectories.push(root);
|
||||
const privateDir = path.join(root, "private");
|
||||
await mkdir(privateDir);
|
||||
const packaged = await packageEvidence({
|
||||
privateDir,
|
||||
uploadDir: path.join(root, "upload"),
|
||||
secrets: [secret],
|
||||
expectPassScreenshot: true,
|
||||
});
|
||||
expect(packaged.missing).toContain("result.json");
|
||||
expect(packaged.missing).toContain("final-state.png");
|
||||
});
|
||||
|
||||
it("can ignore fake key shapes while scanning persisted package state", async () => {
|
||||
const root = await mkdtemp(
|
||||
path.join(os.tmpdir(), "runner-e2e-secret-shape-test-"),
|
||||
@@ -1038,6 +1137,23 @@ describe("runner E2E evidence redaction", () => {
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("retains the two reviewed chat plan captures without admitting arbitrary chat PNGs", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "runner-e2e-chat-captures-"));
|
||||
cleanupDirectories.push(root);
|
||||
const privateDir = path.join(root, "private");
|
||||
const uploadDir = path.join(root, "upload");
|
||||
await mkdir(privateDir, { recursive: true });
|
||||
for (const file of ["chat-plan-draft.png", "chat-plan-revised.png", "chat-secret.png", "chat-plan-extra.png"]) {
|
||||
await writeFile(path.join(privateDir, file), "fixture raster");
|
||||
}
|
||||
const packaged = await packageEvidence({ privateDir, uploadDir, secrets: [secret], expectPassScreenshot: false });
|
||||
expect(packaged.files.sort()).toEqual(["chat-plan-draft.png", "chat-plan-revised.png", "evidence-manifest.json"]);
|
||||
expect(packaged.leaks).toEqual([]);
|
||||
for (const file of packaged.files.filter((file) => file.endsWith(".png"))) {
|
||||
expect(await readFile(path.join(uploadDir, file), "utf8")).toBe("fixture raster");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps raster evidence private to CI and rejects active SVG content", async () => {
|
||||
const root = await mkdtemp(
|
||||
path.join(os.tmpdir(), "runner-e2e-visual-evidence-test-"),
|
||||
|
||||
@@ -10,6 +10,7 @@ export type RunnerGeneration = "legacy" | "native";
|
||||
export type RunnerEnvironmentId = "local" | "daytona";
|
||||
export type RunnerTaskWorkMode = "standard" | "planning" | "ask";
|
||||
export type RunnerTaskFlow =
|
||||
| "agent_chat"
|
||||
| "governed_tool_review"
|
||||
| "single_turn"
|
||||
| "plan_revision_acceptance"
|
||||
@@ -122,7 +123,7 @@ export interface RunnerTaskFixture {
|
||||
expectedRunCount: number;
|
||||
attemptTimeoutMs: Readonly<Record<RunnerEnvironmentId, number>>;
|
||||
expectedTerminalState: {
|
||||
issue: "done";
|
||||
issue: "done" | "in_review";
|
||||
run: "succeeded";
|
||||
};
|
||||
buildTitle(nonce: string): string;
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import path from "node:path";
|
||||
|
||||
// Let the restart-aware supervisor reap Paperclip and finish its log streams.
|
||||
// Its stop path allows 30 seconds for SIGTERM and 5 seconds for SIGKILL.
|
||||
export const runnerE2EWebServerGracefulShutdown = {
|
||||
signal: "SIGTERM" as const,
|
||||
timeout: 45_000,
|
||||
};
|
||||
|
||||
function shellQuote(value: string) {
|
||||
return `'${value.replaceAll("'", `'\\''`)}'`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { reserveRunnerE2EDatabasePort } from "./ports.js";
|
||||
import { runnerE2EWebServerGracefulShutdown } from "./web-server-command.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
it("lets Playwright reap a restarted server through the production bounded shutdown policy", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-playwright-shutdown-"));
|
||||
const reservation = await reserveRunnerE2EDatabasePort(3100);
|
||||
const port = reservation.port;
|
||||
const supervisorPath = path.join(root, "supervisor.cjs");
|
||||
const workerPath = path.join(root, "worker.cjs");
|
||||
const stoppedPath = path.join(root, "stopped.json");
|
||||
const pidsPath = path.join(root, "pids.json");
|
||||
const configPath = path.join(root, "playwright.config.cjs");
|
||||
const testModule = require.resolve("@playwright/test");
|
||||
const cli = path.join(path.dirname(require.resolve("playwright/package.json")), "cli.js");
|
||||
let child: ReturnType<typeof spawn> | undefined;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let reservationReleased = false;
|
||||
try {
|
||||
await writeFile(workerPath, `
|
||||
const http = require('node:http');
|
||||
const server = http.createServer((req, res) => {
|
||||
res.end(process.env.REVISION);
|
||||
if (req.url === '/restart') process.send('restart');
|
||||
});
|
||||
process.on('SIGTERM', () => server.close(() => process.exit(0)));
|
||||
server.listen(${port}, '127.0.0.1');
|
||||
`);
|
||||
await writeFile(supervisorPath, `
|
||||
const { spawn } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
let stopping = false;
|
||||
let revision = 0;
|
||||
let child;
|
||||
const pids = [];
|
||||
function start() {
|
||||
child = spawn(process.execPath, [${JSON.stringify(workerPath)}], {
|
||||
env: { ...process.env, REVISION: String(++revision) },
|
||||
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
|
||||
});
|
||||
pids.push(child.pid);
|
||||
fs.writeFileSync(${JSON.stringify(pidsPath)}, JSON.stringify(pids));
|
||||
child.once('message', () => child.kill('SIGTERM'));
|
||||
child.once('exit', () => {
|
||||
if (!stopping) return start();
|
||||
fs.writeFileSync(${JSON.stringify(stoppedPath)}, JSON.stringify({ revision, pids }));
|
||||
});
|
||||
}
|
||||
process.on('SIGTERM', () => { stopping = true; child.kill('SIGTERM'); });
|
||||
start();
|
||||
`);
|
||||
await writeFile(configPath, `module.exports = {
|
||||
testDir: ${JSON.stringify(root)}, testMatch: 'shutdown.spec.cjs', workers: 1,
|
||||
reporter: 'line', timeout: 10000,
|
||||
webServer: {
|
||||
command: ${JSON.stringify(`"${process.execPath}" "${supervisorPath}"`)},
|
||||
url: 'http://127.0.0.1:${port}', timeout: 10000,
|
||||
gracefulShutdown: ${JSON.stringify(runnerE2EWebServerGracefulShutdown)},
|
||||
},
|
||||
};`);
|
||||
await writeFile(path.join(root, "shutdown.spec.cjs"), `
|
||||
const { test, expect } = require(${JSON.stringify(testModule)});
|
||||
test('restarts before teardown', async ({ request }) => {
|
||||
await request.get('http://127.0.0.1:${port}/restart');
|
||||
await expect.poll(async () => {
|
||||
try { return await (await request.get('http://127.0.0.1:${port}')).text(); }
|
||||
catch { return ''; }
|
||||
}).toBe('2');
|
||||
});
|
||||
`);
|
||||
await reservation.close();
|
||||
reservationReleased = true;
|
||||
child = spawn(process.execPath, [cli, "test", "--config", configPath], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: process.platform !== "win32",
|
||||
env: { ...process.env, CI: "1" },
|
||||
});
|
||||
let output = "";
|
||||
child.stdout?.on("data", (chunk) => { output += chunk; });
|
||||
child.stderr?.on("data", (chunk) => { output += chunk; });
|
||||
const exit = await Promise.race([
|
||||
new Promise<number | null>((resolve, reject) => {
|
||||
child!.once("error", reject);
|
||||
child!.once("exit", resolve);
|
||||
}),
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(`Playwright cleanup stalled: ${output}`)), 20_000);
|
||||
}),
|
||||
]);
|
||||
expect(exit, output).toBe(0);
|
||||
const stopped = JSON.parse(await readFile(stoppedPath, "utf8"));
|
||||
expect(stopped.revision).toBe(2);
|
||||
expect(stopped.pids).toHaveLength(2);
|
||||
for (const pid of stopped.pids) {
|
||||
expect(() => process.kill(pid, 0)).toThrow();
|
||||
}
|
||||
await expect(fetch(`http://127.0.0.1:${port}`, { signal: AbortSignal.timeout(500) })).rejects.toThrow();
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (child?.pid && child.exitCode === null && child.signalCode === null) {
|
||||
try { process.kill(-child.pid, "SIGKILL"); } catch { child.kill("SIGKILL"); }
|
||||
}
|
||||
const pids: number[] = JSON.parse(await readFile(pidsPath, "utf8").catch(() => "[]"));
|
||||
for (const pid of pids) {
|
||||
try { process.kill(pid, "SIGKILL"); } catch { /* Already reaped. */ }
|
||||
}
|
||||
if (!reservationReleased) await reservation.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 25_000);
|
||||
@@ -46,7 +46,9 @@ describe("public repository paid workflow security", () => {
|
||||
.split(/\n(?= {6}- )/u)
|
||||
.filter((step) => step.includes("uses: pnpm/action-setup@"));
|
||||
|
||||
expect(pnpmSetupSteps, workflowName).toHaveLength(7);
|
||||
expect(pnpmSetupSteps, workflowName).toHaveLength(
|
||||
workflowName === "pr-trusted.yml" ? 8 : 7,
|
||||
);
|
||||
for (const step of pnpmSetupSteps) {
|
||||
expect(step, workflowName).toContain('NPM_CONFIG_AUDIT: "false"');
|
||||
expect(step, workflowName).toContain('NPM_CONFIG_FUND: "false"');
|
||||
@@ -75,7 +77,7 @@ describe("public repository paid workflow security", () => {
|
||||
},
|
||||
{
|
||||
name: "pr-trusted.yml",
|
||||
expectedCachedSetupNodeSteps: 7,
|
||||
expectedCachedSetupNodeSteps: 8,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -89,7 +91,9 @@ describe("public repository paid workflow security", () => {
|
||||
step.includes("uses: pnpm/action-setup@") ? [index] : [],
|
||||
);
|
||||
|
||||
expect(pnpmSetupStepIndexes, name).toHaveLength(7);
|
||||
expect(pnpmSetupStepIndexes, name).toHaveLength(
|
||||
name === "pr-trusted.yml" ? 8 : 7,
|
||||
);
|
||||
for (const pnpmSetupStepIndex of pnpmSetupStepIndexes) {
|
||||
const pnpmSetupStep = steps[pnpmSetupStepIndex]!;
|
||||
const nodeBootstrapStep = steps[pnpmSetupStepIndex - 1]!;
|
||||
|
||||
Reference in new issue
Block a user