mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 20:46:28 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Product E2E journeys drive the real UI and run in the QA staging smoke plan > - Two UI changes landed after the previous QA deployment: the Projects toolbar action is now "Create project" (#18716), and Artifacts V1 is on for every instance (#19164), so attachment rows offer download as an icon link with an `aria-label` > - The journeys still clicked "Add Project" and required a visible "Download" text, so they timed out before reaching the server > - This pull request accepts both the old and the current controls > - The benefit is that project-task-context, build-revise and stop-redirect measure the product again on current deployments ## Linked Issues or Issue Description **What happened?** On the QA deployment ofcb8df178c8, three journeys failed with the generic `journey_operation_failed`: project-task-context, build-revise and stop-redirect. Records on the stack show that build-revise and stop-redirect delivered their ZIP and finished. The user's next interaction never reached the server. **Expected behavior** The journeys use the controls that the current UI shows. **Steps to reproduce** 1. Run the `project-task-context` product journey against a managed local target atcb8df178c8. 2. Playwright waits 30 seconds for `getByRole('button', { name: 'Add Project', exact: true })` and times out. 3. For downloads, an Artifacts V1 row renders `<a href=".../content?download=1" aria-label="Download slugify.zip">` with only an icon. `filter({ hasText: /Download/i })` matches no element. **Paperclip version or commit** master at 03eb49053f; deployed QA appcb8df178c8. ## What Changed - `project-context-flow.ts`: open the new-project dialog with the button named "Add Project" or "Create project". - `story-artifact.ts`: find the attachment download link by "Download" text or by an `aria-label` that starts with "Download". The attachment ID is still required in the link. ## Verification - Local managed target atcb8df178c8: project-task-context failed with the old selector (Playwright timeout on "Add Project") and passed 1/1 with this change. - A Chromium check with Playwright on V1, legacy and no-download markup: the old locator finds 0 elements on V1 markup. The new locator finds the download link on V1 and legacy markup, and finds nothing on markup without a download link. - `npx vitest run --config tests/runner-e2e/vitest.config.ts tests/runner-e2e/product-journeys tests/runner-e2e/story-artifact`: 47 files, 1237 tests passed. - Not run live on QA yet. QA uses the pinned harness (`QA_SMOKE_HARNESS_SHA`), so the pin must move to a commit that contains this change. ## Risks - Low risk. Only the selectors change. All assertions, download checks and artifact checks stay the same. - The download link must still point at the exact attachment's content route. The "Open raw" icon link is not selected. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with tool use (shell, browser, code editing). Extended reasoning was used. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
59 lines
3.8 KiB
TypeScript
59 lines
3.8 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { mkdir, readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { expect, type Page } from "@playwright/test";
|
|
import type { StoryCheck } from "./everyday-observations.js";
|
|
|
|
/** The oracle uses exit 1 for a valid failing receipt, not just a process failure. */
|
|
export function parseArtifactOracleChecks(stdout: string, code: number, preflight = false): StoryCheck[] {
|
|
if (![0, 1].includes(code)) throw new Error("artifact_oracle_failed");
|
|
const value = JSON.parse(stdout) as { passed: boolean; checks: StoryCheck[] };
|
|
if (!Array.isArray(value.checks) || !value.checks.length || value.checks.some(check => typeof check.id !== "string"
|
|
|| typeof check.passed !== "boolean" || typeof check.detail !== "string")) throw new Error("artifact_oracle_receipt_invalid");
|
|
const passed = value.checks.every(check => check.passed);
|
|
if (value.passed !== passed || (code === 0) !== passed) throw new Error("artifact_oracle_receipt_invalid");
|
|
if (preflight && !passed) throw new Error("artifact_oracle_preflight_failed");
|
|
return value.checks;
|
|
}
|
|
|
|
/** Runs only the maintained independent oracle, with no provider credentials. */
|
|
export async function runArtifactOracle(file: string | null, mode: "base" | "separator" | "max-length", deadlineAt: number) {
|
|
const args = [path.join(import.meta.dirname, "everyday-artifact.py"), ...(file ? [file, "--mode", mode] : ["--preflight"])];
|
|
const output = await new Promise<{ stdout: string; code: number }>((resolve, reject) => {
|
|
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => ["PATH", "SYSTEMROOT", "TMPDIR", "DOCKER_HOST", "DOCKER_CONTEXT", "HOME"].includes(key)));
|
|
const child = spawn(process.env.PYTHON ?? "python3", args, { env, stdio: ["ignore", "pipe", "pipe"] });
|
|
let stdout = "", exceeded = false;
|
|
const timer = setTimeout(() => child.kill("SIGKILL"), Math.max(1, Math.min(90_000, deadlineAt - Date.now())));
|
|
child.stdout.on("data", chunk => { stdout += String(chunk); if (stdout.length > 1_000_000) { exceeded = true; child.kill("SIGKILL"); } });
|
|
child.stderr.resume();
|
|
child.once("error", () => { clearTimeout(timer); reject(new Error("artifact_oracle_unavailable")); });
|
|
child.once("exit", code => { clearTimeout(timer); code !== null && !exceeded ? resolve({ stdout, code }) : reject(new Error("artifact_oracle_failed")); });
|
|
});
|
|
return parseArtifactOracleChecks(output.stdout, output.code, file === null);
|
|
}
|
|
|
|
/** Download through the visible task control; attachment metadata is not a content oracle. */
|
|
/** Older attachment cards label the link "Download"; Artifacts V1 rows use an icon link named by aria-label. */
|
|
export function storyArtifactDownloadLink(page: Page, attachmentId: string) {
|
|
const content = `a[href*="/api/attachments/${attachmentId}/content"]`;
|
|
return page.locator(content).filter({ hasText: /Download/i })
|
|
.or(page.locator(`${content}[aria-label^="Download" i]`)).first();
|
|
}
|
|
|
|
export async function downloadStoryArtifact(input: {
|
|
page: Page; attachmentId: string; directory: string; phase: string;
|
|
mode: "base" | "separator" | "max-length"; deadlineAt: number;
|
|
}) {
|
|
const link = storyArtifactDownloadLink(input.page, input.attachmentId);
|
|
await expect(link).toBeVisible({ timeout: 30_000 });
|
|
const [download] = await Promise.all([input.page.waitForEvent("download", { timeout: 30_000 }), link.click()]);
|
|
await mkdir(input.directory, { recursive: true, mode: 0o700 });
|
|
const file = path.join(input.directory, `${input.phase}.zip`);
|
|
await download.saveAs(file);
|
|
const bytes = await readFile(file);
|
|
const checks = await runArtifactOracle(file, input.mode, input.deadlineAt);
|
|
return { attachmentId: input.attachmentId, sha256: createHash("sha256").update(bytes).digest("hex"), bytes: bytes.length,
|
|
filename: download.suggestedFilename(), checks };
|
|
}
|