import assert from "node:assert/strict"; import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { spawnSync } from "node:child_process"; import test from "node:test"; import { tsImport } from "tsx/esm/api"; const { CiError, toCiError, ciErrorForCommand, serializeCiError, renderCiErrorAnnotation, renderCiErrorSummary, reportCiError } = await tsImport("../../src/shared/ci-errors.ts", import.meta.url); const { executeCaptureSetup } = await tsImport("../../src/capture/setup-actions.ts", import.meta.url); const { createExplorationInputs } = await tsImport("../../src/config/exploration-inputs.ts", import.meta.url); test("structured failures preserve action and retry intent across normalization", () => { const error = new CiError({ code: "CAPTURE_TIMEOUT", stage: "capture", message: "Profile did not become ready.", nextAction: "Inspect the Profile entry route.", retryable: true, details: { routeId: "Profile" } }); assert.equal(toCiError(error), error); assert.deepEqual(serializeCiError(error), { code: "CAPTURE_TIMEOUT", stage: "capture", message: "Profile did not become ready.", nextAction: "Inspect the Profile entry route.", retryable: true, details: { routeId: "Profile" }, }); const unknown = toCiError(new Error("build rejected"), { code: "APP_BUILD_FAILED", stage: "build", nextAction: "Fix the app build." }); assert.equal(unknown.code, "APP_BUILD_FAILED"); assert.equal(unknown.message, "build rejected"); assert.equal(unknown.retryable, false); }); test("CI boundary fallbacks distinguish planning, capture, export, and publication without replacing precise errors", () => { for (const [command, code, stage] of [ ["source:verify", "PLAN_VERIFICATION_FAILED", "verify"], ["capture:review", "REVIEW_CAPTURE_FAILED", "capture"], ["capture:baseline", "BASELINE_CAPTURE_FAILED", "capture"], ["ci:report", "REPORT_EXPORT_FAILED", "export"], ["ci:publish", "REPORT_PUBLICATION_FAILED", "publication"], ]) { const failure = ciErrorForCommand(new Error("Operation failed"), command); assert.equal(failure.code, code); assert.equal(failure.stage, stage); assert.ok(failure.nextAction.length > 20); } const precise = new CiError({ code: "AGENT_API_KEY_MISSING", stage: "preflight", message: "Key missing", nextAction: "Add the key." }); assert.equal(ciErrorForCommand(precise, "capture:review"), precise); }); test("GitHub annotations escape injected properties, workflow commands, percent, and line breaks", () => { const error = new CiError({ code: "BAD,FILE:x", stage: "check", message: "bad 100%\r\n::warning::injected", nextAction: "Correct config." }); const annotation = renderCiErrorAnnotation(error); assert.match(annotation, /BAD%2CFILE%3Ax/); assert.match(annotation, /100%25%0D%0A::warning::injected/); assert.equal(annotation.includes("\n"), false); }); test("diagnostics redact explicit secrets, credential fields, bearer tokens, signed URLs, and causes", () => { const error = new CiError({ code: "SERVICE_FAILED", stage: "download", message: "custom-secret Bearer bearer-secret https://user:password@host/path?token=signed-value&sig=signature sk-test_abcdefghijklmnop", nextAction: "Replace custom-secret.", details: { authorization: "header", password: "password", nested: { apiKey: "value", url: "https://blob/path?signature=secret" }, secretName: "GITHUB_TOKEN" }, cause: new Error("private-stack-and-cause"), }); const rendered = JSON.stringify(serializeCiError(error, { secrets: ["custom-secret"] })); for (const value of ["custom-secret", "bearer-secret", "user:password", "signed-value", "=signature", "sk-test_abcdefghijklmnop", "private-stack-and-cause"]) { assert.equal(rendered.includes(value), false, value); } assert.match(rendered, /GITHUB_TOKEN/); assert.match(rendered, /\[REDACTED\]/); assert.equal("stack" in serializeCiError(error), false); }); test("diagnostics redact raw, JSON-escaped, and URL-encoded secret values throughout structured errors", () => { const secret = 'review"P\\ass/word+9'; const variants = [secret, JSON.stringify(secret).slice(1, -1), encodeURIComponent(secret)]; const error = new CiError({ code: "SETUP_FAILED", stage: "setup", message: variants.join(" | "), nextAction: `Replace input ${variants[1]}.`, details: { nested: { output: variants, response: JSON.stringify({ input: secret }) } } }); const diagnostic = serializeCiError(error, { secrets: [secret] }); assert.equal(diagnostic.message, "[REDACTED] | [REDACTED] | [REDACTED]"); assert.equal(diagnostic.nextAction, "Replace input [REDACTED]."); assert.deepEqual(diagnostic.details.nested.output, ["[REDACTED]", "[REDACTED]", "[REDACTED]"]); assert.equal(diagnostic.details.nested.response, '{"input":"[REDACTED]"}'); // A malformed explicit secret must not replace the original failure with URIError. assert.doesNotThrow(() => serializeCiError(error, { secrets: ["\ud800"] })); }); test("a resolved fill failure cannot publish encoded application credentials to CI diagnostics", async t => { const root = await mkdtemp(join(tmpdir(), "explore-fill-error-")); t.after(() => rm(root, { recursive: true, force: true })); const password = 'agent"P\\ass/phrase+9'; const environment = { APP_REVIEW_PASSWORD: password, GITHUB_STEP_SUMMARY: join(root, "summary.md") }; const inputs = createExplorationInputs({ instructions: "Fill {{secret:APP_REVIEW_PASSWORD}}.", environment }); let fillFailure; try { await executeCaptureSetup([{ action: "fill", identifier: "auth-password", text: inputs.resolve("{{secret:APP_REVIEW_PASSWORD}}") }], { snapshot: () => ({ refsGeneration: 1, nodes: [{ identifier: "auth-password", type: "SecureTextField", ref: "e1" }] }), open: () => { throw new Error("Unexpected navigation"); }, command: args => { assert.equal(args[0], "fill"); assert.equal(args[2], password); // Mirrors controller JSON output included in the runner's failure message. throw new Error(`Controller fill failed: ${JSON.stringify({ input: args[2] })}; encoded input ${encodeURIComponent(args[2])}`); }, }); } catch (error) { fillFailure = error; } assert.ok(fillFailure); const perTarget = serializeCiError(toCiError(fillFailure, { code: "SETUP_FAILED", stage: "setup" }), { secrets: inputs.values }); assert.equal(perTarget.message, 'Controller fill failed: {"input":"[REDACTED]"}; encoded input [REDACTED]'); const output = []; const diagnosticsPath = join(root, "error.json"); const diagnostic = await reportCiError(ciErrorForCommand(fillFailure, "capture:review"), { env: environment, diagnosticsPath, write: line => output.push(line), }); assert.equal(diagnostic.message, perTarget.message); assert.deepEqual(JSON.parse(await readFile(diagnosticsPath, "utf8")), diagnostic); const summary = await readFile(environment.GITHUB_STEP_SUMMARY, "utf8"); assert.match(summary, /REDACTED/); assert.match(output[0], /REDACTED/); for (const text of [diagnostic.message, summary, output[0]]) { for (const value of [password, JSON.stringify(password).slice(1, -1), encodeURIComponent(password)]) assert.equal(text.includes(value), false); } }); test("summary escapes untrusted Markdown and includes the next action", () => { const error = new CiError({ code: "CONFIG_INVALID", stage: "preflight", message: "[download](https://bad)