mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The chat eval lane runs selected cases from one immutable candidate. > - The finalizer must authenticate every original projection and publication. > - The pinned artifact action places one matched artifact at the download root. > - The finalizer expects named child directories, so a valid one-case run fails before final coverage verification. > - This PR supports both valid layouts and binds one reviewed repair candidate. > - The lane keeps exact selection, source, digest and publication checks. ## Linked Issues or Issue Description Refs: #18740, #18736, #18742, #18734, #18717. This PR is a proposed replacement for the source pin in #18740. That PR remains open. The evaluated candidate contains all five repairs from that prior cut and five further reviewed repair conditions. The product PRs remain unmerged. Merge authority for this replacement is pending; this PR does not dispatch a run. A completed one-case run has a verified original PASS and a verified publication. Its final aggregate fails because the pinned artifact action flattens a single matched artifact. The same path assumption also affects the later publication verifier. The final checks must still run; the repair must preserve their identity and digest checks. ## What Changed - Bind the finite candidate `ab2019e976076ec0eb60b026844f3d9211cb0208` through the manifest and exact descriptor snapshot. - Retain the original 244 case identities and the explicit cumulative definition overrides. Keep the historical definition source separate from the current candidate. - Add the reviewed source dependencies for real hire-review readiness and the strict later-human-intent review overlay from unmerged #18742. Preserve all original clocks, queries and model settings. Keep the earlier Studio144 accounting change explicit. - Document one current target and identify the prior Source5 cut as unmeasured. - Preserve named singleton download destinations for both bundles and publications. Authenticate every bundle before gathering its safe projection. Reject mixed, foreign, missing and symbolic-link layouts. Accept only the producer's exact closed once-marker schema. Do not export or upload results again. ## Verification - Candidate controls: 217 pure checks in 13 files; 242 real factory declarations with zero fixture callbacks; 42 admission controls; unchanged 506-input SDK registry; focused actual database-row type check. - Combined trusted pin and layout controls: 124 Node checks in seven real files, plus source-byte closure for all 244 singleton evidence policies. The unchanged parent reproduces both layout failures. Actual producer-marker, multi-case and malformed-layout controls pass. - The two actual PostgreSQL route regressions on core #18742 head `61996a96dd09e90a5788e8711a94fc5ae631f425` ran and passed. That core PR remains unmerged. This is not chat-case acceptance. - The independent layout logic peer and the whole final eight-path PR source peer passed. Current-head CI completed with 48 successful jobs and one skip. Both required checks passed. All 60 exact-head checks completed: 55 success, two neutral and three skipped. The separate Windows Rust check passed. Greptile and Paperclip Review both scored 5/5, with no unresolved threads. - The optional raw-order note is [disposed with source evidence](https://github.com/paperclipai/paperclip/pull/18749#issuecomment-6598680442). The admitted selector writes canonical order before hashing. A changed raw order fails closed before copying. The checked code remains unchanged. - Full local candidate typecheck and build are not claimed. Installed workspace dependency gaps prevent that full local check. The existing remote source and build preflight must pass before a native query. - No new model run or TestOps upload was used to prepare this PR. Prior attempts and grades stay unchanged. ## Risks A permissive layout repair could hide missing or foreign publications. Exact selected names, singleton counts, source identities, hashes and final readback checks must remain enforced. The layout helper must reject symbolic links and mixed shapes. The active default-branch ruleset requires CODEOWNERS review for the workflow. Normal merge is currently blocked on that review. This PR does not enable auto-merge, change branch rules or dispatch an eval. The candidate contains explicit unmerged product overlays. It is not a core release or a full-catalog qualification. The other five SDK cases remain held. SDK115 completion remains unknown. Provider quota failures are not fixed by the skill observer change. No credentials, caps, runner permissions or workflow admission references are added. ## Model Used OpenAI `gpt-6.1-sol`, with code execution, tool use and independent agent source review. The host does not expose the exact context window. No model eval was run to prepare this PR. ## 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 links) - [x] My branch name describes the change and contains no internal ticket id - [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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
530 lines
41 KiB
JavaScript
530 lines
41 KiB
JavaScript
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { mkdtemp, mkdir, readFile, readdir, writeFile, rm, realpath, cp, symlink, rename, access } from 'node:fs/promises';
|
||
import path from 'node:path';
|
||
import { tmpdir } from 'node:os';
|
||
import { createRequire } from 'node:module';
|
||
import * as streaming from './chat-catalog-testops-streaming.mjs';
|
||
const { parseDocument } = createRequire(import.meta.url)('yaml');
|
||
import { fullCatalogCases, catalogTargetSha } from './chat-full-catalog-testops-manifest.mjs';
|
||
import { hash, model } from './chat-completion-testops-contract.mjs';
|
||
import { run } from './chat-completion-testops-cli.mjs';
|
||
import { prepareCaseBundle, readCaseBundle, publishCaseBundle, publicationSet } from './chat-catalog-testops-streaming.mjs';
|
||
const context = { catalog: 'full', runId: '123', attempt: '1', workflowSha: 'a'.repeat(40), targetSha: catalogTargetSha };
|
||
const caseIds = { 'tasks.create-backlog': 388, 'documents-artifacts.write-plan': 26, 'projects.project-defaults': 100 };
|
||
const cases = fullCatalogCases.filter(row => Object.hasOwn(caseIds, row.key)).map(row => ({ ...row, caseId: caseIds[row.key],
|
||
registeredFullName: row.aliases[0] ?? row.fullName }));
|
||
const selection = { schema: 'paperclip.chat-completion-testops-selection/v1', ...context, projectId: 1,
|
||
planId: 102, jobId: 100, launchId: 12, allureJobRunId: '77', cases };
|
||
const digest = hash(JSON.stringify(selection)), current = cases.find(row => row.caseId === 388), runUrl = 'https://github.com/paperclipai/paperclip/actions/runs/123';
|
||
async function fixture(t, failed = false, owned = current, runtimeContext = context, runtimeSelection = selection) {
|
||
const base = await realpath(await mkdtemp(path.join(tmpdir(), 'chat-stream-test-')));
|
||
t.after(() => rm(base, { recursive: true, force: true }));
|
||
const root = path.join(base, 'projection'), output = path.join(base, 'allure'); await mkdir(root);
|
||
const original = { executionId: owned.executionId, suiteId: 'chat-features', caseId: owned.scenario,
|
||
profileId: 'runner-opencode', provider: 'opencode', environmentId: 'local', model, runtimeMode: 'native', attempt: 1,
|
||
source: { sha: catalogTargetSha, workflowRunUrl: runUrl }, status: failed ? 'failed' : 'passed', cleanup: failed ? 'failed' : 'passed',
|
||
runIds: failed ? [] : Array.from({ length: owned.minimumRuns }, (_, i) => `synthetic-owned-run-${i}`), matcherResults: failed ? [] : Array.from({ length: owned.checks ?? 2 }, (_, i) => ({
|
||
matcher: { kind: 'json_path', path: `owned.check${i}`, expected: true }, passed: true })), screenshots: [] };
|
||
const files = new Map([['normalized-results.json', Buffer.from(JSON.stringify({ schema: 'paperclip.runner-e2e.campaign/v2',
|
||
campaignId: 'gha-123-1', retries: 0, results: [original] }))], ['junit.xml', Buffer.from('<testsuite/>')]]);
|
||
const binding = { schema: 'paperclip.chat-completion-testops-export/v1', ...context, campaignId: 'gha-123-1',
|
||
executionIds: [owned.executionId], files: [...files].map(([name, bytes]) => ({ path: name, bytes: bytes.length, sha256: hash(bytes) })),
|
||
privateEvidence: [{ executionId: owned.executionId, requiredEvidenceComplete: !failed,
|
||
finalScreenshot: failed ? null : { sha256: 'e'.repeat(64), bytes: 24 } }] };
|
||
for (const [name, bytes] of files) await writeFile(path.join(root, name), bytes);
|
||
await writeFile(path.join(root, 'binding.json'), JSON.stringify(binding));
|
||
const prepared = await prepareCaseBundle(root, output, runtimeContext, runtimeSelection, owned.executionId, hash(JSON.stringify(runtimeSelection)));
|
||
return { root, output, prepared };
|
||
}
|
||
function server(prepared, { existing = false, uncertain = false, apply = true, scheduled = false, processingReads = 0 } = {}) {
|
||
let present = existing, posts = 0, pendingReads = processingReads; const calls = [];
|
||
const resultId = scheduled ? 3347 : 501;
|
||
const detail = () => {
|
||
const pending = !present || pendingReads-- > 0;
|
||
return { id: resultId, testCaseId: 388, projectId: 1, launchId: 12,
|
||
fullName: current.registeredFullName, jobRun: { id: 77, launchId: 12, url: runUrl }, links: [],
|
||
...(!pending ? { status: prepared.exported[0].status } : {}) };
|
||
};
|
||
const api = async (route, method = 'GET', body) => {
|
||
calls.push({ route, method });
|
||
if (method === 'POST') {
|
||
posts++; assert.equal(route, '/api/jobrun/77/upload');
|
||
assert.deepEqual([...body.keys()], ['file','file','file']);
|
||
const result = [...body.values()].find(value => value.name.endsWith('-result.json'));
|
||
const parsed = JSON.parse(await result.text());
|
||
assert.equal(parsed.status, prepared.exported[0].status); assert.equal(parsed.labels[0].value, '388');
|
||
if (apply) present = true;
|
||
if (uncertain) throw new Error('SYNTHETIC_PRIVATE_RESPONSE_MUST_NOT_ESCAPE');
|
||
return { filesCount: 3 };
|
||
}
|
||
if (route === '/api/jobrun/77') return { id: 77, job: { id: 100, type: 'github' }, stage: 'finished', launchId: 12, url: runUrl };
|
||
if (route === '/api/launch/12') return { id: 12, projectId: 1, closed: false };
|
||
if (route.startsWith('/api/testresult?')) return { content: scheduled ? cases.map(row => ({
|
||
id: row.caseId === 388 ? resultId : row.caseId === 100 ? 3346 : 3345, testCaseId: row.caseId }))
|
||
: [...(present ? [{ id: resultId, testCaseId: 388 }] : []), { id: 502, testCaseId: 26 }],
|
||
totalElements: scheduled ? 3 : present ? 2 : 1, totalPages: 1, number: 0 };
|
||
if (route === `/api/testresult/${resultId}`) return detail();
|
||
if (route.startsWith('/api/testresult/attachment?')) return { content: prepared.exported[0].attachments.map((row, index) => ({
|
||
...row, id: 700 + index, missed: false, fromTestCase: false })), totalElements: 2, totalPages: 1 };
|
||
throw new Error(`Unexpected test route ${route}`);
|
||
};
|
||
return { api, calls, get posts() { return posts; } };
|
||
}
|
||
const immediate = { now: () => 0, wait: async () => {} };
|
||
async function publish(f, s, mark = async () => {}, options = immediate) {
|
||
return publishCaseBundle(s.api, f.root, f.output, f.prepared, context, selection, current.executionId, digest, mark, options);
|
||
}
|
||
test('one frozen singleton bundle publishes into the bound shared launch beside siblings', async t => {
|
||
const f = await fixture(t), s = server(f.prepared); let markers = 0;
|
||
const names = await readdir(f.output), value = await publish(f, s, async () => { markers++; });
|
||
assert.equal(value.verification, 'VERIFIED'); assert.equal(markers, 1); assert.equal(s.posts, 1);
|
||
assert.equal(value.jobRunStageBefore, 'finished'); assert.equal(value.jobRunStageAfter, 'finished');
|
||
assert.deepEqual(await readdir(f.output), names);
|
||
await assert.rejects(prepareCaseBundle(f.root, f.output, context, selection, current.executionId, digest), /allure_output/);
|
||
});
|
||
test('an existing result is verified without another mutation or a regenerated UUID', async t => {
|
||
const f = await fixture(t), s = server(f.prepared, { existing: true });
|
||
const value = await publish(f, s, async () => assert.fail('must not mark another upload'));
|
||
assert.equal(value.uploadSent, false); assert.equal(s.posts, 0); assert.equal(value.verification, 'VERIFIED');
|
||
});
|
||
test('an existing unknown outcome remains readback-only and never requests another upload', async t => {
|
||
const f = await fixture(t), s = server(f.prepared, { existing: true }), original = s.api;
|
||
s.api = async (...args) => { const row = await original(...args); if (args[0] === '/api/testresult/501') row.status = 'unknown'; return row; };
|
||
await assert.rejects(publish(f, s, async () => assert.fail('must not mark another upload')), /upload_unverified/);
|
||
assert.equal(s.posts, 0);
|
||
});
|
||
test('three scheduled launch rows permit one initial upload and remain unready until an outcome is processed', async t => {
|
||
const f = await fixture(t), s = server(f.prepared, { scheduled: true, processingReads: 1 });
|
||
let markers = 0, waits = 0;
|
||
const value = await publish(f, s, async () => { markers++; }, { now: () => 0, wait: async () => { waits++; } });
|
||
assert.equal(value.verification, 'VERIFIED'); assert.equal(value.uploadSent, true);
|
||
assert.equal(value.exported.status, 'passed'); assert.equal(value.results[0].resultId, 3347);
|
||
assert.equal(s.posts, 1); assert.equal(markers, 1); assert.equal(waits, 1);
|
||
});
|
||
test('permanently scheduled readback stops at the original poll bound without another upload', async t => {
|
||
const f = await fixture(t), s = server(f.prepared, { scheduled: true, processingReads: Infinity });
|
||
let waits = 0;
|
||
await assert.rejects(publish(f, s, async () => {}, { now: () => 0, wait: async () => { waits++; } }), /upload_unverified/);
|
||
assert.equal(s.posts, 1); assert.equal(waits, 11);
|
||
});
|
||
test('scheduled details with a foreign jobrun, contradictory source or malformed status refuse before upload', async t => {
|
||
for (const change of [row => { row.jobRun.id = 78; }, row => { row.jobRun.url = runUrl + '0'; },
|
||
row => { row.status = null; }]) {
|
||
const f = await fixture(t), s = server(f.prepared, { scheduled: true }), original = s.api;
|
||
s.api = async (...args) => { const row = await original(...args); if (args[0] === '/api/testresult/3347') change(row); return row; };
|
||
await assert.rejects(publish(f, s, async () => assert.fail('must not mark an invalid result')), /result_mismatch/);
|
||
assert.equal(s.posts, 0);
|
||
}
|
||
});
|
||
test('an uncertain applied upload reconciles by GET while an absent upload stops without resend', async t => {
|
||
for (const apply of [true, false]) {
|
||
const f = await fixture(t), s = server(f.prepared, { uncertain: true, apply, scheduled: true });
|
||
if (apply) {
|
||
const value = await publish(f, s); assert.equal(value.verification, 'VERIFIED');
|
||
assert.equal(value.uncertainUpload.kind, 'transport_or_response_failure');
|
||
assert.ok(!JSON.stringify(value).includes('SYNTHETIC_PRIVATE'));
|
||
} else await assert.rejects(publish(f, s), error => error.message === 'chat_completion_testops_stream_upload_uncertain'
|
||
&& !JSON.stringify(error).includes('SYNTHETIC_PRIVATE'));
|
||
assert.equal(s.posts, 1);
|
||
}
|
||
});
|
||
test('failed originals remain failed, including zero-run setup or cleanup failure', async t => {
|
||
const f = await fixture(t, true), s = server(f.prepared), value = await publish(f, s);
|
||
assert.equal(value.exported.status, 'failed'); assert.equal(value.exported.cleanup, 'failed');
|
||
assert.equal(value.verification, 'VERIFIED');
|
||
});
|
||
test('changed bundle bytes, source, selection digest and foreign case refuse before transport', async t => {
|
||
const f = await fixture(t); let calls = 0;
|
||
const noApi = async () => { calls++; assert.fail('untrusted bundle must not reach API'); };
|
||
await assert.rejects(publishCaseBundle(noApi, f.root, f.output, f.prepared, { ...context, workflowSha: 'b'.repeat(40) }, selection,
|
||
current.executionId, digest, async () => {}), /selection_binding/);
|
||
await assert.rejects(readCaseBundle(f.root, f.output, f.prepared, context, selection, current.executionId, 'b'.repeat(64)), /stream_binding/);
|
||
await assert.rejects(readCaseBundle(f.root, f.output, f.prepared, context, selection, 'foreign', digest), /stream_scope/);
|
||
await writeFile(path.join(f.output, f.prepared.files[0].name), 'SYNTHETIC_PRIVATE_TAMPER');
|
||
await assert.rejects(publishCaseBundle(noApi, f.root, f.output, f.prepared, context, selection, current.executionId, digest, async () => {}), /stream_bundle/);
|
||
assert.equal(calls, 0);
|
||
});
|
||
|
||
test('final coverage authenticates existing per-case bytes and keeps mixed original grades without re-export', async t => {
|
||
const base = await realpath(await mkdtemp(path.join(tmpdir(), 'chat-stream-final-')));
|
||
t.after(() => rm(base, { recursive: true, force: true }));
|
||
const bundles = path.join(base, 'bundles'), publications = path.join(base, 'publications');
|
||
await mkdir(bundles); await mkdir(publications);
|
||
for (const [index, row] of cases.entries()) {
|
||
const f = await fixture(t, index === 1, row);
|
||
const bundle = path.join(bundles, `chat-catalog-case-bundle-123-1-${index}`);
|
||
const receipt = path.join(publications, `chat-catalog-case-publication-123-1-${index}`);
|
||
await mkdir(bundle); await mkdir(receipt);
|
||
await cp(f.root, path.join(bundle, 'projection'), { recursive: true });
|
||
await cp(f.output, path.join(bundle, 'allure'), { recursive: true });
|
||
await writeFile(path.join(bundle, 'prepared.json'), JSON.stringify(f.prepared));
|
||
await writeFile(path.join(receipt, 'publication.json'), JSON.stringify({ ...f.prepared,
|
||
schema: 'paperclip.chat-catalog-testops-case-publication/v1', exported: f.prepared.exported[0],
|
||
verification: 'VERIFIED', results: [{ executionId: row.executionId, caseId: row.caseId, attachmentsVerified: true }] }));
|
||
}
|
||
const rows = await publicationSet(bundles, publications, context, selection, digest);
|
||
assert.deepEqual(rows.map(row => row.status), ['passed','failed','passed']);
|
||
const receiptPath = path.join(publications, 'chat-catalog-case-publication-123-1-1', 'publication.json');
|
||
const bad = JSON.parse(await readFile(receiptPath)); bad.exported.status = 'passed';
|
||
await writeFile(receiptPath, JSON.stringify(bad));
|
||
await assert.rejects(publicationSet(bundles, publications, context, selection, digest), /stream_publications/);
|
||
await rm(path.dirname(receiptPath), { recursive: true });
|
||
await assert.rejects(publicationSet(bundles, publications, context, selection, digest), /stream_publications/);
|
||
});
|
||
|
||
test('the real CLI prepares without TestOps credentials and retains closed failed or uncertain publication witnesses', async t => {
|
||
for (const uncertain of [false, true]) {
|
||
const f = await fixture(t, true), base = path.dirname(f.root);
|
||
await rm(f.output, { recursive: true });
|
||
const selectionRoot = path.join(base, 'chat-completion-testops-selection'); await mkdir(selectionRoot);
|
||
await writeFile(path.join(selectionRoot, 'testops-pilot-selection.json'), JSON.stringify(selection));
|
||
const event = { repository: { full_name: 'paperclipai/paperclip', private: false, default_branch: 'master' },
|
||
ref: 'refs/heads/master', sender: { id: 42 }, inputs: { operation: 'evaluate', ALLURE_JOB_RUN_ID: '77' } };
|
||
const eventPath = path.join(base, 'event.json'); await writeFile(eventPath, JSON.stringify(event));
|
||
const env = { GITHUB_ACTIONS: 'true', GITHUB_EVENT_NAME: 'workflow_dispatch', GITHUB_REPOSITORY: event.repository.full_name,
|
||
GITHUB_REF: event.ref, GITHUB_WORKFLOW_REF: 'paperclipai/paperclip/.github/workflows/chat-catalog-testops.yml@refs/heads/master',
|
||
GITHUB_SHA: context.workflowSha, GITHUB_RUN_ATTEMPT: '1', GITHUB_RUN_ID: '123', GITHUB_ACTOR_ID: '42',
|
||
TESTOPS_ALLOWED_ACTOR_IDS: '[42]', GITHUB_RUN_NUMBER: '1', GITHUB_EVENT_PATH: eventPath,
|
||
GH_TOKEN: 'SYNTHETIC_GITHUB_SECRET', RUNNER_TEMP: base, GITHUB_WORKSPACE: base, ALLURE_JOB_RUN_ID: '77',
|
||
ALLURE_RESULTS: f.output, CHAT_TESTOPS_EXECUTION_ID: current.executionId, EXPECTED_SELECTION_SHA256: digest };
|
||
const exec = async () => ({ stdout: JSON.stringify({ id: 123, run_attempt: 1, run_number: 1, workflow_id: 99,
|
||
head_sha: context.workflowSha, head_branch: 'master', event: 'workflow_dispatch', path: '.github/workflows/chat-catalog-testops.yml',
|
||
repository: event.repository, actor: { id: 42 }, triggering_actor: { id: 42 } }) });
|
||
assert.equal(await run('prepare-case-upload', f.root, env, { exec, fetchImpl: () => assert.fail('reader must not authenticate to TestOps') }), 0);
|
||
const preparedPath = path.join(base, 'chat-completion-testops-prepare-case-upload', 'prepared.json');
|
||
const prepared = JSON.parse(await readFile(preparedPath)), s = server(prepared, { uncertain, apply: !uncertain });
|
||
const fetchImpl = async (url, options) => {
|
||
if (url.endsWith('/api/uaa/oauth/token')) return Response.json({ access_token: 'SYNTHETIC_ACCESS_TOKEN' });
|
||
if (uncertain && s.posts && new URL(url).pathname === '/api/jobrun/77') return Response.json({ id: 78 });
|
||
return Response.json(await s.api(new URL(url).pathname + new URL(url).search, options.method, options.body));
|
||
};
|
||
let captured = ''; const capture = t.mock.method(process.stderr, 'write', value => { captured += value; return true; });
|
||
assert.equal(await run('upload-case', f.root, { ...env, ALLURE_TOKEN: 'SYNTHETIC_TESTOPS_SECRET',
|
||
CHAT_TESTOPS_PREPARED_FILE: preparedPath }, { exec, fetchImpl }), 1);
|
||
capture.mock.restore();
|
||
const state = path.join(base, 'chat-completion-testops-upload-case');
|
||
assert.equal(s.posts, 1);
|
||
const receipt = JSON.parse(await readFile(path.join(state, uncertain ? 'failure.json' : 'publication.json')));
|
||
if (uncertain) {
|
||
assert.equal(receipt.code, 'chat_completion_testops_stream_upload_uncertain');
|
||
assert.equal(receipt.uploadFailure.kind, 'transport_or_response_failure');
|
||
await assert.rejects(readFile(path.join(state, 'publication.json')), { code: 'ENOENT' });
|
||
} else { assert.equal(receipt.verification, 'VERIFIED'); assert.equal(receipt.exported.status, 'failed'); }
|
||
assert.deepEqual(JSON.parse(await readFile(path.join(state, 'once-upload_case.json'))),
|
||
{ stage: 'upload_case', status: 'STARTED_NO_AUTOMATIC_RESEND' });
|
||
assert.doesNotMatch(captured + JSON.stringify(receipt), /SYNTHETIC_|ACCESS_TOKEN/);
|
||
const yaml = await readFile(new URL('../../.github/workflows/chat-catalog-testops-case.yml', import.meta.url), 'utf8');
|
||
for (const filename of ['publication.json', 'failure.json', 'once-upload_case.json']) {
|
||
assert.ok(yaml.includes(`/chat-completion-testops-upload-case/${filename}`));
|
||
}
|
||
}
|
||
});
|
||
|
||
test('source host admission follows only the fixed same-commit callee with exact named caller secret references', async () => {
|
||
const { parse } = await import('yaml');
|
||
const { paidWorker } = await import('./chat-completion-testops-host-control.mjs');
|
||
const main = parse(await readFile(new URL('../../.github/workflows/chat-catalog-testops.yml', import.meta.url), 'utf8'));
|
||
const callee = parse(await readFile(new URL('../../.github/workflows/chat-catalog-testops-case.yml', import.meta.url), 'utf8'));
|
||
assert.equal(paidWorker(main, callee).environment, 'runner-e2e-private-paid');
|
||
for (const change of [
|
||
value => { value.jobs.chat.uses = 'foreign/repository/workflow.yml@master'; },
|
||
value => { value.jobs.chat.secrets = 'inherit'; },
|
||
value => { value.jobs.chat_external.secrets.ALLURE_TOKEN = ''; },
|
||
value => { value.jobs.chat.with.selection_sha256 = 'arbitrary'; },
|
||
]) { const mutant = structuredClone(main); change(mutant); assert.throws(() => paidWorker(mutant, callee), /boundary/); }
|
||
const wrong = structuredClone(callee); wrong.jobs.chat.environment = 'runner-e2e-testops-reporting';
|
||
assert.throws(() => paidWorker(main, wrong), /boundary/);
|
||
});
|
||
|
||
|
||
test('the final CLI verifies all authenticated mixed-grade publications without re-export or upload and refuses a contradictory merged grade', async t => {
|
||
for (const contradiction of [false, true]) {
|
||
const base = await realpath(await mkdtemp(path.join(tmpdir(), 'chat-stream-cli-final-')));
|
||
t.after(() => rm(base, { recursive: true, force: true }));
|
||
const bundles = path.join(base, 'bundles'), publications = path.join(base, 'publications'), merged = path.join(base, 'merged');
|
||
for (const root of [bundles, publications, merged]) await mkdir(root);
|
||
const actual = { ...context, operation: 'evaluate', actorId: 42, triggeringActorId: 42, workflowId: 99 };
|
||
const originals = [], privateEvidence = [], exported = [];
|
||
for (const [index, row] of cases.entries()) {
|
||
const f = await fixture(t, index === 1, row, actual);
|
||
const bundle = path.join(bundles, `chat-catalog-case-bundle-123-1-${index}`);
|
||
const publication = path.join(publications, `chat-catalog-case-publication-123-1-${index}`);
|
||
await mkdir(bundle); await mkdir(publication);
|
||
await cp(f.root, path.join(bundle, 'projection'), { recursive: true });
|
||
await cp(f.output, path.join(bundle, 'allure'), { recursive: true });
|
||
await writeFile(path.join(bundle, 'prepared.json'), JSON.stringify(f.prepared));
|
||
await writeFile(path.join(publication, 'publication.json'), JSON.stringify({ ...f.prepared,
|
||
schema: 'paperclip.chat-catalog-testops-case-publication/v1', exported: f.prepared.exported[0], verification: 'VERIFIED',
|
||
results: [{ executionId: row.executionId, caseId: row.caseId, attachmentsVerified: true }] }));
|
||
originals.push(JSON.parse(await readFile(path.join(f.root, 'normalized-results.json'))).results[0]);
|
||
privateEvidence.push(...JSON.parse(await readFile(path.join(f.root, 'binding.json'))).privateEvidence);
|
||
exported.push(f.prepared.exported[0]);
|
||
}
|
||
if (contradiction) Object.assign(originals[0], { status: 'failed', cleanup: 'failed', runIds: [], matcherResults: [] });
|
||
const files = new Map([['normalized-results.json', Buffer.from(JSON.stringify({ schema: 'paperclip.runner-e2e.campaign/v2',
|
||
campaignId: 'gha-123-1', retries: 0, results: originals }))], ['junit.xml', Buffer.from('<testsuite/>')]]);
|
||
for (const [name, bytes] of files) await writeFile(path.join(merged, name), bytes);
|
||
await writeFile(path.join(merged, 'binding.json'), JSON.stringify({ schema: 'paperclip.chat-completion-testops-export/v1',
|
||
...context, campaignId: 'gha-123-1', executionIds: cases.map(row => row.executionId), privateEvidence,
|
||
files: [...files].map(([name, bytes]) => ({ path: name, bytes: bytes.length, sha256: hash(bytes) })) }));
|
||
const selectionRoot = path.join(base, 'chat-completion-testops-selection'); await mkdir(selectionRoot);
|
||
await writeFile(path.join(selectionRoot, 'testops-pilot-selection.json'), JSON.stringify(selection));
|
||
const event = { repository: { full_name: 'paperclipai/paperclip', private: false, default_branch: 'master' },
|
||
ref: 'refs/heads/master', sender: { id: 42 }, inputs: { operation: 'evaluate', ALLURE_JOB_RUN_ID: '77' } };
|
||
const eventPath = path.join(base, 'event.json'); await writeFile(eventPath, JSON.stringify(event));
|
||
const env = { GITHUB_ACTIONS: 'true', GITHUB_EVENT_NAME: 'workflow_dispatch', GITHUB_REPOSITORY: event.repository.full_name,
|
||
GITHUB_REF: event.ref, GITHUB_WORKFLOW_REF: 'paperclipai/paperclip/.github/workflows/chat-catalog-testops.yml@refs/heads/master',
|
||
GITHUB_SHA: context.workflowSha, GITHUB_RUN_ATTEMPT: '1', GITHUB_RUN_ID: '123', GITHUB_ACTOR_ID: '42',
|
||
TESTOPS_ALLOWED_ACTOR_IDS: '[42]', GITHUB_RUN_NUMBER: '1', GITHUB_EVENT_PATH: eventPath,
|
||
GH_TOKEN: 'SYNTHETIC_GITHUB_SECRET', RUNNER_TEMP: base, GITHUB_WORKSPACE: base, ALLURE_JOB_RUN_ID: '77',
|
||
ALLURE_TOKEN: 'SYNTHETIC_REPORTING_SECRET', EXPECTED_SELECTION_SHA256: digest,
|
||
CHAT_TESTOPS_BUNDLES: bundles, CHAT_TESTOPS_PUBLICATIONS: publications, ALLURE_RESULTS: path.join(base, 'must-not-export') };
|
||
const exec = async () => ({ stdout: JSON.stringify({ id: 123, run_attempt: 1, run_number: 1, workflow_id: 99,
|
||
head_sha: context.workflowSha, head_branch: 'master', event: 'workflow_dispatch', path: '.github/workflows/chat-catalog-testops.yml',
|
||
repository: event.repository, actor: { id: 42 }, triggering_actor: { id: 42 } }) });
|
||
const details = [];
|
||
const fetchImpl = async (url, options) => {
|
||
const u = new URL(url), route = u.pathname + u.search;
|
||
if (u.pathname === '/api/uaa/oauth/token') return Response.json({ access_token: 'SYNTHETIC_ACCESS_TOKEN' });
|
||
assert.equal(options.method, 'GET', 'final verification must not upload or change TestOps');
|
||
if (route === '/api/jobrun/77') return Response.json({ id: 77, job: { id: 100, type: 'github' }, stage: 'finished', launchId: 12, url: runUrl });
|
||
if (route === '/api/launch/12') return Response.json({ id: 12, projectId: 1 });
|
||
if (u.pathname === '/api/testresult') return Response.json({ content: cases.map((row, i) => ({ id: 501 + i, testCaseId: row.caseId })), totalElements: 3, totalPages: 1, number: 0 });
|
||
const match = u.pathname.match(/^\/api\/testresult\/(\d+)$/);
|
||
if (match) {
|
||
const i = Number(match[1]) - 501; details.push(cases[i].caseId);
|
||
return Response.json({ id: Number(match[1]), projectId: 1, launchId: 12, testCaseId: cases[i].caseId, fullName: cases[i].registeredFullName,
|
||
jobRun: { id: 77, launchId: 12, url: runUrl }, links: [], status: exported[i].status });
|
||
}
|
||
assert.equal(u.pathname, '/api/testresult/attachment');
|
||
const i = Number(u.searchParams.get('testResultId')) - 501;
|
||
return Response.json({ content: exported[i].attachments.map((row, index) => ({ ...row, id: 700 + index, missed: false, fromTestCase: false })), totalElements: 2, totalPages: 1 });
|
||
};
|
||
const before = await Promise.all((await readdir(bundles)).map(async name => [name, hash(await readFile(path.join(bundles, name, 'prepared.json')))]));
|
||
let stderr = ''; const capture = t.mock.method(process.stderr, 'write', value => { stderr += value; return true; });
|
||
assert.equal(await run('verify-stream', merged, env, { exec, fetchImpl }), 1);
|
||
capture.mock.restore();
|
||
const state = path.join(base, 'chat-completion-testops-verify-stream');
|
||
if (contradiction) {
|
||
assert.equal(JSON.parse(await readFile(path.join(state, 'failure.json'))).code, 'chat_completion_testops_upload_receipt');
|
||
assert.deepEqual(details, []);
|
||
} else {
|
||
const verified = JSON.parse(await readFile(path.join(state, 'verification.json')));
|
||
assert.equal(verified.verification, 'VERIFIED'); assert.deepEqual(details.sort((a,b) => a-b), [26,100,388]);
|
||
assert.deepEqual(verified.results.map(row => row.status), ['passed','failed','passed']);
|
||
}
|
||
await assert.rejects(readFile(env.ALLURE_RESULTS), { code: 'ENOENT' });
|
||
assert.deepEqual(await Promise.all((await readdir(bundles)).map(async name => [name, hash(await readFile(path.join(bundles, name, 'prepared.json')))])), before);
|
||
assert.doesNotMatch(stderr, /SYNTHETIC_/);
|
||
}
|
||
});
|
||
|
||
// Exact download-path decision from pinned download-artifact 3e5f45b, lines185–194.
|
||
// Even merge-multiple:false flattens a one-artifact pattern result.
|
||
const pinnedDownloadPath = (resolvedPath, artifact, artifacts, isSingleArtifactDownload, mergeMultiple = false) =>
|
||
isSingleArtifactDownload || mergeMultiple || artifacts.length === 1 ? resolvedPath : path.join(resolvedPath, artifact.name);
|
||
async function downloaded(t, selected, available = selected.cases.length, runtimeContext = context) {
|
||
const base = await realpath(await mkdtemp(path.join(tmpdir(), 'chat-stream-download-')));
|
||
t.after(() => rm(base, { recursive: true, force: true }));
|
||
const workflow = parseDocument(await readFile(new URL('../../.github/workflows/chat-catalog-testops.yml', import.meta.url), 'utf8')).toJS();
|
||
const roots = { bundle: path.join(base, 'catalog-bundles'), publication: path.join(base, 'catalog-publications') };
|
||
const active = step => !step.if || (step.if.includes('!= null') ? selected.cases.length > 1 : selected.cases.length === 1);
|
||
for (const role of ['bundle', 'publication']) {
|
||
const steps = workflow.jobs.verify.steps.filter(step => step.uses?.startsWith('actions/download-artifact@')
|
||
&& (step.with.pattern ?? step.with.name ?? '').startsWith(`chat-catalog-case-${role}-`) && active(step));
|
||
assert.equal(steps.length, 1);
|
||
const step = steps[0], artifacts = Array.from({ length: available }, (_, i) => ({ name: `chat-catalog-case-${role}-123-1-${i}` }));
|
||
await mkdir(roots[role]);
|
||
for (const [index, artifact] of artifacts.entries()) {
|
||
const destination = pinnedDownloadPath(path.join(base, step.with.path.replaceAll('${{ github.run_id }}', '123')),
|
||
artifact, artifacts, Boolean(step.with.name));
|
||
await mkdir(destination, { recursive: true });
|
||
const row = selected.cases[index];
|
||
if (role === 'bundle') {
|
||
const f = await fixture(t, index === 1, row, runtimeContext, selected);
|
||
await cp(f.root, path.join(destination, 'projection'), { recursive: true });
|
||
await cp(f.output, path.join(destination, 'allure'), { recursive: true });
|
||
await writeFile(path.join(destination, 'prepared.json'), JSON.stringify(f.prepared));
|
||
} else {
|
||
// Use the matching bundle's frozen UUID bytes; never independently export the receipt.
|
||
const bundlePath = artifacts.length === 1 && selected.cases.length > 1 ? roots.bundle
|
||
: path.join(roots.bundle, `chat-catalog-case-bundle-123-1-${index}`);
|
||
let prepared;
|
||
try { prepared = JSON.parse(await readFile(path.join(bundlePath, 'prepared.json'))); }
|
||
catch { prepared = JSON.parse(await readFile(path.join(roots.bundle, 'prepared.json'))); }
|
||
await writeFile(path.join(destination, 'publication.json'), JSON.stringify({ ...prepared,
|
||
schema: 'paperclip.chat-catalog-testops-case-publication/v1', exported: prepared.exported[0], verification: 'VERIFIED',
|
||
results: [{ executionId: row.executionId, caseId: row.caseId, attachmentsVerified: true }] }));
|
||
// The unchanged real upload-case producer retains this marker on a fresh POST.
|
||
await writeFile(path.join(destination, 'once-upload_case.json'), JSON.stringify({ stage: 'upload_case', status: 'STARTED_NO_AUTOMATIC_RESEND' }));
|
||
}
|
||
}
|
||
}
|
||
return { base, workflow, bundles: roots.bundle, publications: roots.publication, digest: hash(JSON.stringify(selected)) };
|
||
}
|
||
test('actual pinned singleton download reaches safe gather', async t => {
|
||
const selected = { ...selection, cases: [current] }, f = await downloaded(t, selected);
|
||
const output = path.join(f.base, 'catalog-projections');
|
||
await streaming.gatherCaseProjections(f.bundles, output, context, selected, f.digest);
|
||
assert.deepEqual(await readdir(output), ['chat-catalog-case-bundle-123-1-0']);
|
||
});
|
||
test('actual pinned singleton download reaches final publication validation', async t => {
|
||
const selected = { ...selection, cases: [current] }, f = await downloaded(t, selected);
|
||
const exported = await publicationSet(f.bundles, f.publications, context, selected, f.digest);
|
||
assert.deepEqual(exported.map(row => row.status), ['passed']);
|
||
});
|
||
test('actual pinned multi download preserves all mixed grades and missing singleton among a multi selection still refuses', async t => {
|
||
const f = await downloaded(t, selection);
|
||
assert.deepEqual((await publicationSet(f.bundles, f.publications, context, selection, f.digest)).map(row => row.status), ['passed','failed','passed']);
|
||
const incomplete = await downloaded(t, selection, 1);
|
||
await assert.rejects(publicationSet(incomplete.bundles, incomplete.publications, context, selection, incomplete.digest));
|
||
});
|
||
|
||
test('named layouts reject empty, mixed, foreign, malformed and symlink entries before gathering', async t => {
|
||
const selected = { ...selection, cases: [current] };
|
||
const changes = [
|
||
async f => rm(path.join(f.bundles, 'chat-catalog-case-bundle-123-1-0'), { recursive: true }),
|
||
async f => writeFile(path.join(f.bundles, 'prepared.json'), '{}'),
|
||
async f => rename(path.join(f.bundles, 'chat-catalog-case-bundle-123-1-0'), path.join(f.bundles, 'chat-catalog-case-bundle-999-1-0')),
|
||
async f => writeFile(path.join(f.bundles, 'chat-catalog-case-bundle-123-1-0', 'unknown.json'), '{}'),
|
||
async f => {
|
||
const directory = path.join(f.bundles, 'chat-catalog-case-bundle-123-1-0');
|
||
await rename(directory, path.join(f.base, 'outside'));
|
||
await symlink(path.join(f.base, 'outside'), directory);
|
||
},
|
||
async f => {
|
||
const file = path.join(f.bundles, 'chat-catalog-case-bundle-123-1-0', 'projection', 'normalized-results.json');
|
||
await rename(file, path.join(f.base, 'outside.json')); await symlink(path.join(f.base, 'outside.json'), file);
|
||
},
|
||
async f => {
|
||
const file = path.join(f.bundles, 'chat-catalog-case-bundle-123-1-0', 'prepared.json');
|
||
const prepared = JSON.parse(await readFile(file)); prepared.targetSha = 'f'.repeat(40);
|
||
await writeFile(file, JSON.stringify(prepared));
|
||
},
|
||
async f => {
|
||
const file = path.join(f.bundles, 'chat-catalog-case-bundle-123-1-0', 'allure');
|
||
await rename(file, path.join(f.base, 'outside')); await symlink(path.join(f.base, 'outside'), file);
|
||
},
|
||
];
|
||
for (const change of changes) {
|
||
const f = await downloaded(t, selected); await change(f);
|
||
const output = path.join(f.base, 'must-not-gather');
|
||
await assert.rejects(streaming.gatherCaseProjections(f.bundles, output, context, selected, f.digest));
|
||
await assert.rejects(access(output));
|
||
await assert.rejects(publicationSet(f.bundles, f.publications, context, selected, f.digest));
|
||
}
|
||
for (const change of [
|
||
async f => writeFile(path.join(f.publications, 'chat-catalog-case-publication-123-1-0', 'failure.json'), JSON.stringify({ command: 'upload-case', code: 'chat_completion_testops_upload_unverified' })),
|
||
async f => writeFile(path.join(f.publications, 'chat-catalog-case-publication-123-1-0', 'once-upload_case.json'), JSON.stringify({ stage: 'upload_case', status: 'COMPLETE' })),
|
||
async f => {
|
||
const file = path.join(f.publications, 'chat-catalog-case-publication-123-1-0', 'once-upload_case.json');
|
||
await rename(file, path.join(f.base, 'marker')); await symlink(path.join(f.base, 'marker'), file);
|
||
},
|
||
async f => writeFile(path.join(f.publications, 'extra'), '{}'),
|
||
async f => writeFile(path.join(f.publications, 'chat-catalog-case-publication-123-1-0', 'extra'), '{}'),
|
||
async f => {
|
||
const file = path.join(f.publications, 'chat-catalog-case-publication-123-1-0', 'publication.json');
|
||
await rename(file, path.join(f.base, 'outside')); await symlink(path.join(f.base, 'outside'), file);
|
||
},
|
||
]) {
|
||
const f = await downloaded(t, selected); await change(f);
|
||
await assert.rejects(publicationSet(f.bundles, f.publications, context, selected, f.digest));
|
||
}
|
||
});
|
||
test('the owning gather CLI authenticates selection and copies only unchanged projections without TestOps access', async t => {
|
||
const selected = { ...selection, cases: [current] };
|
||
const actual = { ...context, operation: 'evaluate', actorId: 42, triggeringActorId: 42, workflowId: 99 };
|
||
const f = await downloaded(t, selected, 1, actual), selectionRoot = path.join(f.base, 'chat-completion-testops-selection');
|
||
await mkdir(selectionRoot); await writeFile(path.join(selectionRoot, 'testops-pilot-selection.json'), JSON.stringify(selected));
|
||
const event = { repository: { full_name: 'paperclipai/paperclip', private: false, default_branch: 'master' },
|
||
ref: 'refs/heads/master', sender: { id: 42 }, inputs: { operation: 'evaluate', ALLURE_JOB_RUN_ID: '77' } };
|
||
const eventPath = path.join(f.base, 'event.json'); await writeFile(eventPath, JSON.stringify(event));
|
||
const env = { GITHUB_ACTIONS: 'true', GITHUB_EVENT_NAME: 'workflow_dispatch', GITHUB_REPOSITORY: event.repository.full_name,
|
||
GITHUB_REF: event.ref, GITHUB_WORKFLOW_REF: 'paperclipai/paperclip/.github/workflows/chat-catalog-testops.yml@refs/heads/master',
|
||
GITHUB_SHA: context.workflowSha, GITHUB_RUN_ATTEMPT: '1', GITHUB_RUN_ID: '123', GITHUB_ACTOR_ID: '42',
|
||
TESTOPS_ALLOWED_ACTOR_IDS: '[42]', GITHUB_RUN_NUMBER: '1', GITHUB_EVENT_PATH: eventPath,
|
||
GH_TOKEN: 'SYNTHETIC_GITHUB_SECRET', RUNNER_TEMP: f.base, GITHUB_WORKSPACE: f.base, ALLURE_JOB_RUN_ID: '77',
|
||
EXPECTED_SELECTION_SHA256: f.digest, CHAT_TESTOPS_BUNDLES: f.bundles };
|
||
const exec = async () => ({ stdout: JSON.stringify({ id: 123, run_attempt: 1, run_number: 1, workflow_id: 99,
|
||
head_sha: context.workflowSha, head_branch: 'master', event: 'workflow_dispatch', path: '.github/workflows/chat-catalog-testops.yml',
|
||
repository: event.repository, actor: { id: 42 }, triggering_actor: { id: 42 } }) });
|
||
const output = path.join(f.base, 'projections');
|
||
assert.equal(await run('gather-case-projections', output, { ...env, GITHUB_RUN_ATTEMPT: '2' }, { exec,
|
||
fetchImpl: async () => assert.fail('unsupported retry must not contact TestOps') }), 1);
|
||
await assert.rejects(access(output));
|
||
assert.equal(await run('gather-case-projections', output, env, { exec, fetchImpl: async () => assert.fail('metadata gather must not contact TestOps') }), 0);
|
||
assert.deepEqual(await readdir(output), ['chat-catalog-case-bundle-123-1-0']);
|
||
const copied = path.join(output, 'chat-catalog-case-bundle-123-1-0');
|
||
assert.deepEqual((await readdir(copied)).sort(), ['binding.json', 'junit.xml', 'normalized-results.json']);
|
||
for (const name of await readdir(copied)) assert.deepEqual(await readFile(path.join(copied, name)),
|
||
await readFile(path.join(f.bundles, 'chat-catalog-case-bundle-123-1-0', 'projection', name)));
|
||
const gather = f.workflow.jobs.aggregate.steps.find(step => step.name?.startsWith('Gather only'));
|
||
assert.match(gather.run, /chat-completion-testops-cli.mjs gather-case-projections catalog-projections/);
|
||
for (const job of ['aggregate', 'verify']) {
|
||
const downloads = f.workflow.jobs[job].steps.filter(step => step.with?.name?.startsWith('chat-catalog-case-bundle-'));
|
||
assert.equal(downloads.length, 1); assert.equal(downloads[0].with.name, 'chat-catalog-case-bundle-${{ github.run_id }}-1-0');
|
||
assert.equal(downloads[0].with.path, 'catalog-bundles/chat-catalog-case-bundle-${{ github.run_id }}-1-0');
|
||
}
|
||
});
|
||
|
||
test('singleton destination uses trusted run naming; the admitted producer remains first-attempt-only', async () => {
|
||
const workflow = parseDocument(await readFile(new URL('../../.github/workflows/chat-catalog-testops.yml', import.meta.url), 'utf8')).toJS();
|
||
const callee = parseDocument(await readFile(new URL('../../.github/workflows/chat-catalog-testops-case.yml', import.meta.url), 'utf8')).toJS();
|
||
for (const role of ['bundle', 'publication']) {
|
||
const step = workflow.jobs.verify.steps.find(step => step.with?.name?.startsWith(`chat-catalog-case-${role}-`));
|
||
const name = step.with.name.replaceAll('${{ github.run_id }}', '987654321');
|
||
const destination = step.with.path.replaceAll('${{ github.run_id }}', '987654321');
|
||
assert.equal(path.basename(destination), name);
|
||
assert.equal(pinnedDownloadPath(destination, { name }, [{ name }], true), destination);
|
||
const job = role === 'bundle' ? callee.jobs.report : callee.jobs.upload;
|
||
const producer = job.steps.find(step => step.with?.name?.startsWith(`chat-catalog-case-${role}-`));
|
||
assert.equal(producer.with.name.replaceAll('${{ github.run_id }}', '987654321').replaceAll('${{ fromJSON(inputs.cell).index }}', '0'), name);
|
||
if (role === 'publication') {
|
||
assert.match(producer.with.path, /once-upload_case.json/);
|
||
assert.match(producer.with.path, /failure.json/);
|
||
}
|
||
}
|
||
});
|
||
|
||
test('the unchanged upload CLI real publication and once files validate after a named singleton download', async t => {
|
||
const selected = { ...selection, cases: [current] };
|
||
const actual = { ...context, operation: 'evaluate', actorId: 42, triggeringActorId: 42, workflowId: 99 };
|
||
const f = await downloaded(t, selected, 1, actual), bundle = path.join(f.bundles, 'chat-catalog-case-bundle-123-1-0');
|
||
const preparedFile = path.join(bundle, 'prepared.json'), prepared = JSON.parse(await readFile(preparedFile));
|
||
const selectionRoot = path.join(f.base, 'chat-completion-testops-selection');
|
||
await mkdir(selectionRoot); await writeFile(path.join(selectionRoot, 'testops-pilot-selection.json'), JSON.stringify(selected));
|
||
const event = { repository: { full_name: 'paperclipai/paperclip', private: false, default_branch: 'master' },
|
||
ref: 'refs/heads/master', sender: { id: 42 }, inputs: { operation: 'evaluate', ALLURE_JOB_RUN_ID: '77' } };
|
||
const eventPath = path.join(f.base, 'event.json'); await writeFile(eventPath, JSON.stringify(event));
|
||
const env = { GITHUB_ACTIONS: 'true', GITHUB_EVENT_NAME: 'workflow_dispatch', GITHUB_REPOSITORY: event.repository.full_name,
|
||
GITHUB_REF: event.ref, GITHUB_WORKFLOW_REF: 'paperclipai/paperclip/.github/workflows/chat-catalog-testops.yml@refs/heads/master',
|
||
GITHUB_SHA: context.workflowSha, GITHUB_RUN_ATTEMPT: '1', GITHUB_RUN_ID: '123', GITHUB_ACTOR_ID: '42',
|
||
TESTOPS_ALLOWED_ACTOR_IDS: '[42]', GITHUB_RUN_NUMBER: '1', GITHUB_EVENT_PATH: eventPath,
|
||
GH_TOKEN: 'SYNTHETIC_GITHUB_SECRET', RUNNER_TEMP: f.base, GITHUB_WORKSPACE: f.base, ALLURE_JOB_RUN_ID: '77',
|
||
ALLURE_TOKEN: 'SYNTHETIC_REPORT_TOKEN', EXPECTED_SELECTION_SHA256: f.digest,
|
||
ALLURE_RESULTS: path.join(bundle, 'allure'), CHAT_TESTOPS_PREPARED_FILE: preparedFile, CHAT_TESTOPS_EXECUTION_ID: current.executionId };
|
||
const exec = async () => ({ stdout: JSON.stringify({ id: 123, run_attempt: 1, run_number: 1, workflow_id: 99,
|
||
head_sha: context.workflowSha, head_branch: 'master', event: 'workflow_dispatch', path: '.github/workflows/chat-catalog-testops.yml',
|
||
repository: event.repository, actor: { id: 42 }, triggering_actor: { id: 42 } }) });
|
||
const transport = server(prepared);
|
||
const fetchImpl = async (url, options) => {
|
||
const u = new URL(url);
|
||
if (u.pathname === '/api/uaa/oauth/token') return Response.json({ access_token: 'SYNTHETIC_ACCESS_TOKEN' });
|
||
const value = await transport.api(u.pathname + u.search, options.method, options.body);
|
||
if (u.pathname === '/api/testresult') {
|
||
value.content = value.content.filter(row => row.testCaseId === current.caseId);
|
||
value.totalElements = value.content.length;
|
||
}
|
||
return Response.json(value);
|
||
};
|
||
assert.equal(await run('upload-case', path.join(bundle, 'projection'), env, { exec, fetchImpl }), 0);
|
||
assert.equal(transport.posts, 1);
|
||
const emitted = path.join(f.base, 'chat-completion-testops-upload-case');
|
||
assert.deepEqual((await readdir(emitted)).sort(), ['once-upload_case.json', 'publication.json']);
|
||
const target = path.join(f.publications, 'chat-catalog-case-publication-123-1-0');
|
||
await rm(target, { recursive: true }); await cp(emitted, target, { recursive: true });
|
||
const exported = await publicationSet(f.bundles, f.publications, actual, selected, f.digest);
|
||
assert.equal(exported[0].status, 'passed'); assert.equal(transport.posts, 1, 'finalizer must not resend');
|
||
});
|