Files
PaperClipAI/tests/runner-acceptance/report.test.ts
T
Dotta bfb98aff5d test(runner): add credential-free acceptance foundation (#12652)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Paperclip supports direct adapters and an experimental native Runner
path.
> - These paths need one stable compatibility matrix.
> - The matrix must not launch providers or load credentials during
normal tests.
> - Result handling must reject incomplete output and sensitive values.
> - This pull request adds a credential-free acceptance foundation.
> - The benefit is a reviewable contract for later end-to-end executors.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

This improves verification for direct adapters and Paperclip Runner
providers.

**Subsystem affected**

Cross-cutting test infrastructure for adapters, the server runtime, and
the task thread.

**Current behavior**

The repository has subsystem tests. It does not have one declarative
matrix for direct and native compatibility.

**Proposed behavior**

Add a pure acceptance catalog, result validator, redaction helpers, and
failure classification. Keep all execution authority outside this
change.

**Reason and benefit**

The matrix makes legacy isolation and native recovery requirements
explicit. The helpers let later executors report safe and complete
results.

**Breaking changes**

None. This change does not alter production runtime selection or start
any provider.

## What Changed

- Add a catalog for built-in direct adapters and qualified native
provider profiles.
- Add compatibility cases for runtime selection, task threads,
questions, and flag-change recovery.
- Add pure redaction and transient-failure classification helpers.
- Add fail-closed Markdown and JUnit report aggregation.
- Add isolated test and type-check commands.
- Document the credential-free boundary and deferred live execution
work.

## Verification

GitHub Actions must run:

- `pnpm test:runner-acceptance`
- `pnpm test:runner-acceptance:typecheck`
- The repository test, type-check, build, policy, and security gates.

No local test command was run. The repository owner requested
GitHub-only verification.

## Risks

Low production risk. The change adds test-only files and root scripts.
The catalog can drift when a built-in adapter changes. Its validation
fails closed on that drift.

## Model Used

OpenAI Codex with the GPT-5 agent model. The work used high reasoning,
repository inspection, tool use, and parallel code review.

## 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
- [ ] 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
2026-09-01 04:46:04 -05:00

190 lines
5.9 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { runnerAcceptanceMatrix } from "./catalog.js";
import {
buildRunnerAcceptanceReport,
renderRunnerAcceptanceJUnit,
renderRunnerAcceptanceMarkdown,
} from "./report.js";
import type { RunnerAcceptanceCell, RunnerAcceptanceResult } from "./types.js";
function passingResult(
cell: RunnerAcceptanceCell,
attempt = 1,
): RunnerAcceptanceResult {
return {
schema: "paperclip.runner-acceptance.result/v1",
cellId: cell.id,
attempt,
status: "passed",
startedAt: "2026-09-01T00:00:00.000Z",
finishedAt: `2026-09-01T00:00:0${attempt}.000Z`,
durationMs: attempt * 1_000,
redaction: "passed",
assertions: cell.assertions.map((id) => ({ id, passed: true })),
};
}
describe("Runner acceptance report", () => {
const cells = runnerAcceptanceMatrix.slice(0, 2);
it("selects the newest attempt and reports missing cells without publishing evidence", () => {
const retry = passingResult(cells[0]!, 2);
const report = buildRunnerAcceptanceReport({
cells,
generatedAt: "2026-09-01T00:01:00.000Z",
results: [
{
...passingResult(cells[0]!),
status: "failed",
failureClass: "transient_infrastructure",
error: "connection timed out",
},
retry,
],
});
expect(report).toMatchObject({
selected: 2,
passed: 1,
failed: 1,
retries: 1,
});
expect(report.results[0]).toMatchObject({ attempt: 2, valid: true });
expect(report.results[1]).toMatchObject({
attempt: 0,
valid: false,
error: "No result was supplied",
});
});
it("fails closed on incomplete assertions or unsafe structured values", () => {
const cell = cells[0]!;
const incomplete = passingResult(cell);
const report = buildRunnerAcceptanceReport({
cells: [cell],
results: [{
...incomplete,
error: "redacted diagnostic",
assertions: incomplete.assertions.slice(1),
}],
});
expect(report.failed).toBe(1);
expect(report.results[0]?.validationErrors).toContain(
`missing assertion ${cell.assertions[0]}`,
);
const unsafeResult = {
...passingResult(cell),
diagnostic: { accessToken: "not-a-real-sensitive-value" },
} as unknown as RunnerAcceptanceResult;
const unsafe = buildRunnerAcceptanceReport({
cells: [cell],
results: [unsafeResult],
});
expect(unsafe.failed).toBe(1);
expect(unsafe.results[0]?.validationErrors).toContain(
"unsafe result payload: sensitive field accessToken",
);
expect(JSON.stringify(unsafe.results[0])).not.toContain(
"not-a-real-sensitive-value",
);
expect(unsafe.results[0]).not.toHaveProperty("diagnostic");
});
it("fails closed when assertions are missing or are not an array", () => {
const cell = cells[0]!;
const withoutAssertions = { ...passingResult(cell) } as Record<string, unknown>;
delete withoutAssertions.assertions;
const missing = buildRunnerAcceptanceReport({
cells: [cell],
results: [withoutAssertions],
});
const nonArray = buildRunnerAcceptanceReport({
cells: [cell],
results: [{ ...passingResult(cell), assertions: { passed: true } }],
});
for (const report of [missing, nonArray]) {
expect(report).toMatchObject({ selected: 1, passed: 0, failed: 1 });
expect(report.results[0]).toMatchObject({
valid: false,
assertions: [],
});
expect(report.results[0]?.validationErrors).toEqual(expect.arrayContaining([
"assertions must be an array",
`missing assertion ${cell.assertions[0]}`,
]));
}
});
it("normalizes invalid scalar and assertion types without throwing", () => {
const cell = cells[0]!;
const report = buildRunnerAcceptanceReport({
cells: [cell],
results: [{
schema: 1,
cellId: cell.id,
attempt: "1",
status: true,
failureClass: 42,
error: { message: "failed" },
startedAt: 1,
finishedAt: null,
durationMs: "1000",
redaction: false,
assertions: [
null,
{ id: cell.assertions[0], passed: "yes", detail: 42 },
],
}],
});
expect(report).toMatchObject({ selected: 1, passed: 0, failed: 1 });
expect(report.results[0]).toMatchObject({
attempt: 0,
status: "failed",
startedAt: "",
finishedAt: "",
durationMs: 0,
redaction: "failed",
valid: false,
});
expect(report.results[0]?.validationErrors).toEqual(expect.arrayContaining([
"unsupported result schema",
"attempt must be a positive safe integer",
"duration must be a non-negative finite number",
"timestamps must be valid ISO-compatible values",
"status must be passed or failed",
"failureClass is unsupported",
"error must be a string when present",
"redaction did not pass",
"assertion 0 must be an object",
`assertion ${cell.assertions[0]} passed must be a boolean`,
`assertion ${cell.assertions[0]} detail must be a string when present`,
]));
});
it("renders deterministic Markdown and JUnit summaries", () => {
const report = buildRunnerAcceptanceReport({
cells,
results: cells.map((cell) => passingResult(cell)),
generatedAt: "2026-09-01T00:01:00.000Z",
});
const markdown = renderRunnerAcceptanceMarkdown(report);
const junit = renderRunnerAcceptanceJUnit(report);
expect(markdown).toContain("Passed: 2/2");
expect(markdown).toContain(cells[0]!.id);
expect(junit).toContain('tests="2" failures="0"');
expect(junit).toContain('classname="runner-acceptance"');
});
it("rejects results for cells outside the declared catalog", () => {
expect(() => buildRunnerAcceptanceReport({
cells,
results: [{ ...passingResult(cells[0]!), cellId: "unknown.cell" }],
})).toThrow("unknown acceptance cells");
});
});