fix(evals): drop playwright-output copies the private HTML report already holds

Playwright's HTML reporter copies every attachment, including the failure
trace and video, into html-report/data/<sha1>.<ext>, while the same bytes
stay in playwright-output. Private chat cells seal both into the original
and the packaged trees, so a large failed attempt carried the trace four
times outside the blob report and lost all private evidence once the
bundle crossed the 256 MB allowance.

Before packaging, drop a playwright-output file only when html-report/data
holds an independently verified, byte-identical regular file under its
content-derived name, and record what was dropped in a snapshot receipt.
The HTML report, its trace viewer and the blob report are unchanged.
Framework-JSON cells, which register playwright-output attachments by path,
are left untouched. No trusted transfer file, limit or scan changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 committed 2026-11-07 19:20:45 -06:00
1 parent d76ff10aaa
commit 5a53f93b08
4 files changed
+273 -2

No files matched your search

+5
View File
@@ -57,6 +57,7 @@ import { privateCellEvidenceOwner } from "./manual-private-report-assets.js";
import { readManualPrivateHandoff, sealManualResultEvidence, preserveOriginalFrameworkResult, sealStandardOriginalEvidence, type ManualPrivateHandoff } from "./manual-private-handoff.js";
import { requiresPrivateManualEvidence, manualCellKind } from "./manual-private-retention.js";
import { copySharedEvidence } from "./shared-evidence.js";
import { dropDuplicatePlaywrightOutput } from "./private-report-copies.js";
import { classifyFailure } from "./failure-classifier.js";
import { mustPreserveRecoveryState, shouldKeepFailedDiagnostics } from "./cleanup-verification.js";
import { effectiveAutomaticRetryLimit, executeWithAutomaticRetry } from "./automatic-retry.js";
@@ -893,6 +894,10 @@ async function runAttempt(input: {
`${JSON.stringify(sanitizeJson(result, credentials), null, 2)}\n`,
"utf8",
);
// html-report/data already holds identical bytes of each failure trace
// and video; drop the playwright-output copies so failures fit the
// private evidence allowance with their HTML report still viewable.
await dropDuplicatePlaywrightOutput(casePrivateDir);
let evidence = await packageEvidence({
privateDir: casePrivateDir,
uploadDir,
@@ -20,6 +20,7 @@ vi.mock("node:fs/promises", async original => {
};
});
import { copySharedEvidence } from "./shared-evidence.js";
import { dropDuplicatePlaywrightOutput } from "./private-report-copies.js";
import { packageEvidence, publishRunnerE2EAttemptEvidence, isStandardEvidencePath } from "./evidence.js";
import { readManualPrivateHandoff, sealStandardOriginalEvidence, sealManualResultEvidence, preserveOriginalFrameworkResult, type ManualPrivateHandoff } from "./manual-private-handoff.js";
import { canonicalGeneratedReportAsset, cellTraceAsset, inspectCellReportProducer, privateCellEvidenceOwner, readPrivateCellTraceAsset, type CellReportProducerIdentity } from "./manual-private-report-assets.js";
@@ -290,6 +291,14 @@ it.each([0, 1])("actual launcher packaging call %s retains the verified private
for (const name of ["playwright-logo.svg", "manifest.webmanifest", "index.html"]) expect(packaged.files).toContain(`html-report/trace/${name}`);
});
async function walkFiles(root: string, relative = ""): Promise<string[]> {
const found: string[] = [];
for (const item of await readdir(path.join(root, relative), { withFileTypes: true })) {
const name = relative ? `${relative}/${item.name}` : item.name;
if (item.isDirectory()) found.push(...await walkFiles(root, name)); else found.push(name);
}
return found;
}
async function actualLauncherEvidenceBranch(packageCall = packageEvidence) {
const source = await readFile(new URL("./launch.ts", import.meta.url), "utf8");
const parser = createRequire(import.meta.url)("../../node_modules/.pnpm/@babel+parser@7.29.8/node_modules/@babel/parser");
@@ -308,8 +317,8 @@ async function actualLauncherEvidenceBranch(packageCall = packageEvidence) {
expect(start).toBeGreaterThan(0); expect(end).toBeGreaterThan(start);
const code = stripTypeScriptTypes(`async function actualBranch(manualPrivateHandoff, candidate, casePrivateDir, resultsRoot, uploadDir, credentials, result, resultPath) { let manualDiagnostic; ${source.slice(start, end)} return result; }`, { mode: "strip" });
const publisher = vi.fn(publishRunnerE2EAttemptEvidence);
const branch = Function("preserveOriginalFrameworkResult", "writeFile", "sanitizeJson", "packageEvidence", "privateCellEvidenceOwner", "sealStandardOriginalEvidence", "publishRunnerE2EAttemptEvidence", "readPrivateAttemptObservation", `${code}; return actualBranch;`)(
preserveOriginalFrameworkResult, writeFile, sanitizeJson, packageCall, privateCellEvidenceOwner, sealStandardOriginalEvidence, publisher, readPrivateAttemptObservation,
const branch = Function("preserveOriginalFrameworkResult", "writeFile", "sanitizeJson", "packageEvidence", "privateCellEvidenceOwner", "sealStandardOriginalEvidence", "publishRunnerE2EAttemptEvidence", "readPrivateAttemptObservation", "dropDuplicatePlaywrightOutput", `${code}; return actualBranch;`)(
preserveOriginalFrameworkResult, writeFile, sanitizeJson, packageCall, privateCellEvidenceOwner, sealStandardOriginalEvidence, publisher, readPrivateAttemptObservation, dropDuplicatePlaywrightOutput,
) as (context: ManualPrivateHandoff | undefined, candidate: { id: string }, source: string, root: string, target: string, secrets: string[], result: RunnerE2EResult, resultPath: string) => Promise<RunnerE2EResult>;
return { branch, publisher };
}
@@ -328,6 +337,16 @@ it("actual launcher public branch uses its normal publisher without retaining pr
await expect(readFile(path.join(uploadDir, "html-report/trace/playwright-logo.svg"))).rejects.toMatchObject({ code: "ENOENT" });
await expect(readFile(path.join(f.cell, "snapshots/manual-original-framework-result.json"))).rejects.toMatchObject({ code: "ENOENT" });
});
it("actual launcher private branch seals one HTML report copy of the failure trace instead of a second playwright-output copy", async () => {
const f = await fixture(), { branch, publisher } = await actualLauncherEvidenceBranch();
const traces = async (root: string) => (await walkFiles(root)).filter(name => name.endsWith("trace.zip") || /^html-report\/data\/[0-9a-f]{40}\.zip$/.test(name));
expect((await traces(f.cell)).length).toBe(2);
const result = await branch(f.context, { id: f.id }, f.cell, path.join(f.root, "results"), f.upload, [], failedLauncherResult(f.id), path.join(f.cell, "result.json"));
expect(result.status).toBe("failed"); expect(publisher).not.toHaveBeenCalled();
const sealed = await validateSealedOriginals(f.root, f.id, f.context);
const retained = sealed.files.map((row: { path: string }) => row.path).filter((name: string) => name.endsWith("trace.zip") || /^html-report\/data\/[0-9a-f]{40}\.zip$/.test(name));
expect(retained).toHaveLength(1); expect(retained[0]).toMatch(/^html-report\/data\//);
});
it("actual launcher private package failure preserves original bytes and never falls back to ordinary publication", async () => {
const f = await fixture(), original = await readFile(path.join(f.cell, "result.json"));
const fail = vi.fn<typeof packageEvidence>().mockRejectedValue(new Error("synthetic private packaging failure"));
@@ -0,0 +1,147 @@
import { afterAll, afterEach, beforeAll, expect, it } from "vitest";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { createRequire } from "node:module";
import { cp, lstat, mkdir, mkdtemp, readdir, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { copySharedEvidence } from "./shared-evidence.js";
import { packageEvidence } from "./evidence.js";
import { sealManualResultEvidence, sealStandardOriginalEvidence, type ManualPrivateHandoff } from "./manual-private-handoff.js";
import { inspectCellReportProducer, privateCellEvidenceOwner, type CellReportProducerIdentity } from "./manual-private-report-assets.js";
import { stageSealedPrivateCell, validateSealedInventory, validateSealedOriginals } from "./manual-private-transfer.js";
import { MANUAL_PRIVATE_EXECUTIONS, MANUAL_PRIVATE_PROTOCOL, manualCellKind } from "./manual-private-retention.js";
import { archivePrivateEvidence } from "./private-history.js";
import { dropDuplicatePlaywrightOutput, PRIVATE_REPORT_COPIES_RECEIPT } from "./private-report-copies.js";
const sha1 = (value: Buffer) => createHash("sha1").update(value).digest("hex");
const roots: string[] = [];
const temporaryRoot = await realpath(os.tmpdir());
let reporter: string, producerRoot: string, producer: CellReportProducerIdentity;
async function files(root: string, relative = ""): Promise<string[]> {
const found: string[] = [];
for (const item of await readdir(path.join(root, relative), { withFileTypes: true })) {
const name = relative ? `${relative}/${item.name}` : item.name;
if (item.isDirectory()) found.push(...await files(root, name)); else found.push(name);
}
return found.sort();
}
async function bytesOf(root: string) {
let total = 0;
for (const name of await files(root)) total += (await lstat(path.join(root, name))).size;
return total;
}
beforeAll(async () => {
producerRoot = await mkdtemp(path.join(temporaryRoot, "synthetic-report-copies-"));
reporter = path.join(producerRoot, "reporter"); const tests = path.join(producerRoot, "node-tests");
await Promise.all([mkdir(reporter), mkdir(tests)]);
const require = createRequire(import.meta.url), pkg = require.resolve("@playwright/test/package.json"), entry = require.resolve("@playwright/test");
await writeFile(path.join(tests, "failure.spec.cjs"), `const {test,expect}=require(${JSON.stringify(entry)});test('synthetic retained failure',async({},info)=>{await info.attach('synthetic-video',{body:Buffer.from('synthetic video bytes'),contentType:'video/webm'});expect('actual wrong outcome').toBe('expected outcome');});`);
const config = path.join(tests, "isolated.config.cjs");
// The same reporter set as the private chat lane: HTML, JUnit and blob.
await writeFile(config, `module.exports=${JSON.stringify({ testDir: tests, testMatch: "failure.spec.cjs", workers: 1, outputDir: path.join(reporter, "playwright-output"), use: { trace: "retain-on-failure" }, reporter: [["html", { outputFolder: path.join(reporter, "html-report"), open: "never" }], ["junit", { outputFile: path.join(reporter, "junit.xml") }], ["blob", { outputDir: path.join(reporter, "blob-report") }]] })};`);
const failed = await promisify(execFile)(process.execPath, [path.join(path.dirname(pkg), "cli.js"), "test", "--config", config], { cwd: tests, timeout: 30_000, maxBuffer: 1024 * 1024, env: { PATH: process.env.PATH, CI: "1", NODE_OPTIONS: "", NODE_PATH: "", PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1" } }).then(() => false, error => { expect(error.code).toBe(1); return true; });
expect(failed).toBe(true);
producer = await inspectCellReportProducer();
}, 30_000);
afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); });
afterAll(async () => { await rm(producerRoot, { recursive: true, force: true }); });
async function fixture() {
const root = await mkdtemp(path.join(temporaryRoot, "synthetic-private-copies-")); roots.push(root);
const id = MANUAL_PRIVATE_EXECUTIONS[0]!, cell = path.join(root, "capture/cases/activity.agent-feedback"); await mkdir(cell, { recursive: true });
await copySharedEvidence(reporter, cell, path.join(root, "controller"), []);
await writeFile(path.join(cell, "result.json"), '{"status":"failed","failureClass":"assertion"}');
const context = { root, private: true, protocol: MANUAL_PRIVATE_PROTOCOL, workflowSha: "a".repeat(40), targetSha: "b".repeat(40), runId: "123", attempt: "1", executionIds: [id], cellReportProducer: producer } as ManualPrivateHandoff;
const prefix = `gha-123-1-${id}/chat-features/runner-opencode/local/activity.agent-feedback/attempt-1`;
return { root, id, cell, context, prefix, upload: path.join(root, "results", prefix) };
}
async function traceOf(cell: string) {
const outputs = (await files(cell)).filter(name => /^playwright-output\/[^/]+\/trace\.zip$/.test(name));
expect(outputs).toHaveLength(1);
return { relative: outputs[0]!, bytes: await readFile(path.join(cell, outputs[0]!)) };
}
it("drops only playwright-output copies the HTML report already holds, leaving the HTML report and blob report unchanged", async () => {
const f = await fixture(), trace = await traceOf(f.cell);
const keptAt = `html-report/data/${sha1(trace.bytes)}.zip`;
expect(await readFile(path.join(f.cell, keptAt))).toEqual(trace.bytes);
const before = await files(f.cell), reports = before.filter(name => /^(?:html-report|blob-report)\//.test(name));
const reportBytes = await Promise.all(reports.map(name => readFile(path.join(f.cell, name))));
const dropped = await dropDuplicatePlaywrightOutput(f.cell);
expect(dropped.map(row => row.path)).toContain(trace.relative);
expect(dropped.find(row => row.path === trace.relative)).toMatchObject({ keptAt, bytes: trace.bytes.length });
// Every dropped file has its exact bytes at the recorded HTML report path.
for (const row of dropped) expect(sha1(await readFile(path.join(f.cell, row.keptAt)))).toBe(path.basename(row.keptAt, path.extname(row.keptAt)));
const after = await files(f.cell);
expect(after.filter(name => name.endsWith("trace.zip"))).toEqual([]);
expect(after).toContain(keptAt);
expect(await Promise.all(reports.map(name => readFile(path.join(f.cell, name))))).toEqual(reportBytes);
// Files the HTML report does not copy, such as the run bookkeeping, stay.
expect(after).toContain("playwright-output/.last-run.json");
expect(JSON.parse(await readFile(path.join(f.cell, PRIVATE_REPORT_COPIES_RECEIPT), "utf8")).dropped).toEqual(dropped);
});
it("keeps every validator accepting the deduplicated cell through package, both seals, trusted transfer, archive and report staging", async () => {
const f = await fixture(), trace = await traceOf(f.cell);
const keptAt = `html-report/data/${sha1(trace.bytes)}.zip`;
const originalBytes = await bytesOf(f.cell);
const dropped = await dropDuplicatePlaywrightOutput(f.cell);
const receiptBytes = (await lstat(path.join(f.cell, PRIVATE_REPORT_COPIES_RECEIPT))).size;
expect(originalBytes - await bytesOf(f.cell) + receiptBytes).toBe(dropped.reduce((sum, row) => sum + row.bytes, 0));
const owner = await privateCellEvidenceOwner(f.context, f.id);
const evidence = await packageEvidence({ privateDir: f.cell, uploadDir: f.upload, secrets: [], expectPassScreenshot: false, privateCell: { owner, layout: "original" } });
expect(evidence.leaks).toEqual([]);
const original = await sealStandardOriginalEvidence({ context: f.context, executionId: f.id, source: f.cell, evidence, secrets: [], expectPassScreenshot: false });
await sealManualResultEvidence(f.context, []);
const originalDir = path.join(f.root, "originals", manualCellKind(f.id)), finalDir = path.join(f.root, "sealed-results/html-report");
expect((await validateSealedOriginals(f.root, f.id, f.context)).inventorySha256).toBe(original.inventorySha256);
const final = await validateSealedInventory(finalDir, "../result-evidence-inventory.json", undefined, "standard", f.context);
for (const tree of [original.files.map((row: { path: string }) => row.path), final.files.map((row: { path: string }) => row.path.slice(f.prefix.length + 1))]) {
expect(tree).toContain(keptAt);
expect(tree).toContain(PRIVATE_REPORT_COPIES_RECEIPT);
expect(tree.filter((name: string) => name.endsWith("trace.zip"))).toEqual([]);
expect(tree.some((name: string) => /^blob-report\/.+\.zip$/.test(name))).toBe(true);
}
expect(await readFile(path.join(originalDir, keptAt))).toEqual(trace.bytes);
const bundle = path.join(f.root, "bundle"); await mkdir(bundle);
await cp(path.join(f.root, "originals"), path.join(bundle, "originals"), { recursive: true });
await cp(path.join(f.root, "sealed-results"), path.join(bundle, "sealed-results"), { recursive: true });
await writeFile(path.join(bundle, "identity.json"), JSON.stringify(f.context));
const zip = path.join(f.root, "retained.zip"), restored = path.join(f.root, "restored");
await archivePrivateEvidence("pack", bundle, zip); await archivePrivateEvidence("unpack", zip, restored);
const downloaded = { ...f.context, root: restored };
expect((await validateSealedOriginals(restored, f.id, downloaded)).complete).toBe(true);
const selected = path.join(f.root, "selected"); await mkdir(selected);
await stageSealedPrivateCell(restored, selected, f.id, downloaded);
const staged = path.join(selected, manualCellKind(f.id), f.prefix);
expect(await readFile(path.join(staged, keptAt))).toEqual(trace.bytes);
});
it("keeps the playwright-output file when the HTML report copy is missing, differs, or is not a regular file", async () => {
const f = await fixture(), trace = await traceOf(f.cell), keptAt = path.join(f.cell, `html-report/data/${sha1(trace.bytes)}.zip`);
const tampered = Buffer.from(trace.bytes); tampered[tampered.length - 1] ^= 1;
await writeFile(keptAt, tampered);
expect((await dropDuplicatePlaywrightOutput(f.cell)).map(row => row.path)).not.toContain(trace.relative);
expect(await readFile(path.join(f.cell, trace.relative))).toEqual(trace.bytes);
await rm(path.join(f.cell, PRIVATE_REPORT_COPIES_RECEIPT), { force: true });
await rm(keptAt); await symlink(path.join(f.cell, trace.relative), keptAt);
expect((await dropDuplicatePlaywrightOutput(f.cell)).map(row => row.path)).not.toContain(trace.relative);
expect(await readFile(path.join(f.cell, trace.relative))).toEqual(trace.bytes);
});
it("leaves trees alone when the HTML data directory is a symlink or the framework JSON reporter owns playwright-output", async () => {
const linked = await fixture(), data = path.join(linked.cell, "html-report/data"), moved = path.join(linked.root, "moved-data");
await cp(data, moved, { recursive: true }); await rm(data, { recursive: true }); await symlink(moved, data);
const linkedBefore = await files(linked.cell);
expect(await dropDuplicatePlaywrightOutput(linked.cell)).toEqual([]);
expect(await files(linked.cell)).toEqual(linkedBefore);
const framework = await fixture();
await writeFile(path.join(framework.cell, "framework-report.json"), "{}");
const frameworkBefore = await files(framework.cell);
expect(await dropDuplicatePlaywrightOutput(framework.cell)).toEqual([]);
expect(await files(framework.cell)).toEqual(frameworkBefore);
});
+100
View File
@@ -0,0 +1,100 @@
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { lstat, mkdir, readdir, realpath, rmdir, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
/** Records which Playwright output files were dropped because the per-cell HTML
* report already holds the identical bytes. It names paths and digests only. */
export const PRIVATE_REPORT_COPIES_RECEIPT = "snapshots/playwright-output-copies.json";
export interface DroppedReportCopy { path: string; keptAt: string; bytes: number; sha256: string }
const outputDirectory = "playwright-output";
const dataDirectory = "html-report/data";
const entryAllowance = 4096;
const depthAllowance = 16;
// The JSON framework reporter registers playwright-output attachments by path;
// those trees have no HTML report copy and must stay untouched.
const frameworkReports = ["framework-report.json", "snapshots/playwright-framework.json"];
async function entry(file: string) {
return lstat(file).catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return undefined;
throw error;
});
}
async function digests(file: string) {
const sha1 = createHash("sha1"), sha256 = createHash("sha256");
let bytes = 0;
for await (const chunk of createReadStream(file, { flags: "r" }) as AsyncIterable<Buffer>) {
sha1.update(chunk); sha256.update(chunk); bytes += chunk.length;
}
return { sha1: sha1.digest("hex"), sha256: sha256.digest("hex"), bytes };
}
/**
* Playwright's HTML reporter copies every test attachment, including the failure
* trace and video, into html-report/data/<sha1 of content><extension>. The same
* bytes also stay in playwright-output, so a retained failure stores its largest
* files twice before the blob report adds a third (compressed) copy.
*
* This removes a playwright-output file only when the HTML report holds an
* independently verified, byte-identical regular file under the content-derived
* name. The HTML report and its trace viewer keep working, the blob report is
* untouched, and every remaining file still passes the normal packaging,
* sealing and transfer checks. Anything unexpected leaves the file in place.
*/
export async function dropDuplicatePlaywrightOutput(cellDirectory: string): Promise<DroppedReportCopy[]> {
const root = await realpath(cellDirectory);
const rootInfo = await lstat(cellDirectory);
if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink() || root !== path.resolve(cellDirectory))
throw new Error("Invalid private report cell directory");
for (const relative of frameworkReports) if (await entry(path.join(root, relative))) return [];
const output = path.join(root, outputDirectory), data = path.join(root, dataDirectory);
for (const directory of [output, path.join(root, "html-report"), data]) {
const info = await entry(directory);
if (!info || !info.isDirectory() || info.isSymbolicLink()) return [];
}
const dropped: DroppedReportCopy[] = [];
let visited = 0;
async function visit(relative: string, depth: number): Promise<boolean> {
if (depth > depthAllowance) return false;
const directory = path.join(root, relative);
let remaining = 0;
for (const item of await readdir(directory, { withFileTypes: true })) {
if (++visited > entryAllowance) throw new Error("Playwright output exceeds entry allowance");
const name = `${relative}/${item.name}`, file = path.join(root, name);
if (item.isDirectory() && !item.isSymbolicLink()) {
if (await visit(name, depth + 1)) await rmdir(file); else remaining++;
continue;
}
remaining++;
if (!item.isFile() || item.isSymbolicLink()) continue;
const before = await lstat(file);
if (!before.isFile() || before.isSymbolicLink() || before.size === 0) continue;
const extension = path.extname(item.name);
if (!/^\.[A-Za-z0-9]{1,16}$/.test(extension)) continue;
const source = await digests(file);
const keptAt = `${dataDirectory}/${source.sha1}${extension}`, kept = path.join(root, keptAt);
const keptInfo = await entry(kept);
if (!keptInfo || !keptInfo.isFile() || keptInfo.isSymbolicLink() || keptInfo.size !== before.size || source.bytes !== before.size) continue;
const copy = await digests(kept);
const after = await lstat(file);
if (copy.sha256 !== source.sha256 || copy.bytes !== source.bytes || after.ino !== before.ino
|| after.size !== before.size || after.mtimeMs !== before.mtimeMs) continue;
await unlink(file);
remaining--;
dropped.push({ path: name, keptAt, bytes: source.bytes, sha256: source.sha256 });
}
return remaining === 0;
}
await visit(outputDirectory, 0);
if (!dropped.length) return dropped;
dropped.sort((a, b) => a.path.localeCompare(b.path));
const receipt = path.join(root, PRIVATE_REPORT_COPIES_RECEIPT);
await mkdir(path.dirname(receipt), { recursive: true, mode: 0o700 });
await writeFile(receipt, `${JSON.stringify({ schema: "paperclip.private-report-copies/v1",
meaning: "playwright-output files removed because html-report/data holds identical bytes", dropped }, null, 2)}\n`, { mode: 0o600, flag: "wx" });
return dropped;
}