diff --git a/src/methods.ts b/src/methods.ts index 363f60d..3b576e4 100644 --- a/src/methods.ts +++ b/src/methods.ts @@ -1,6 +1,6 @@ import { ux } from './utils/progress'; import { createHash } from 'node:crypto'; -import { createReadStream, readdirSync, writeFileSync } from 'node:fs'; +import { createReadStream, mkdirSync, readdirSync, writeFileSync } from 'node:fs'; import { access, readFile } from 'node:fs/promises'; import * as path from 'node:path'; import * as StreamZip from 'node-stream-zip'; @@ -1064,6 +1064,10 @@ export const writeJSONFile = ( logger: { log: (message: string) => void; warn: (message: string) => void }, ) => { try { + const directory = path.dirname(filePath); + if (directory !== '.') { + mkdirSync(directory, { recursive: true }); + } writeFileSync(filePath, JSON.stringify(data, null, 2)); logger.log(colors.dim('JSON output written to: ') + colors.highlight(path.resolve(filePath))); } catch (error) { diff --git a/test/integration/artifacts.integration.test.ts b/test/integration/artifacts.integration.test.ts index 91fe572..30babf4 100644 --- a/test/integration/artifacts.integration.test.ts +++ b/test/integration/artifacts.integration.test.ts @@ -1,147 +1,156 @@ import { expect } from 'chai'; -import { exec as execCallback } from 'node:child_process'; -import { promisify } from 'node:util'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + CLI, + DEAD_API_URL, + MOCK_API_KEY, + MOCK_API_URL, + exec, + runExpectingFailure, +} from './helpers'; -const exec = promisify(execCallback); +describe('Artifacts Command Integration Tests', () => { + const mockApiUrl = MOCK_API_URL; + const mockApiKey = MOCK_API_KEY; + const mockUploadId = '123e4567-e89b-12d3-a456-426614174000'; + // Downloads write into cwd, so keep them out of the repo tree. + let tempDir: string; -const run = (args: string, env?: Record) => - exec(`./dist/index.js artifacts ${args}`, { - env: { ...process.env, ...env }, - timeout: 15_000, + before(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dcd-artifacts-test-')); }); -const errorOutput = (error: unknown): string => { - if (error && typeof error === 'object') { - if ('stderr' in error && typeof error.stderr === 'string' && error.stderr) return error.stderr; - if ('stdout' in error && typeof error.stdout === 'string' && error.stdout) return error.stdout; - } + after(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { force: true, recursive: true }); + } + }); - return ''; -}; + const run = (args: string, env?: Record) => + exec(`${CLI} artifacts ${args}`, { + cwd: tempDir, + env: { ...process.env, ...env }, + timeout: 15_000, + }); -describe('Artifacts Command Integration Tests', () => { - const mockApiUrl = 'http://localhost:3001'; - const mockApiKey = 'test-api-key-123'; - const mockUploadId = '123e4567-e89b-12d3-a456-426614174000'; + const runFailing = (args: string, env?: Record) => + runExpectingFailure(`${CLI} artifacts ${args}`, { + cwd: tempDir, + env: { ...process.env, ...env }, + }); describe('flag validation', () => { it('should require --upload-id', async () => { - try { - await run(`--download-artifacts FAILED --api-key ${mockApiKey} --api-url ${mockApiUrl}`); - expect.fail('Should have thrown'); - } catch (error) { - expect(errorOutput(error)).to.match(/upload-id/i); - } + const { output } = await runFailing( + `--download-artifacts FAILED --api-key ${mockApiKey} --api-url ${mockApiUrl}`, + ); + expect(output).to.match(/upload-id/i); }); it('should require either --download-artifacts or --report', async () => { - try { - await run(`--upload-id ${mockUploadId} --api-key ${mockApiKey} --api-url ${mockApiUrl}`); - expect.fail('Should have thrown'); - } catch (error) { - expect(errorOutput(error)).to.match(/download-artifacts|report/i); - } + const { output } = await runFailing( + `--upload-id ${mockUploadId} --api-key ${mockApiKey} --api-url ${mockApiUrl}`, + ); + expect(output).to.match(/download-artifacts|report/i); }); it('should reject --download-artifacts and --report together', async () => { - try { - await run( - `--upload-id ${mockUploadId} --download-artifacts FAILED --report junit --api-key ${mockApiKey} --api-url ${mockApiUrl}`, - ); - expect.fail('Should have thrown'); - } catch (error) { - expect(errorOutput(error)).to.match(/cannot also be provided/i); - } + const { output } = await runFailing( + `--upload-id ${mockUploadId} --download-artifacts FAILED --report junit --api-key ${mockApiKey} --api-url ${mockApiUrl}`, + ); + expect(output).to.match(/cannot also be provided/i); }); it('should reject --artifacts-path without --download-artifacts', async () => { - try { - await run( - `--upload-id ${mockUploadId} --artifacts-path ./out.zip --api-key ${mockApiKey} --api-url ${mockApiUrl}`, - ); - expect.fail('Should have thrown'); - } catch (error) { - expect(errorOutput(error)).to.match(/artifacts-path|download-artifacts/i); - } + const { output } = await runFailing( + `--upload-id ${mockUploadId} --artifacts-path ./out.zip --api-key ${mockApiKey} --api-url ${mockApiUrl}`, + ); + expect(output).to.match(/artifacts-path|download-artifacts/i); }); it('should reject --junit-path without --report', async () => { - try { - await run( - `--upload-id ${mockUploadId} --junit-path ./report.xml --api-key ${mockApiKey} --api-url ${mockApiUrl}`, - ); - expect.fail('Should have thrown'); - } catch (error) { - expect(errorOutput(error)).to.match(/junit-path|report/i); - } + const { output } = await runFailing( + `--upload-id ${mockUploadId} --junit-path ./report.xml --api-key ${mockApiKey} --api-url ${mockApiUrl}`, + ); + expect(output).to.match(/junit-path|report/i); }); it('should only accept ALL or FAILED for --download-artifacts', async () => { - try { - await run( - `--upload-id ${mockUploadId} --download-artifacts SOME --api-key ${mockApiKey} --api-url ${mockApiUrl}`, - ); - expect.fail('Should have thrown'); - } catch (error) { - expect(errorOutput(error)).to.match(/all|failed|expected.*to be one of/i); - } + const { output } = await runFailing( + `--upload-id ${mockUploadId} --download-artifacts SOME --api-key ${mockApiKey} --api-url ${mockApiUrl}`, + ); + expect(output).to.match(/all|failed|expected.*to be one of/i); }); }); describe('authentication', () => { it('should require an API key', async () => { - try { - await run( - `--upload-id ${mockUploadId} --download-artifacts FAILED --api-url ${mockApiUrl}`, - { DEVICE_CLOUD_API_KEY: '' }, - ); - expect.fail('Should have thrown'); - } catch (error) { - expect(errorOutput(error)).to.match(/api key/i); - } + const { output } = await runFailing( + `--upload-id ${mockUploadId} --download-artifacts FAILED --api-url ${mockApiUrl}`, + { DEVICE_CLOUD_API_KEY: '' }, + ); + expect(output).to.match(/api key/i); }); it('should accept API key from environment variable', async () => { - try { - await run( - `--upload-id ${mockUploadId} --download-artifacts FAILED --api-url ${mockApiUrl}`, - { DEVICE_CLOUD_API_KEY: mockApiKey }, - ); - } catch (error) { - // API unreachable is fine — the key was accepted if we don't see the key error - expect(errorOutput(error)).to.not.match(/api key is required/i); - } + // Download failures against the mock are warnings, not errors — the + // command exits 0 once the key is accepted (see download behaviour below). + const { stderr } = await run( + `--upload-id ${mockUploadId} --download-artifacts FAILED --api-url ${mockApiUrl}`, + { DEVICE_CLOUD_API_KEY: mockApiKey }, + ); + expect(stderr).to.not.match(/api key is required/i); + }); + }); + + describe('download behaviour against the mock API', () => { + // Prism can't serve the binary download endpoints, so the deterministic + // outcome is a warning and exit 0 — download failures must not fail the + // command or crash. + it('should warn and exit 0 when artifacts download fails', async () => { + const { stderr } = await run( + `--upload-id ${mockUploadId} --download-artifacts FAILED --api-key ${mockApiKey} --api-url ${mockApiUrl}`, + ); + expect(stderr).to.include('Failed to download artifacts'); + }); + + it('should download the junit report', async () => { + // Unlike the artifacts zip, Prism can serve the junit report endpoint. + const { stdout } = await run( + `--upload-id ${mockUploadId} --report junit --api-key ${mockApiKey} --api-url ${mockApiUrl}`, + ); + expect(stdout).to.include('JUNIT test report has been downloaded'); + expect(fs.existsSync(path.join(tempDir, 'report.xml'))).to.be.true; }); }); describe('network error handling', () => { it('should handle unreachable API gracefully for --download-artifacts', async () => { - try { - await run( - `--upload-id ${mockUploadId} --download-artifacts FAILED --api-key ${mockApiKey} --api-url http://localhost:9999`, - ); - } catch (error) { - // Should warn, not crash with an uncaught exception - const out = errorOutput(error); - expect(out).to.not.match(/typeerror|unhandledpromiserejection/i); - } + // Should warn, not crash with an uncaught exception + const { stderr, stdout } = await run( + `--upload-id ${mockUploadId} --download-artifacts FAILED --api-key ${mockApiKey} --api-url ${DEAD_API_URL}`, + ); + expect(stderr + stdout).to.not.match(/typeerror|unhandledpromiserejection/i); + expect(stderr).to.include('Failed to download artifacts'); }); it('should handle unreachable API gracefully for --report', async () => { - try { - await run( - `--upload-id ${mockUploadId} --report junit --api-key ${mockApiKey} --api-url http://localhost:9999`, - ); - } catch (error) { - const out = errorOutput(error); - expect(out).to.not.match(/typeerror|unhandledpromiserejection/i); - } + const { stderr, stdout } = await run( + `--upload-id ${mockUploadId} --report junit --api-key ${mockApiKey} --api-url ${DEAD_API_URL}`, + ); + expect(stderr + stdout).to.not.match(/typeerror|unhandledpromiserejection/i); + expect(stderr).to.match(/failed to download/i); }); }); describe('help', () => { it('should display help with all expected flags', async () => { - const { stdout } = await exec('./dist/index.js artifacts --help', { timeout: 10_000 }); + const { stdout } = await exec(`${CLI} artifacts --help`, { + timeout: 10_000, + }); expect(stdout).to.include('--upload-id'); expect(stdout).to.include('--download-artifacts'); expect(stdout).to.include('--artifacts-path'); diff --git a/test/integration/cloud.integration.test.ts b/test/integration/cloud.integration.test.ts index 8ae8bf3..a03543d 100644 --- a/test/integration/cloud.integration.test.ts +++ b/test/integration/cloud.integration.test.ts @@ -1,30 +1,21 @@ import { expect } from 'chai'; -import { exec as execCallback } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { promisify } from 'node:util'; -const exec = promisify(execCallback); - -function getErrorOutput(error: unknown): string { - if (!error || typeof error !== 'object') return ''; - - if ('stderr' in error && typeof error.stderr === 'string') { - return error.stderr; - } - - if ('stdout' in error && typeof error.stdout === 'string') { - return error.stdout; - } - - return ''; -} +import { + CLI, + MOCK_API_KEY, + MOCK_API_URL, + exec, + runExpectingFailure, +} from './helpers'; describe('DCD Cloud Command Integration Tests', () => { - const mockApiUrl = 'http://localhost:3001'; - const mockApiKey = 'test-api-key-123'; + const mockApiUrl = MOCK_API_URL; + const mockApiKey = MOCK_API_KEY; let tempDir: string; + let outputDir: string; let androidAppFile: string; let iosAppFile: string; let testFlowFile: string; @@ -33,6 +24,9 @@ describe('DCD Cloud Command Integration Tests', () => { before(async () => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dcd-test-')); + // Separate cwd for tests that write output files, so flow-directory tests + // never pick up JSON artifacts and nothing lands in the repo tree. + outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dcd-test-out-')); // Use real binary files androidAppFile = path.resolve('test/fixtures/wikipedia.apk'); @@ -63,397 +57,161 @@ appId: com.example.app }); after(() => { - if (fs.existsSync(tempDir)) { - fs.rmSync(tempDir, { force: true, recursive: true }); + for (const dir of [tempDir, outputDir]) { + if (fs.existsSync(dir)) { + fs.rmSync(dir, { force: true, recursive: true }); + } } }); + // The mock API serves Prism example responses, so a successful async run + // always yields this shape (string-typed example values). + const expectAsyncRunJson = (stdout: string) => { + const result = JSON.parse(stdout); + expect(result).to.have.property('uploadId'); + expect(result.uploadId).to.be.a('string'); + expect(result).to.have.property('status', 'PENDING'); + expect(result).to.have.property('tests'); + expect(result.tests).to.be.an('array').that.is.not.empty; + return result; + }; + describe('uploadFlow path', () => { it('should successfully upload Android flow with valid parameters', async () => { - const command = `./dist/index.js cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --async --json`; - - try { - const { stderr, stdout } = await exec(command, { - cwd: process.cwd(), - timeout: 30_000, - }); - - const result = JSON.parse(stdout); - expect(result).to.have.property('uploadId'); - expect(result).to.have.property('status', 'PENDING'); - expect(result).to.have.property('tests'); - expect(result.tests).to.be.an('array'); - expect(stderr).to.be.empty; - } catch (error: unknown) { - // Mock API may not have compatibility endpoint - verify command processes correctly - if ( - error && - typeof error === 'object' && - 'code' in error && - error.code === 1 && - 'stdout' in error && - typeof error.stdout === 'string' - ) { - const output = error.stdout; - // JSON error format or stderr text format - expect(output).to.match( - /compatibility|device.*data|failed to fetch|"oclif"|"error"|"status": "FAILED"|Submitting new job/i, - ); - } else { - throw error; - } - } + const command = `${CLI} cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --async --json`; + + const { stdout } = await exec(command, { timeout: 30_000 }); + expectAsyncRunJson(stdout); }); it('should successfully upload iOS flow with valid parameters', async () => { - const command = `./dist/index.js cloud ${iosAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --async --json`; - - try { - const { stderr, stdout } = await exec(command, { - cwd: process.cwd(), - timeout: 30_000, - }); - - const result = JSON.parse(stdout); - expect(result).to.have.property('uploadId'); - expect(result).to.have.property('status', 'PENDING'); - expect(result).to.have.property('tests'); - expect(result.tests).to.be.an('array'); - expect(stderr).to.be.empty; - } catch (error: unknown) { - // Mock API may not have compatibility endpoint - verify command processes correctly - if ( - error && - typeof error === 'object' && - 'code' in error && - error.code === 1 && - 'stdout' in error && - typeof error.stdout === 'string' - ) { - const output = error.stdout; - // JSON error format or stderr text format - expect(output).to.match( - /compatibility|device.*data|failed to fetch|"oclif"|"error"|"status": "FAILED"|Submitting new job/i, - ); - } else { - throw error; - } - } + const command = `${CLI} cloud ${iosAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --async --json`; + + const { stdout } = await exec(command, { timeout: 30_000 }); + expectAsyncRunJson(stdout); }); it('should handle invalid app file format', async () => { const invalidAppFile = path.join(tempDir, 'invalid-app.txt'); fs.writeFileSync(invalidAppFile, 'not an app file'); - const command = `./dist/index.js cloud ${invalidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl}`; + const command = `${CLI} cloud ${invalidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl}`; - try { - await exec(command, { timeout: 10_000 }); - expect.fail('Should have thrown an error for invalid app file'); - } catch (error: unknown) { - const errorOutput = getErrorOutput(error); - expect(errorOutput).to.match( - /app file must be|failed to fetch.*compatibility/i, - ); - } + const { output } = await runExpectingFailure(command); + expect(output).to.match(/App file must be/i); }); it('should validate required flow file parameter', async () => { - const command = `./dist/index.js cloud ${androidAppFile} --api-key ${mockApiKey} --api-url ${mockApiUrl}`; - - try { - await exec(command, { timeout: 10_000 }); - expect.fail('Should have thrown an error for missing flow file'); - } catch (error: unknown) { - const errorOutput = getErrorOutput(error); - expect(errorOutput).to.match( - /flow file|failed to fetch.*compatibility/i, - ); - } + const command = `${CLI} cloud ${androidAppFile} --api-key ${mockApiKey} --api-url ${mockApiUrl}`; + + const { output } = await runExpectingFailure(command); + expect(output).to.include('You must provide a flow file'); }); }); describe('authentication path', () => { it('should fail without API key', async () => { - const command = `./dist/index.js cloud ${androidAppFile} ${testFlowFile} --api-url ${mockApiUrl}`; - - try { - await exec(command, { timeout: 10_000 }); - expect.fail('Should have thrown an error for missing API key'); - } catch (error: unknown) { - const errorOutput = getErrorOutput(error); - expect(errorOutput).to.include('API key'); - } + const command = `${CLI} cloud ${androidAppFile} ${testFlowFile} --api-url ${mockApiUrl}`; + + const { output } = await runExpectingFailure(command); + expect(output).to.include('API key'); }); it('should accept API key from environment variable', async () => { - const command = `./dist/index.js cloud ${androidAppFile} ${testFlowFile} --api-url ${mockApiUrl} --async --json`; - - try { - const { stderr, stdout } = await exec(command, { - env: { ...process.env, DEVICE_CLOUD_API_KEY: mockApiKey }, - timeout: 30_000, - }); - - const result = JSON.parse(stdout); - expect(result).to.have.property('uploadId'); - expect(stderr).to.be.empty; - } catch (error: unknown) { - // If mock server returns expected error, verify it's not about missing API key - if ( - error && - typeof error === 'object' && - 'code' in error && - error.code === 1 && - 'stdout' in error && - typeof error.stdout === 'string' - ) { - const output = error.stdout; - expect(output).to.not.include('You must provide an API key'); - } else { - throw error; - } - } + const command = `${CLI} cloud ${androidAppFile} ${testFlowFile} --api-url ${mockApiUrl} --async --json`; + + const { stdout } = await exec(command, { + env: { ...process.env, DEVICE_CLOUD_API_KEY: mockApiKey }, + timeout: 30_000, + }); + expectAsyncRunJson(stdout); }); it('should handle authentication failure with invalid API key', async () => { - const command = `./dist/index.js cloud ${androidAppFile} ${testFlowFile} --api-key invalid-key --api-url ${mockApiUrl}`; - - try { - await exec(command, { timeout: 15_000 }); - expect.fail('Should have thrown an error for invalid API key'); - } catch (error: unknown) { - const errorOutput = getErrorOutput(error); - expect(errorOutput).to.match( - /compatibility|device.*data|failed to fetch/i, - ); - } + const command = `${CLI} cloud ${androidAppFile} ${testFlowFile} --api-key invalid-key --api-url ${mockApiUrl}`; + + const { code, output } = await runExpectingFailure(command); + expect(code).to.equal(1); + // The first authenticated call is the compatibility fetch, which + // surfaces the mock's 401. + expect(output).to.include('Failed to fetch device compatibility data'); + expect(output).to.include('401'); }); }); describe('device management path', () => { it('should accept valid iOS device configuration', async () => { - const command = `./dist/index.js cloud ${iosAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --ios-device iphone-14 --ios-version 17 --async --json`; - - try { - const { stderr, stdout } = await exec(command, { - timeout: 30_000, - }); - - const result = JSON.parse(stdout); - expect(result).to.have.property('uploadId'); - expect(stderr).to.be.empty; - } catch (error: unknown) { - // If mock server returns expected error, verify device config was accepted - if ( - error && - typeof error === 'object' && - 'code' in error && - error.code === 1 && - 'stdout' in error && - typeof error.stdout === 'string' - ) { - const output = error.stdout; - expect(output).to.not.include('Device'); - expect(output).to.not.include('not supported'); - } else { - throw error; - } - } + const command = `${CLI} cloud ${iosAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --ios-device iphone-14 --ios-version 17 --async --json`; + + const { stdout } = await exec(command, { timeout: 30_000 }); + expectAsyncRunJson(stdout); }); it('should accept valid Android device configuration', async () => { - const command = `./dist/index.js cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --android-device pixel-7 --android-api-level 34 --async --json`; - - try { - const { stderr, stdout } = await exec(command, { - timeout: 30_000, - }); - - const result = JSON.parse(stdout); - expect(result).to.have.property('uploadId'); - expect(stderr).to.be.empty; - } catch (error: unknown) { - // If mock server returns expected error, verify device config was accepted - if ( - error && - typeof error === 'object' && - 'code' in error && - error.code === 1 && - 'stdout' in error && - typeof error.stdout === 'string' - ) { - const output = error.stdout; - expect(output).to.not.include("don't support that device"); - } else { - throw error; - } - } + const command = `${CLI} cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --android-device pixel-7 --android-api-level 34 --async --json`; + + const { stdout } = await exec(command, { timeout: 30_000 }); + expectAsyncRunJson(stdout); }); it('should handle unsupported device configurations', async () => { - const command = `./dist/index.js cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --ios-device unsupported-device --ios-version 99`; - - try { - await exec(command, { timeout: 15_000 }); - expect.fail('Should have thrown an error for unsupported device'); - } catch (error: unknown) { - const errorOutput = getErrorOutput(error); - expect(errorOutput).to.match( - /not supported|unsupported|supported.*versions/i, - ); - } + const command = `${CLI} cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --ios-device unsupported-device --ios-version 99`; + + const { output } = await runExpectingFailure(command); + expect(output).to.include('Invalid value for --ios-device'); + expect(output).to.include('unsupported-device'); }); }); describe('device configuration options', () => { - it('should support all Android device options', async () => { - const command = `./dist/index.js cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --name test-android-devices --async`; + // Async non-JSON runs always reach submission against the mock API. + // `.include` keeps these robust to incidental extra lines (e.g. the + // new-version notice on dev machines). + const expectAsyncSubmission = (stdout: string) => { + expect(stdout).to.include('Submitting new job'); + expect(stdout).to.include('Not waiting for results as async flag is set'); + }; - try { - const { stdout } = await exec(command, { timeout: 15_000 }); - // May show version notification, then should run tests - expect(stdout).to.match(/Submitting new job|A new version/); - if (stdout.includes('Submitting new job')) { - expect(stdout).to.include( - 'Not waiting for results as async flag is set', - ); - } - } catch (error: unknown) { - if ( - error && - typeof error === 'object' && - 'stdout' in error && - typeof error.stdout === 'string' - ) { - const output = error.stdout; - if (output.includes('Submitting new job')) { - expect(true).to.be.true; - return; - } - } + it('should support all Android device options', async () => { + const command = `${CLI} cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --name test-android-devices --async`; - throw error; - } + const { stdout } = await exec(command, { timeout: 15_000 }); + expectAsyncSubmission(stdout); }); it('should support all iOS device options', async () => { - const command = `./dist/index.js cloud ${iosAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --name test-ios-devices --async`; + const command = `${CLI} cloud ${iosAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --name test-ios-devices --async`; - try { - const { stdout } = await exec(command, { timeout: 15_000 }); - // May show version notification, then should run tests - expect(stdout).to.match(/Submitting new job|A new version/); - if (stdout.includes('Submitting new job')) { - expect(stdout).to.include( - 'Not waiting for results as async flag is set', - ); - } - } catch (error: unknown) { - if ( - error && - typeof error === 'object' && - 'stdout' in error && - typeof error.stdout === 'string' - ) { - const output = error.stdout; - if (output.includes('Submitting new job')) { - expect(true).to.be.true; - return; - } - } - - throw error; - } + const { stdout } = await exec(command, { timeout: 15_000 }); + expectAsyncSubmission(stdout); }); it('should support device orientation options', async () => { - const command = `./dist/index.js cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --orientation 90 --name test-orientation --async`; - - try { - const { stdout } = await exec(command, { timeout: 15_000 }); - // May show version notification, then should run tests - expect(stdout).to.match(/Submitting new job|A new version/); - if (stdout.includes('Submitting new job')) { - expect(stdout).to.include( - 'Not waiting for results as async flag is set', - ); - } - } catch (error: unknown) { - if ( - error && - typeof error === 'object' && - 'stdout' in error && - typeof error.stdout === 'string' - ) { - const output = error.stdout; - if (output.includes('Submitting new job')) { - return; - } - } + const command = `${CLI} cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --orientation 90 --name test-orientation --async`; - throw error; - } + const { stdout } = await exec(command, { timeout: 15_000 }); + expectAsyncSubmission(stdout); }); it('should support device locale configuration', async () => { - const command = `./dist/index.js cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --device-locale en_US --name test-locale --async`; + const command = `${CLI} cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --device-locale en_US --name test-locale --async`; - try { - const { stdout } = await exec(command, { timeout: 15_000 }); - // May show version notification, then should run tests - expect(stdout).to.match(/Submitting new job|A new version/); - if (stdout.includes('Submitting new job')) { - expect(stdout).to.include( - 'Not waiting for results as async flag is set', - ); - } - } catch (error: unknown) { - if ( - error && - typeof error === 'object' && - 'stdout' in error && - typeof error.stdout === 'string' - ) { - const output = error.stdout; - if (output.includes('Submitting new job')) { - return; - } - } - - throw error; - } + const { stdout } = await exec(command, { timeout: 15_000 }); + expectAsyncSubmission(stdout); }); }); describe('advanced execution options', () => { - it('should support custom Maestro versions', async () => { - const command = `./dist/index.js cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --maestro-version 1.39.5 --name test-maestro-version --async`; + const expectAsyncSubmission = (stdout: string) => { + expect(stdout).to.include('Submitting new job'); + expect(stdout).to.include('Not waiting for results as async flag is set'); + }; - try { - const { stdout } = await exec(command, { timeout: 15_000 }); - // May show version notification, then should run tests - expect(stdout).to.match(/Submitting new job|A new version/); - if (stdout.includes('Submitting new job')) { - expect(stdout).to.include( - 'Not waiting for results as async flag is set', - ); - } - } catch (error: unknown) { - if ( - error && - typeof error === 'object' && - 'stdout' in error && - typeof error.stdout === 'string' - ) { - const output = error.stdout; - if (output.includes('Submitting new job')) { - return; - } - } + it('should support custom Maestro versions', async () => { + const command = `${CLI} cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --maestro-version 1.39.5 --name test-maestro-version --async`; - throw error; - } + const { stdout } = await exec(command, { timeout: 15_000 }); + expectAsyncSubmission(stdout); }); it('should support runner type options', async () => { @@ -461,128 +219,42 @@ appId: com.example.app // Run runner type tests sequentially to avoid overwhelming mock API for (const runnerType of runnerTypes) { - const command = `./dist/index.js cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --runner-type ${runnerType} --android-device pixel-6 --name test-runner-${runnerType} --async`; - - try { - const { stdout } = await exec(command, { timeout: 15_000 }); - // May show version notification, then should run tests - expect(stdout).to.match(/Submitting new job|A new version/); - if (stdout.includes('Submitting new job')) { - expect(stdout).to.include( - 'Not waiting for results as async flag is set', - ); - if (runnerType === 'm4') { - expect(stdout).to.include('experimental'); - } - } - } catch (error: unknown) { - if ( - error && - typeof error === 'object' && - 'stdout' in error && - typeof error.stdout === 'string' - ) { - const output = error.stdout; - if (output.includes('Submitting new job')) { - continue; - } - } - - throw error; + const command = `${CLI} cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --runner-type ${runnerType} --android-device pixel-6 --name test-runner-${runnerType} --async`; + + const { stdout } = await exec(command, { timeout: 15_000 }); + expectAsyncSubmission(stdout); + if (runnerType === 'm4') { + expect(stdout).to.include('experimental'); } } }); it('should support retry configuration', async () => { - const command = `./dist/index.js cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --retry 2 --android-device pixel-6 --name test-retry --async`; - - try { - const { stdout } = await exec(command, { timeout: 15_000 }); - // May show version notification, then should run tests - expect(stdout).to.match(/Submitting new job|A new version/); - if (stdout.includes('Submitting new job')) { - expect(stdout).to.include( - 'Not waiting for results as async flag is set', - ); - } - } catch (error: unknown) { - if ( - error && - typeof error === 'object' && - 'stdout' in error && - typeof error.stdout === 'string' - ) { - const output = error.stdout; - if (output.includes('Submitting new job')) { - return; - } - } + const command = `${CLI} cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --retry 2 --android-device pixel-6 --name test-retry --async`; - throw error; - } + const { stdout } = await exec(command, { timeout: 15_000 }); + expectAsyncSubmission(stdout); }); it('should limit retry attempts to maximum of 2', async () => { - const command = `./dist/index.js cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --retry 5 --android-device pixel-6 --async`; + const command = `${CLI} cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --retry 5 --android-device pixel-6 --async`; - try { - const { stdout } = await exec(command, { timeout: 15_000 }); - // Should show warning about retry limit and still run - expect(stdout).to.include('limited to 2'); - expect(stdout).to.include('Submitting new job'); - } catch (error: unknown) { - if ( - error && - typeof error === 'object' && - 'stdout' in error && - typeof error.stdout === 'string' - ) { - const output = error.stdout; - if ( - output.includes('limited to 2') || - output.includes('Submitting new job') - ) { - return; - } - } - - throw error; - } + const { stdout } = await exec(command, { timeout: 15_000 }); + expect(stdout).to.include('limited to 2'); + expect(stdout).to.include('Submitting new job'); }); it('should support report format options', async () => { - const command = `./dist/index.js cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --report junit --android-device pixel-6 --name test-report --async`; + const command = `${CLI} cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --report junit --android-device pixel-6 --name test-report --async`; - try { - const { stdout } = await exec(command, { timeout: 15_000 }); - // May show version notification, then should run tests - expect(stdout).to.match(/Submitting new job|A new version/); - if (stdout.includes('Submitting new job')) { - expect(stdout).to.include( - 'Not waiting for results as async flag is set', - ); - } - } catch (error: unknown) { - if ( - error && - typeof error === 'object' && - 'stdout' in error && - typeof error.stdout === 'string' - ) { - const output = error.stdout; - if (output.includes('Submitting new job')) { - return; - } - } - - throw error; - } + const { stdout } = await exec(command, { timeout: 15_000 }); + expectAsyncSubmission(stdout); }); }); describe('tag and flow filtering', () => { it('should support tag filtering', async () => { - const command = `./dist/index.js cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --include-tags smoke --exclude-tags slow --dry-run`; + const command = `${CLI} cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --include-tags smoke --exclude-tags slow --dry-run`; const { stdout } = await exec(command, { timeout: 15_000 }); expect(stdout).to.include('Dry run mode'); @@ -591,14 +263,14 @@ appId: com.example.app describe('file and binary management', () => { it('should support app binary ID instead of file', async () => { - const command = `./dist/index.js cloud --app-binary-id test-binary-123 ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --dry-run`; + const command = `${CLI} cloud --app-binary-id test-binary-123 ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --dry-run`; const { stdout } = await exec(command, { timeout: 15_000 }); expect(stdout).to.include('Dry run mode'); }); it('should support custom config file', async () => { - const command = `./dist/index.js cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --config ${basicConfigFile} --dry-run`; + const command = `${CLI} cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --config ${basicConfigFile} --dry-run`; const { stdout } = await exec(command, { timeout: 15_000 }); expect(stdout).to.include('Dry run mode'); @@ -608,7 +280,7 @@ appId: com.example.app }); it('should process config file tag filtering', async () => { - const command = `./dist/index.js cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --config ${tagFilteringConfigFile} --dry-run`; + const command = `${CLI} cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --config ${tagFilteringConfigFile} --dry-run`; const { stdout } = await exec(command, { timeout: 15_000 }); expect(stdout).to.include('Dry run mode'); @@ -624,7 +296,7 @@ appId: com.example.app 'TEST_VAR=test_value\nANOTHER_VAR=another_value', ); - const command = `./dist/index.js cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --env ${envFile} --dry-run`; + const command = `${CLI} cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --env ${envFile} --dry-run`; const { stdout } = await exec(command, { timeout: 15_000 }); expect(stdout).to.include('Dry run mode'); @@ -633,89 +305,29 @@ appId: com.example.app describe('output and debugging options', () => { it('should support quiet mode', async () => { - const command = `./dist/index.js cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --quiet --dry-run`; + const command = `${CLI} cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --quiet --dry-run`; const { stdout } = await exec(command, { timeout: 15_000 }); expect(stdout).to.include('Dry run mode'); - // In quiet mode, should have less verbose output }); it('should support debug mode', async () => { - const command = `./dist/index.js cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --debug --dry-run`; + const command = `${CLI} cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --debug --dry-run`; - try { - const { stdout } = await exec(command, { timeout: 15_000 }); - expect(stdout).to.include('[DEBUG]'); - expect(stdout).to.include('Dry run mode'); - } catch (error: unknown) { - if ( - error && - typeof error === 'object' && - 'stdout' in error && - typeof error.stdout === 'string' - ) { - const output = error.stdout; - if (output.includes('[DEBUG]')) { - expect(true).to.be.true; - return; - } - } - - throw error; - } + const { stdout } = await exec(command, { timeout: 15_000 }); + expect(stdout).to.include('[DEBUG]'); + expect(stdout).to.include('Dry run mode'); }); it('should support JSON output format', async () => { - const command = `./dist/index.js cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --json --async`; - - try { - const { stdout } = await exec(command, { timeout: 15_000 }); - const result = JSON.parse(stdout); - expect(result).to.have.property('uploadId'); - } catch (error: unknown) { - if ( - error && - typeof error === 'object' && - 'stdout' in error && - typeof error.stdout === 'string' - ) { - const output = error.stdout; - if (output.includes('"oclif"') || output.includes('"status": "FAILED"')) { - expect(true).to.be.true; - return; - } - } - - throw error; - } - }); - - it('should support JSON file output', async () => { - const command = `./dist/index.js cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --json-file --name test-run --async`; - - try { - const { stdout } = await exec(command, { timeout: 15_000 }); - expect(stdout).to.include('JSON output will be written to file'); - } catch (error: unknown) { - if ( - error && - typeof error === 'object' && - 'stdout' in error && - typeof error.stdout === 'string' - ) { - const output = error.stdout; - if (output.includes('JSON output will be written to file')) { - expect(true).to.be.true; - return; - } - } + const command = `${CLI} cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --json --async`; - throw error; - } + const { stdout } = await exec(command, { timeout: 15_000 }); + expectAsyncRunJson(stdout); }); it('should support custom naming', async () => { - const command = `./dist/index.js cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --name "My Test Run" --dry-run`; + const command = `${CLI} cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --name "My Test Run" --dry-run`; const { stdout } = await exec(command, { timeout: 15_000 }); expect(stdout).to.include('Dry run mode'); @@ -724,7 +336,7 @@ appId: com.example.app describe('advanced features', () => { it('should support Google Play and advanced options', async () => { - const command = `./dist/index.js cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --google-play --show-crosshairs --dry-run`; + const command = `${CLI} cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --google-play --show-crosshairs --dry-run`; const { stdout } = await exec(command, { timeout: 15_000 }); expect(stdout).to.include('Dry run mode'); @@ -753,32 +365,13 @@ tags: `, ); - const command = `./dist/index.js cloud ${androidAppFile} ${testFlowWithOverrides} --api-key ${mockApiKey} --api-url ${mockApiUrl} --debug --dry-run`; + const command = `${CLI} cloud ${androidAppFile} ${testFlowWithOverrides} --api-key ${mockApiKey} --api-url ${mockApiUrl} --debug --dry-run`; - try { - const { stdout } = await exec(command, { timeout: 15_000 }); - expect(stdout).to.include('Dry run mode'); - expect(stdout).to.include('[DEBUG]'); - - // In debug mode, the CLI should show that overrides are being processed - // The exact debug output format may vary, but it should contain references to the test file - expect(stdout).to.include('test-flow-with-overrides.yaml'); - } catch (error: unknown) { - if ( - error && - typeof error === 'object' && - 'stdout' in error && - typeof error.stdout === 'string' - ) { - const output = error.stdout; - if (output.includes('Dry run mode') && output.includes('[DEBUG]')) { - expect(output).to.include('test-flow-with-overrides.yaml'); - return; - } - } - - throw error; - } + const { stdout } = await exec(command, { timeout: 15_000 }); + expect(stdout).to.include('Dry run mode'); + expect(stdout).to.include('[DEBUG]'); + // In debug mode, the CLI should show the test file being processed + expect(stdout).to.include('test-flow-with-overrides.yaml'); }); it('should handle test files without device cloud overrides normally', async () => { @@ -800,28 +393,11 @@ tags: `, ); - const command = `./dist/index.js cloud ${androidAppFile} ${normalTestFlow} --api-key ${mockApiKey} --api-url ${mockApiUrl} --dry-run`; - - try { - const { stdout } = await exec(command, { timeout: 15_000 }); - expect(stdout).to.include('Dry run mode'); - expect(stdout).to.include('normal-test-flow.yaml'); - } catch (error: unknown) { - if ( - error && - typeof error === 'object' && - 'stdout' in error && - typeof error.stdout === 'string' - ) { - const output = error.stdout; - if (output.includes('Dry run mode')) { - expect(output).to.include('normal-test-flow.yaml'); - return; - } - } + const command = `${CLI} cloud ${androidAppFile} ${normalTestFlow} --api-key ${mockApiKey} --api-url ${mockApiUrl} --dry-run`; - throw error; - } + const { stdout } = await exec(command, { timeout: 15_000 }); + expect(stdout).to.include('Dry run mode'); + expect(stdout).to.include('normal-test-flow.yaml'); }); it('should process multiple test files with different override configurations', async () => { @@ -872,127 +448,84 @@ tags: ); // Test with a directory containing multiple flows - const testDir = tempDir; - const command = `./dist/index.js cloud ${androidAppFile} ${testDir} --api-key ${mockApiKey} --api-url ${mockApiUrl} --debug --dry-run`; + const command = `${CLI} cloud ${androidAppFile} ${tempDir} --api-key ${mockApiKey} --api-url ${mockApiUrl} --debug --dry-run`; - try { - const { stdout } = await exec(command, { timeout: 15_000 }); - expect(stdout).to.include('Dry run mode'); - expect(stdout).to.include('[DEBUG]'); - - // Should process all test files - expect(stdout).to.include('test-with-overrides-1.yaml'); - expect(stdout).to.include('test-with-overrides-2.yaml'); - expect(stdout).to.include('test-no-overrides.yaml'); - } catch (error: unknown) { - if ( - error && - typeof error === 'object' && - 'stdout' in error && - typeof error.stdout === 'string' - ) { - const output = error.stdout; - if (output.includes('Dry run mode') && output.includes('[DEBUG]')) { - // At least verify that the files are being processed - expect(true).to.be.true; - return; - } - } + const { stdout } = await exec(command, { timeout: 15_000 }); + expect(stdout).to.include('Dry run mode'); + expect(stdout).to.include('[DEBUG]'); - throw error; - } + // Should process all test files + expect(stdout).to.include('test-with-overrides-1.yaml'); + expect(stdout).to.include('test-with-overrides-2.yaml'); + expect(stdout).to.include('test-no-overrides.yaml'); }); }); describe('json-file-name functionality', () => { - it('should accept json-file flag and show expected message', async () => { - const command = `./dist/index.js cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --json-file --name test-json-default --async`; - - try { - const { stdout } = await exec(command, { timeout: 15_000 }); - expect(stdout).to.include('JSON output will be written to file'); - } catch (error: unknown) { - if ( - error && - typeof error === 'object' && - 'stdout' in error && - typeof error.stdout === 'string' - ) { - const output = error.stdout; - if (output.includes('JSON output will be written to file')) { - expect(true).to.be.true; - return; - } - } - - throw error; - } - }); - - it('should accept json-file-name with json-file flag', async () => { + // These run with cwd: outputDir so the written files are verified and + // never pollute the repo working tree. + const readWrittenJson = (filePath: string) => { + expect(fs.existsSync(filePath), `expected ${filePath} to exist`).to.be + .true; + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + }; + + it('should write JSON output to the default file', async () => { + const command = `${CLI} cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --json-file --name test-json-default --async`; + + const { stdout } = await exec(command, { + cwd: outputDir, + timeout: 15_000, + }); + expect(stdout).to.include('JSON output will be written to file'); + + // Default file name is _dcd.json. + const written = fs + .readdirSync(outputDir) + .filter((f) => f.endsWith('_dcd.json')); + expect(written).to.have.lengthOf(1); + const result = readWrittenJson(path.join(outputDir, written[0])); + expect(result).to.have.property('uploadId'); + expect(result).to.have.property('status', 'PENDING'); + }); + + it('should write JSON output to a custom file name', async () => { const customJsonFile = 'custom-output.json'; - const command = `./dist/index.js cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --json-file --json-file-name ${customJsonFile} --name test-json-custom --async`; + const command = `${CLI} cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --json-file --json-file-name ${customJsonFile} --name test-json-custom --async`; - try { - const { stdout } = await exec(command, { timeout: 15_000 }); - expect(stdout).to.include('JSON output will be written to file'); - // Command accepts the custom filename parameter without errors - } catch (error: unknown) { - if ( - error && - typeof error === 'object' && - 'stdout' in error && - typeof error.stdout === 'string' - ) { - const output = error.stdout; - if (output.includes('JSON output will be written to file')) { - expect(true).to.be.true; - return; - } - } + const { stdout } = await exec(command, { + cwd: outputDir, + timeout: 15_000, + }); + expect(stdout).to.include('JSON output will be written to file'); - throw error; - } + const result = readWrittenJson(path.join(outputDir, customJsonFile)); + expect(result).to.have.property('uploadId'); }); - it('should accept relative paths in json-file-name', async () => { + it('should write JSON output to a relative path', async () => { const customJsonFile = './output/results.json'; - const command = `./dist/index.js cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --json-file --json-file-name ${customJsonFile} --name test-json-path --async`; + const command = `${CLI} cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --json-file --json-file-name ${customJsonFile} --name test-json-path --async`; - try { - const { stdout } = await exec(command, { timeout: 15_000 }); - expect(stdout).to.include('JSON output will be written to file'); - // Command accepts the relative path parameter without errors - } catch (error: unknown) { - if ( - error && - typeof error === 'object' && - 'stdout' in error && - typeof error.stdout === 'string' - ) { - const output = error.stdout; - if (output.includes('JSON output will be written to file')) { - expect(true).to.be.true; - return; - } - } + const { stdout } = await exec(command, { + cwd: outputDir, + timeout: 15_000, + }); + expect(stdout).to.include('JSON output will be written to file'); - throw error; - } + const result = readWrittenJson( + path.join(outputDir, 'output', 'results.json'), + ); + expect(result).to.have.property('uploadId'); }); it('should fail when json-file-name is used without json-file flag', async () => { - const command = `./dist/index.js cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --json-file-name custom.json`; - - try { - await exec(command, { timeout: 15_000 }); - expect.fail('Command should have failed'); - } catch (error) { - const errorOutput = getErrorOutput(error); - // Citty port raises a CliError when --json-file-name is used without --json-file. - expect(errorOutput).to.match(/--json-file-name.*--json-file|must also provide/i); - expect(errorOutput).to.include('--json-file'); - } + const command = `${CLI} cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --json-file-name custom.json`; + + const { output } = await runExpectingFailure(command); + // Citty port raises a CliError when --json-file-name is used without --json-file. + expect(output).to.match(/--json-file-name.*--json-file|must also provide/i); + expect(output).to.include('--json-file'); }); }); }); diff --git a/test/integration/helpers.ts b/test/integration/helpers.ts new file mode 100644 index 0000000..d60eeb1 --- /dev/null +++ b/test/integration/helpers.ts @@ -0,0 +1,59 @@ +/** + * Shared utilities for the CLI integration suites. + * + * The mock API (Prism mocking swagger.json behind an API-key shim — see + * ../dcd/mock-api) is booted by scripts/test-runner.mjs before mocha starts, + * with readiness polling and an isolated DCD_CONFIG_DIR. Tests therefore + * assert the success path unconditionally: a dead or missing mock API must + * fail the suite, never soften it. + */ +import { exec as execCallback } from 'node:child_process'; +import * as path from 'node:path'; +import { promisify } from 'node:util'; + +export const exec = promisify(execCallback); + +/** Absolute path to the built CLI so tests can run with any cwd. */ +export const CLI = path.resolve('dist/index.js'); + +export const MOCK_API_URL = process.env.MOCK_API_URL ?? 'http://localhost:3001'; + +/** One of the keys accepted by the mock API's auth shim. */ +export const MOCK_API_KEY = 'test-api-key-123'; + +// TCP port 9 ("discard") is essentially never bound on dev or CI machines, +// so connections are refused immediately — simulates an unreachable API +// without depending on a magic unbound high port. +export const DEAD_API_URL = 'http://127.0.0.1:9'; + +export interface FailedExec { + code: number; + stdout: string; + stderr: string; + /** stderr if non-empty, otherwise stdout — wherever the CLI's error landed. */ + output: string; +} + +/** + * Run a command that must exit non-zero. Returns the captured output for + * assertions; throws if the command unexpectedly succeeds. Replaces the + * `expect.fail` inside try/catch pattern, which chai's own AssertionError + * could satisfy. + */ +export async function runExpectingFailure( + command: string, + opts: { cwd?: string; env?: NodeJS.ProcessEnv; timeout?: number } = {}, +): Promise { + try { + await exec(command, { timeout: 15_000, ...opts }); + } catch (error) { + const e = error as { code?: number; stdout?: string; stderr?: string }; + const stdout = typeof e.stdout === 'string' ? e.stdout : ''; + const stderr = typeof e.stderr === 'string' ? e.stderr : ''; + return { code: e.code ?? -1, stdout, stderr, output: stderr || stdout }; + } + + throw new Error( + `Expected command to exit non-zero but it succeeded: ${command}`, + ); +} diff --git a/test/integration/list.integration.test.ts b/test/integration/list.integration.test.ts index b46613c..b1ce934 100644 --- a/test/integration/list.integration.test.ts +++ b/test/integration/list.integration.test.ts @@ -1,418 +1,177 @@ import { expect } from 'chai'; -import { exec as execCallback } from 'node:child_process'; -import { promisify } from 'node:util'; -const exec = promisify(execCallback); +import { + CLI, + DEAD_API_URL, + MOCK_API_KEY, + MOCK_API_URL, + exec, + runExpectingFailure, +} from './helpers'; describe('List Command Integration Tests', () => { - const mockApiUrl = 'http://localhost:3001'; - const mockApiKey = 'test-api-key-123'; + const mockApiUrl = MOCK_API_URL; + const mockApiKey = MOCK_API_KEY; + + // The mock API returns Prism's example list response: one upload with + // total 1, limit 20, offset 0. + const expectListJson = (stdout: string) => { + const result = JSON.parse(stdout); + expect(result).to.have.property('uploads'); + expect(result.uploads).to.be.an('array').that.is.not.empty; + expect(result).to.have.property('total'); + expect(result).to.have.property('limit'); + expect(result).to.have.property('offset'); + return result; + }; describe('basic list functionality', () => { it('should require API key', async () => { - const command = `./dist/index.js list --api-url ${mockApiUrl}`; - - try { - await exec(command, { timeout: 10_000 }); - expect.fail('Should have thrown an error for missing API key'); - } catch (error: unknown) { - const errorOutput = - (error && - typeof error === 'object' && - 'stderr' in error && - typeof error.stderr === 'string' - ? error.stderr - : '') || - (error && - typeof error === 'object' && - 'stdout' in error && - typeof error.stdout === 'string' - ? error.stdout - : ''); - expect(errorOutput).to.include('API key'); - } + const command = `${CLI} list --api-url ${mockApiUrl}`; + + const { output } = await runExpectingFailure(command); + expect(output).to.include('API key'); }); it('should accept API key from environment variable', async () => { - const command = `./dist/index.js list --api-url ${mockApiUrl} --json`; - - try { - await exec(command, { - env: { ...process.env, DEVICE_CLOUD_API_KEY: mockApiKey }, - timeout: 15_000, - }); - // If it reaches here without error, the API key was accepted - } catch (error: unknown) { - if ( - error && - typeof error === 'object' && - 'code' in error && - error.code === 1 && - 'stdout' in error && - typeof error.stdout === 'string' - ) { - const output = error.stdout; - // Should not complain about missing API key - expect(output).to.not.include('You must provide an API key'); - } else { - throw error; - } - } + const command = `${CLI} list --api-url ${mockApiUrl} --json`; + + const { stdout } = await exec(command, { + env: { ...process.env, DEVICE_CLOUD_API_KEY: mockApiKey }, + timeout: 15_000, + }); + expectListJson(stdout); }); it('should accept API key from flag', async () => { - const command = `./dist/index.js list --api-key ${mockApiKey} --api-url ${mockApiUrl} --json`; - - try { - await exec(command, { timeout: 15_000 }); - } catch (error: unknown) { - if ( - error && - typeof error === 'object' && - 'code' in error && - error.code === 1 && - 'stdout' in error && - typeof error.stdout === 'string' - ) { - const output = error.stdout; - // Should not complain about missing API key - expect(output).to.not.include('API key is required'); - } else { - throw error; - } - } + const command = `${CLI} list --api-key ${mockApiKey} --api-url ${mockApiUrl} --json`; + + const { stdout } = await exec(command, { timeout: 15_000 }); + expectListJson(stdout); }); }); describe('filtering options', () => { it('should accept name filter with wildcard', async () => { - const command = `./dist/index.js list --name "nightly-*" --api-key ${mockApiKey} --api-url ${mockApiUrl} --json`; - - try { - await exec(command, { timeout: 15_000 }); - } catch (error: unknown) { - if ( - error && - typeof error === 'object' && - 'code' in error && - error.code === 1 && - 'stdout' in error && - typeof error.stdout === 'string' - ) { - const output = error.stdout; - // Should not complain about invalid name filter - expect(output).to.not.include('Invalid name'); - } else { - throw error; - } - } + const command = `${CLI} list --name "nightly-*" --api-key ${mockApiKey} --api-url ${mockApiUrl} --json`; + + const { stdout } = await exec(command, { timeout: 15_000 }); + expectListJson(stdout); }); it('should accept from date filter', async () => { - const command = `./dist/index.js list --from 2024-01-01 --api-key ${mockApiKey} --api-url ${mockApiUrl} --json`; - - try { - await exec(command, { timeout: 15_000 }); - } catch (error: unknown) { - if ( - error && - typeof error === 'object' && - 'code' in error && - error.code === 1 && - 'stdout' in error && - typeof error.stdout === 'string' - ) { - const output = error.stdout; - // Should not complain about invalid date - expect(output).to.not.include('Invalid --from date'); - } else { - throw error; - } - } + const command = `${CLI} list --from 2024-01-01 --api-key ${mockApiKey} --api-url ${mockApiUrl} --json`; + + const { stdout } = await exec(command, { timeout: 15_000 }); + expectListJson(stdout); }); it('should accept to date filter', async () => { - const command = `./dist/index.js list --to 2024-12-31 --api-key ${mockApiKey} --api-url ${mockApiUrl} --json`; - - try { - await exec(command, { timeout: 15_000 }); - } catch (error: unknown) { - if ( - error && - typeof error === 'object' && - 'code' in error && - error.code === 1 && - 'stdout' in error && - typeof error.stdout === 'string' - ) { - const output = error.stdout; - // Should not complain about invalid date - expect(output).to.not.include('Invalid --to date'); - } else { - throw error; - } - } + const command = `${CLI} list --to 2024-12-31 --api-key ${mockApiKey} --api-url ${mockApiUrl} --json`; + + const { stdout } = await exec(command, { timeout: 15_000 }); + expectListJson(stdout); }); it('should accept date range filters', async () => { - const command = `./dist/index.js list --from 2024-01-01 --to 2024-12-31 --api-key ${mockApiKey} --api-url ${mockApiUrl} --json`; - - try { - await exec(command, { timeout: 15_000 }); - } catch (error: unknown) { - if ( - error && - typeof error === 'object' && - 'code' in error && - error.code === 1 && - 'stdout' in error && - typeof error.stdout === 'string' - ) { - const output = error.stdout; - // Should not complain about dates - expect(output).to.not.include('Invalid'); - } else { - throw error; - } - } + const command = `${CLI} list --from 2024-01-01 --to 2024-12-31 --api-key ${mockApiKey} --api-url ${mockApiUrl} --json`; + + const { stdout } = await exec(command, { timeout: 15_000 }); + expectListJson(stdout); }); it('should reject invalid from date format', async () => { - const command = `./dist/index.js list --from "not-a-date" --api-key ${mockApiKey} --api-url ${mockApiUrl}`; - - try { - await exec(command, { timeout: 10_000 }); - expect.fail('Should have thrown an error for invalid date format'); - } catch (error: unknown) { - const errorOutput = - (error && - typeof error === 'object' && - 'stderr' in error && - typeof error.stderr === 'string' - ? error.stderr - : '') || - (error && - typeof error === 'object' && - 'stdout' in error && - typeof error.stdout === 'string' - ? error.stdout - : ''); - expect(errorOutput).to.match(/invalid.*from.*date|iso.*8601/i); - } + const command = `${CLI} list --from "not-a-date" --api-key ${mockApiKey} --api-url ${mockApiUrl}`; + + const { output } = await runExpectingFailure(command); + expect(output).to.include('Invalid --from date'); + expect(output).to.include('ISO 8601'); }); it('should reject invalid to date format', async () => { - const command = `./dist/index.js list --to "invalid" --api-key ${mockApiKey} --api-url ${mockApiUrl}`; - - try { - await exec(command, { timeout: 10_000 }); - expect.fail('Should have thrown an error for invalid date format'); - } catch (error: unknown) { - const errorOutput = - (error && - typeof error === 'object' && - 'stderr' in error && - typeof error.stderr === 'string' - ? error.stderr - : '') || - (error && - typeof error === 'object' && - 'stdout' in error && - typeof error.stdout === 'string' - ? error.stdout - : ''); - expect(errorOutput).to.match(/invalid.*to.*date|iso.*8601/i); - } + const command = `${CLI} list --to "invalid" --api-key ${mockApiKey} --api-url ${mockApiUrl}`; + + const { output } = await runExpectingFailure(command); + expect(output).to.include('Invalid --to date'); + expect(output).to.include('ISO 8601'); }); }); describe('pagination options', () => { it('should accept limit parameter', async () => { - const command = `./dist/index.js list --limit 10 --api-key ${mockApiKey} --api-url ${mockApiUrl} --json`; - - try { - await exec(command, { timeout: 15_000 }); - } catch (error: unknown) { - if ( - error && - typeof error === 'object' && - 'code' in error && - error.code === 1 && - 'stdout' in error && - typeof error.stdout === 'string' - ) { - const output = error.stdout; - // Should not complain about invalid limit - expect(output).to.not.include('Invalid limit'); - } else { - throw error; - } - } + const command = `${CLI} list --limit 10 --api-key ${mockApiKey} --api-url ${mockApiUrl} --json`; + + const { stdout } = await exec(command, { timeout: 15_000 }); + expectListJson(stdout); }); it('should accept offset parameter', async () => { - const command = `./dist/index.js list --offset 20 --api-key ${mockApiKey} --api-url ${mockApiUrl} --json`; - - try { - await exec(command, { timeout: 15_000 }); - } catch (error: unknown) { - if ( - error && - typeof error === 'object' && - 'code' in error && - error.code === 1 && - 'stdout' in error && - typeof error.stdout === 'string' - ) { - const output = error.stdout; - // Should not complain about invalid offset - expect(output).to.not.include('Invalid offset'); - } else { - throw error; - } - } + const command = `${CLI} list --offset 20 --api-key ${mockApiKey} --api-url ${mockApiUrl} --json`; + + const { stdout } = await exec(command, { timeout: 15_000 }); + expectListJson(stdout); + }); + + it('should reject a non-numeric limit', async () => { + const command = `${CLI} list --limit ten --api-key ${mockApiKey} --api-url ${mockApiUrl}`; + + const { output } = await runExpectingFailure(command); + expect(output).to.match(/invalid integer value for --limit/i); }); it('should use default limit of 20', async () => { - const command = `./dist/index.js list --api-key ${mockApiKey} --api-url ${mockApiUrl} --json`; - - try { - const { stdout } = await exec(command, { timeout: 15_000 }); - const result = JSON.parse(stdout); - expect(result.limit).to.equal(20); - } catch (error: unknown) { - // Mock API may not be available - this is acceptable - if ( - error && - typeof error === 'object' && - 'code' in error && - error.code === 1 - ) { - // Expected when mock API is not running - } else { - throw error; - } - } + const command = `${CLI} list --api-key ${mockApiKey} --api-url ${mockApiUrl} --json`; + + const { stdout } = await exec(command, { timeout: 15_000 }); + const result = expectListJson(stdout); + expect(result.limit).to.equal(20); }); }); describe('output formats', () => { it('should support JSON output format', async () => { - const command = `./dist/index.js list --api-key ${mockApiKey} --api-url ${mockApiUrl} --json`; - - try { - const { stdout } = await exec(command, { timeout: 15_000 }); - expect(() => JSON.parse(stdout)).to.not.throw(); - const result = JSON.parse(stdout); - expect(result).to.have.property('uploads'); - expect(result).to.have.property('total'); - expect(result).to.have.property('limit'); - expect(result).to.have.property('offset'); - } catch (error: unknown) { - // Mock API may not have the endpoint or return proper response - if ( - error && - typeof error === 'object' && - 'code' in error && - error.code === 1 - ) { - const stdout = - error && typeof error === 'object' && 'stdout' in error - ? String(error.stdout) - : ''; - const stderr = - error && typeof error === 'object' && 'stderr' in error - ? String(error.stderr) - : ''; - const output = stdout + stderr; - // Accept various error formats from network issues or missing mock endpoint - expect(output).to.match( - /failed to list|network error|fetch|error|405|no.*method/i, - ); - } else { - throw error; - } - } + const command = `${CLI} list --api-key ${mockApiKey} --api-url ${mockApiUrl} --json`; + + const { stdout } = await exec(command, { timeout: 15_000 }); + expectListJson(stdout); }); it('should support table output format by default', async () => { - const command = `./dist/index.js list --api-key ${mockApiKey} --api-url ${mockApiUrl}`; - - try { - const { stdout } = await exec(command, { timeout: 15_000 }); - // Should contain table-like output - expect(stdout).to.match(/upload|recent|id|created/i); - } catch (error: unknown) { - // Mock API may not have the endpoint or return proper response - // Command exits with non-zero code, which throws an error - const stdout = - error && typeof error === 'object' && 'stdout' in error - ? String(error.stdout) - : ''; - const stderr = - error && typeof error === 'object' && 'stderr' in error - ? String(error.stderr) - : ''; - const output = stdout + stderr; - // Accept various error formats from network issues or missing mock endpoint - expect(output).to.match( - /failed to list|network error|fetch|error|405|no.*method/i, - ); - } + const command = `${CLI} list --api-key ${mockApiKey} --api-url ${mockApiUrl}`; + + const { stdout } = await exec(command, { timeout: 15_000 }); + expect(stdout).to.include('Recent Uploads'); + expect(stdout).to.match(/Showing \d+ of \d+ uploads/); }); }); describe('error handling', () => { it('should handle network failures gracefully', async () => { - const command = `./dist/index.js list --api-key ${mockApiKey} --api-url http://localhost:9999`; - - try { - await exec(command, { timeout: 30_000 }); - expect.fail('Should have failed due to network error'); - } catch (error: unknown) { - const errorOutput = - (error && - typeof error === 'object' && - 'stderr' in error && - typeof error.stderr === 'string' - ? error.stderr - : '') || - (error && - typeof error === 'object' && - 'stdout' in error && - typeof error.stdout === 'string' - ? error.stdout - : ''); - expect(errorOutput).to.match(/network|failed|error|fetch/i); - } + const command = `${CLI} list --api-key ${mockApiKey} --api-url ${DEAD_API_URL}`; + + const { code, output } = await runExpectingFailure(command, { + timeout: 30_000, + }); + expect(code).to.equal(1); + expect(output).to.include('Network request failed'); }); it('should handle invalid API key', async () => { - const command = `./dist/index.js list --api-key invalid-key --api-url ${mockApiUrl} --json`; - - try { - await exec(command, { timeout: 15_000 }); - } catch (error: unknown) { - if ( - error && - typeof error === 'object' && - 'stdout' in error && - typeof error.stdout === 'string' - ) { - const output = error.stdout; - expect(output).to.match( - /failed to list|unauthorized|authentication|invalid|error/i, - ); - } else { - throw error; - } - } + const command = `${CLI} list --api-key invalid-key --api-url ${mockApiUrl} --json`; + + const { code, stdout } = await runExpectingFailure(command); + expect(code).to.equal(1); + // --json failures are emitted as JSON on stdout. + const result = JSON.parse(stdout); + expect(result).to.have.property('error'); + expect(result.error).to.include('Failed to list uploads'); + expect(result.error).to.include('Authentication failed'); }); }); describe('help and validation', () => { it('should show help information', async () => { - const command = `./dist/index.js list --help`; + const command = `${CLI} list --help`; const { stdout } = await exec(command, { timeout: 10_000 }); expect(stdout).to.include('List recent flow uploads'); @@ -426,7 +185,7 @@ describe('List Command Integration Tests', () => { }); it('should show usage line in help', async () => { - const command = `./dist/index.js list --help`; + const command = `${CLI} list --help`; const { stdout } = await exec(command, { timeout: 10_000 }); expect(stdout).to.match(/USAGE/i); @@ -435,26 +194,10 @@ describe('List Command Integration Tests', () => { describe('combined filters', () => { it('should accept multiple filters together', async () => { - const command = `./dist/index.js list --name "test-*" --from 2024-01-01 --to 2024-12-31 --limit 10 --offset 0 --api-key ${mockApiKey} --api-url ${mockApiUrl} --json`; - - try { - await exec(command, { timeout: 15_000 }); - } catch (error: unknown) { - if ( - error && - typeof error === 'object' && - 'code' in error && - error.code === 1 && - 'stdout' in error && - typeof error.stdout === 'string' - ) { - const output = error.stdout; - // Should not complain about any filter being invalid - expect(output).to.not.include('Invalid'); - } else { - throw error; - } - } + const command = `${CLI} list --name "test-*" --from 2024-01-01 --to 2024-12-31 --limit 10 --offset 0 --api-key ${mockApiKey} --api-url ${mockApiUrl} --json`; + + const { stdout } = await exec(command, { timeout: 15_000 }); + expectListJson(stdout); }); }); }); diff --git a/test/integration/status.integration.test.ts b/test/integration/status.integration.test.ts index 4012cb3..99db8c2 100644 --- a/test/integration/status.integration.test.ts +++ b/test/integration/status.integration.test.ts @@ -1,203 +1,125 @@ import { expect } from 'chai'; -import { exec as execCallback } from 'node:child_process'; -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { promisify } from 'node:util'; -const exec = promisify(execCallback); +import { + CLI, + DEAD_API_URL, + MOCK_API_KEY, + MOCK_API_URL, + exec, + runExpectingFailure, +} from './helpers'; describe('Status Command Integration Tests', () => { - const mockApiUrl = 'http://localhost:3001'; - const mockApiKey = 'test-api-key-123'; - let tempDir: string; - - before(async () => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dcd-status-test-')); - }); - - after(() => { - if (fs.existsSync(tempDir)) { - fs.rmSync(tempDir, { force: true, recursive: true }); - } - }); + const mockApiUrl = MOCK_API_URL; + const mockApiKey = MOCK_API_KEY; + + // The mock API returns Prism's example status response. + const expectStatusJson = (stdout: string) => { + const result = JSON.parse(stdout); + expect(result).to.have.property('uploadId'); + expect(result.uploadId).to.be.a('string'); + expect(result).to.have.property('status'); + expect(result).to.have.property('tests'); + expect(result.tests).to.be.an('array'); + return result; + }; describe('basic status functionality', () => { it('should require API key', async () => { - const command = `./dist/index.js status --upload-id test-123 --api-url ${mockApiUrl}`; - - try { - await exec(command, { timeout: 10_000 }); - expect.fail('Should have thrown an error for missing API key'); - } catch (error: unknown) { - const errorOutput = (error && typeof error === 'object' && 'stderr' in error && typeof error.stderr === 'string' ? error.stderr : '') || - (error && typeof error === 'object' && 'stdout' in error && typeof error.stdout === 'string' ? error.stdout : ''); - expect(errorOutput).to.include('API key'); - } + const command = `${CLI} status --upload-id test-123 --api-url ${mockApiUrl}`; + + const { output } = await runExpectingFailure(command); + expect(output).to.include('API key'); }); it('should accept API key from environment variable', async () => { - const command = `./dist/index.js status --upload-id test-123 --api-url ${mockApiUrl} --json`; - - try { - await exec(command, { - env: { ...process.env, DEVICE_CLOUD_API_KEY: mockApiKey }, - timeout: 15_000 - }); - // If it reaches here without error, the API key was accepted - } catch (error: unknown) { - if (error && typeof error === 'object' && 'code' in error && error.code === 1 && 'stdout' in error && typeof error.stdout === 'string') { - const output = error.stdout; - // Should not complain about missing API key - expect(output).to.not.include('You must provide an API key'); - } else { - throw error; - } - } + const command = `${CLI} status --upload-id test-123 --api-url ${mockApiUrl} --json`; + + const { stdout } = await exec(command, { + env: { ...process.env, DEVICE_CLOUD_API_KEY: mockApiKey }, + timeout: 15_000, + }); + expectStatusJson(stdout); }); it('should require either upload-id or name parameter', async () => { - const command = `./dist/index.js status --api-key ${mockApiKey} --api-url ${mockApiUrl}`; - - try { - await exec(command, { timeout: 10_000 }); - expect.fail('Should have thrown an error for missing upload-id or name'); - } catch (error: unknown) { - const errorOutput = (error && typeof error === 'object' && 'stderr' in error && typeof error.stderr === 'string' ? error.stderr : '') || - (error && typeof error === 'object' && 'stdout' in error && typeof error.stdout === 'string' ? error.stdout : ''); - expect(errorOutput).to.match(/upload.*id|name.*required/i); - } + const command = `${CLI} status --api-key ${mockApiKey} --api-url ${mockApiUrl}`; + + const { output } = await runExpectingFailure(command); + expect(output).to.include('Either --name or --upload-id must be provided'); }); it('should handle status check by upload ID', async () => { - const command = `./dist/index.js status --upload-id test-upload-123 --api-key ${mockApiKey} --api-url ${mockApiUrl} --json`; - - try { - const { stdout } = await exec(command, { timeout: 15_000 }); - const result = JSON.parse(stdout); - expect(result).to.have.property('status'); - } catch (error: unknown) { - // Mock API may not be fully configured - verify command processes correctly - if (error && typeof error === 'object' && 'code' in error && error.code === 1 && 'stdout' in error && typeof error.stdout === 'string') { - const output = error.stdout; - expect(output).to.match(/failed to get status|network error|fetch.*status/i); - } else { - throw error; - } - } + const command = `${CLI} status --upload-id test-upload-123 --api-key ${mockApiKey} --api-url ${mockApiUrl} --json`; + + const { stdout } = await exec(command, { timeout: 15_000 }); + expectStatusJson(stdout); }); it('should handle status check by name', async () => { - const command = `./dist/index.js status --name test-run-name --api-key ${mockApiKey} --api-url ${mockApiUrl} --json`; - - try { - const { stdout } = await exec(command, { timeout: 15_000 }); - const result = JSON.parse(stdout); - expect(result).to.have.property('status'); - } catch (error: unknown) { - // Mock API may not be fully configured - verify command processes correctly - if (error && typeof error === 'object' && 'code' in error && error.code === 1 && 'stdout' in error && typeof error.stdout === 'string') { - const output = error.stdout; - expect(output).to.match(/failed to get status|network error|fetch.*status/i); - } else { - throw error; - } - } + const command = `${CLI} status --name test-run-name --api-key ${mockApiKey} --api-url ${mockApiUrl} --json`; + + const { stdout } = await exec(command, { timeout: 15_000 }); + expectStatusJson(stdout); }); it('should reject both upload-id and name parameters', async () => { - const command = `./dist/index.js status --upload-id test-123 --name test-name --api-key ${mockApiKey} --api-url ${mockApiUrl}`; - - try { - await exec(command, { timeout: 10_000 }); - expect.fail('Should have thrown an error for providing both upload-id and name'); - } catch (error: unknown) { - const errorOutput = (error && typeof error === 'object' && 'stderr' in error && typeof error.stderr === 'string' ? error.stderr : '') || - (error && typeof error === 'object' && 'stdout' in error && typeof error.stdout === 'string' ? error.stdout : ''); - expect(errorOutput).to.match(/cannot.*also.*provided|exclusive/i); - } + const command = `${CLI} status --upload-id test-123 --name test-name --api-key ${mockApiKey} --api-url ${mockApiUrl}`; + + const { output } = await runExpectingFailure(command); + expect(output).to.include('Cannot provide both --name and --upload-id'); + expect(output).to.include('mutually exclusive'); }); }); describe('output formats', () => { it('should support JSON output format', async () => { - const command = `./dist/index.js status --upload-id test-123 --api-key ${mockApiKey} --api-url ${mockApiUrl} --json`; - - try { - const { stdout } = await exec(command, { timeout: 15_000 }); - expect(() => JSON.parse(stdout)).to.not.throw(); - } catch (error: unknown) { - // Mock API may not return proper response - check that JSON flag was processed - if (error && typeof error === 'object' && 'code' in error && error.code === 1 && 'stdout' in error && typeof error.stdout === 'string') { - const output = error.stdout; - // Should be attempting JSON format even if it fails - expect(output).to.match(/failed to get status|network error|fetch.*status|{.*}/i); - } else { - throw error; - } - } + const command = `${CLI} status --upload-id test-123 --api-key ${mockApiKey} --api-url ${mockApiUrl} --json`; + + const { stdout } = await exec(command, { timeout: 15_000 }); + expectStatusJson(stdout); }); it('should support table output format by default', async () => { - const command = `./dist/index.js status --upload-id test-123 --api-key ${mockApiKey} --api-url ${mockApiUrl}`; - - try { - const { stdout } = await exec(command, { timeout: 15_000 }); - // Should contain table-like output - expect(stdout).to.match(/status|test|duration/i); - } catch (error: unknown) { - // Mock API may not return proper response - verify non-JSON output attempt - if (error && typeof error === 'object' && 'code' in error && error.code === 1 && 'stdout' in error && typeof error.stdout === 'string') { - const output = error.stdout; - expect(output).to.match(/failed to get status|network error|fetch.*status/i); - // Should not be JSON format - expect(() => JSON.parse(output)).to.throw(); - } else { - throw error; - } - } + const command = `${CLI} status --upload-id test-123 --api-key ${mockApiKey} --api-url ${mockApiUrl}`; + + const { stdout } = await exec(command, { timeout: 15_000 }); + expect(stdout).to.include('Upload Status'); + expect(stdout).to.include('Test Results'); + // Should not be JSON format + expect(() => JSON.parse(stdout)).to.throw(); }); }); describe('error handling and retries', () => { it('should handle network failures with retries', async () => { - const command = `./dist/index.js status --upload-id test-123 --api-key ${mockApiKey} --api-url http://localhost:9999`; - - try { - await exec(command, { timeout: 30_000 }); - expect.fail('Should have failed due to network error'); - } catch (error: unknown) { - const errorOutput = (error && typeof error === 'object' && 'stderr' in error && typeof error.stderr === 'string' ? error.stderr : '') || - (error && typeof error === 'object' && 'stdout' in error && typeof error.stdout === 'string' ? error.stdout : ''); - expect(errorOutput).to.match(/network error|failed.*attempts|retrying/i); - } + const command = `${CLI} status --upload-id test-123 --api-key ${mockApiKey} --api-url ${DEAD_API_URL}`; + + // Retries with backoff take ~10s before giving up. + const { code, output } = await runExpectingFailure(command, { + timeout: 30_000, + }); + expect(code).to.equal(1); + expect(output).to.include('Network request failed'); }); - it('should handle invalid API key', async () => { - const command = `./dist/index.js status --upload-id test-123 --api-key invalid-key --api-url ${mockApiUrl} --json`; - - try { - const { stdout } = await exec(command, { timeout: 15_000 }); - const result = JSON.parse(stdout); - expect(result).to.have.property('error'); - expect(result.error).to.match(/failed to get status|unauthorized|authentication|invalid api key/i); - } catch (error: unknown) { - // Command might exit with non-zero code but still return JSON - if (error && typeof error === 'object' && 'stdout' in error && typeof error.stdout === 'string') { - const result = JSON.parse(error.stdout); - expect(result).to.have.property('error'); - expect(result.error).to.match(/failed to get status|unauthorized|authentication|invalid api key/i); - } else { - throw error; - } - } + it('should report invalid API key errors in the JSON output', async () => { + const command = `${CLI} status --upload-id test-123 --api-key invalid-key --api-url ${mockApiUrl} --json`; + + // status --json reports errors in-band (JSON on stdout, exit 0) and + // does not retry client errors — `attempts` stays at 1. + const { stdout } = await exec(command, { timeout: 15_000 }); + const result = JSON.parse(stdout); + expect(result).to.have.property('status', 'FAILED'); + expect(result.error).to.include('Authentication failed'); + expect(result).to.have.property('attempts', 1); }); }); describe('help and validation', () => { it('should show help information', async () => { - const command = `./dist/index.js status --help`; - + const command = `${CLI} status --help`; + const { stdout } = await exec(command, { timeout: 10_000 }); expect(stdout).to.include('Get the status of an upload'); expect(stdout).to.include('--upload-id'); @@ -205,17 +127,11 @@ describe('Status Command Integration Tests', () => { expect(stdout).to.include('--api-key'); }); - it('should validate upload-id format', async () => { - const command = `./dist/index.js status --upload-id "" --api-key ${mockApiKey} --api-url ${mockApiUrl}`; - - try { - await exec(command, { timeout: 10_000 }); - expect.fail('Should have failed due to empty upload-id'); - } catch (error: unknown) { - const errorOutput = (error && typeof error === 'object' && 'stderr' in error && typeof error.stderr === 'string' ? error.stderr : '') || - (error && typeof error === 'object' && 'stdout' in error && typeof error.stdout === 'string' ? error.stdout : ''); - expect(errorOutput).to.match(/upload.*id|required|empty/i); - } + it('should reject an empty upload-id', async () => { + const command = `${CLI} status --upload-id "" --api-key ${mockApiKey} --api-url ${mockApiUrl}`; + + const { output } = await runExpectingFailure(command); + expect(output).to.include('Either --name or --upload-id must be provided'); }); }); -}); \ No newline at end of file +}); diff --git a/test/integration/upload.integration.test.ts b/test/integration/upload.integration.test.ts index 19fe69a..8e970ca 100644 --- a/test/integration/upload.integration.test.ts +++ b/test/integration/upload.integration.test.ts @@ -1,22 +1,27 @@ import { expect } from 'chai'; -import { exec as execCallback } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { promisify } from 'node:util'; -const exec = promisify(execCallback); +import { + CLI, + DEAD_API_URL, + MOCK_API_KEY, + MOCK_API_URL, + exec, + runExpectingFailure, +} from './helpers'; describe('Upload Command Integration Tests', () => { - const mockApiUrl = 'http://localhost:3001'; - const mockApiKey = 'test-api-key-123'; + const mockApiUrl = MOCK_API_URL; + const mockApiKey = MOCK_API_KEY; let tempDir: string; let androidAppFile: string; let iosAppFile: string; before(async () => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dcd-upload-test-')); - + // Use real binary files androidAppFile = path.resolve('test/fixtures/wikipedia.apk'); iosAppFile = path.resolve('test/fixtures/wikipedia.zip'); @@ -28,51 +33,38 @@ describe('Upload Command Integration Tests', () => { } }); + // Against the mock API the SHA dedup check always matches (Prism example + // response), so a default upload short-circuits to an existing binary id. + const expectUploadJson = (stdout: string) => { + const result = JSON.parse(stdout); + expect(result).to.have.property('appBinaryId'); + expect(result.appBinaryId).to.be.a('string'); + return result; + }; + describe('basic upload functionality', () => { it('should require API key', async () => { - const command = `./dist/index.js upload ${androidAppFile} --api-url ${mockApiUrl}`; - - try { - await exec(command, { timeout: 10_000 }); - expect.fail('Should have thrown an error for missing API key'); - } catch (error: unknown) { - const errorOutput = (error && typeof error === 'object' && 'stderr' in error && typeof error.stderr === 'string' ? error.stderr : '') || - (error && typeof error === 'object' && 'stdout' in error && typeof error.stdout === 'string' ? error.stdout : ''); - expect(errorOutput).to.include('API key'); - } + const command = `${CLI} upload ${androidAppFile} --api-url ${mockApiUrl}`; + + const { output } = await runExpectingFailure(command); + expect(output).to.include('API key'); }); it('should accept API key from environment variable', async () => { - const command = `./dist/index.js upload ${androidAppFile} --api-url ${mockApiUrl} --json`; - - try { - await exec(command, { - env: { ...process.env, DEVICE_CLOUD_API_KEY: mockApiKey }, - timeout: 15_000 - }); - // If it reaches here without error, the API key was accepted - } catch (error: unknown) { - if (error && typeof error === 'object' && 'code' in error && error.code === 1 && 'stdout' in error && typeof error.stdout === 'string') { - const output = error.stdout; - // Should not complain about missing API key - expect(output).to.not.include('You must provide an API key'); - } else { - throw error; - } - } + const command = `${CLI} upload ${androidAppFile} --api-url ${mockApiUrl} --json`; + + const { stdout } = await exec(command, { + env: { ...process.env, DEVICE_CLOUD_API_KEY: mockApiKey }, + timeout: 15_000, + }); + expectUploadJson(stdout); }); it('should require app file argument', async () => { - const command = `./dist/index.js upload --api-key ${mockApiKey} --api-url ${mockApiUrl}`; - - try { - await exec(command, { timeout: 10_000 }); - expect.fail('Should have thrown an error for missing app file'); - } catch (error: unknown) { - const errorOutput = (error && typeof error === 'object' && 'stderr' in error && typeof error.stderr === 'string' ? error.stderr : '') || - (error && typeof error === 'object' && 'stdout' in error && typeof error.stdout === 'string' ? error.stdout : ''); - expect(errorOutput).to.match(/missing.*required.*arg|app.*file.*required|provide an app file/i); - } + const command = `${CLI} upload --api-key ${mockApiKey} --api-url ${mockApiUrl}`; + + const { output } = await runExpectingFailure(command); + expect(output).to.include('You must provide an app file'); }); it('should validate app file format', async () => { @@ -80,86 +72,28 @@ describe('Upload Command Integration Tests', () => { const invalidFile = path.join(tempDir, 'invalid.txt'); fs.writeFileSync(invalidFile, 'not a binary file'); - const command = `./dist/index.js upload ${invalidFile} --api-key ${mockApiKey} --api-url ${mockApiUrl}`; - - try { - await exec(command, { timeout: 10_000 }); - expect.fail('Should have thrown an error for invalid file format'); - } catch (error: unknown) { - const errorOutput = (error && typeof error === 'object' && 'stderr' in error && typeof error.stderr === 'string' ? error.stderr : '') || - (error && typeof error === 'object' && 'stdout' in error && typeof error.stdout === 'string' ? error.stdout : ''); - expect(errorOutput).to.match(/app file must be|invalid.*format/i); - } + const command = `${CLI} upload ${invalidFile} --api-key ${mockApiKey} --api-url ${mockApiUrl}`; + + const { output } = await runExpectingFailure(command); + expect(output).to.match(/App file must be/i); }); }); describe('Android APK upload', () => { it('should upload Android APK successfully', async () => { - const command = `./dist/index.js upload ${androidAppFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --json`; - - try { - const { stdout } = await exec(command, { timeout: 30_000 }); - const result = JSON.parse(stdout); - expect(result).to.have.property('appBinaryId'); - expect(typeof result.appBinaryId).to.equal('string'); - } catch (error: unknown) { - // Mock API may not be fully configured - verify command processes correctly - if (error && typeof error === 'object' && 'code' in error && error.code === 1 && 'stdout' in error && typeof error.stdout === 'string') { - const output = error.stdout; - expect(output).to.match(/uploading|binary|failed to upload|network error/i); - } else { - throw error; - } - } - }); + const command = `${CLI} upload ${androidAppFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --json`; - it('should upload Android APK with ignore SHA check', async () => { - const command = `./dist/index.js upload ${androidAppFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --ignore-sha-check --json`; - - try { - const { stdout } = await exec(command, { timeout: 30_000 }); - if (!stdout || stdout.trim() === '') { - // Empty stdout is acceptable - mock API may not return data - return; - } - - const result = JSON.parse(stdout); - expect(result).to.have.property('appBinaryId'); - } catch (error: unknown) { - // Mock API may not be fully configured - verify command processes correctly - if (error && typeof error === 'object' && 'code' in error && error.code === 1 && 'stdout' in error && typeof error.stdout === 'string') { - const output = error.stdout; - // Accept citty JSON error format ({"status":"FAILED","error":...}) or - // plain stdout containing an upload/network error marker. - const hasJsonError = - output.includes('"status": "FAILED"') || output.includes('"error"'); - const hasExpectedError = /uploading|binary|failed to upload|network error/i.test(output); - expect(hasJsonError || hasExpectedError).to.be.true; - } else { - throw error; - } - } + const { stdout } = await exec(command, { timeout: 30_000 }); + expectUploadJson(stdout); }); }); describe('iOS ZIP upload', () => { it('should upload iOS ZIP successfully', async () => { - const command = `./dist/index.js upload ${iosAppFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --json`; - - try { - const { stdout } = await exec(command, { timeout: 30_000 }); - const result = JSON.parse(stdout); - expect(result).to.have.property('appBinaryId'); - expect(typeof result.appBinaryId).to.equal('string'); - } catch (error: unknown) { - // Mock API may not be fully configured - verify command processes correctly - if (error && typeof error === 'object' && 'code' in error && error.code === 1 && 'stdout' in error && typeof error.stdout === 'string') { - const output = error.stdout; - expect(output).to.match(/uploading|binary|failed to upload|network error/i); - } else { - throw error; - } - } + const command = `${CLI} upload ${iosAppFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --json`; + + const { stdout } = await exec(command, { timeout: 30_000 }); + expectUploadJson(stdout); }); it('should validate ZIP file structure', async () => { @@ -167,109 +101,67 @@ describe('Upload Command Integration Tests', () => { const invalidZip = path.join(tempDir, 'invalid.zip'); fs.writeFileSync(invalidZip, 'not a valid zip file'); - const command = `./dist/index.js upload ${invalidZip} --api-key ${mockApiKey} --api-url ${mockApiUrl}`; - - try { - await exec(command, { timeout: 15_000 }); - expect.fail('Should have thrown an error for invalid ZIP structure'); - } catch (error: unknown) { - const errorOutput = (error && typeof error === 'object' && 'stderr' in error && typeof error.stderr === 'string' ? error.stderr : '') || - (error && typeof error === 'object' && 'stdout' in error && typeof error.stdout === 'string' ? error.stdout : ''); - expect(errorOutput).to.match(/zip.*invalid|must contain.*app|error/i); - } + const command = `${CLI} upload ${invalidZip} --api-key ${mockApiKey} --api-url ${mockApiUrl}`; + + const { output } = await runExpectingFailure(command); + // node-stream-zip rejects the corrupt archive before any upload starts. + expect(output).to.include('Bad archive'); }); }); describe('output formats', () => { it('should support JSON output format', async () => { - const command = `./dist/index.js upload ${androidAppFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --json`; - - try { - const { stdout } = await exec(command, { timeout: 30_000 }); - expect(() => JSON.parse(stdout)).to.not.throw(); - const result = JSON.parse(stdout); - expect(result).to.have.property('appBinaryId'); - } catch (error: unknown) { - // Mock API may not return proper response - check that JSON flag was processed - if (error && typeof error === 'object' && 'code' in error && error.code === 1 && 'stdout' in error && typeof error.stdout === 'string') { - const output = error.stdout; - // Should be attempting JSON format even if it fails - expect(output).to.match(/uploading|binary|failed to upload|network error|\\{.*\\}/i); - } else { - throw error; - } - } + const command = `${CLI} upload ${androidAppFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --json`; + + const { stdout } = await exec(command, { timeout: 30_000 }); + expectUploadJson(stdout); }); it('should support standard output format by default', async () => { - const command = `./dist/index.js upload ${androidAppFile} --api-key ${mockApiKey} --api-url ${mockApiUrl}`; - - try { - const { stdout } = await exec(command, { timeout: 30_000 }); - // Should contain standard upload output - expect(stdout).to.match(/uploading|binary.*id|upload.*complete/i); - // Should not be JSON format - expect(() => JSON.parse(stdout)).to.throw(); - } catch (error: unknown) { - // Mock API may not return proper response - verify non-JSON output attempt - if (error && typeof error === 'object' && 'code' in error && error.code === 1 && 'stdout' in error && typeof error.stdout === 'string') { - const output = error.stdout; - expect(output).to.match(/uploading|binary|failed to upload|network error/i); - // Should not be JSON format - expect(() => JSON.parse(output)).to.throw(); - } else { - throw error; - } - } + const command = `${CLI} upload ${androidAppFile} --api-key ${mockApiKey} --api-url ${mockApiUrl}`; + + const { stdout } = await exec(command, { timeout: 30_000 }); + expect(stdout).to.include('Upload complete'); + expect(stdout).to.include('Binary ID'); + expect(stdout).to.include('dcd cloud --app-binary-id'); + // Should not be JSON format + expect(() => JSON.parse(stdout)).to.throw(); }); }); describe('error handling', () => { it('should handle network failures gracefully', async () => { - const command = `./dist/index.js upload ${androidAppFile} --api-key ${mockApiKey} --api-url http://localhost:9999`; - - try { - await exec(command, { timeout: 30_000 }); - expect.fail('Should have failed due to network error'); - } catch (error: unknown) { - const errorOutput = (error && typeof error === 'object' && 'stderr' in error && typeof error.stderr === 'string' ? error.stderr : '') || - (error && typeof error === 'object' && 'stdout' in error && typeof error.stdout === 'string' ? error.stdout : ''); - expect(errorOutput).to.match(/network error|failed.*upload|connection|fetch failed/i); - } + const command = `${CLI} upload ${androidAppFile} --api-key ${mockApiKey} --api-url ${DEAD_API_URL}`; + + const { code, output } = await runExpectingFailure(command, { + timeout: 30_000, + }); + expect(code).to.equal(1); + expect(output).to.include('Network request failed'); }); it('should handle invalid API key', async () => { - const command = `./dist/index.js upload ${androidAppFile} --api-key invalid-key --api-url ${mockApiUrl}`; - - try { - await exec(command, { timeout: 15_000 }); - expect.fail('Should have failed due to invalid API key'); - } catch (error: unknown) { - const errorOutput = (error && typeof error === 'object' && 'stderr' in error && typeof error.stderr === 'string' ? error.stderr : '') || - (error && typeof error === 'object' && 'stdout' in error && typeof error.stdout === 'string' ? error.stdout : ''); - expect(errorOutput).to.match(/failed.*upload|unauthorized|authentication|invalid.*api.*key/i); - } + const command = `${CLI} upload ${androidAppFile} --api-key invalid-key --api-url ${mockApiUrl}`; + + const { code, output } = await runExpectingFailure(command); + expect(code).to.equal(1); + // The 401 from the SHA dedup check aborts before any upload starts. + expect(output).to.include('Authentication failed'); }); it('should handle missing file', async () => { const missingFile = path.join(tempDir, 'nonexistent.apk'); - const command = `./dist/index.js upload ${missingFile} --api-key ${mockApiKey} --api-url ${mockApiUrl}`; - - try { - await exec(command, { timeout: 10_000 }); - expect.fail('Should have failed for missing file'); - } catch (error: unknown) { - const errorOutput = (error && typeof error === 'object' && 'stderr' in error && typeof error.stderr === 'string' ? error.stderr : '') || - (error && typeof error === 'object' && 'stdout' in error && typeof error.stdout === 'string' ? error.stdout : ''); - expect(errorOutput).to.match(/no such file|not found|enoent/i); - } + const command = `${CLI} upload ${missingFile} --api-key ${mockApiKey} --api-url ${mockApiUrl}`; + + const { output } = await runExpectingFailure(command); + expect(output).to.match(/ENOENT|no such file/i); }); }); describe('help and validation', () => { it('should show help information', async () => { - const command = `./dist/index.js upload --help`; - + const command = `${CLI} upload --help`; + const { stdout } = await exec(command, { timeout: 10_000 }); expect(stdout).to.include('Upload an app binary'); expect(stdout).to.include('--api-key'); @@ -278,7 +170,7 @@ describe('Upload Command Integration Tests', () => { }); it('should show usage line in help', async () => { - const command = `./dist/index.js upload --help`; + const command = `${CLI} upload --help`; const { stdout } = await exec(command, { timeout: 10_000 }); expect(stdout).to.match(/USAGE/i); @@ -287,40 +179,29 @@ describe('Upload Command Integration Tests', () => { }); describe('SHA check functionality', () => { - it('should perform SHA check by default', async () => { - const command = `./dist/index.js upload ${androidAppFile} --api-key ${mockApiKey} --api-url ${mockApiUrl}`; - - try { - const { stdout } = await exec(command, { timeout: 30_000 }); - // Should contain SHA-related output or skip message - expect(stdout).to.match(/sha.*hash|checking|skipping.*upload|upload.*complete/i); - } catch (error: unknown) { - // Mock API may not support SHA check endpoint - verify command processes - if (error && typeof error === 'object' && 'code' in error && error.code === 1 && 'stdout' in error && typeof error.stdout === 'string') { - const output = error.stdout; - expect(output).to.match(/uploading|binary|failed to upload|network error|sha/i); - } else { - throw error; - } - } - }); - - it('should skip SHA check when flag is provided', async () => { - const command = `./dist/index.js upload ${androidAppFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --ignore-sha-check`; - - try { - const { stdout } = await exec(command, { timeout: 30_000 }); - // Should upload without SHA check (no skip message) - expect(stdout).to.match(/uploading|binary.*id|upload.*complete/i); - } catch (error: unknown) { - // Mock API may not be fully configured - verify command processes - if (error && typeof error === 'object' && 'code' in error && error.code === 1 && 'stdout' in error && typeof error.stdout === 'string') { - const output = error.stdout; - expect(output).to.match(/uploading|binary|failed to upload|network error/i); - } else { - throw error; - } - } + it('should perform SHA check by default and skip the upload on a match', async () => { + const command = `${CLI} upload ${androidAppFile} --api-key ${mockApiKey} --api-url ${mockApiUrl}`; + + const { stdout } = await exec(command, { timeout: 30_000 }); + // The mock's checkForExistingUpload always reports a match. + expect(stdout).to.include('SHA hash matches existing binary'); + expect(stdout).to.include('skipping upload'); + }); + + it('should attempt a real upload when the SHA check is bypassed', async () => { + const command = `${CLI} upload ${androidAppFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --ignore-sha-check --json`; + + // Bypassing dedup makes the CLI upload to the storage URLs from the + // mock's example response, which point at real (unwritable) hosts — + // so this deterministically fails after attempting every upload path. + // It still verifies --ignore-sha-check skips the dedup short-circuit. + const { code, stdout } = await runExpectingFailure(command, { + timeout: 60_000, + }); + expect(code).to.equal(1); + const result = JSON.parse(stdout); + expect(result).to.have.property('status', 'FAILED'); + expect(result.error).to.include('All uploads failed'); }); }); -}); \ No newline at end of file +});