Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 8 additions & 39 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -183,42 +172,22 @@ const run = async (): Promise<void> => {
);

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,
status: test.status,
}));
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', '[]');
Expand Down
96 changes: 96 additions & 0 deletions src/methods/status.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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();
});
});
113 changes: 113 additions & 0 deletions src/methods/status.ts
Original file line number Diff line number Diff line change
@@ -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}`,
};
}
Loading