diff --git a/CLAUDE.md b/CLAUDE.md index 2917e1e..bc760b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ Top-level `defineCommand` in `src/index.ts` wires eleven subcommands (`cloud`, ` **Flag composition.** Flag definitions are split by domain in `src/config/flags/*.flags.ts` (api, binary, device, environment, execution, github, output) and re-exported as a single `flags` object from `src/constants.ts`. Commands that need the full cloud surface spread `...flags` into their citty `args`; subset commands import individual flag groups. -**Output rendering.** All human-facing output goes through `src/utils/ui.ts` (the canonical layer: `section`/`branch`/`fields`/`status`/`success`/`info`/`warn`) on top of `src/utils/styling.ts` (`colors`, `symbols`, `statusPalette`). The visual language is a Claude Code-style tree (`⏺` section headings, `⎿` branch groups). Commands must not hand-roll layouts from `colors`/`symbols` or call `console.log` (except the single `JSON.stringify` line under `--json`). Full rules and a cookbook live in `STYLE_GUIDE.md`. +**Output rendering.** All human-facing output goes through `src/utils/ui.ts` (the canonical layer: `section`/`branch`/`fields`/`status`/`success`/`info`/`warn`) on top of `src/utils/styling.ts` (`colors`, `symbols`, `statusPalette`). The visual language is a Claude Code-style tree (`⏺` section headings, `⎿` branch groups). Commands must not hand-roll layouts from `colors`/`symbols` or call `console.log`; the `--json` result goes through `logger.json`. Every command takes `--json` (`jsonFlag`), and `src/index.ts` turns thrown errors into `{ status: 'FAILED', error }` whenever `--json` is in argv. Full rules and a cookbook live in `STYLE_GUIDE.md`. **Layered call stack.** - `src/commands/*` — thin citty command definitions; orchestrate services, no I/O logic. diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index 243bd8d..6ce99d9 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -26,8 +26,8 @@ rows beneath them. need a shape that isn't there, add it to `ui` so every command gets it. 2. **Print through the logger.** Use `logger` from `src/utils/cli.ts` - (`logger.log` / `logger.warn` / `logger.error`). Never `console.log` directly - except for the single `JSON.stringify` line in `--json` mode. + (`logger.log` / `logger.warn` / `logger.error`, and `logger.json` for the + `--json` result). Never `console.log` directly. 3. **Sections then branches.** A screen is one or more `ui.section(title)` headings, each followed by a `ui.branch([...])` group of detail rows. Key/ @@ -48,9 +48,16 @@ rows beneath them. field labels are dim; colour is reserved for status and for emphasising a value (IDs via `formatId`, URLs via `formatUrl`). Don't colour a label. -7. **`--json` suppresses everything else.** When `--json` is set, emit only the - single serialized object and gate all other output behind the flag (the - `out()` wrapper pattern in `cloud.ts`/`upload.ts`). +7. **`--json` suppresses everything else.** Every command takes `--json` + (`jsonFlag` from `output.flags.ts`). When it is set, stdout carries exactly + one object, printed with `logger.json`. Gate all other output behind the + flag (the `out()` wrapper pattern in `cloud.ts`/`upload.ts`). Warnings stay + on stderr. Interactive commands (`login`, `switch-org`) draw their prompts + and the login URL on stderr instead. Thrown errors need no handling: the + top-level handler in `index.ts` prints `{ "status": "FAILED", "error" }` + whenever `--json` is in argv. If a command has already printed its result + and still has to exit non-zero, use `logger.fail` so a second object isn't + printed. 8. **No full-width rulers.** The 80-char `dividers` and `box` are deprecated; the tree provides the structure. Status words are lowercase. diff --git a/src/commands/artifacts.ts b/src/commands/artifacts.ts index ee6f75b..4abbac5 100644 --- a/src/commands/artifacts.ts +++ b/src/commands/artifacts.ts @@ -1,7 +1,11 @@ import { defineCommand } from 'citty'; import { apiFlags } from '../config/flags/api.flags.js'; -import { ReportDownloadService } from '../services/report-download.service.js'; +import { jsonFlag } from '../config/flags/output.flags.js'; +import { + type DownloadResult, + ReportDownloadService, +} from '../services/report-download.service.js'; import { resolveAuth } from '../utils/auth.js'; import { CliError, logger, validateEnum } from '../utils/cli.js'; import { resolveApiUrl } from '../utils/config-store.js'; @@ -56,6 +60,7 @@ export const artifactsCommand = defineCommand({ description: 'Custom file path for downloaded JUnit report (default: ./report.xml)', }, + json: jsonFlag, }, async run({ args }) { const apiKeyFlag = args['api-key'] as string | undefined; @@ -76,6 +81,9 @@ export const artifactsCommand = defineCommand({ const allurePath = args['allure-path'] as string | undefined; const htmlPath = args['html-path'] as string | undefined; const junitPath = args['junit-path'] as string | undefined; + const json = Boolean(args.json); + // Success lines are dropped under --json; warnings stay on stderr. + const log = json ? undefined : (m: string) => logger.log(m); const auth = await resolveAuth({ apiKeyFlag }); @@ -92,33 +100,42 @@ export const artifactsCommand = defineCommand({ } const service = new ReportDownloadService(); + const downloads: DownloadResult[] = []; if (downloadArtifacts) { - await service.downloadArtifacts({ + downloads.push(await service.downloadArtifacts({ auth, apiUrl, artifactsPath: artifactsPath ?? './artifacts.zip', debug, downloadType: downloadArtifacts, - logger: (m: string) => logger.log(m), + logger: log, uploadId, warnLogger: (m: string) => logger.warn(m), - }); + })); } if (report) { - await service.downloadReports({ + const result = await service.downloadReports({ allurePath, auth, apiUrl, debug, htmlPath, junitPath, - logger: (m: string) => logger.log(m), + logger: log, reportType: report, uploadId, warnLogger: (m: string) => logger.warn(m), }); + if (result) downloads.push(result); + } + + if (json) { + const ok = downloads.some((d) => d.ok); + logger.json({ status: ok ? 'SUCCESS' : 'FAILED', uploadId, downloads }); + // Same exit code a thrown error would give, without a second JSON object. + if (!ok) logger.fail('Every download failed', 1); } }, }); diff --git a/src/commands/cloud.ts b/src/commands/cloud.ts index 8dabab3..1420b78 100644 --- a/src/commands/cloud.ts +++ b/src/commands/cloud.ts @@ -1051,8 +1051,7 @@ export const cloudCommand = defineCommand({ } if (json) { - // eslint-disable-next-line no-console - console.log(JSON.stringify(jsonOutput, null, 2)); + logger.json(jsonOutput); return; } @@ -1192,8 +1191,7 @@ export const cloudCommand = defineCommand({ } if (output) { - // eslint-disable-next-line no-console - console.log(JSON.stringify(output, null, 2)); + logger.json(output); } } diff --git a/src/commands/list.ts b/src/commands/list.ts index c057b38..4ed1fbf 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -169,8 +169,7 @@ export const listCommand = defineCommand({ })) as ListResponse; if (json) { - // eslint-disable-next-line no-console - console.log(JSON.stringify(response, null, 2)); + logger.json(response); return; } diff --git a/src/commands/live.ts b/src/commands/live.ts index 23a8e57..a5f82d0 100644 --- a/src/commands/live.ts +++ b/src/commands/live.ts @@ -1,13 +1,14 @@ -import { defineCommand } from 'citty'; +import { type CommandMeta, defineCommand } from 'citty'; import { readFileSync, writeFileSync } from 'node:fs'; import { resolveFrontendUrl } from '../config/environments.js'; import { apiFlags } from '../config/flags/api.flags.js'; +import { jsonFlag } from '../config/flags/output.flags.js'; import { ApiGateway } from '../gateways/api-gateway.js'; import type { AuthContext } from '../types/domain/auth.types.js'; import type { LiveSession } from '../types/domain/live.types.js'; import { resolveAuth } from '../utils/auth.js'; -import { CliError, logger, validateEnum } from '../utils/cli.js'; +import { CliError, logger, validateEnum, wantsJson } from '../utils/cli.js'; import { resolveApiUrl } from '../utils/config-store.js'; import { colors, formatUrl } from '../utils/styling.js'; import { type Field, ui } from '../utils/ui.js'; @@ -22,6 +23,13 @@ async function requireAuth(keyFlag: string | undefined): Promise { return resolveAuth({ apiKeyFlag: keyFlag }); } +/** Human output, dropped under --json so stdout carries only the result. */ +function humanLog(json: boolean): (m: string) => void { + return (m) => { + if (!json) logger.log(m); + }; +} + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -36,6 +44,7 @@ async function waitForReady( apiUrl: string, auth: AuthContext, sessionName: string, + log: (m: string) => void = (m) => logger.log(m), timeoutMs = READY_TIMEOUT_MS, ): Promise { const deadline = Date.now() + timeoutMs; @@ -51,7 +60,7 @@ async function waitForReady( session.device_state?.phase_label ?? session.device_state?.phase ?? session.status; if (phase && phase !== lastPhase) { lastPhase = phase; - logger.log(` ${colors.dim(phase)}`); + log(` ${colors.dim(phase)}`); } if (Date.now() >= deadline) { @@ -103,7 +112,20 @@ function readFlowFile(filePath: string): string { } } -function printExecResult(result: { error?: string; output?: string; success: boolean }): void { +function printExecResult( + result: { error?: string; output?: string; success: boolean }, + json: boolean, + extra: Record = {}, +): void { + if (json) { + logger.json({ + success: result.success, + ...(result.output && { output: result.output }), + ...(result.error && { error: result.error }), + ...extra, + }); + return; + } logger.log( result.success ? ui.success('Command executed successfully') @@ -150,6 +172,7 @@ const startSub = defineCommand({ default: false, description: 'Block until the device is ready to accept commands', }, + json: jsonFlag, }, async run({ args }) { const auth = await requireAuth(args['api-key'] as string | undefined); @@ -171,15 +194,18 @@ const startSub = defineCommand({ throw new CliError('--android-device and --android-api-level must be provided together.'); } - logger.log(ui.warn(colors.bold('Live is in beta'))); - logger.log( + const json = Boolean(args.json); + const out = humanLog(json); + + out(ui.warn(colors.bold('Live is in beta'))); + out( ui.branch([ 'Live device sessions are a beta feature, billed at $0.03/min.', `${colors.dim('Not enrolled?')} Contact support to request access.`, ]), ); - logger.log(ui.running(`Starting ${platform} live session…`)); + out(ui.running(`Starting ${platform} live session…`)); const session = await ApiGateway.startLiveSession(apiUrl, auth, { binaryUploadId: binaryId, @@ -190,25 +216,57 @@ const startSub = defineCommand({ }); const frontendUrl = resolveFrontendUrl(apiUrl); + const consoleUrl = `${frontendUrl}/live?session=${session.session_name}`; - logger.log(ui.success('Live session started')); - logger.log( + out(ui.success('Live session started')); + out( ui.branch( ui.fields([ ['session', colors.highlight(session.session_name)], ['platform', session.platform], ['status', session.status], - ['console', formatUrl(`${frontendUrl}/live?session=${session.session_name}`)], + ['console', formatUrl(consoleUrl)], ]), ), ); + let ready: LiveSession | undefined; if (args.wait) { - logger.log(ui.running('Waiting for the device to be ready…')); - const ready = await waitForReady(apiUrl, auth, session.session_name); - logger.log( - ui.success(`Device ready${ready.device_model ? ` (${ready.device_model})` : ''}`), - ); + out(ui.running('Waiting for the device to be ready…')); + try { + ready = await waitForReady(apiUrl, auth, session.session_name, out); + } catch (error) { + // The session exists and is billing. Under --json the name was never + // printed, so the top-level `{ status, error }` would leave a script + // with no way to `dcd live stop` it. Report the name with the error. + if (!json) throw error; + logger.json({ + status: 'FAILED', + error: error instanceof Error ? error.message : String(error), + session_name: session.session_name, + platform: session.platform, + ready: false, + consoleUrl, + }); + logger.fail( + error instanceof Error ? error : String(error), + error instanceof CliError ? error.exitCode : 1, + ); + } + + out(ui.success(`Device ready${ready.device_model ? ` (${ready.device_model})` : ''}`)); + } + + if (json) { + logger.json({ + session_name: session.session_name, + platform: session.platform, + status: ready?.status ?? session.status, + ready: Boolean(ready?.ready), + consoleUrl, + ...(ready?.device_model && { device_model: ready.device_model }), + }); + return; } logger.log(ui.section('Next steps')); @@ -230,6 +288,7 @@ const installSub = defineCommand({ args: { ...apiFlags, session: { type: 'string', required: true, description: 'Live session name' }, + json: jsonFlag, 'app-binary-id': { type: 'string', required: true, @@ -246,8 +305,10 @@ const installSub = defineCommand({ const apiUrl = resolveApiUrl(args['api-url'] as string | undefined); const sessionName = args.session as string; const binaryId = args['app-binary-id'] as string; + const json = Boolean(args.json); + const out = humanLog(json); - logger.log( + out( ui.running( `Installing binary ${colors.highlight(binaryId)} on session ${colors.highlight(sessionName)}…`, ), @@ -255,12 +316,21 @@ const installSub = defineCommand({ await ApiGateway.installLiveBinary(apiUrl, auth, sessionName, binaryId); - logger.log(ui.success('Binary installed successfully')); + out(ui.success('Binary installed successfully')); if (args.wait) { - logger.log(ui.running('Waiting for the device to be ready…')); - await waitForReady(apiUrl, auth, sessionName); - logger.log(ui.success('Device ready')); + out(ui.running('Waiting for the device to be ready…')); + await waitForReady(apiUrl, auth, sessionName, out); + out(ui.success('Device ready')); + } + + if (json) { + logger.json({ + status: 'SUCCESS', + session: sessionName, + appBinaryId: binaryId, + ready: Boolean(args.wait), + }); } }, }); @@ -270,6 +340,7 @@ const execSub = defineCommand({ args: { ...apiFlags, session: { type: 'string', required: true, description: 'Live session name' }, + json: jsonFlag, yaml: { type: 'string', description: 'Maestro YAML commands to execute' }, file: { type: 'string', @@ -296,16 +367,17 @@ const execSub = defineCommand({ throw new CliError('Provide commands to execute via --yaml or --file.'); } + const json = Boolean(args.json); + const out = humanLog(json); + if (args.wait) { - await waitForReady(apiUrl, auth, sessionName); + await waitForReady(apiUrl, auth, sessionName, out); } - logger.log( - ui.running(`Executing commands on session ${colors.highlight(sessionName)}…`), - ); + out(ui.running(`Executing commands on session ${colors.highlight(sessionName)}…`)); const result = await ApiGateway.execLiveYaml(apiUrl, auth, sessionName, yaml); - printExecResult(result); + printExecResult(result, json); }, }); @@ -317,6 +389,7 @@ const runSub = defineCommand({ args: { ...apiFlags, session: { type: 'string', required: true, description: 'Live session name' }, + json: jsonFlag, wait: { type: 'boolean', default: false, @@ -345,11 +418,26 @@ const runSub = defineCommand({ throw new CliError(`Flow file '${flowFile}' has no commands to run.`); } + const json = Boolean(args.json); + const out = humanLog(json); + + // Print the result, then fail with exit 2 if the flow failed. Under --json + // the result object is the output, so exit without printing a second one. + const finish = ( + result: { error?: string; output?: string; success: boolean }, + extra: Record = {}, + ): void => { + printExecResult(result, json, extra); + if (result.success) return; + if (json) logger.fail(result.error || 'Flow failed.', 2); + throw new CliError(result.error || 'Flow failed.', 2); + }; + if (args.wait) { - await waitForReady(apiUrl, auth, sessionName); + await waitForReady(apiUrl, auth, sessionName, out); } - logger.log( + out( ui.running( `Running ${colors.highlight(flowFile)} on session ${colors.highlight(sessionName)}…`, ), @@ -363,8 +451,7 @@ const runSub = defineCommand({ }); if (!submitted.commandId) { - printExecResult(submitted); - if (!submitted.success) throw new CliError(submitted.error || 'Flow failed.', 2); + finish(submitted); return; } @@ -374,8 +461,7 @@ const runSub = defineCommand({ for (;;) { const status = await ApiGateway.getLiveCommand(apiUrl, auth, sessionName, commandId); if (status.done) { - printExecResult(status); - if (!status.success) throw new CliError(status.error || 'Flow failed.', 2); + finish(status, { commandId }); return; } if (Date.now() >= deadline) { @@ -404,6 +490,7 @@ const screenshotSub = defineCommand({ args: { ...apiFlags, session: { type: 'string', required: true, description: 'Live session name' }, + json: jsonFlag, output: { type: 'string', alias: ['o'], @@ -432,6 +519,19 @@ const screenshotSub = defineCommand({ writeFileSync(output, Buffer.from(base64, 'base64')); + if (args.json) { + logger.json({ + status: 'SUCCESS', + path: output, + format: ext, + ...(session.hierarchy && { + width: session.hierarchy.width, + height: session.hierarchy.height, + }), + }); + return; + } + logger.log(ui.success(`Screenshot saved to ${colors.highlight(output)}`)); if (session.hierarchy) { logger.log( @@ -453,7 +553,11 @@ const hierarchySub = defineCommand({ args: { ...apiFlags, session: { type: 'string', required: true, description: 'Live session name' }, - json: { type: 'boolean', description: 'Output the raw hierarchy as JSON' }, + json: { + type: 'boolean', + description: + 'Output the raw hierarchy as JSON (with --output, the file gets the hierarchy and stdout gets { status, path })', + }, output: { type: 'string', alias: ['o'], @@ -477,13 +581,11 @@ const hierarchySub = defineCommand({ } if (json) { - const out = JSON.stringify(hierarchy, null, 2); if (output) { - writeFileSync(output, out); - logger.log(ui.success(`Hierarchy written to ${colors.highlight(output)}`)); + writeFileSync(output, JSON.stringify(hierarchy, null, 2)); + logger.json({ status: 'SUCCESS', path: output }); } else { - // eslint-disable-next-line no-console - console.log(out); + logger.json(hierarchy); } return; } @@ -520,16 +622,23 @@ const stopSub = defineCommand({ args: { ...apiFlags, session: { type: 'string', required: true, description: 'Live session name' }, + json: jsonFlag, }, async run({ args }) { const auth = await requireAuth(args['api-key'] as string | undefined); const apiUrl = resolveApiUrl(args['api-url'] as string | undefined); const sessionName = args.session as string; - logger.log(ui.running(`Stopping session ${colors.highlight(sessionName)}…`)); + const json = Boolean(args.json); + + humanLog(json)(ui.running(`Stopping session ${colors.highlight(sessionName)}…`)); await ApiGateway.stopLiveSession(apiUrl, auth, sessionName); + if (json) { + logger.json({ status: 'SUCCESS', session: sessionName }); + return; + } logger.log(ui.success('Session stopped')); }, }); @@ -539,6 +648,7 @@ const statusSub = defineCommand({ args: { ...apiFlags, session: { type: 'string', required: true, description: 'Live session name' }, + json: jsonFlag, }, async run({ args }) { const auth = await requireAuth(args['api-key'] as string | undefined); @@ -547,6 +657,14 @@ const statusSub = defineCommand({ const session = await ApiGateway.getLiveSession(apiUrl, auth, sessionName); + if (args.json) { + // The screenshot and hierarchy are large; `screenshot` / `hierarchy` + // are the commands for those. + const { hierarchy: _hierarchy, screenshot_data_url: _screenshot, ...rest } = session; + logger.json(rest); + return; + } + const fields: Field[] = [ ['session', colors.highlight(session.session_name)], ['platform', session.platform], @@ -576,39 +694,42 @@ const statusSub = defineCommand({ }, }); +const subCommands = { + start: startSub, + install: installSub, + exec: execSub, + run: runSub, + screenshot: screenshotSub, + hierarchy: hierarchySub, + stop: stopSub, + status: statusSub, +}; + export const liveCommand = defineCommand({ meta: { name: 'live', description: 'Start and interact with a live device session', }, - subCommands: { - start: startSub, - install: installSub, - exec: execSub, - run: runSub, - screenshot: screenshotSub, - hierarchy: hierarchySub, - stop: stopSub, - status: statusSub, - }, + subCommands, // citty's runCommand does not early-return after dispatching to a // subcommand — it still invokes the parent `run` afterwards. So when a // subcommand was matched, bail out here; otherwise the menu would print // after every successful subcommand. run({ rawArgs }) { - const subNames = new Set([ - 'start', - 'install', - 'exec', - 'run', - 'screenshot', - 'hierarchy', - 'stop', - 'status', - ]); + const subNames = new Set(Object.keys(subCommands)); const firstPositional = rawArgs.find((arg) => !arg.startsWith('-')); if (firstPositional && subNames.has(firstPositional)) return; + if (wantsJson(rawArgs)) { + logger.json({ + commands: Object.entries(subCommands).map(([name, sub]) => ({ + name, + description: (sub.meta as CommandMeta).description, + })), + }); + return; + } + logger.log(ui.section('Live Session Commands')); logger.log( ui.branch( diff --git a/src/commands/login.ts b/src/commands/login.ts index ba6e9dc..12771c6 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -34,6 +34,7 @@ import { spawn } from 'node:child_process'; import { createHash, randomBytes } from 'node:crypto'; import { ENVIRONMENTS, inferEnvFromApiUrl, resolveFrontendUrl } from '../config/environments.js'; +import { jsonFlag } from '../config/flags/output.flags.js'; import { CliError, logger } from '../utils/cli.js'; import { readConfig, writeConfig } from '../utils/config-store.js'; import { fetchOrgs, pickOrg } from '../utils/orgs.js'; @@ -76,11 +77,20 @@ export const loginCommand = defineCommand({ default: true, description: 'Open the login URL in a browser (pass --no-browser to just print it)', }, + json: jsonFlag, }, async run({ args }) { const apiUrl = (args['api-url'] as string).replace(/\/$/, ''); const frontendOverride = args['frontend-url'] as string | undefined; const noBrowser = args.browser === false; + const json = Boolean(args.json); + // Login is interactive, so under --json the human rows and prompts move to + // stderr (the user still needs the URL) and stdout gets only the result. + const say = (m: string) => { + if (json) process.stderr.write(`${m}\n`); + else logger.log(m); + }; + const promptOutput = json ? process.stderr : undefined; // If there's an existing stored session, make the user confirm before we // overwrite it. Silent clobber is fine for power users but surprising if @@ -92,7 +102,7 @@ export const loginCommand = defineCommand({ if (existing?.session) { const now = Math.floor(Date.now() / 1000); if (existing.session.expires_at <= now) { - logger.log( + say( ui.info( `Your session for ${existing.session.user_email} has expired — signing in again.`, ), @@ -105,9 +115,14 @@ export const loginCommand = defineCommand({ (currentOrg ? ` (org ${currentOrg})` : '') + `. Sign out and log in again?`, initialValue: true, + output: promptOutput, }); if (p.isCancel(ok) || !ok) { - logger.log(ui.info('Keeping existing session.')); + if (json) { + logger.json({ status: 'UNCHANGED', user: existing.session.user_email }); + } else { + logger.log(ui.info('Keeping existing session.')); + } return; } } @@ -123,7 +138,7 @@ export const loginCommand = defineCommand({ const loginUrl = `${frontendUrl}/cli-login?state=${state}&code_challenge=${codeChallenge}`; - logger.log(ui.section('Signing in to devicecloud.dev')); + say(ui.section('Signing in to devicecloud.dev')); const rows: string[] = []; if (noBrowser) { @@ -142,7 +157,7 @@ export const loginCommand = defineCommand({ } } rows.push(ui.note('Waiting for login to complete…')); - logger.log(ui.branch(rows)); + say(ui.branch(rows)); try { const payload = await pollForClaim(apiUrl, state, codeVerifier); @@ -175,7 +190,7 @@ export const loginCommand = defineCommand({ ].filter(Boolean); throw new CliError(`${(err as Error).message} [${parts.join(' | ')}]`); } - const chosen = await pickOrg(orgs); + const chosen = await pickOrg(orgs, undefined, promptOutput); writeConfig({ version: 1, @@ -193,6 +208,16 @@ export const loginCommand = defineCommand({ current_org_name: chosen.name, }); + if (json) { + logger.json({ + status: 'SUCCESS', + user: payload.user_email, + org: { id: chosen.id, name: chosen.name }, + env, + }); + return; + } + logger.log(ui.success(`Logged in as ${colors.highlight(payload.user_email)}`)); logger.log( ui.branch([ @@ -203,7 +228,7 @@ export const loginCommand = defineCommand({ ]), ); } catch (error) { - logger.error(error as Error, { exit: 1 }); + logger.error(error as Error, { exit: 1, json }); } }, }); diff --git a/src/commands/logout.ts b/src/commands/logout.ts index 5d25480..8072b31 100644 --- a/src/commands/logout.ts +++ b/src/commands/logout.ts @@ -5,6 +5,7 @@ import { defineCommand } from 'citty'; import { ENVIRONMENTS } from '../config/environments.js'; +import { jsonFlag } from '../config/flags/output.flags.js'; import { CliAuthGateway } from '../gateways/cli-auth-gateway.js'; import { logger } from '../utils/cli.js'; import { clearConfig, getConfigPath, readConfig } from '../utils/config-store.js'; @@ -16,17 +17,29 @@ export const logoutCommand = defineCommand({ name: 'logout', description: 'Clear the stored devicecloud.dev session', }, - async run() { + args: { + json: jsonFlag, + }, + async run({ args }) { + const json = Boolean(args.json); const config = readConfig(); if (!config?.session) { - logger.log(ui.info(`No active session found (${colors.dim(getConfigPath())}).`)); clearConfig(); + if (json) { + logger.json({ status: 'SUCCESS', loggedOut: false }); + } else { + logger.log(ui.info(`No active session found (${colors.dim(getConfigPath())}).`)); + } return; } const { anonKey } = ENVIRONMENTS[config.env].supabase; await CliAuthGateway.signOut(config.supabase_url, anonKey, config.session); clearConfig(); + if (json) { + logger.json({ status: 'SUCCESS', loggedOut: true, user: config.session.user_email }); + return; + } logger.log(ui.success(`Logged out ${colors.dim(`(${config.session.user_email})`)}`)); }, }); diff --git a/src/commands/status.ts b/src/commands/status.ts index 9724897..a629709 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -163,19 +163,12 @@ async function statusMain({ if (isClientApiError(lastError)) { const errorMessage = lastError?.message || 'Unknown error'; if (json) { - // eslint-disable-next-line no-console - console.log( - JSON.stringify( - { - status: 'FAILED' as const, - error: errorMessage, - attempts: attemptsMade, - tests: [], - }, - null, - 2, - ), - ); + logger.json({ + status: 'FAILED' as const, + error: errorMessage, + attempts: attemptsMade, + tests: [], + }); return; } @@ -204,24 +197,17 @@ async function statusMain({ } if (json) { - // eslint-disable-next-line no-console - console.log( - JSON.stringify( - { - status: 'FAILED' as const, - error: errorMessage, - attempts: attemptsMade, - connectivityCheck: { - connected: connectivityCheck.connected, - endpointResults: connectivityCheck.endpointResults, - message: connectivityCheck.message, - }, - tests: [], - }, - null, - 2, - ), - ); + logger.json({ + status: 'FAILED' as const, + error: errorMessage, + attempts: attemptsMade, + connectivityCheck: { + connected: connectivityCheck.connected, + endpointResults: connectivityCheck.endpointResults, + message: connectivityCheck.message, + }, + tests: [], + }); return; } @@ -231,8 +217,7 @@ async function statusMain({ try { if (json) { const { tests, ...rest } = status; - // eslint-disable-next-line no-console - console.log(JSON.stringify({ ...rest, tests }, null, 2)); + logger.json({ ...rest, tests }); return; } diff --git a/src/commands/switch-org.ts b/src/commands/switch-org.ts index 969e97f..87de101 100644 --- a/src/commands/switch-org.ts +++ b/src/commands/switch-org.ts @@ -7,6 +7,7 @@ */ import { defineCommand } from 'citty'; +import { jsonFlag } from '../config/flags/output.flags.js'; import { apiKeyOverride, resolveAuth } from '../utils/auth.js'; import { CliError, logger } from '../utils/cli.js'; import { readConfig, resolveApiUrl, writeConfig } from '../utils/config-store.js'; @@ -29,6 +30,7 @@ export const switchOrgCommand = defineCommand({ required: false, description: 'Org name to switch to (omit for an interactive picker)', }, + json: jsonFlag, }, async run({ args }) { const config = readConfig(); @@ -40,6 +42,7 @@ export const switchOrgCommand = defineCommand({ // a dev Bearer token to the prod API. const apiUrl = resolveApiUrl(args['api-url'] as string | undefined); const target = args.org as string | undefined; + const json = Boolean(args.json); // sessionOnly: an exported DEVICE_CLOUD_API_KEY must not shadow the // browser session this command requires. @@ -51,7 +54,8 @@ export const switchOrgCommand = defineCommand({ if (target) { chosen = matchOrg(orgs, target); } else { - chosen = await pickOrg(orgs); + // Under --json the picker draws on stderr so stdout stays pure JSON. + chosen = await pickOrg(orgs, undefined, json ? process.stderr : undefined); } writeConfig({ @@ -60,10 +64,21 @@ export const switchOrgCommand = defineCommand({ current_org_name: chosen.name, }); + // The switch only changes the stored session; an exported key still wins. + const keyOverride = apiKeyOverride() === 'DEVICE_CLOUD_API_KEY'; + + if (json) { + logger.json({ + status: 'SUCCESS', + org: { id: chosen.id, name: chosen.name, slug: chosen.slug ?? null }, + apiKeyOverride: keyOverride, + }); + return; + } + logger.log(ui.success(`Switched to ${colors.highlight(chosen.name)}`)); - // The switch only changes the stored session; an exported key still wins. - if (apiKeyOverride() === 'DEVICE_CLOUD_API_KEY') { + if (keyOverride) { logger.log( ui.warn( `${colors.highlight('DEVICE_CLOUD_API_KEY')} is set, so other dcd commands still authenticate with that API key and its org, not ${chosen.name}. Unset it to use the session.`, diff --git a/src/commands/upgrade.ts b/src/commands/upgrade.ts index 719ab60..a2b5e9f 100644 --- a/src/commands/upgrade.ts +++ b/src/commands/upgrade.ts @@ -20,6 +20,7 @@ import { pipeline } from 'node:stream/promises'; import { defineCommand } from 'citty'; +import { jsonFlag } from '../config/flags/output.flags.js'; import { VersionService } from '../services/version.service.js'; import { CliError, @@ -47,7 +48,15 @@ export const upgradeCommand = defineCommand({ name: 'upgrade', description: 'Upgrade dcd in place to the latest released version', }, - async run() { + args: { + json: jsonFlag, + }, + async run({ args }) { + const json = Boolean(args.json); + const out = (m: string) => { + if (!json) logger.log(m); + }; + if (getInstallMethod() !== 'binary') { throw new CliError( '`dcd upgrade` only applies to the standalone binary install. ' + @@ -68,6 +77,10 @@ export const upgradeCommand = defineCommand({ // Reachable, but nothing published on this channel yet (e.g. a stable // install while only betas exist). Not an error — just nothing to do. if (result.version === null) { + if (json) { + logger.json({ status: 'NO_RELEASE', current, channel: result.channel }); + return; + } logger.log( ui.info(`No newer release available on the ${result.channel} channel.`), ); @@ -76,6 +89,10 @@ export const upgradeCommand = defineCommand({ const latest = result.version; if (!versionService.isOutdated(current, latest)) { + if (json) { + logger.json({ status: 'UP_TO_DATE', current, latest, channel: result.channel }); + return; + } logger.log( ui.success(`Already on the latest version (${colors.highlight(current)})`), ); @@ -107,10 +124,8 @@ export const upgradeCommand = defineCommand({ const binaryUrl = `${base}/download/${latest}/${asset}`; const sumsUrl = `${base}/download/${latest}/SHA256SUMS`; - logger.log( - ui.info(`Upgrading ${colors.highlight(current)} → ${colors.highlight(latest)}`), - ); - logger.log(ui.note(` ${binaryUrl}`)); + out(ui.info(`Upgrading ${colors.highlight(current)} → ${colors.highlight(latest)}`)); + out(ui.note(` ${binaryUrl}`)); const execPath = process.execPath; const tmpPath = `${execPath}.new`; @@ -135,6 +150,10 @@ export const upgradeCommand = defineCommand({ ); } + if (json) { + logger.json({ status: 'UPGRADED', current, latest, channel: result.channel }); + return; + } logger.log(ui.success(`Upgraded to ${colors.highlight(latest)}`)); }, }); diff --git a/src/commands/upload.ts b/src/commands/upload.ts index 5933f36..c8c4955 100644 --- a/src/commands/upload.ts +++ b/src/commands/upload.ts @@ -138,8 +138,7 @@ export const uploadCommand = defineCommand({ telemetry.recordEncryption({ binary: encryption.enabled }); if (json) { - // eslint-disable-next-line no-console - console.log(JSON.stringify({ appBinaryId }, null, 2)); + logger.json({ appBinaryId }); return; } diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts index 3dab8c9..3bbea2c 100644 --- a/src/commands/whoami.ts +++ b/src/commands/whoami.ts @@ -8,6 +8,7 @@ import { defineCommand } from 'citty'; import { apiFlags } from '../config/flags/api.flags.js'; +import { jsonFlag } from '../config/flags/output.flags.js'; import { apiKeyOverride } from '../utils/auth.js'; import { logger } from '../utils/cli.js'; import { readConfig } from '../utils/config-store.js'; @@ -25,10 +26,27 @@ export const whoamiCommand = defineCommand({ description: 'An API key as you would pass it to other commands; whoami says that it takes precedence over the stored session', }, + json: jsonFlag, }, run({ args }) { const config = readConfig(); const keySource = apiKeyOverride(args['api-key'] as string | undefined); + + if (args.json) { + logger.json({ + status: 'SUCCESS', + loggedIn: Boolean(config?.session), + ...(config?.session && { + user: config.session.user_email, + org: config.current_org_id + ? { id: config.current_org_id, name: config.current_org_name ?? null } + : null, + env: config.env, + }), + apiKeySource: keySource ?? null, + }); + return; + } const keyName = colors.highlight(keySource ?? ''); if (!config?.session) { diff --git a/src/config/flags/output.flags.ts b/src/config/flags/output.flags.ts index 304f5b4..c4fdff0 100644 --- a/src/config/flags/output.flags.ts +++ b/src/config/flags/output.flags.ts @@ -1,5 +1,14 @@ import type { ArgsDef } from 'citty'; +/** + * Generic `--json` flag for commands outside `cloud`. With it, stdout carries + * exactly one JSON object; failures print `{ status: 'FAILED', error }`. + */ +export const jsonFlag = { + type: 'boolean', + description: 'Output the result as a single JSON object on stdout', +} as const satisfies ArgsDef[string]; + /** * Output format and artifact download flags */ diff --git a/src/index.ts b/src/index.ts index ba63c2f..da07bd4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,7 +15,7 @@ import { upgradeCommand } from './commands/upgrade.js'; import { uploadCommand } from './commands/upload.js'; import { whoamiCommand } from './commands/whoami.js'; import { telemetry } from './services/telemetry.service.js'; -import { CliError, getCliVersion, logger } from './utils/cli.js'; +import { CliError, getCliVersion, logger, wantsJson } from './utils/cli.js'; import { withoutHiddenArgs } from './utils/help.js'; // @clack/prompts ships the US spelling ("Canceled") for its built-in @@ -103,9 +103,13 @@ async function run(): Promise { telemetry.recordCommandSuccess(); await telemetry.flush(); } catch (error) { + // Under --json, stdout must stay a single JSON object: no help text, and + // the error itself is printed as `{ status: 'FAILED', error }`. + const json = wantsJson(rawArgs); + // citty throws CLIError (by name — the class isn't exported) for usage // problems like unknown commands or missing required args. - if (error instanceof Error && error.name === 'CLIError') { + if (!json && error instanceof Error && error.name === 'CLIError') { await showHelp(rawArgs); } @@ -114,6 +118,7 @@ async function run(): Promise { // synchronously, and exits with the given code. logger.error(error instanceof Error ? error : String(error), { exit: exitCode, + json, }); } } diff --git a/src/services/report-download.service.ts b/src/services/report-download.service.ts index d1bfc96..c938809 100644 --- a/src/services/report-download.service.ts +++ b/src/services/report-download.service.ts @@ -25,6 +25,14 @@ export interface ReportDownloadOptions extends DownloadOptions { reportType: 'allure' | 'html' | 'html-detailed' | 'junit'; } +/** What one download attempt did; failures are warned about, not thrown. */ +export interface DownloadResult { + error?: string; + ok: boolean; + path: string; + type: 'allure' | 'artifacts' | 'html' | 'junit'; +} + /** * Service for downloading test artifacts and reports */ @@ -32,11 +40,11 @@ export class ReportDownloadService { /** * Download test artifacts as a zip file * @param options Download configuration - * @returns Promise that resolves when download is complete + * @returns What the download did */ public async downloadArtifacts( options: ArtifactsDownloadOptions, - ): Promise { + ): Promise { const { apiUrl, auth, @@ -69,6 +77,8 @@ export class ReportDownloadService { `Test artifacts have been downloaded to ${artifactsPath}`, ); } + + return { ok: true, path: artifactsPath, type: 'artifacts' }; } catch (error) { if (debug && logger) { logger(`[DEBUG] Error downloading artifacts: ${error}`); @@ -80,17 +90,19 @@ export class ReportDownloadService { 'No artifacts found for this upload. Make sure your tests generated results.', error, ); + + return { error: errorText(error), ok: false, path: artifactsPath, type: 'artifacts' }; } } /** * Handle downloading reports based on the report type specified * @param options Report download configuration - * @returns Promise that resolves when download is complete + * @returns What the download did, or undefined for an unknown report type */ public async downloadReports( options: ReportDownloadOptions, - ): Promise { + ): Promise { const { reportType, junitPath, @@ -103,20 +115,18 @@ export class ReportDownloadService { switch (reportType) { case 'junit': { const reportPath = path.resolve(process.cwd(), junitPath || 'report.xml'); - await this.downloadReport('junit', reportPath, { + return this.downloadReport('junit', reportPath, { ...downloadOptions, warnLogger, }); - break; } case 'allure': { const reportPath = path.resolve(process.cwd(), allurePath || 'report.html'); - await this.downloadReport('allure', reportPath, { + return this.downloadReport('allure', reportPath, { ...downloadOptions, warnLogger, }); - break; } case 'html': @@ -124,17 +134,18 @@ export class ReportDownloadService { // The HTML report is a ZIP (report.html plus its screenshots and // assets), so the default name says so. An explicit path is kept as-is. const htmlReportPath = path.resolve(process.cwd(), htmlPath || 'report.zip'); - await this.downloadReport('html', htmlReportPath, { + return this.downloadReport('html', htmlReportPath, { ...downloadOptions, warnLogger, }); - break; } default: { if (warnLogger) { warnLogger(`Unknown report type: ${reportType}`); } + + return undefined; } } } @@ -144,13 +155,13 @@ export class ReportDownloadService { * @param type Report type to download * @param filePath Path where report should be saved * @param options Download configuration - * @returns Promise that resolves when download is complete + * @returns What the download did */ private async downloadReport( type: 'allure' | 'html' | 'junit', filePath: string, options: DownloadOptions, - ): Promise { + ): Promise { const { apiUrl, auth, uploadId, debug = false, logger, warnLogger } = options; try { @@ -172,6 +183,8 @@ export class ReportDownloadService { `${type.toUpperCase()} test report has been downloaded to ${filePath}`, ); } + + return { ok: true, path: filePath, type }; } catch (error) { if (debug && logger) { logger(`[DEBUG] Error downloading ${type.toUpperCase()} report: ${error}`); @@ -183,6 +196,8 @@ export class ReportDownloadService { `No ${type.toUpperCase()} reports found for this upload. Make sure your tests generated results.`, error, ); + + return { error: errorText(error), ok: false, path: filePath, type }; } } @@ -205,7 +220,7 @@ export class ReportDownloadService { return; } - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = errorText(error); warnLogger(`Failed to download ${subject}: ${errorMessage}`); if (errorMessage.includes('404')) { @@ -217,3 +232,7 @@ export class ReportDownloadService { } } } + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/utils/cli.ts b/src/utils/cli.ts index 6b152cd..31b0033 100644 --- a/src/utils/cli.ts +++ b/src/utils/cli.ts @@ -7,6 +7,7 @@ * sites ported from oclif keep working. */ import { readFileSync } from 'node:fs'; +import { stripVTControlCharacters } from 'node:util'; import { telemetry } from '../services/telemetry.service.js'; @@ -61,8 +62,10 @@ export class CliError extends Error { export interface Logger { log(message: string): void; warn(message: string): void; + json(value: unknown): void; error(message: Error | string, opts?: { exit?: number; json?: boolean }): never; exit(code?: number): never; + fail(message: Error | string, exitCode: number): never; } export const logger: Logger = { @@ -74,6 +77,11 @@ export const logger: Logger = { // eslint-disable-next-line no-console console.warn(symbols.warning + ' ' + message); }, + json(value: unknown): void { + // The one stdout write under --json; everything else is gated off. + // eslint-disable-next-line no-console + console.log(JSON.stringify(value, null, 2)); + }, error( message: Error | string, opts: { exit?: number; json?: boolean } = {}, @@ -82,27 +90,39 @@ export const logger: Logger = { if (opts.json) { // When --json is active, emit failures as JSON on stdout so that // scripts consuming the output never need to parse stderr too. - // eslint-disable-next-line no-console - console.log(JSON.stringify({ status: 'FAILED', error: text }, null, 2)); + // citty colours some of its messages; JSON consumers want plain text. + logger.json({ status: 'FAILED', error: stripVTControlCharacters(text) }); } else { // The literal "Error:" prefix is important for tests and for grep-friendly // CI logs — it survives color stripping and matches /error/i assertions. // eslint-disable-next-line no-console console.error(symbols.error + ' Error: ' + text); } + return logger.fail(message, opts.exit ?? 1); + }, + exit(code: number = 0): never { + process.exit(code); + }, + // Exit as a failure without printing anything — for --json paths that have + // already written their result object and must not print a second one. + fail(message: Error | string, exitCode: number): never { // process.exit bypasses beforeExit, so async fetch in telemetry.flush() // would be killed mid-flight. flushSync uses curl to ship synchronously // before we exit; it's a no-op if telemetry never reached configure(). - const exitCode = opts.exit ?? 1; telemetry.recordCommandFailure({ error: message, exitCode }); telemetry.flushSync(); process.exit(exitCode); }, - exit(code: number = 0): never { - process.exit(code); - }, }; +/** + * Whether the invocation asked for JSON output. Read from raw argv so the + * top-level error handler can honour it even when citty never parsed args. + */ +export function wantsJson(rawArgs: string[]): boolean { + return rawArgs.some((a) => a === '--json' || a === '--json=true'); +} + /** * Validate that a string flag value is one of the allowed options. * Returns the value untouched on success; throws CliError otherwise. diff --git a/src/utils/orgs.ts b/src/utils/orgs.ts index 47e44aa..97a2c60 100644 --- a/src/utils/orgs.ts +++ b/src/utils/orgs.ts @@ -3,6 +3,7 @@ * `dcd login` (after the PKCE claim returns a session) and `dcd switch-org`. */ import * as p from '@clack/prompts'; +import type { Writable } from 'node:stream'; import { CliError } from './cli.js'; @@ -34,6 +35,7 @@ export async function fetchOrgs( export async function pickOrg( orgs: OrgListItem[], message = 'Pick an organization', + output?: Writable, ): Promise { if (orgs.length === 0) { throw new CliError('No organizations found for this user.'); @@ -42,6 +44,7 @@ export async function pickOrg( const picked = await p.select({ message, + output, options: orgs.map((o) => ({ value: o.id, label: o.name })), }); if (p.isCancel(picked)) throw new CliError('Cancelled.'); diff --git a/test/unit/json-output.test.ts b/test/unit/json-output.test.ts new file mode 100644 index 0000000..a6573b5 --- /dev/null +++ b/test/unit/json-output.test.ts @@ -0,0 +1,176 @@ +import { expect } from 'chai'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { liveCommand } from '../../src/commands/live.js'; +import { logoutCommand } from '../../src/commands/logout.js'; +import { whoamiCommand } from '../../src/commands/whoami.js'; +import { ApiGateway } from '../../src/gateways/api-gateway.js'; +import type { LiveSession } from '../../src/types/domain/live.types.js'; +import { wantsJson } from '../../src/utils/cli.js'; +import { writeConfig } from '../../src/utils/config-store.js'; + +/** + * Every command takes --json, and with it stdout must be exactly one JSON + * object — scripts pipe it straight into a parser. + */ +describe('--json output', () => { + const ORIGINAL_ENV = { ...process.env }; + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dcd-json-test-')); + process.env.DCD_CONFIG_DIR = tempDir; + process.env.DCD_TELEMETRY_DISABLED = '1'; + delete process.env.DEVICE_CLOUD_API_KEY; + }); + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + fs.rmSync(tempDir, { force: true, recursive: true }); + }); + + const logIn = () => + writeConfig({ + version: 1, + env: 'prod', + api_url: 'https://api.devicecloud.dev', + supabase_url: 'https://cloud.devicecloud.dev', + session: { + access_token: 'a', + refresh_token: 'r', + expires_at: Math.floor(Date.now() / 1000) + 3600, + user_email: 'u@example.com', + user_id: 'u1', + }, + current_org_id: '42', + current_org_name: 'Acme', + }); + + /** Run a command's `run` and parse stdout, which must be a single JSON value. */ + async function runJson( + run: unknown, + ctx: { args?: Record; rawArgs?: string[] }, + ): Promise> { + let out = ''; + const original = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string) => { + out += chunk; + return true; + }) as typeof process.stdout.write; + try { + await (run as (c: typeof ctx) => Promise | void)({ args: {}, rawArgs: [], ...ctx }); + } finally { + process.stdout.write = original; + } + + return JSON.parse(out) as Record; + } + + describe('wantsJson', () => { + it('spots --json anywhere in argv', () => { + expect(wantsJson(['whoami', '--json'])).to.equal(true); + expect(wantsJson(['--json=true', 'logout'])).to.equal(true); + }); + + it('ignores look-alike flags', () => { + expect(wantsJson(['cloud', '--json-file'])).to.equal(false); + expect(wantsJson(['whoami'])).to.equal(false); + }); + }); + + describe('dcd whoami --json', () => { + it('reports the session', async () => { + logIn(); + expect(await runJson(whoamiCommand.run, { args: { json: true } })).to.deep.equal({ + status: 'SUCCESS', + loggedIn: true, + user: 'u@example.com', + org: { id: '42', name: 'Acme' }, + env: 'prod', + apiKeySource: null, + }); + }); + + it('reports an exported API key', async () => { + logIn(); + process.env.DEVICE_CLOUD_API_KEY = 'exported-key'; + const out = await runJson(whoamiCommand.run, { args: { json: true } }); + expect(out.apiKeySource).to.equal('DEVICE_CLOUD_API_KEY'); + }); + + it('reports being logged out', async () => { + expect(await runJson(whoamiCommand.run, { args: { json: true } })).to.deep.equal({ + status: 'SUCCESS', + loggedIn: false, + apiKeySource: null, + }); + }); + }); + + describe('dcd logout --json', () => { + it('says nothing was logged out when there is no session', async () => { + expect(await runJson(logoutCommand.run, { args: { json: true } })).to.deep.equal({ + status: 'SUCCESS', + loggedOut: false, + }); + }); + }); + + describe('dcd live --json', () => { + const originalGet = ApiGateway.getLiveSession; + const originalStop = ApiGateway.stopLiveSession; + const liveArgs = { 'api-key': 'k', 'api-url': 'https://api.example.com', json: true }; + const sub = (name: string) => + (liveCommand.subCommands as Record)[name].run; + + afterEach(() => { + ApiGateway.getLiveSession = originalGet; + ApiGateway.stopLiveSession = originalStop; + }); + + it('lists the subcommands from the bare menu', async () => { + const out = await runJson(liveCommand.run, { rawArgs: ['--json'] }); + const names = (out.commands as { name: string }[]).map((c) => c.name); + expect(names).to.include.members(['start', 'run', 'status', 'stop']); + }); + + it('status prints the session without the heavy payloads', async () => { + ApiGateway.getLiveSession = async () => + ({ + session_name: 's1', + platform: 'android', + status: 'RUNNING', + ready: true, + created_at: '2026-01-01T00:00:00Z', + screenshot_data_url: 'data:image/png;base64,AAAA', + hierarchy: { width: 1, height: 1, elements: [] }, + }) as unknown as LiveSession; + + const out = await runJson(sub('status'), { args: { ...liveArgs, session: 's1' } }); + expect(out).to.include({ session_name: 's1', status: 'RUNNING', ready: true }); + expect(out).to.not.have.property('screenshot_data_url'); + expect(out).to.not.have.property('hierarchy'); + }); + + it('hierarchy --output writes the file and prints only a result object', async () => { + const hierarchy = { width: 1, height: 1, elements: [] }; + ApiGateway.getLiveSession = async () => + ({ session_name: 's1', hierarchy }) as unknown as LiveSession; + const file = path.join(tempDir, 'hierarchy.json'); + + expect( + await runJson(sub('hierarchy'), { args: { ...liveArgs, session: 's1', output: file } }), + ).to.deep.equal({ status: 'SUCCESS', path: file }); + expect(JSON.parse(fs.readFileSync(file, 'utf8'))).to.deep.equal(hierarchy); + }); + + it('stop prints only the result object', async () => { + ApiGateway.stopLiveSession = async () => {}; + expect( + await runJson(sub('stop'), { args: { ...liveArgs, session: 's1' } }), + ).to.deep.equal({ status: 'SUCCESS', session: 's1' }); + }); + }); +}); diff --git a/test/unit/report-download.service.test.ts b/test/unit/report-download.service.test.ts index 9feda29..fa0a072 100644 --- a/test/unit/report-download.service.test.ts +++ b/test/unit/report-download.service.test.ts @@ -201,6 +201,35 @@ describe('ReportDownloadService', () => { expect(logs.join(' ')).to.include(outPath); }); + + // `dcd artifacts --json` reports these, since failures only warn. + it('returns ok: true with the path when the download succeeds', async () => { + const outPath = path.join(tempDir, 'result-ok.zip'); + mockFetch(200, 'data'); + + const result = await service.downloadArtifacts({ + ...BASE, + artifactsPath: outPath, + downloadType: 'ALL', + }); + + expect(result).to.deep.equal({ ok: true, path: outPath, type: 'artifacts' }); + }); + + it('returns ok: false with the error when the download fails', async () => { + const outPath = path.join(tempDir, 'result-fail.zip'); + mockFetch(404, 'not found'); + + const result = await service.downloadArtifacts({ + ...BASE, + artifactsPath: outPath, + downloadType: 'ALL', + }); + + expect(result.ok).to.equal(false); + expect(result.type).to.equal('artifacts'); + expect(result.error).to.be.a('string').and.not.be.empty; + }); }); // -------------------------------------------------------------------------