From ba895ccfaccfe3cec7244875228b89e0f9d71001 Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Fri, 12 Jun 2026 18:56:32 +0100 Subject: [PATCH 1/2] fix: create parent directories when writing JSON output files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --json-file-name ./output/results.json failed with ENOENT because writeJSONFile never created intermediate directories — masked until now by an integration test that only asserted the announcement message. Co-Authored-By: Claude Fable 5 --- src/methods.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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) { From da74f89a1507958882b2ba3fde4977ea58aa6695 Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Fri, 12 Jun 2026 18:56:32 +0100 Subject: [PATCH 2/2] test: make integration suites assert deterministic outcomes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The integration tests wrapped nearly every CLI invocation in try/catch and accepted failures matching broad regexes (one alternation was literally /error/i), plus tautological expect(true) assertions — a fully broken command still passed the suite. The excuses are gone: the mock API (Prism + auth shim) is booted deterministically by the test runner with readiness polling and an isolated config dir. Every scenario was probed against the live mock API and now asserts its real outcome unconditionally: - happy paths assert parsed JSON shapes / exact output markers; a dead or missing mock API now fails 61 tests instead of zero - failure paths use a runExpectingFailure helper (replacing expect.fail inside try/catch, which chai's own AssertionError could satisfy) and assert the precise error message and exit code - file-writing tests run in temp cwds, verify the written JSON content, and no longer pollute the repo working tree - shared constants live in test/integration/helpers.ts: mock URL (env override), API key, CLI path, and a dead-API URL on port 9 (discard) replacing the assumed-unbound localhost:9999 - stale oclif-era regex alternations and a broken \\{ escape removed Net -914 lines. Also surfaced two real behaviors: junit report downloads succeed against the mock (now asserted), and --json-file-name with intermediate directories was broken (fixed in the previous commit). Co-Authored-By: Claude Fable 5 --- .../integration/artifacts.integration.test.ts | 205 ++-- test/integration/cloud.integration.test.ts | 879 ++++-------------- test/integration/helpers.ts | 59 ++ test/integration/list.integration.test.ts | 485 +++------- test/integration/status.integration.test.ts | 256 ++--- test/integration/upload.integration.test.ts | 333 +++---- 6 files changed, 679 insertions(+), 1538 deletions(-) create mode 100644 test/integration/helpers.ts 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 +});