From 312e54c55de144464ac13a3f66940fa93b7fe42a Mon Sep 17 00:00:00 2001 From: finalerock44 Date: Wed, 23 Sep 2026 15:40:01 +0100 Subject: [PATCH] fix: pass the job when a newer run superseded this one `--cancel-previous` is passed straight through to the CLI, so a newer run from the same CI context can cancel an older run's queued tests. /uploads/status rolls those cancelled tests up to FAILED, so the older EAS job exited 1. The status now carries `supersededBy: ` for such a run. When it's present: - log "Superseded by " with the newer run's console link; - set upload_status to SUPERSEDED; - exit 0, whatever dcd exited with (an older CLI exits 2 for it). This mirrors `dcd cloud` 5.6.0, which exits 0 for a superseded run. Nothing changes when the field is absent, as it is on every other run and on APIs that predate it. The verdict moves into src/methods/status.ts (evaluateRun) so it can be unit-tested; the existing branches and messages are unchanged. --- src/index.ts | 47 +++------------ src/methods/status.test.ts | 96 +++++++++++++++++++++++++++++++ src/methods/status.ts | 113 +++++++++++++++++++++++++++++++++++++ 3 files changed, 217 insertions(+), 39 deletions(-) create mode 100644 src/methods/status.test.ts create mode 100644 src/methods/status.ts diff --git a/src/index.ts b/src/index.ts index 31848a7..77435df 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,21 +2,10 @@ import { spawn } from 'child_process'; import { getEnv } from './methods/env'; import { setOutput, escapeShellValue } from './methods/output'; +import { evaluateRun, StatusResponse, TestResult } from './methods/status'; const dcdPackageName = '@devicecloud.dev/dcd'; -interface TestResult { - name: string; - status: 'PASSED' | 'FAILED' | 'CANCELLED' | 'PENDING' | 'RUNNING'; -} - -interface StatusResponse { - status: 'PASSED' | 'FAILED' | 'CANCELLED' | 'PENDING' | 'RUNNING'; - tests: TestResult[]; - consoleUrl?: string; - appBinaryId?: string; -} - const executeCommand = ( command: string, log: boolean = true @@ -183,9 +172,13 @@ const run = async (): Promise => { ); if (result) { + // Superseded (--cancel-previous), passed, failed or indeterminate: see + // evaluateRun for how the status and the exit code combine. + const verdict = evaluateRun(result, cloudExitCode); + setOutput('console_url', result.consoleUrl || ''); setOutput('app_binary_id', result.appBinaryId || ''); - setOutput('upload_status', result.status || 'PENDING'); + setOutput('upload_status', verdict.uploadStatus); const flowResults = (result.tests || []).map((test: TestResult) => ({ name: test.name, @@ -193,32 +186,8 @@ const run = async (): Promise => { })); setOutput('flow_results', JSON.stringify(flowResults)); - // Fail on either signal. The exit code is authoritative for a run that - // finished badly; the status call can only add failures the CLI could not - // see. A non-terminal status (PENDING/RUNNING) alongside a clean exit is a - // racy or degraded status call, not a failure — the CLI watched the run to - // completion, so warn rather than fail the job. - if (cloudExitCode !== 0) { - console.error( - `Test run failed (dcd exited ${cloudExitCode}, status ${result.status}). ` + - `Check flow results: ${result.consoleUrl}` - ); - process.exit(1); - } else if (result.status === 'PASSED') { - console.error('Successfully completed test run.'); - process.exit(0); - } else if (result.status === 'FAILED' || result.status === 'CANCELLED') { - console.error( - `Test run ${result.status}. Check flow results: ${result.consoleUrl}` - ); - process.exit(1); - } else { - console.error( - `dcd reported success but the upload status is ${result.status}. ` + - `Treating the run as passed: ${result.consoleUrl}` - ); - process.exit(0); - } + console.error(verdict.message); + process.exit(verdict.exitCode); } else { setOutput('upload_status', 'ERROR'); setOutput('flow_results', '[]'); diff --git a/src/methods/status.test.ts b/src/methods/status.test.ts new file mode 100644 index 0000000..1d4ae75 --- /dev/null +++ b/src/methods/status.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; + +import { + evaluateRun, + StatusResponse, + supersedingConsoleUrl, +} from './status'; + +const consoleUrl = + 'https://console.devicecloud.dev/results?upload=old-upload&result=101'; + +const status = (overrides: Partial): StatusResponse => ({ + status: 'PASSED', + tests: [], + consoleUrl, + appBinaryId: 'abi', + ...overrides, +}); + +describe('evaluateRun', () => { + it('passes a PASSED run that dcd agreed with', () => { + expect(evaluateRun(status({ status: 'PASSED' }), 0)).toEqual({ + exitCode: 0, + uploadStatus: 'PASSED', + message: 'Successfully completed test run.', + }); + }); + + it('fails a FAILED run even when dcd exited 0 (e.g. with --json-file)', () => { + expect(evaluateRun(status({ status: 'FAILED' }), 0)).toEqual({ + exitCode: 1, + uploadStatus: 'FAILED', + message: `Test run FAILED. Check flow results: ${consoleUrl}`, + }); + }); + + it('fails when dcd exited non-zero, whatever the status says', () => { + const verdict = evaluateRun(status({ status: 'PASSED' }), 2); + expect(verdict.exitCode).toBe(1); + expect(verdict.message).toContain('dcd exited 2, status PASSED'); + }); + + it('passes, with a warning, a non-terminal status after a clean exit', () => { + const verdict = evaluateRun(status({ status: 'RUNNING' }), 0); + expect(verdict.exitCode).toBe(0); + expect(verdict.uploadStatus).toBe('RUNNING'); + expect(verdict.message).toContain('Treating the run as passed'); + }); + + it('passes a superseded run and reports SUPERSEDED', () => { + // The API rolls the superseded run's cancelled tests up to FAILED. + expect( + evaluateRun(status({ status: 'FAILED', supersededBy: 'new-upload' }), 0) + ).toEqual({ + exitCode: 0, + uploadStatus: 'SUPERSEDED', + message: + 'Superseded by new-upload: a newer run from the same CI context ' + + 'replaced this one, so this job does not fail. Newer run: ' + + 'https://console.devicecloud.dev/results?upload=new-upload', + }); + }); + + it('passes a superseded run even when an older CLI exited 2 for it', () => { + const verdict = evaluateRun( + status({ status: 'FAILED', supersededBy: 'new-upload' }), + 2 + ); + expect(verdict.exitCode).toBe(0); + expect(verdict.uploadStatus).toBe('SUPERSEDED'); + }); + + it('treats an absent, null or empty supersededBy as not superseded', () => { + for (const supersededBy of [undefined, null, '']) { + const verdict = evaluateRun( + status({ status: 'FAILED', supersededBy }), + 0 + ); + expect(verdict.exitCode).toBe(1); + expect(verdict.uploadStatus).toBe('FAILED'); + } + }); +}); + +describe('supersedingConsoleUrl', () => { + it("swaps in the newer upload and drops this run's result link", () => { + expect(supersedingConsoleUrl(consoleUrl, 'new-upload')).toBe( + 'https://console.devicecloud.dev/results?upload=new-upload' + ); + }); + + it('returns undefined without a usable console URL', () => { + expect(supersedingConsoleUrl(undefined, 'new-upload')).toBeUndefined(); + expect(supersedingConsoleUrl('not a url', 'new-upload')).toBeUndefined(); + }); +}); diff --git a/src/methods/status.ts b/src/methods/status.ts new file mode 100644 index 0000000..566dfcc --- /dev/null +++ b/src/methods/status.ts @@ -0,0 +1,113 @@ +type Status = 'PASSED' | 'FAILED' | 'CANCELLED' | 'PENDING' | 'QUEUED' | 'RUNNING'; + +export interface TestResult { + name: string; + status: Status; +} + +/** What `dcd status --json` prints (the API's /uploads/status response). */ +export interface StatusResponse { + status: Status; + tests: TestResult[]; + consoleUrl?: string; + appBinaryId?: string; + /** + * The upload that replaced this one through --cancel-previous. Only set on + * a superseded run, and absent from APIs that predate it. + */ + supersededBy?: string | null; +} + +export type Verdict = { + /** The process exit code: 0 passes the EAS job, 1 fails it. */ + exitCode: 0 | 1; + /** The upload_status output. */ + uploadStatus: string; + message: string; +}; + +/** + * The console link for the run that superseded this one. The status call's + * consoleUrl deep-links one of THIS run's results, which the newer run does + * not contain, so swap the upload and drop the result (as the CLI does). + */ +export function supersedingConsoleUrl( + consoleUrl: string | undefined, + supersededBy: string +): string | undefined { + if (!consoleUrl) return undefined; + try { + const url = new URL(consoleUrl); + url.searchParams.set('upload', supersededBy); + url.searchParams.delete('result'); + return url.toString(); + } catch { + return undefined; + } +} + +/** + * Decide the job's outcome from the status call and the `dcd cloud` exit code. + * + * A superseded run passes, whatever else is true of it. A newer run from the + * same CI context cancelled its queued tests (--cancel-previous), and the API + * rolls those up to FAILED, but the run no longer speaks for the commit. + * Failing the job for it would fail it for work nobody is waiting on. + * `dcd cloud` 5.6.0 exits 0 for such a run; an older CLI exits 2. + * + * Otherwise, fail on either signal. The exit code is authoritative for a run + * that finished badly; the status call can only add failures the CLI could not + * see. A non-terminal status (PENDING/RUNNING) alongside a clean exit is a racy + * or degraded status call, not a failure: the CLI watched the run to + * completion, so warn rather than fail the job. + */ +export function evaluateRun( + result: StatusResponse, + cloudExitCode: number +): Verdict { + const supersededBy = + typeof result.supersededBy === 'string' ? result.supersededBy : ''; + if (supersededBy) { + const newer = supersedingConsoleUrl(result.consoleUrl, supersededBy); + return { + exitCode: 0, + uploadStatus: 'SUPERSEDED', + message: + `Superseded by ${supersededBy}: a newer run from the same CI context ` + + `replaced this one, so this job does not fail.` + + (newer ? ` Newer run: ${newer}` : ''), + }; + } + + const uploadStatus = result.status || 'PENDING'; + if (cloudExitCode !== 0) { + return { + exitCode: 1, + uploadStatus, + message: + `Test run failed (dcd exited ${cloudExitCode}, status ${result.status}). ` + + `Check flow results: ${result.consoleUrl}`, + }; + } + if (result.status === 'PASSED') { + return { + exitCode: 0, + uploadStatus, + message: 'Successfully completed test run.', + }; + } + if (result.status === 'FAILED' || result.status === 'CANCELLED') { + return { + exitCode: 1, + uploadStatus, + message: `Test run ${result.status}. Check flow results: ${result.consoleUrl}`, + }; + } + return { + exitCode: 0, + uploadStatus, + message: + `dcd reported success but the upload status is ${result.status}. ` + + `Treating the run as passed: ${result.consoleUrl}`, + }; +}