Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/commands/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand Down
12 changes: 11 additions & 1 deletion src/config/flags/execution.flags.ts
Original file line number Diff line number Diff line change
@@ -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
*/
Expand Down Expand Up @@ -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;
14 changes: 10 additions & 4 deletions src/mcp/tools/run-cloud-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down
1 change: 0 additions & 1 deletion src/types/domain/device.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
12 changes: 8 additions & 4 deletions src/utils/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends string>(
export function validateEnum<T extends string, U extends string = never>(
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;
}

/**
Expand Down
84 changes: 84 additions & 0 deletions test/unit/runner-type.test.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof call>>) =>
(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');
});
});
});
Loading