From 4a2719c9cae70eba7a12a952d84a43e49f2f370f Mon Sep 17 00:00:00 2001 From: finalerock44 <77282157+finalerock44@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:26:27 +0000 Subject: [PATCH 1/4] fix(install): reject a DCD_VERSION that isn't a release version (#296) Both installers now check the version (pinned or from the manifest) before building the download URLs, and stop with a clear message unless it looks like 5.0.0 or 5.0.0-beta.1. --- install.ps1 | 5 +++++ install.sh | 14 ++++++++++++++ 2 files changed, 19 insertions(+) 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" From 7015a776740d796d8d944560e3993710debab256 Mon Sep 17 00:00:00 2001 From: finalerock44 <77282157+finalerock44@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:14:24 +0000 Subject: [PATCH 2/4] =?UTF-8?q?fix(device):=20a=20start=20that=20got=20no?= =?UTF-8?q?=20answer=20says=20a=20session=20may=20still=20have=E2=80=A6=20?= =?UTF-8?q?(#297)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(device): a start that got no answer says a session may still have started `dcd device start` printed a bare error when POST /device/sessions got no answer (a network error) or a proxy's 502 in the API's place. The API may have created the session anyway, and it is billed while it runs, so running the command again could leave two. When the outcome is unknown, the error now says so first and lists the open sessions started in the last 2 minutes (the one in the --json envelope's session when there is exactly one), with how to check or stop them, or says it is safe to start again. The API's own errors (its { statusCode, message } envelope) still come through unchanged: those are refusals made before any session exists. The recent-session lookup moves into DeviceGateway.recentlyStarted, shared with the MCP bridge's explanation of a failed start_device. --- src/commands/device.ts | 49 ++++++++++++++++++- src/gateways/device-gateway.ts | 45 +++++++++++++++++- src/mcp/bridge.ts | 13 ++--- test/unit/device-command.test.ts | 82 ++++++++++++++++++++++++++++++++ 4 files changed, 175 insertions(+), 14 deletions(-) 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/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/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 }], From 9bfcdb9105e4115649e23863476ad9d5a33170c4 Mon Sep 17 00:00:00 2001 From: finalerock44 <77282157+finalerock44@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:59:39 +0000 Subject: [PATCH 3/4] =?UTF-8?q?feat(report):=20deprecate=20allure=20and=20?= =?UTF-8?q?html-detailed,=20removed=20on=207=20Decemb=E2=80=A6=20(#298)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(report): deprecate allure and html-detailed, removed on 7 December 2026 The allure and html-detailed report formats are deprecated and will be removed on 7 December 2026. Both values stay valid after that date. A run that asks for html-detailed gets the standard HTML report, and a run that asks for allure gets no report, but neither run fails. - `dcd cloud` adds `report` to the notice context, so the server-side deprecation notices can be shown to runs that ask for either format. Older CLIs leave it out and evaluate the gate as false. - `dcd artifacts --report allure|html-detailed` prints a deprecation line on stderr, because that command fetches no notices. - The help for --report and --allure-path, and the MCP download tool's description, give the date. The old advice to use allure for a complete HTML report is gone. --- src/commands/artifacts.ts | 13 +++++++-- src/commands/cloud.ts | 3 +++ src/config/flags/output.flags.ts | 4 +-- src/mcp/tools/download-artifacts.ts | 4 ++- src/services/report-download.service.ts | 24 +++++++++++++++++ .../integration/artifacts.integration.test.ts | 26 ++++++++++++++++++ test/unit/notices.test.ts | 24 +++++++++++++++++ test/unit/report-download.service.test.ts | 27 ++++++++++++++++++- 8 files changed, 119 insertions(+), 6 deletions(-) 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/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/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/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); + } + }); +}); From 2d1ad50bd89669805812efd920804c4dce1e410f Mon Sep 17 00:00:00 2001 From: finalerock44 Date: Fri, 6 Nov 2026 17:46:56 +0000 Subject: [PATCH 4/4] chore: pin the 5.12.0 promotion Release-As: 5.12.0