diff --git a/install.ps1 b/install.ps1 index c0c181e..039ea36 100644 --- a/install.ps1 +++ b/install.ps1 @@ -61,6 +61,11 @@ No stable dcd release is available yet. } } +# Only a release version goes into the download URLs below. +if ($version -cnotmatch '^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?\z') { + throw "Invalid version '$version'. Use a release version such as 5.0.0 or 5.0.0-beta.1." +} + $url = "$DownloadBase/download/$version/$asset" $sumsUrl = "$DownloadBase/download/$version/SHA256SUMS" diff --git a/install.sh b/install.sh index 195663f..19444b9 100755 --- a/install.sh +++ b/install.sh @@ -86,6 +86,16 @@ no_stable_release_err() { exit 1 } +# A release version: 5.0.0 or 5.0.0-beta.1 (a leading v is fine). The case +# check comes first because grep matches line by line, so it would accept a +# value with a newline in it on its first line alone. +valid_version() { + case "$1" in + '' | *[!0-9A-Za-z.-]*) return 1 ;; + esac + printf '%s\n' "$1" | grep -Eq '^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$' +} + # Find a dcd on PATH other than the one we just installed — usually a leftover # `npm install -g @devicecloud.dev/dcd` that can shadow this binary. Runs in a # subshell so the temporary IFS change never leaks back to the caller. @@ -224,6 +234,10 @@ main() { detail "${CYAN}${version}${RESET} ${DIM}(latest ${channel})${RESET}" fi + # Only a release version goes into the download URLs below. + valid_version "$version" \ + || err "Invalid version '$version'. Use a release version such as 5.0.0 or 5.0.0-beta.1." + url="$DOWNLOAD_BASE/download/${version}/${asset}" sums_url="$DOWNLOAD_BASE/download/${version}/SHA256SUMS" diff --git a/src/commands/artifacts.ts b/src/commands/artifacts.ts index 4abbac5..c87448b 100644 --- a/src/commands/artifacts.ts +++ b/src/commands/artifacts.ts @@ -5,10 +5,12 @@ import { jsonFlag } from '../config/flags/output.flags.js'; import { type DownloadResult, ReportDownloadService, + reportFormatDeprecation, } 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'; +import { ui } from '../utils/ui.js'; const DOWNLOAD_OPTIONS = ['ALL', 'FAILED'] as const; const REPORT_OPTIONS = ['allure', 'html', 'html-detailed', 'junit'] as const; @@ -43,12 +45,12 @@ export const artifactsCommand = defineCommand({ report: { type: 'string', description: - 'Download a test report in the specified format (options: allure, html, html-detailed, junit)', + 'Download a test report in the specified format (options: junit, html, html-detailed, allure). allure and html-detailed are deprecated and will be removed on 7 December 2026.', }, 'allure-path': { type: 'string', description: - 'Custom file path for downloaded Allure report (default: ./report.html)', + 'Custom file path for downloaded Allure report (default: ./report.html). Deprecated: Allure reports will be removed on 7 December 2026.', }, 'html-path': { type: 'string', @@ -85,6 +87,13 @@ export const artifactsCommand = defineCommand({ // Success lines are dropped under --json; warnings stay on stderr. const log = json ? undefined : (m: string) => logger.log(m); + // This command fetches no notices and submits nothing, so neither the + // deprecation notice nor the API's submit warning reaches it. + const deprecation = reportFormatDeprecation(report); + if (deprecation) { + process.stderr.write(`${ui.deprecation(deprecation)}\n`); + } + const auth = await resolveAuth({ apiKeyFlag }); if (downloadArtifacts && report) { diff --git a/src/commands/cloud.ts b/src/commands/cloud.ts index f9a8261..13870b4 100644 --- a/src/commands/cloud.ts +++ b/src/commands/cloud.ts @@ -509,6 +509,9 @@ export const cloudCommand = defineCommand({ // As requested ('default' when not passed), not as the API resolves // it: the gpu1 retirement notice is aimed at runs that ask for gpu1. runner_type: runnerType, + // As passed (undefined without --report): the allure and + // html-detailed retirement notices are gated on it. + report, cli_version: cliVersion, ci_provider: ciContext.provider, ci_wrapper_version: ciContext.wrapperVersion, diff --git a/src/commands/device.ts b/src/commands/device.ts index 099099e..f23eaaf 100644 --- a/src/commands/device.ts +++ b/src/commands/device.ts @@ -5,7 +5,12 @@ import { resolve as resolvePath } from 'node:path'; import { apiFlags } from '../config/flags/api.flags.js'; import { jsonFlag } from '../config/flags/output.flags.js'; -import { DeviceApiError, DeviceGateway } from '../gateways/device-gateway.js'; +import { + DeviceApiError, + DeviceGateway, + START_RECOVERY_WINDOW_MS, + startOutcomeUnknown, +} from '../gateways/device-gateway.js'; import type { AuthContext } from '../types/domain/auth.types.js'; import { type DeviceCatalogue, @@ -204,6 +209,39 @@ async function resolveApp( return { binaryUploadId }; } +/** + * A start that got no answer, or a proxy's 5xx in the API's place, may still + * have made a session, which is billed while it runs. Someone who just runs + * `dcd device start` again would have two. So name the sessions started in + * the last couple of minutes, and say to check before starting again. + */ +async function failedStartError(ctx: Ctx, error: unknown): Promise { + const reason = error instanceof Error ? error.message : String(error); + const status = error instanceof DeviceApiError ? error.status : 502; + const head = + 'Starting the session failed, but the request may still have started a session, and a session is billed while it runs.'; + // What to do first: a network error's message runs to a dozen lines. + const failed = (advice: string, session: DeviceSession | null = null) => + new DeviceApiError(`${head} ${advice}\n\nThe error: ${reason}`, status, null, session); + const minutes = START_RECOVERY_WINDOW_MS / 60_000; + const recent = await DeviceGateway.recentlyStarted(ctx.apiUrl, ctx.auth); + if (recent === null) { + return failed('Check for one with `dcd device list` before you start another.'); + } + + if (recent.length === 0) { + return failed(`No session was started in the last ${minutes} minutes, so it is safe to start one again.`); + } + + const listed = recent + .map((s) => `${s.id} (${s.client_label ?? 'no label'}, ${s.state}, started ${s.created_at})`) + .join(', '); + return failed( + `Open sessions started in the last ${minutes} minutes: ${listed}. If one is yours, use it instead of starting another; check it with \`dcd device status \`. Otherwise stop it with \`dcd device stop \`.`, + recent.length === 1 ? recent[0] : null, + ); +} + function parsePositiveInt(value: unknown, flag: string): number | undefined { if (value === undefined || value === '') return undefined; const n = Number(value); @@ -353,7 +391,14 @@ const startSub = defineCommand({ if (ttl) body.maxDurationMin = ttl; ctx.progress(ui.running(`Starting ${platform} device session…`)); - let { session } = await DeviceGateway.start(ctx.apiUrl, ctx.auth, body); + let session: DeviceSession; + try { + ({ session } = await DeviceGateway.start(ctx.apiUrl, ctx.auth, body)); + } catch (error) { + if (!startOutcomeUnknown(error)) throw error; + throw await failedStartError(ctx, error); + } + ctx.progress(ui.success(`Session ${session.id} started`)); // The session exists and bills from here on: every later failure must diff --git a/src/config/flags/output.flags.ts b/src/config/flags/output.flags.ts index c4fdff0..eeeac3c 100644 --- a/src/config/flags/output.flags.ts +++ b/src/config/flags/output.flags.ts @@ -26,7 +26,7 @@ export const outputFlags = { 'allure-path': { type: 'string', description: - 'Custom file path for downloaded Allure report (requires --report allure, default: ./report.html)', + 'Custom file path for downloaded Allure report (requires --report allure, default: ./report.html). Deprecated: Allure reports will be removed on 7 December 2026.', }, 'html-path': { type: 'string', @@ -79,6 +79,6 @@ export const outputFlags = { type: 'string', alias: ['format'], description: - 'Generate and download test reports in the specified format (options: allure, junit, html, html-detailed). Use "allure" for a complete HTML report.', + 'Generate and download test reports in the specified format (options: junit, html, html-detailed, allure). allure and html-detailed are deprecated and will be removed on 7 December 2026: from that date html-detailed gives the standard HTML report and allure gives none, and runs still run.', }, } as const satisfies ArgsDef; diff --git a/src/gateways/device-gateway.ts b/src/gateways/device-gateway.ts index 74b7277..e192396 100644 --- a/src/gateways/device-gateway.ts +++ b/src/gateways/device-gateway.ts @@ -24,6 +24,11 @@ import { ApiGateway } from './api-gateway.js'; */ export class DeviceApiError extends Error { code: null | string; + /** + * The API itself answered (its `{ statusCode, message }` envelope), not a + * proxy in front of it. A proxy's 502 can come after the API did the work. + */ + fromApi: boolean; session: DeviceSession | null; status: number; @@ -32,15 +37,31 @@ export class DeviceApiError extends Error { status: number, code: null | string, session: DeviceSession | null, + fromApi = false, ) { super(message); this.name = 'DeviceApiError'; this.status = status; this.code = code; this.session = session; + this.fromApi = fromApi; } } +/** How recent an open session must be to count as one a failed start may have made. */ +export const START_RECOVERY_WINDOW_MS = 2 * 60 * 1000; + +/** + * Whether a failed start may still have made a session: no answer arrived + * (the connection failed, or the reply was unreadable), or a proxy answered + * with a 5xx in the API's place. The API's own errors are refusals made + * before any session exists. + */ +export function startOutcomeUnknown(error: unknown): boolean { + if (!(error instanceof DeviceApiError)) return true; + return error.status >= 500 && !error.fromApi; +} + /** * The API's error envelope: `{ statusCode, message, error? }`, where `error` * is either Nest's status text or a machine code such as LIVE_NOT_FOUND. @@ -48,7 +69,7 @@ export class DeviceApiError extends Error { */ async function toDeviceApiError(res: Response, path: string): Promise { const text = await res.text(); - let body: { error?: unknown; message?: unknown; session?: unknown } = {}; + let body: { error?: unknown; message?: unknown; session?: unknown; statusCode?: unknown } = {}; try { body = JSON.parse(text); } catch { @@ -76,7 +97,10 @@ async function toDeviceApiError(res: Response, path: string): Promise( @@ -211,6 +235,23 @@ export const DeviceGateway = { return request(baseUrl, auth, 'GET', '/device/models'); }, + /** + * The open sessions started in the last START_RECOVERY_WINDOW_MS: the ones + * a start that got no answer may have made. Null when they can't be read. + */ + async recentlyStarted( + baseUrl: string, + auth: AuthContext, + now = Date.now(), + ): Promise { + try { + const { sessions } = await DeviceGateway.list(baseUrl, auth); + return sessions.filter((s) => now - Date.parse(s.created_at) <= START_RECOVERY_WINDOW_MS); + } catch { + return null; + } + }, + registerBundle( baseUrl: string, auth: AuthContext, diff --git a/src/mcp/bridge.ts b/src/mcp/bridge.ts index 3dab41e..91ee0f5 100644 --- a/src/mcp/bridge.ts +++ b/src/mcp/bridge.ts @@ -29,11 +29,10 @@ import { type Tool, } from '@modelcontextprotocol/sdk/types.js'; -import { DeviceGateway } from '../gateways/device-gateway.js'; +import { DeviceGateway, START_RECOVERY_WINDOW_MS } from '../gateways/device-gateway.js'; import { processDependencies, readTestYamlFileAsJson } from '../services/execution-plan.utils.js'; import { computeCommonRoot } from '../services/flow-paths.js'; import type { AuthContext } from '../types/domain/auth.types.js'; -import type { DeviceSession } from '../types/domain/device-session.types.js'; import { uploadLocalApp } from '../utils/app-upload.js'; import { getCliVersion } from '../utils/cli.js'; import { uploadFlowBundle } from '../utils/device-bundle.js'; @@ -165,9 +164,6 @@ export async function resolveLocalInputs( return args; } -/** How recent an open session must be to count as one a failed start may have made. */ -export const START_RECOVERY_WINDOW_MS = 2 * 60 * 1000; - /** * What to tell the agent when a start_device call failed without an answer * (the connection dropped, or the server never replied). The request may @@ -181,11 +177,8 @@ export async function explainFailedStart( now = Date.now(), ): Promise { const head = `start_device failed: ${message}. The request may still have started a session, and a session is billed while it runs.`; - let recent: DeviceSession[]; - try { - const { sessions } = await DeviceGateway.list(ctx.apiUrl, ctx.auth); - recent = sessions.filter((s) => now - Date.parse(s.created_at) <= START_RECOVERY_WINDOW_MS); - } catch { + const recent = await DeviceGateway.recentlyStarted(ctx.apiUrl, ctx.auth, now); + if (recent === null) { return `${head} Call list_devices to check for one before you call start_device again.`; } diff --git a/src/mcp/tools/download-artifacts.ts b/src/mcp/tools/download-artifacts.ts index 5af9b97..c5705d0 100644 --- a/src/mcp/tools/download-artifacts.ts +++ b/src/mcp/tools/download-artifacts.ts @@ -37,7 +37,9 @@ export function registerDownloadArtifacts(server: McpServer): void { report: z .enum(['junit', 'allure', 'html']) .optional() - .describe('Also download a formatted report of this type'), + .describe( + 'Also download a formatted report of this type. allure is deprecated and will be removed on 7 December 2026; prefer junit or html.', + ), reportPath: z .string() .optional() diff --git a/src/services/report-download.service.ts b/src/services/report-download.service.ts index c938809..6587e22 100644 --- a/src/services/report-download.service.ts +++ b/src/services/report-download.service.ts @@ -25,6 +25,30 @@ export interface ReportDownloadOptions extends DownloadOptions { reportType: 'allure' | 'html' | 'html-detailed' | 'junit'; } +/** + * The report formats retired on 7 December 2026, and what each one + * does from that date. `dcd cloud` users are told by the API's submit message + * and a notice; `dcd artifacts` fetches neither, so it prints this itself. + * The values stay valid after the date: a run that asks for one must not fail. + */ +export function reportFormatDeprecation( + report: string | undefined, +): string | undefined { + switch (report) { + case 'allure': { + return '--report allure is deprecated and will be removed on 7 December 2026. From that date runs get no Allure report. Use --report html or --report junit instead.'; + } + + case 'html-detailed': { + return '--report html-detailed is deprecated and will be removed on 7 December 2026. From that date it gives the standard HTML report. Use --report html instead.'; + } + + default: { + return undefined; + } + } +} + /** What one download attempt did; failures are warned about, not thrown. */ export interface DownloadResult { error?: string; diff --git a/test/integration/artifacts.integration.test.ts b/test/integration/artifacts.integration.test.ts index 9a79b04..5069bc9 100644 --- a/test/integration/artifacts.integration.test.ts +++ b/test/integration/artifacts.integration.test.ts @@ -146,6 +146,32 @@ describe('Artifacts Command Integration Tests', () => { }); }); + // This command fetches no notices, so it prints the deprecation + // itself. On stderr, before any download is tried. + describe('report format deprecation', () => { + it('warns that --report allure goes on 7 December 2026', async () => { + const { stderr } = await run( + `--upload-id ${mockUploadId} --report allure --api-key ${mockApiKey} --api-url ${DEAD_API_URL}`, + ); + expect(stderr).to.include('--report allure is deprecated'); + expect(stderr).to.include('7 December 2026'); + }); + + it('warns about --report html-detailed', async () => { + const { stderr } = await run( + `--upload-id ${mockUploadId} --report html-detailed --api-key ${mockApiKey} --api-url ${DEAD_API_URL}`, + ); + expect(stderr).to.include('--report html-detailed is deprecated'); + }); + + it('says nothing for --report junit', async () => { + const { stderr } = await run( + `--upload-id ${mockUploadId} --report junit --api-key ${mockApiKey} --api-url ${DEAD_API_URL}`, + ); + expect(stderr).to.not.include('deprecated'); + }); + }); + describe('help', () => { it('should display help with all expected flags', async () => { const { stdout } = await exec(`${CLI} artifacts --help`, { diff --git a/test/unit/device-command.test.ts b/test/unit/device-command.test.ts index a8fb123..c656f28 100644 --- a/test/unit/device-command.test.ts +++ b/test/unit/device-command.test.ts @@ -360,6 +360,88 @@ describe('dcd device', () => { }); }); + describe('a start that may have made a session anyway', () => { + // What Railway's proxy answers when the API did not reply in time. + const proxy502 = { + status: 502, + body: { status: 'error', code: 502, message: 'Application failed to respond', request_id: 'r-1' }, + }; + const justNow = () => new Date().toISOString(); + const tenMinutesAgo = () => new Date(Date.now() - 10 * 60_000).toISOString(); + + it('names the session a proxy 502 hid, and says how to stop it', async () => { + const calls = stubFetch((call) => + call.method === 'POST' + ? proxy502 + : { + body: { + sessions: [ + session('jolly-orange-lion', { state: 'provisioning', created_at: justNow() }), + session('old-one', { created_at: tenMinutesAgo() }), + ], + }, + }, + ); + const { exit, out } = await runJson('start', { platform: 'ios' }); + expect(exit).to.equal(DEVICE_EXIT.failed); + const message = (out.error as { message: string }).message; + expect(message) + .to.include('Application failed to respond') + .and.include('may still have started a session') + .and.include('jolly-orange-lion') + .and.include('dcd device stop'); + expect(message).to.not.include('old-one'); + expect((out.session as DeviceSession).id).to.equal('jolly-orange-lion'); + expect(calls.map((c) => c.method)).to.deep.equal(['POST', 'GET']); + }); + + it('says it is safe to start again when no session was made', async () => { + stubFetch((call) => + call.method === 'POST' + ? proxy502 + : { body: { sessions: [session('old-one', { created_at: tenMinutesAgo() })] } }, + ); + const { exit, out } = await runJson('start', { platform: 'ios' }); + expect(exit).to.equal(DEVICE_EXIT.failed); + expect((out.error as { message: string }).message).to.include('safe to start one again'); + expect(out.session ?? null).to.equal(null); + }); + + it('treats a start with no answer at all the same way', async () => { + stubFetch((call) => { + if (call.method === 'POST') throw new TypeError('fetch failed'); + return { body: { sessions: [session('jolly-orange-lion', { created_at: justNow() })] } }; + }); + const { exit, out } = await runJson('start', { platform: 'ios' }); + expect(exit).to.equal(DEVICE_EXIT.failed); + expect((out.error as { message: string }).message) + .to.include('may still have started a session') + .and.include('jolly-orange-lion'); + }); + + it('says to check `dcd device list` when the sessions cannot be read either', async () => { + stubFetch(() => proxy502); + const { exit, out } = await runJson('start', { platform: 'ios' }); + expect(exit).to.equal(DEVICE_EXIT.failed); + expect((out.error as { message: string }).message) + .to.include('may still have started a session') + .and.include('dcd device list'); + }); + + it("leaves the API's own errors alone: they come before any session exists", async () => { + for (const [status, message] of [ + [500, 'Failed to create live session'], + [503, 'Could not check your live spend cap right now (a temporary problem on our side). Please retry.'], + ] as const) { + const calls = stubFetch(() => ({ status, body: { statusCode: status, message, error: 'Error' } })); + const { exit, out } = await runJson('start', { platform: 'ios' }); + expect(exit).to.equal(DEVICE_EXIT.failed); + expect((out.error as { message: string }).message).to.equal(message); + expect(calls).to.have.length(1); + } + }); + }); + describe('start --model without --platform', () => { const catalogue = { ios: [{ model: 'iphone-16', name: 'iPhone 16', os: ['18'], default_os: '18', google_play: false, default: true }], diff --git a/test/unit/notices.test.ts b/test/unit/notices.test.ts index 38d5413..f3cbaaf 100644 --- a/test/unit/notices.test.ts +++ b/test/unit/notices.test.ts @@ -24,3 +24,27 @@ describe('notice gating on runner_type', () => { expect(matchesRules(gpu1Gate, { platform: 'android' })).to.equal(false); }); }); + +// The allure and html-detailed retirement notices (dcd +// `deprecate-report-{allure,html-detailed}-2026-12-07`) are gated on `report` +// with no min_client_version, for the same reason as gpu1: a CLI from before +// 5.12.0 has no report in its context and must evaluate the gate as false. +describe('notice gating on report', () => { + const allureGate = { + rules: [{ field: 'report', op: 'equals' as const, value: 'allure' }], + }; + + it('shows the notice to a run that asks for allure', () => { + expect(matchesRules(allureGate, { report: 'allure' })).to.equal(true); + }); + + it('hides it from runs with another format, or none', () => { + expect(matchesRules(allureGate, { report: 'junit' })).to.equal(false); + expect(matchesRules(allureGate, { report: 'html-detailed' })).to.equal(false); + expect(matchesRules(allureGate, { report: undefined })).to.equal(false); + }); + + it('hides it from a context with no report, as an older CLI sends', () => { + expect(matchesRules(allureGate, { platform: 'android' })).to.equal(false); + }); +}); diff --git a/test/unit/report-download.service.test.ts b/test/unit/report-download.service.test.ts index fa0a072..5077afb 100644 --- a/test/unit/report-download.service.test.ts +++ b/test/unit/report-download.service.test.ts @@ -4,7 +4,10 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { ApiGateway } from '../../src/gateways/api-gateway.js'; -import { ReportDownloadService } from '../../src/services/report-download.service.js'; +import { + ReportDownloadService, + reportFormatDeprecation, +} from '../../src/services/report-download.service.js'; import type { AuthContext } from '../../src/types/domain/auth.types.js'; const TEST_AUTH: AuthContext = { @@ -550,3 +553,25 @@ describe('ReportDownloadService default report paths', () => { ]); }); }); + +// allure and html-detailed go on 7 December 2026. The values stay +// valid (a run that asks for one must not fail), so the only signal is this. +describe('reportFormatDeprecation', () => { + it('names the date and the replacement for allure', () => { + const message = reportFormatDeprecation('allure'); + expect(message).to.include('7 December 2026'); + expect(message).to.include('--report html or --report junit'); + }); + + it('names the date and the replacement for html-detailed', () => { + const message = reportFormatDeprecation('html-detailed'); + expect(message).to.include('7 December 2026'); + expect(message).to.include('--report html instead'); + }); + + it('says nothing for the formats that stay, or for no format', () => { + for (const report of ['junit', 'html', undefined]) { + expect(reportFormatDeprecation(report)).to.equal(undefined); + } + }); +});