Files
PaperClipAI/tests/runner-e2e/product-regression-testops.test.mjs
T
DottaandClaude Opus 5.5 02a2299173 feat(codex): default Codex to gpt-6.1-sol, half the gpt-5.6-sol price (#19623)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Codex agents run through Paperclip Runner or the legacy
`codex_local` harness, and the runner E2E suites run paid Codex cells
> - The shared Codex default and many selections preferred `gpt-5.6-sol`
> - The reviewed OpenAI rate card in
`server/src/services/codex-pricing.ts` (`openai-standard-2026-09-30`)
prices `gpt-6.1-sol` at $2 input / $0.10 cached / $10 output per 1M
tokens, and `gpt-5.6-sol` at $4 / $0.40 / $20
> - So every default run and every paid Codex eval cell cost twice as
much as necessary
> - This pull request moves every place that prefers or selects
`gpt-5.6-sol` to `gpt-6.1-sol`, and keeps historical facts, rate cards
and compatibility fallbacks on `gpt-5.6-sol`
> - The benefit is half the token price for new default Codex agents and
for the Codex E2E and TestOps cells

## Linked Issues or Issue Description

**What existing behavior does this improve?**
The default Codex model for new agents and the Codex model that the
runner E2E suites and TestOps plans select.

**Subsystem affected**
Adapters (`adapter-utils`, `codex-local`), server native runtime
fallback, UI AI connection pools, Paperclip Runner devtools and live
evals, runner E2E catalog and TestOps mappings.

**Current behavior**
New Codex agents without an explicit model store `gpt-5.6-sol`. The
runner E2E `runner-codex` profile, the Reliability `queue-steer` plan
cell, the Product regression TestOps mapping and the ACPX Codex
qualification selection all use `gpt-5.6-sol`.

**Proposed behavior**
They use `gpt-6.1-sol`. Existing agents that store a model keep it.
`gpt-5.6-sol` stays selectable.

**Reason and benefit**
`gpt-6.1-sol` costs half as much per token on the in-repo reviewed rate
card. The current OpenAI pricing page (retrieved 2026-11-06) shows the
same short-context rates and also lists long-context rates of $4 / $0.20
/ $15 for `gpt-6.1-sol` against $8 / $0.80 / $30 for `gpt-5.6-sol`.

**Breaking changes**
None for stored configurations. See Risks.

## What Changed

Changed defaults and selections (all now `gpt-6.1-sol`):

- `PAPERCLIP_RUNNER_DEFAULT_MODELS.codex` in
`packages/adapter-utils/src/paperclip-runner-permissions.ts`. This also
changes `DEFAULT_CODEX_LOCAL_MODEL`,
`resolvePaperclipRunnerModel("codex", …)`, runner config normalization,
`codex_local` → Paperclip Runner conversion, the UI new-agent and
agent-config defaults, the composer default model and the public MCP
effort lookup.
- `codex-local` `models`: the 5.6 entry is now the literal
`gpt-5.6-sol`, because it used to be `DEFAULT_CODEX_LOCAL_MODEL`. The
list order and contents do not change.
- AI connection pool: a new OpenAI member profile uses
`PAPERCLIP_RUNNER_DEFAULT_MODELS.codex` instead of a hard-coded
`gpt-5.6-sol`.
- Paperclip Runner devtools ACPX Codex default and preset, the
`runner-codex-sol` local provider smoke, and the `acpx-codex-sol` live
workflow candidate.
- `tests/acpx-qualification-models.ts` Codex selection (runner E2E and
runner acceptance ACPX Codex profiles).
- Runner E2E: the `runner-codex` profile follows the default, so all
native Codex cells move. This includes the `runner-codex` cells among
the 52 standard `everyday-workflows` cells and the seven Codex
reliability cells. `runner-codex-mini` stays on `gpt-5.4-mini`. The
Reliability plan `queue-steer` `steeringProfile` is now
`['runner-codex', 'gpt-6.1-sol']`. Its executionId
(`everyday-workflows.runner-codex.daytona.queue-steer`), its fullName
and its TestOps case identity do not change. No separate profile is
necessary.
- `product-regression-testops.mjs`: the declared model for every
`runner-codex` case is now `gpt-6.1-sol` (it was already that for
`configured-repository`).
- `tests/runner-e2e/installed-cli.ts`: the credential-free installed
startup proof opens a session on the default model.
- `tests/runner-e2e/scheduled-prices.json`: add a `gpt-6.1-sol` price
(`openai-standard-2026-11-06`, including 30m/12h cache writes and
long-context rates from the OpenAI pricing page). The weekly scheduled
Codex cells need an exact price for their model, or admission fails
closed.
- Storybook: the composer model picker fixtures, the connection pool
stories and host, and the agent settings preview show `gpt-6.1-sol` as
the default.
- Docs: `doc/DEVELOPING.md` (the Codex conversion default) and
`tests/runner-e2e/EVERYDAY-WORKFLOWS.md` (local matrix, reliability
extras, `queue-steer` row).
- Tests that assert the default now expect `gpt-6.1-sol`. New
assertions: the default has the `0.159.0` CLI floor, the compatibility
fallback is floor-free, the `queue-steer` plan cell runs `runner-codex`
on `gpt-6.1-sol`, and all native Codex everyday cells (standard and
reliability) use `gpt-6.1-sol`.

Deliberately kept on `gpt-5.6-sol`:

- Rate cards: `server/src/services/codex-pricing.ts` and
`packages/paperclip-runner/src/evals/model-pricing.ts`. Old receipts
need them. (The runner eval rate card has no `gpt-6.1-sol` entry. An
unpriced candidate is reported as unpriced, not as zero. I did not add a
rate there because that card has its own version and source.)
- The remote Codex CLI compatibility fallback. A new
`CODEX_LOCAL_COMPATIBILITY_FALLBACK_MODEL = "gpt-5.6-sol"` replaces the
default in `codexModelFallbackCandidates`. `gpt-6.1-sol` needs Codex
0.159.0 or newer, so it cannot be the floor-free last resort. Fallback
behavior is unchanged.
- `tests/runner-e2e/provider-usage.ts`: the implicit request
cache-policy attribution stays qualified only for `gpt-5.6-sol`. I did
not verify that policy for `gpt-6.1-sol`. A `gpt-6.1-sol` response that
omits the write-TTL split keeps its cache-write cost unknown, which is
the existing behavior for unqualified models.
- `tests/runner-e2e/scheduled-measurements.json`: historical
`gpt-5.6-sol` measurements. They no longer match the current suite
definitions, so the forecast reports them as historical only. The
forecast tests now build their synthetic cohorts on the current
definition hash.
- Model catalogs that list `gpt-5.6-sol` as available (`codex-local`,
`cursor-local`, `opencode-local`, Speko voice diagnostics, Pi picker
fixture).
- The `gpt-5.6` → `gpt-5.6-sol` alias for old stored configs.
- Dated or evidence docs: `doc/plans/*`, `doc/adapter-model-audit-*`,
`QUALIFICATION-*`, `doc/connections/MEMORY.md`,
`doc/connections/TASK-REVIEWS-VERIFICATION.md`,
`tests/runner-e2e/CHAT-FEATURES.md`, the Slack eval evidence, and the
S49 soak signature text (the soak failure happened on `gpt-5.6-sol`).
- Test fixtures that parse, price, migrate or round-trip an existing
`gpt-5.6-sol` value (Rust runner tests, driver tests, pricing tests,
stored-config tests, stories that show an existing agent).

## Verification

- `npx vitest run` on every server, UI, adapter-utils and codex-local
test file that mentions `gpt-5.6-sol` or the default, plus all of
`packages/adapter-utils` and `packages/adapters/codex-local`: 5,483
passed. Three timeouts under load (`sandbox-managed-runtime`,
`acp.quota`) passed when rerun.
- `packages/paperclip-runner`: `vitest run` for `src/eval`, `src/evals`,
`src/drivers/acpx`, `src/drivers/codex`, `src/cli`, `src/contracts`,
`src/backends`: 2,641 passed.
- `node --test tests/runner-e2e/reliability-testops.test.mjs
tests/runner-e2e/product-regression-testops.test.mjs`: 28 passed.
- `node tests/runner-e2e/run-unit.mjs`: all model, catalog, reliability,
scheduled and TestOps tests pass. Failures remain only in
`copilot-protection-flow`, `denied-target-isolation-flow`,
`pi-controls-flow`, `workflow-target` and `unit-launcher`. These five
also fail on unmodified master in this local environment (filesystem
watch and git environment), and they do not touch models.
- `vitest run --config tests/runner-acceptance/vitest.config.ts`: 19
passed.
- `pnpm test:e2e:runner -- --list --suite everyday-workflows --group
reliability`: the seven `everyday-workflows.runner-codex.daytona.<case>`
cells show `gpt-6.1-sol` with `OPENAI_API_KEY,DAYTONA_API_KEY`.
`--matrix-json` gives 16 reliability cells with the same credentials as
before. `--list --all` shows no `gpt-5.6-sol` cell.
- Typecheck: `adapter-utils`, `adapter-codex-local`, `server`, `ui`,
`paperclip-runner` (TypeScript), and `pnpm test:e2e:runner:typecheck`.
- `pnpm check:token-gates`: clean.
- Not run: paid cells, Rust tests (no Rust change), the browser suites,
and the full `pnpm -r typecheck` / `pnpm build` (CI runs them).

## Risks

- Agents without an explicit model change model on their next save or
normalization, and Paperclip Runner rows with a blank model use
`gpt-6.1-sol` at execution. Stored models, including `gpt-5.6-sol`, are
never rewritten. No migration is necessary.
- `gpt-6.1-sol` needs Codex CLI 0.159.0 or newer with ChatGPT sign-in.
The runner pins `@openai/codex@0.160.0`. On a remote target with an
older image CLI, the existing compatibility check falls back to
`gpt-6-sol` and then `gpt-5.6-sol`, and that probe now runs for
default-model agents too. A legacy `codex_local` agent created with the
new default on a host with an older Codex CLI gets the existing
actionable "upgrade Codex" Test result.
- Qualification: the native Codex profile uses `adapter_constant` model
qualification, so it follows the default. No qualification gate is
dropped. The ACPX Codex qualification selection moves to `gpt-6.1-sol`.
Its next qualification run uses the new model. The provider-usage
cache-policy attribution stays limited to `gpt-5.6-sol`.
- Runner E2E history: the Codex cells change their suite definition
hashes, so earlier `gpt-5.6-sol` results are a separate cohort and do
not count as current.
- The `gpt-6.1-sol` scheduled price uses the file's existing `expiresAt`
(2026-11-19). It needs the same review as the other entries before then.
- No `.github/` change. The Reliability queue-steer cell keeps the
`runner-codex` profile id, so the existing workflow credential and setup
steps still match.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 5.5 (Anthropic), model ID `claude-opus-5-5`, running in
Claude Code with tool use (shell, file edits, web fetch of the OpenAI
pricing page).

## 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

Related: #19604 (commits the Reliability TestOps identities; this PR
changes only `steeringProfile` in the same file).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 15:35:34 -06:00

293 lines
22 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, readFile, rm, writeFile, lstat } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { productRegressionCases, selectProductRegression, validateSelection, projectProductResults, workflowUrl, workflowUrls } from './product-regression-testops.mjs';
import { admitProductTestOps, officialProductContext, verifyProductJob, validateUploadedProductResults, productTestOps } from './product-regression-testops-cli.mjs';
const workflowSha = 'a'.repeat(40), targetSha = 'b'.repeat(40);
const env = {
GITHUB_REPOSITORY: 'paperclipai/paperclip', GITHUB_REF: 'refs/heads/master', GITHUB_EVENT_NAME: 'workflow_dispatch',
GITHUB_WORKFLOW_REF: 'paperclipai/paperclip/.github/workflows/runner-full-stack-e2e.yml@refs/heads/master',
GITHUB_RUN_ID: '123', GITHUB_RUN_ATTEMPT: '1', GITHUB_SHA: workflowSha, SOURCE_SHA: targetSha,
ALLURE_ENDPOINT: 'https://paperclip.testops.cloud', ALLURE_PROJECT_ID: '1',
};
const selected = productRegressionCases.find(row => row.id === 66);
const selection = cases => ({ schema: 'paperclip.product-regression-testops-selection/v1', runId: '123', attempt: '1',
workflowSha, targetSha, jobRunId: '456', launchId: 789, cases });
const result = (overrides = {}) => ({
executionId: selected.executionId, suiteId: selected.suite, caseId: selected.name,
profileId: selected.profile, environmentId: 'local', source: { sha: targetSha }, attempt: 1,
status: 'passed', cleanup: 'passed', model: 'gpt-6.1-sol', suiteDefinitionHash: 'c'.repeat(64),
matcherResults: [{ matcher: { kind: 'json_path', path: 'observed.outputVerified', expected: true }, passed: true }],
...overrides,
});
const campaign = (results = [result()]) => ({ schema: 'paperclip.runner-e2e.campaign/v2', campaignId: 'gha-123-1',
source: { sha: targetSha }, expected: [selected.executionId], results });
const allPlan = { version: '1.0', tests: productRegressionCases.map(row => ({ id: String(row.id), selector: row.fullName })) };
test('standing plan has 20 unique imported identities and one existing local cell per case', () => {
assert.equal(productRegressionCases.length, 20);
assert.equal(new Set(productRegressionCases.map(row => row.id)).size, 20);
assert.equal(new Set(productRegressionCases.map(row => row.executionId)).size, 20);
assert.deepEqual(productRegressionCases.filter(row => row.profile !== 'runner-codex').map(row => row.id), [54]);
assert.equal(selectProductRegression(allPlan, 'message-marker', true).length, 20);
assert.equal(selectProductRegression(undefined, 'message-marker')[0].id, 66);
});
test('a TestOps subset selects only its exact original case; unknown, duplicate and mismatched identities fail', () => {
const subset = { version: '1.0', tests: [{ id: '66', selector: selected.fullName }] };
assert.deepEqual(selectProductRegression(subset, 'all', true), [selected]);
for (const invalid of [undefined, { version: '1.0', tests: [] },
{ version: '1.0', tests: [{ id: '66', selector: productRegressionCases[0].fullName }] },
{ version: '1.0', tests: [...subset.tests, ...subset.tests] },
{ version: '1.0', tests: [{ id: '9999', selector: selected.fullName }] }]) {
assert.throws(() => selectProductRegression(invalid, 'all', true));
}
});
test('selection authority cannot enter through an untrusted workflow, foreign ref, mixed selector or recurring mode', () => {
admitProductTestOps(env);
for (const mutation of [{ GITHUB_REF: 'refs/heads/candidate' }, { GITHUB_EVENT_NAME: 'pull_request_target' },
{ GITHUB_WORKFLOW_REF: 'paperclipai/paperclip/.github/workflows/other.yml@refs/heads/master' },
{ ALLURE_ENDPOINT: 'https://attacker.invalid' }, { SELECT_SUITE: 'core-compatibility' },
{ EVAL_CADENCE: 'daily' }, { SOURCE_SHA: '' }]) assert.throws(() => admitProductTestOps({ ...env, ...mutation }));
validateSelection(selection([selected]), env);
assert.throws(() => validateSelection(selection([selected]), { ...env, GITHUB_RUN_ID: '124' }));
assert.throws(() => validateSelection(selection([selected]), { ...env, SOURCE_SHA: workflowSha }));
});
test('official environment parsing keeps only bound IDs and rejects duplicate or malformed context', () => {
assert.deepEqual(officialProductContext('ALLURE_TOKEN=private\nALLURE_JOB_RUN_ID=456\nALLURE_LAUNCH_ID=789\n'), { jobRunId: '456', launchId: 789 });
for (const stdout of ['ALLURE_JOB_RUN_ID=456\n', 'ALLURE_JOB_RUN_ID=456\nALLURE_JOB_RUN_ID=456\nALLURE_LAUNCH_ID=789',
'ALLURE_JOB_RUN_ID=456\nALLURE_LAUNCH_ID=not-an-id']) assert.throws(() => officialProductContext(stdout));
});
test('reported pass requires real assertions, successful cleanup and exact current source', () => {
const projected = projectProductResults(campaign(), selection([selected]), targetSha);
assert.equal(projected.summary.passed, 1);
assert.equal(projected.records[0].value.labels.find(row => row.name === 'AS_ID').value, '66');
assert.equal(projected.records[0].value.fullName, selected.fullName);
for (const partial of [{ cleanup: 'failed' }, { matcherResults: [] },
{ matcherResults: [{ matcher: { kind: 'json_path', path: 'output' }, passed: false }] }]) {
assert.equal(projectProductResults(campaign([result(partial)]), selection([selected]), targetSha).summary.passed, 0);
}
assert.throws(() => projectProductResults(campaign([result({ source: { sha: workflowSha } })]), selection([selected]), targetSha));
assert.throws(() => projectProductResults({ ...campaign(), campaignId: 'gha-older-1' }, selection([selected]), targetSha));
});
test('missing results remain explicit broken cases and cannot improve the denominator', () => {
const projected = projectProductResults(undefined, selection(productRegressionCases), targetSha);
const { cases, ...counts } = projected.summary;
assert.deepEqual(counts, { selected: 20, passed: 0, failed: 0, broken: 20, plan: 'product-regression', fullPlan: true });
assert.equal(cases.length, 20);
assert.equal(cases.every(row => row.status === 'broken'), true);
assert.equal(projected.records.filter(row => row.file.endsWith('-result.json')).length, 20);
const partial = projectProductResults(campaign([]), selection([selected]), targetSha);
assert.equal(partial.summary.broken, 1);
assert.equal(partial.summary.fullPlan, false);
const missing = result({ attempt: 0, status: 'failed', failureClass: 'permanent_infrastructure', cleanup: 'not_started',
error: 'No result artifact was uploaded', matcherResults: undefined, source: undefined });
assert.equal(projectProductResults(campaign([missing]), selection([selected]), targetSha).summary.broken, 1);
assert.throws(() => projectProductResults(campaign([{ ...missing, status: 'passed' }]), selection([selected]), targetSha));
});
test('original functional and infrastructure failures survive', () => {
for (const [failureClass, expected] of [['candidate_failure', 'failed'], ['permanent_infrastructure', 'broken'], ['transient_infrastructure', 'broken']]) {
const projected = projectProductResults(campaign([result({ status: 'failed', failureClass })]), selection([selected]), targetSha);
assert.equal(projected.records[0].value.status, expected);
}
assert.throws(() => projectProductResults(campaign([result(), result()]), selection([selected]), targetSha));
});
test('authoritative normalized accounting preserves earlier failed attempt verdicts beside the selected result', () => {
const measured = { ...campaign([result({ attempt: 2 })]), accounting: { version: 1, scope: 'retained_attempts',
missingAttempts: 0, excludedArtifacts: 0, attempts: [
{ id: 'retained-failure', executionId: selected.executionId, attempt: 1, status: 'failed', cleanup: 'passed', durationMs: 1000 },
{ id: 'selected-success', executionId: selected.executionId, attempt: 2, status: 'passed', cleanup: 'passed', durationMs: 1000 },
] } };
const projected = projectProductResults(measured, selection([selected]), targetSha);
assert.equal(projected.summary.passed, 1);
assert.equal(projected.records.filter(row => row.file.endsWith('-result.json')).length, 1);
assert.deepEqual(projected.records[1].value.retainedAttempts.map(row => row.status), ['failed', 'passed']);
assert.deepEqual(projected.records[1].value.attemptAccounting, { scope: 'retained_attempts', missingAttempts: 0, excludedArtifacts: 0 });
assert.throws(() => projectProductResults({ ...measured, accounting: { ...measured.accounting, attempts: [{ ...measured.accounting.attempts[0], status: 'unknown' }] } }, selection([selected]), targetSha));
const openCode = productRegressionCases.find(row => row.id === 54);
const model = 'openrouter/deepseek/deepseek-v4-flash-0731';
const record = result({ executionId: openCode.executionId, suiteId: openCode.suite, caseId: openCode.name, profileId: openCode.profile, model });
const openCodeCampaign = { ...campaign([record]), expected: [openCode.executionId] };
assert.equal(projectProductResults(openCodeCampaign, selection([openCode]), targetSha).records[1].value.model, model);
});
test('the transmission projection excludes raw errors, provider IDs, narratives and unexpected fields', () => {
const dirty = result({ error: 'secret-error', runIds: ['private-provider-session'], detail: 'private-context',
usage: { credential: 'private-token' }, matcherResults: [{ passed: true, detail: 'secret-detail',
matcher: { kind: 'message_contains', expected: 'fixture-answer-secret', path: 'unsafe narrative with secrets' } }] });
const text = JSON.stringify(projectProductResults(campaign([dirty]), selection([selected]), targetSha));
for (const secret of ['secret-error', 'private-provider-session', 'private-context', 'private-token', 'secret-detail', 'fixture-answer-secret', 'unsafe narrative']) {
assert.equal(text.includes(secret), false, secret);
}
});
test('secret-shaped and unknown metadata cannot cross the reporting boundary', () => {
const sentinel = 'sk-proj-syntheticcredentialabcdefghijklmnop';
for (const mutation of [{ model: sentinel }, { model: 'gpt-5.6-sol' }, { failureClass: sentinel }, { cleanup: sentinel },
{ matcherResults: [{ passed: true, matcher: { kind: sentinel } }] }]) {
assert.throws(() => projectProductResults(campaign([result(mutation)]), selection([selected]), targetSha),
error => error.message === 'product_regression_testops_metadata_invalid');
}
const projected = projectProductResults(campaign([result({ matcherResults: [
{ passed: false, matcher: { kind: 'json_path', path: sentinel } },
] })]), selection([selected]), targetSha);
assert.equal(JSON.stringify(projected).includes(sentinel), false);
assert.equal(projected.summary.passed, 0);
assert.equal(projected.records[1].value.checks[0].name, 'json_path');
assert.equal(projected.records[1].value.checks[0].ordinal, 1);
assert.equal(projected.records[1].value.checks[0].passed, false);
assert.equal(projected.records[1].value.checks[0].pathSha256, createHash('sha256').update(sentinel).digest('hex'));
});
test('a TestOps job run must belong to this workflow, source launch and GitHub execution', async () => {
const values = { '/api/jobrun/456': { id: 456, launchId: 789, job: { id: 50, type: 'github' }, url: 'https://github.com/paperclipai/paperclip/actions/runs/123' },
'/api/job/50': { id: 50, type: 'github', url: workflowUrl }, '/api/launch/789': { id: 789, projectId: 1 } };
assert.deepEqual(await verifyProductJob(route => values[route], { jobRunId: '456', launchId: 789 }, env), { jobId: 50, launchId: 789 });
for (const url of workflowUrls) {
assert.deepEqual(await verifyProductJob(route => route === '/api/job/50' ? { ...values[route], url } : values[route],
{ jobRunId: '456', launchId: 789 }, env), { jobId: 50, launchId: 789 });
}
const pending = route => route === '/api/jobrun/456' ? { ...values[route], url: null } : values[route];
await verifyProductJob(pending, { jobRunId: '456', launchId: 789 }, env, true);
await assert.rejects(verifyProductJob(pending, { jobRunId: '456', launchId: 789 }, env));
await assert.rejects(verifyProductJob(route => route === '/api/launch/789' ? { id: 789, projectId: 2 } : values[route], { jobRunId: '456', launchId: 789 }, env));
await assert.rejects(verifyProductJob(route => route === '/api/job/50' ? { ...values[route], url: 'https://github.com/other/repo' } : values[route], { jobRunId: '456', launchId: 789 }, env));
await assert.rejects(verifyProductJob(route => route === '/api/job/50' ? { ...values[route], url: workflowUrl.replace('runner-full-stack-e2e', 'runner-protocol-live-evals') } : values[route], { jobRunId: '456', launchId: 789 }, env));
});
test('server verification accepts real Allure string statuses and rejects wrong verdicts or identities', () => {
const rows = [{ testCaseId: selected.id, fullName: selected.fullName, projectId: 1, launchId: 789, jobRun: { id: 456 }, status: 'passed' }];
const summary = { passed: 1, failed: 0, broken: 0, cases: [{ caseId: selected.id, status: 'passed' }] };
validateUploadedProductResults(rows, selection([selected]), summary);
assert.throws(() => validateUploadedProductResults([{ ...rows[0], status: 'failed' }], selection([selected]), summary));
assert.throws(() => validateUploadedProductResults([{ ...rows[0], jobRun: { id: 999 } }], selection([selected]), summary));
const other = productRegressionCases[0];
const mixed = [rows[0], { ...rows[0], testCaseId: other.id, fullName: other.fullName, status: 'failed' }];
const expected = { passed: 1, failed: 1, broken: 0, cases: [{ caseId: selected.id, status: 'passed' }, { caseId: other.id, status: 'failed' }] };
validateUploadedProductResults(mixed, selection([selected, other]), expected);
assert.throws(() => validateUploadedProductResults([{ ...mixed[0], status: 'failed' }, { ...mixed[1], status: 'passed' }],
selection([selected, other]), expected), /uploaded_status_mismatch/);
});
test('the separated selection, retained-report projection and server verification preserve one TestOps subset', async t => {
const root = await mkdtemp(path.join(tmpdir(), 'product-testops-control-'));
t.after(() => rm(root, { recursive: true, force: true }));
const selectionFile = path.join(root, 'selection.json');
const runtime = { ...env, RUNNER_TEMP: root, GITHUB_OUTPUT: path.join(root, 'output'), GITHUB_STEP_SUMMARY: path.join(root, 'summary'),
ALLURE_JOB_RUN_ID: '456', ALLURE_TOKEN: 'synthetic-test-token', TESTOPS_CASE: 'all',
REPORT_DIR: path.join(root, 'report'), ALLURE_RESULTS: path.join(root, 'allure'), EXPORT_SUMMARY: path.join(root, 'export.json') };
let uploadedStatus = 'passed';
const fetchImpl = async url => {
const parsed = new URL(url), route = parsed.pathname;
const resources = {
'/api/uaa/oauth/token': { access_token: 'synthetic-access-token' },
'/api/jobrun/456': { id: 456, launchId: 789, job: { id: 50, type: 'github' }, url: 'https://github.com/paperclipai/paperclip/actions/runs/123' },
'/api/job/50': { id: 50, type: 'github', url: [...workflowUrls][1] }, '/api/launch/789': { id: 789, projectId: 1 },
'/api/testresult': { content: [{ id: 501, testCaseId: 66 }], totalPages: 1, totalElements: 1 },
'/api/testresult/501': { id: 501, testCaseId: 66, fullName: selected.fullName, projectId: 1, launchId: 789, jobRun: { id: 456 }, status: uploadedStatus },
};
assert.ok(resources[route], route);
return new Response(JSON.stringify(resources[route]), { status: 200, headers: { 'Content-Type': 'application/json' } });
};
const exec = async (command, args) => {
assert.equal(command, 'allurectl');
if (args[1] === 'env') return { stdout: 'ALLURE_JOB_RUN_ID=456\nALLURE_LAUNCH_ID=789\n' };
if (args[1] === 'plan') await writeFile(args.at(-1), JSON.stringify({ version: '1.0', tests: [{ id: '66', selector: selected.fullName }] }));
return { stdout: '' };
};
assert.equal(await productTestOps('select', selectionFile, runtime, exec, fetchImpl), 0);
const receipt = JSON.parse(await readFile(selectionFile));
assert.deepEqual(receipt.cases, [selected]);
runtime.SELECTION_SHA256 = (await readFile(runtime.GITHUB_OUTPUT, 'utf8')).match(/selection_sha256=([a-f0-9]{64})/)[1];
await mkdir(path.join(runtime.REPORT_DIR, 'normalized'), { recursive: true });
await writeFile(path.join(runtime.REPORT_DIR, 'normalized', 'normalized-results.json'), JSON.stringify(campaign()));
assert.equal(await productTestOps('project', selectionFile, runtime, exec, fetchImpl), 0);
const unsafeRuntime = { ...runtime, ALLURE_RESULTS: path.join(root, 'unsafe-results'), EXPORT_SUMMARY: path.join(root, 'unsafe-export.json') };
for (const mutation of [{ model: 'sk-proj-syntheticcredentialabcdefghijklmnop' },
{ cleanup: 'sk-proj-syntheticcredentialabcdefghijklmnop' }]) {
await writeFile(path.join(runtime.REPORT_DIR, 'normalized', 'normalized-results.json'), JSON.stringify(campaign([result(mutation)])));
await assert.rejects(productTestOps('project', selectionFile, unsafeRuntime, exec, fetchImpl),
error => error.message === 'product_regression_testops_metadata_invalid');
await assert.rejects(lstat(unsafeRuntime.ALLURE_RESULTS), { code: 'ENOENT' });
await assert.rejects(lstat(unsafeRuntime.EXPORT_SUMMARY), { code: 'ENOENT' });
}
await writeFile(path.join(runtime.REPORT_DIR, 'normalized', 'normalized-results.json'), JSON.stringify(campaign()));
assert.equal(await productTestOps('verify', selectionFile, runtime, exec, fetchImpl), 0);
uploadedStatus = 'unknown';
let readinessWaits = 0;
assert.equal(await productTestOps('verify', selectionFile, runtime, exec, fetchImpl, async () => { readinessWaits++; uploadedStatus = 'passed'; }), 0);
assert.equal(readinessWaits, 1);
uploadedStatus = 'unknown';
let virtualMs = 0;
assert.equal(await productTestOps('verify', selectionFile, runtime, exec, fetchImpl, async ms => {
virtualMs += ms;
if (virtualMs >= 40_000) uploadedStatus = 'passed';
}), 0);
assert.equal(virtualMs, 40_000);
const failureOutput = path.join(root, 'failed-selection-output');
const badPlan = async (command, args) => args[1] === 'plan'
? (await writeFile(args.at(-1), JSON.stringify({ version: '1.0', tests: [{ id: '999', selector: selected.fullName }] })), { stdout: '' })
: exec(command, args);
await assert.rejects(productTestOps('select', path.join(root, 'failed-selection.json'), { ...runtime, GITHUB_OUTPUT: failureOutput }, badPlan, fetchImpl), /case_mismatch/);
const cleanupContext = await readFile(failureOutput, 'utf8');
assert.match(cleanupContext, /job_run_started=true/);
assert.match(cleanupContext, /job_run_id=456/);
let opened = false;
await assert.rejects(productTestOps('select', path.join(root, 'invalid-manual.json'),
{ ...runtime, ALLURE_JOB_RUN_ID: '', TESTOPS_CASE: 'unknown' }, async () => { opened = true; }, fetchImpl), /manual_selection_invalid/);
assert.equal(opened, false);
uploadedStatus = 'failed';
let deadlineMs = 0;
await assert.rejects(productTestOps('verify', selectionFile, runtime, exec, fetchImpl,
async ms => { deadlineMs += ms; }), /uploaded_status_mismatch/);
assert.equal(deadlineMs, 55_000);
await writeFile(selectionFile, JSON.stringify({ ...receipt, targetSha: workflowSha }));
await assert.rejects(productTestOps('project', selectionFile, runtime, exec, fetchImpl), /selection_hash/);
});
test('workflow keeps reporting credentials and trusted code outside candidate/provider jobs', async () => {
const workflow = await readFile(new URL('../../.github/workflows/runner-full-stack-e2e.yml', import.meta.url), 'utf8');
const selectionJob = workflow.split(' testops_selection:\n')[1].split('\n catalog:\n')[0];
const reportingJob = workflow.split(' publish_testops:\n')[1].split('\n publish_history:\n')[0];
for (const block of [selectionJob, reportingJob]) {
assert.match(block, /environment: runner-e2e-testops-reporting/);
assert.match(block, /ref: \$\{\{ github.sha \}\}/);
assert.doesNotMatch(block, /secrets\.(OPENAI|OPENROUTER|ANTHROPIC)|ref: \$\{\{ needs.authorize.outputs.target_sha/);
}
assert.match(workflow, /SELECT_ID: \$\{\{ needs.testops_selection.outputs.ids \|\| inputs.id \}\}/);
assert.match(reportingJob.split(' steps:\n')[0], /if: always\(\) && needs.testops_selection.outputs.job_run_started == 'true'/);
assert.doesNotMatch(reportingJob.split(' steps:\n')[0], /!cancelled\(\)/);
assert.match(reportingJob, /Finish the original TestOps job run\n\s+if: always\(\)/);
assert.doesNotMatch(workflow.slice(0, workflow.indexOf('\njobs:')), /workflow_call:/);
});
test('ordinary campaigns still run cells when the TestOps selection job is skipped', async () => {
const { parse } = await import('yaml');
const { jobs } = parse(await readFile(new URL('../../.github/workflows/runner-full-stack-e2e.yml', import.meta.url), 'utf8'));
const needs = name => [jobs[name].needs ?? []].flat();
const descends = name => needs(name).some(parent => parent === 'testops_selection' || descends(parent));
// GitHub's implicit success() also fails on a skipped ancestor, so any job
// below the optional selection must use a status function and then require
// each real dependency result itself.
const below = Object.keys(jobs).filter(descends);
assert.ok(below.includes('test') && below.includes('daytona_image'));
for (const name of below) {
const condition = String(jobs[name].if ?? '');
assert.match(condition, /always\(\)|!cancelled\(\)/, `${name} needs an explicit status function`);
if (condition.includes('always()') || name === 'catalog') continue;
for (const parent of needs(name).filter(parent => !['authorize', 'target_lock'].includes(parent))) {
assert.match(condition, new RegExp(`needs\\.${parent}\\.result == 'success'`), `${name} must still require ${parent}`);
}
}
});