diff --git a/src/commands/cloud.ts b/src/commands/cloud.ts index 13870b4..d1b5908 100644 --- a/src/commands/cloud.ts +++ b/src/commands/cloud.ts @@ -3,6 +3,10 @@ import { defineCommand } from 'citty'; import { existsSync } from 'node:fs'; import * as path from 'node:path'; +import { + RETIRED_RUNNER_TYPES, + RUNNER_TYPE_OPTIONS, +} from '../config/flags/execution.flags.js'; import { flags as allFlags } from '../constants.js'; import { ApiError, ApiGateway } from '../gateways/api-gateway.js'; import { @@ -83,7 +87,6 @@ process.on('warning', (warning) => { const DOWNLOAD_OPTIONS = ['ALL', 'FAILED'] as const; const REPORT_OPTIONS = ['allure', 'html', 'html-detailed', 'junit'] as const; const ORIENTATION_OPTIONS = ['0', '90'] as const; -const RUNNER_TYPE_OPTIONS = ['default', 'm4', 'm1', 'gpu1', 'cpu1'] as const; const PRIORITY_OPTIONS = ['low', 'normal', 'high'] as const; const RENDER_ENGINE_OPTIONS = ['lavapipe', 'swiftshader'] as const; @@ -252,6 +255,9 @@ export const cloudCommand = defineCommand({ args['runner-type'] as string | undefined, RUNNER_TYPE_OPTIONS, 'runner-type', + // gpu1 still validates so old scripts keep working; the API runs it + // on cpu1. + RETIRED_RUNNER_TYPES, ) ?? 'default'; const renderEngine = validateEnum( args['render-engine'] as string | undefined, diff --git a/src/config/flags/execution.flags.ts b/src/config/flags/execution.flags.ts index 3018962..7b619b7 100644 --- a/src/config/flags/execution.flags.ts +++ b/src/config/flags/execution.flags.ts @@ -1,5 +1,15 @@ import type { ArgsDef } from 'citty'; +/** The runner types offered in help text, error messages and the MCP schema. */ +export const RUNNER_TYPE_OPTIONS = ['default', 'm4', 'm1', 'cpu1'] as const; + +/** + * Runner types that were removed but are still accepted, so scripts that pass + * them keep working. gpu1 was removed on 9 November 2026 and the API runs it + * on cpu1. Never offered anywhere a user picks from. + */ +export const RETIRED_RUNNER_TYPES = ['gpu1'] as const; + /** * Test execution and flow management flags */ @@ -56,6 +66,6 @@ export const executionFlags = { type: 'string', default: 'default', description: - '[experimental] The type of runner to use (options: default, m4, m1, gpu1, cpu1) - note: anything other than default or cpu1 will incur premium pricing tiers; gpu1 is deprecated and will be removed on 9 November 2026 (gpu1 runs will then use cpu1). See https://docs.devicecloud.dev/configuration/runner-type for more information.', + '[experimental] The type of runner to use (options: default, m4, m1, cpu1) - note: anything other than default or cpu1 will incur premium pricing tiers. See https://docs.devicecloud.dev/configuration/runner-type for more information.', }, } as const satisfies ArgsDef; diff --git a/src/mcp/tools/run-cloud-test.ts b/src/mcp/tools/run-cloud-test.ts index 6801ed9..1910a82 100644 --- a/src/mcp/tools/run-cloud-test.ts +++ b/src/mcp/tools/run-cloud-test.ts @@ -3,6 +3,10 @@ import * as path from 'node:path'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; +import { + RETIRED_RUNNER_TYPES, + RUNNER_TYPE_OPTIONS, +} from '../../config/flags/execution.flags.js'; import { ApiGateway } from '../../gateways/api-gateway.js'; import { type IExecutionPlan, plan } from '../../services/execution-plan.service.js'; import { @@ -104,12 +108,14 @@ export function registerRunCloudTest(server: McpServer): void { excludeFlows: z.array(z.string()).optional().describe('Flow paths/patterns to exclude'), maestroVersion: z.string().optional().describe('Pin a specific Maestro version'), retry: z.number().int().min(0).max(2).optional().describe('Auto-retry failed tests (max 2)'), + // A retired runner type still validates, so an agent configured with + // one keeps working (the API runs gpu1 on cpu1), but the schema's + // `enum` lists only the current ones: zod metadata overrides it. runnerType: z - .enum(['default', 'm4', 'm1', 'gpu1', 'cpu1']) + .enum([...RUNNER_TYPE_OPTIONS, ...RETIRED_RUNNER_TYPES]) + .meta({ enum: [...RUNNER_TYPE_OPTIONS] }) .optional() - .describe( - 'Runner type (default "default"). gpu1 is deprecated and will be removed on 9 November 2026; prefer cpu1.', - ), + .describe('Runner type (default "default").'), priority: z .enum(['low', 'normal', 'high']) .optional() diff --git a/src/types/domain/device.types.ts b/src/types/domain/device.types.ts index 3cce15e..eadaf1b 100644 --- a/src/types/domain/device.types.ts +++ b/src/types/domain/device.types.ts @@ -19,7 +19,6 @@ export enum EiOSDevices { 'iphone-14' = 'iphone-14', 'iphone-16' = 'iphone-16', 'iphone-16-pro' = 'iphone-16-pro', - 'iphone-16-pro-max' = 'iphone-16-pro-max', 'iphone-17' = 'iphone-17', 'iphone-18-pro' = 'iphone-18-pro', 'iphone-18-pro-max' = 'iphone-18-pro-max', diff --git a/src/utils/cli.ts b/src/utils/cli.ts index 31b0033..da4aba7 100644 --- a/src/utils/cli.ts +++ b/src/utils/cli.ts @@ -126,19 +126,23 @@ export function wantsJson(rawArgs: string[]): boolean { /** * Validate that a string flag value is one of the allowed options. * Returns the value untouched on success; throws CliError otherwise. + * + * `unlisted` values are accepted too but left out of the error message: a + * retired option that old scripts may still pass, which nobody should pick. */ -export function validateEnum( +export function validateEnum( value: string | undefined, allowed: readonly T[], flagName: string, -): T | undefined { + unlisted: readonly U[] = [], +): T | U | undefined { if (value === undefined || value === null || value === '') return undefined; - if (!allowed.includes(value as T)) { + if (!allowed.includes(value as T) && !unlisted.includes(value as U)) { throw new CliError( `Invalid value for --${flagName}: "${value}". Expected one of: ${allowed.join(', ')}`, ); } - return value as T; + return value as T | U; } /** diff --git a/test/unit/runner-type.test.ts b/test/unit/runner-type.test.ts new file mode 100644 index 0000000..6999d06 --- /dev/null +++ b/test/unit/runner-type.test.ts @@ -0,0 +1,84 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { expect } from 'chai'; + +import { + executionFlags, + RETIRED_RUNNER_TYPES, + RUNNER_TYPE_OPTIONS, +} from '../../src/config/flags/execution.flags.js'; +import { registerRunCloudTest } from '../../src/mcp/tools/run-cloud-test.js'; +import { validateEnum } from '../../src/utils/cli.js'; + +/** + * gpu1 was removed on 9 November 2026 and the API runs it on cpu1. A script + * or agent that still passes it must keep working, but nothing may offer it. + */ +describe('retired runner types', () => { + describe('--runner-type', () => { + it('lists only the current runner types in its help', () => { + const help = executionFlags['runner-type'].description; + for (const retired of RETIRED_RUNNER_TYPES) { + expect(help).to.not.include(retired); + } + expect(help).to.include(`options: ${RUNNER_TYPE_OPTIONS.join(', ')}`); + }); + + it('still accepts a retired runner type', () => { + expect( + validateEnum('gpu1', RUNNER_TYPE_OPTIONS, 'runner-type', RETIRED_RUNNER_TYPES), + ).to.equal('gpu1'); + }); + + it('leaves retired runner types out of the error for an unknown one', () => { + expect(() => + validateEnum('gpu2', RUNNER_TYPE_OPTIONS, 'runner-type', RETIRED_RUNNER_TYPES), + ).to.throw( + 'Invalid value for --runner-type: "gpu2". Expected one of: default, m4, m1, cpu1', + ); + }); + }); + + describe('dcd_run_cloud_test runnerType', () => { + let client: Client; + + beforeEach(async () => { + const server = new McpServer({ name: 'test', version: '0.0.0' }); + registerRunCloudTest(server); + const [a, b] = InMemoryTransport.createLinkedPair(); + await server.connect(b); + client = new Client({ name: 'agent', version: '1' }); + await client.connect(a); + }); + + afterEach(async () => { + await client.close(); + }); + + it('advertises only the current runner types', async () => { + const { tools } = await client.listTools(); + const tool = tools.find((t) => t.name === 'dcd_run_cloud_test')!; + const runnerType = tool.inputSchema.properties!.runnerType as { enum: string[] }; + expect(runnerType.enum).to.deep.equal([...RUNNER_TYPE_OPTIONS]); + }); + + // Passing both appFile and appBinaryId fails inside the handler, before + // any network call, so reaching that error means the input validated. + const call = (runnerType: string) => + client.callTool({ + arguments: { appBinaryId: 'b', appFile: 'a.apk', flowFile: 'f.yaml', runnerType }, + name: 'dcd_run_cloud_test', + }); + const text = (result: Awaited>) => + (result.content as { text: string }[])[0].text; + + it('still accepts a retired runner type', async () => { + expect(text(await call('gpu1'))).to.include('Provide only one of appFile or appBinaryId'); + }); + + it('rejects an unknown runner type', async () => { + expect(text(await call('gpu2'))).to.include('Input validation error'); + }); + }); +});