@devicecloud.dev/dcd 5.5.0 → 5.6.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +4 -2
  2. package/dist/commands/artifacts.d.ts +1 -1
  3. package/dist/commands/artifacts.js +1 -1
  4. package/dist/commands/cloud.d.ts +9 -4
  5. package/dist/commands/cloud.js +25 -8
  6. package/dist/commands/switch-org.js +5 -1
  7. package/dist/commands/upload.js +5 -1
  8. package/dist/commands/whoami.d.ts +7 -1
  9. package/dist/commands/whoami.js +26 -2
  10. package/dist/config/flags/device.flags.d.ts +3 -3
  11. package/dist/config/flags/device.flags.js +5 -3
  12. package/dist/config/flags/execution.flags.d.ts +5 -0
  13. package/dist/config/flags/execution.flags.js +5 -0
  14. package/dist/config/flags/output.flags.d.ts +1 -1
  15. package/dist/config/flags/output.flags.js +1 -1
  16. package/dist/constants.d.ts +9 -4
  17. package/dist/gateways/api-gateway.js +2 -1
  18. package/dist/gateways/realtime-gateway.d.ts +7 -0
  19. package/dist/gateways/realtime-gateway.js +12 -1
  20. package/dist/mcp/context.d.ts +14 -3
  21. package/dist/mcp/context.js +15 -3
  22. package/dist/mcp/tools/download-artifacts.js +9 -6
  23. package/dist/mcp/tools/list-devices.js +1 -1
  24. package/dist/mcp/tools/run-cloud-test.js +20 -4
  25. package/dist/services/device-validation.service.d.ts +17 -2
  26. package/dist/services/device-validation.service.js +93 -12
  27. package/dist/services/execution-plan.service.d.ts +6 -0
  28. package/dist/services/execution-plan.service.js +98 -2
  29. package/dist/services/execution-plan.utils.js +69 -6
  30. package/dist/services/flow-paths.d.ts +9 -1
  31. package/dist/services/flow-paths.js +14 -2
  32. package/dist/services/notices.service.js +3 -0
  33. package/dist/services/report-download.service.js +9 -3
  34. package/dist/services/results-polling.service.d.ts +42 -1
  35. package/dist/services/results-polling.service.js +133 -7
  36. package/dist/services/test-submission.service.d.ts +18 -0
  37. package/dist/services/test-submission.service.js +30 -6
  38. package/dist/services/workspace-config.schema.d.ts +1 -0
  39. package/dist/services/workspace-config.schema.js +6 -0
  40. package/dist/types/domain/auth.types.d.ts +6 -0
  41. package/dist/types/domain/device.types.d.ts +18 -4
  42. package/dist/types/domain/device.types.js +17 -3
  43. package/dist/types/generated/schema.types.d.ts +127 -167
  44. package/dist/utils/auth.d.ts +33 -0
  45. package/dist/utils/auth.js +61 -2
  46. package/package.json +52 -12
package/README.md CHANGED
@@ -45,13 +45,15 @@ Add it to your MCP client config:
45
45
  "mcpServers": {
46
46
  "devicecloud": {
47
47
  "command": "npx",
48
- "args": ["-y", "@devicecloud.dev/dcd", "dcd-mcp"],
48
+ "args": ["-y", "--package=@devicecloud.dev/dcd", "dcd-mcp"],
49
49
  "env": { "DEVICE_CLOUD_API_KEY": "<your-api-key>" }
50
50
  }
51
51
  }
52
52
  }
53
53
  ```
54
54
 
55
+ `--package` matters: the package's default bin is `dcd`, so `npx @devicecloud.dev/dcd dcd-mcp` would run `dcd dcd-mcp` (an unknown command) instead of the server.
56
+
55
57
  Auth is inherited from the CLI: set `DEVICE_CLOUD_API_KEY` as above, or run `dcd login` once and the server picks up the stored session. Point it at a non-prod environment with `DCD_API_URL`.
56
58
 
57
59
  **Tools**
@@ -64,7 +66,7 @@ Auth is inherited from the CLI: set `DEVICE_CLOUD_API_KEY` as above, or run `dcd
64
66
  | `dcd_download_artifacts` | Download a run's artifacts/report to disk |
65
67
  | `dcd_run_cloud_test` | Submit a flow to run on the cloud (**billable**) |
66
68
 
67
- **Read-only mode.** `dcd_run_cloud_test` consumes test minutes, so it is annotated as non-read-only/destructive (clients can prompt before calling it). To hide it entirely — recommended for autonomous or untrusted agents — pass `--read-only` in `args`, or set `DCD_MCP_READONLY=1` in `env`.
69
+ **Read-only mode.** `dcd_run_cloud_test` consumes test minutes, so it is annotated as non-read-only/destructive (clients can prompt before calling it). To hide it entirely — recommended for autonomous or untrusted agents — pass `--read-only` at the end of `args` (`["-y", "--package=@devicecloud.dev/dcd", "dcd-mcp", "--read-only"]`), or set `DCD_MCP_READONLY=1` in `env`.
68
70
 
69
71
  By default `dcd_run_cloud_test` is async: it returns an `uploadId` immediately, which you poll with `dcd_get_status`. Pass `wait: true` (bounded by `waitTimeoutSeconds`) to block until completion, or `dryRun: true` to preview the flows without submitting.
70
72
 
@@ -27,7 +27,7 @@ export declare const artifactsCommand: import("citty").CommandDef<{
27
27
  };
28
28
  readonly 'html-path': {
29
29
  readonly type: "string";
30
- readonly description: "Custom file path for downloaded HTML report (default: ./report.html)";
30
+ readonly description: "Custom file path for the downloaded HTML report, a ZIP of report.html and its assets (default: ./report.zip)";
31
31
  };
32
32
  readonly 'junit-path': {
33
33
  readonly type: "string";
@@ -41,7 +41,7 @@ export const artifactsCommand = defineCommand({
41
41
  },
42
42
  'html-path': {
43
43
  type: 'string',
44
- description: 'Custom file path for downloaded HTML report (default: ./report.html)',
44
+ description: 'Custom file path for the downloaded HTML report, a ZIP of report.html and its assets (default: ./report.zip)',
45
45
  },
46
46
  'junit-path': {
47
47
  type: 'string',
@@ -35,7 +35,7 @@ export declare const cloudCommand: import("citty").CommandDef<{
35
35
  };
36
36
  readonly 'html-path': {
37
37
  readonly type: "string";
38
- readonly description: "Custom file path for downloaded HTML report (requires --report html, default: ./report.html)";
38
+ readonly description: "Custom file path for the downloaded HTML report, a ZIP of report.html and its assets (requires --report html or html-detailed, default: ./report.zip)";
39
39
  };
40
40
  readonly async: {
41
41
  readonly type: "boolean";
@@ -98,6 +98,11 @@ export declare const cloudCommand: import("citty").CommandDef<{
98
98
  readonly type: "string";
99
99
  readonly description: "Pull request URL for this run (stored as gh_pr_url metadata)";
100
100
  };
101
+ readonly 'cancel-previous': {
102
+ readonly type: "boolean";
103
+ readonly default: false;
104
+ readonly description: "Cancel the still-queued tests of the previous run from the same CI context: the same repo and branch or PR (and check name, if one is set), read from the run metadata. Needs repo plus branch/PR metadata (--repo-name with --branch or --pr-number, or --metadata gh_repo=… with gh_branch=… or gh_pr_number=…); without it nothing is superseded. Tests already running are left to finish; cancelled tests are refunded at 75%.";
105
+ };
101
106
  readonly config: {
102
107
  readonly type: "string";
103
108
  readonly description: "Path to custom config.yaml file. If not provided, defaults to config.yaml in root flows folders.";
@@ -206,12 +211,12 @@ export declare const cloudCommand: import("citty").CommandDef<{
206
211
  readonly 'android-no-snapshot': {
207
212
  readonly type: "boolean";
208
213
  readonly default: false;
209
- readonly description: "[Android only] Force cold boot instead of using snapshot boot. This is automatically enabled for API 35+ but can be used to force cold boot on older API levels.";
214
+ readonly description: "[Android only] Force cold boot instead of using snapshot boot. This is automatically enabled for API 34+ but can be used to force cold boot on older API levels.";
210
215
  };
211
216
  readonly 'disable-animations': {
212
217
  readonly type: "boolean";
213
- readonly default: false;
214
- readonly description: "Disable device animations during test execution. On Android, disables system animation scales. On iOS, enables Reduce Motion. Reduces CPU load and may improve test reliability.";
218
+ readonly description: "Disable device animations during test execution. On Android, disables system animation scales. On iOS, enables Reduce Motion. Reduces CPU load and may improve test reliability. Overrides platform.ios/android.disableAnimations in config.yaml.";
219
+ readonly negativeDescription: "Keep device animations on, even where config.yaml sets platform.ios/android.disableAnimations";
215
220
  };
216
221
  readonly 'app-binary-id': {
217
222
  readonly type: "string";
@@ -14,7 +14,7 @@ import { deviceFromResultRow, ResultsPollingService, RunFailedError, } from '../
14
14
  import { telemetry } from '../services/telemetry.service.js';
15
15
  import { TestSubmissionService } from '../services/test-submission.service.js';
16
16
  import { VersionService } from '../services/version.service.js';
17
- import { EAndroidApiLevels, EAndroidDevices, EiOSDevices, EiOSVersions, isIosMatrixConfig, } from '../types/domain/device.types.js';
17
+ import { isIosMatrixConfig } from '../types/domain/device.types.js';
18
18
  import { resolveAuth } from '../utils/auth.js';
19
19
  import { assertMatrixSupported, matrixIsIos, parseDeviceMatrix, } from '../utils/device-matrix.js';
20
20
  import { isEncryptionEnabled } from '../utils/envelope.js';
@@ -132,6 +132,7 @@ export const cloudCommand = defineCommand({
132
132
  const excludeFlows = coerceArray(collectRepeatedFlag(rawArgs, ['--exclude-flows']));
133
133
  const excludeTags = coerceArray(collectRepeatedFlag(rawArgs, ['--exclude-tags']));
134
134
  let flows = args.flows;
135
+ const cancelPrevious = Boolean(args['cancel-previous']);
135
136
  const googlePlay = Boolean(args['google-play']);
136
137
  const ignoreShaCheck = Boolean(args['ignore-sha-check']);
137
138
  // Single opt-in for client-side envelope encryption of every sensitive
@@ -139,10 +140,14 @@ export const cloudCommand = defineCommand({
139
140
  // (#1152). Flag wins; otherwise DCD_ENCRYPT / DCD_ENCRYPT_BINARIES.
140
141
  const encrypt = isEncryptionEnabled(args['encrypt'] ? true : undefined);
141
142
  const includeTags = coerceArray(collectRepeatedFlag(rawArgs, ['--include-tags']));
142
- const iOSDevice = validateEnum(args['ios-device'], Object.values(EiOSDevices), 'ios-device');
143
- const iOSVersion = validateEnum(args['ios-version'], Object.values(EiOSVersions), 'ios-version');
144
- const androidApiLevel = validateEnum(args['android-api-level'], Object.values(EAndroidApiLevels), 'android-api-level');
145
- const androidDevice = validateEnum(args['android-device'], Object.values(EAndroidDevices), 'android-device');
143
+ // Devices and OS versions are checked against the API's live
144
+ // compatibility data further down (DeviceValidationService), not the
145
+ // CLI's enums, which only feed --help: a device the API adds must not
146
+ // need a CLI release, and one it drops must fail with the API's list.
147
+ const iOSDevice = args['ios-device'] || undefined;
148
+ const iOSVersion = args['ios-version'] || undefined;
149
+ const androidApiLevel = args['android-api-level'] || undefined;
150
+ const androidDevice = args['android-device'] || undefined;
146
151
  const androidNoSnapshot = Boolean(args['android-no-snapshot']);
147
152
  // Repeatable device-matrix flags: one validated cell each, no cross-product.
148
153
  const iosMatrixFlags = collectRepeatedFlag(rawArgs, ['--ios-device-matrix']);
@@ -163,7 +168,9 @@ export const cloudCommand = defineCommand({
163
168
  const renderEngine = validateEnum(args['render-engine'], RENDER_ENGINE_OPTIONS, 'render-engine');
164
169
  const showCrosshairs = Boolean(args['show-crosshairs']);
165
170
  const maestroChromeOnboarding = Boolean(args['maestro-chrome-onboarding']);
166
- const disableAnimations = Boolean(args['disable-animations']);
171
+ // Left undefined when not passed, so config.yaml's per-platform value
172
+ // applies; --no-disable-animations is an explicit false that beats it.
173
+ const disableAnimations = args['disable-animations'];
167
174
  const ghBranch = args.branch;
168
175
  const ghCommitSha = args['commit-sha'];
169
176
  const ghRepoName = args['repo-name'];
@@ -401,15 +408,22 @@ export const cloudCommand = defineCommand({
401
408
  }
402
409
  throw error;
403
410
  }
404
- const { allExcludeTags, allIncludeTags, flowMetadata, flowOverrides, flowsToRun: testFileNames, referencedFiles, sequence, } = executionPlan;
411
+ const { allExcludeTags, allIncludeTags, flowMetadata, flowOverrides, flowsToRun: testFileNames, includedFiles, referencedFiles, sequence, } = executionPlan;
405
412
  if (debug) {
406
413
  out(`[DEBUG] All include tags: ${allIncludeTags?.join(', ') || 'none'}`);
407
414
  out(`[DEBUG] All exclude tags: ${allExcludeTags?.join(', ') || 'none'}`);
408
415
  out(`[DEBUG] Test file names: ${testFileNames.join(', ')}`);
409
416
  }
410
- const commonRoot = computeCommonRoot(testFileNames, referencedFiles);
417
+ const commonRoot = computeCommonRoot(testFileNames, referencedFiles, includedFiles);
411
418
  if (debug) {
412
419
  out(`[DEBUG] Common root directory: ${commonRoot}`);
420
+ // `includedPaths` files sitting beside the flows tree rather than
421
+ // inside it raise the common root, so every server-side flow key gains
422
+ // a leading segment. Harmless but visible in the console, so say it.
423
+ const rootWithoutIncludes = computeCommonRoot(testFileNames, referencedFiles);
424
+ if (includedFiles.length > 0 && rootWithoutIncludes !== commonRoot) {
425
+ out(`[DEBUG] \`includedPaths\` raised the common root from ${rootWithoutIncludes} to ${commonRoot} — flow paths gain a leading segment`);
426
+ }
413
427
  }
414
428
  const testMetadataMap = buildTestMetadataMap(flowMetadata, commonRoot);
415
429
  if (debug) {
@@ -549,6 +563,9 @@ export const cloudCommand = defineCommand({
549
563
  androidNoSnapshot,
550
564
  apiUrl,
551
565
  appBinaryId: finalBinaryId,
566
+ // With --app-binary-id the binary's platform is unknown here.
567
+ appPlatform: appBinaryId ? undefined : platformFromAppFile(finalAppFile),
568
+ cancelPrevious,
552
569
  cliVersion,
553
570
  commonRoot,
554
571
  continueOnFailure,
@@ -6,7 +6,7 @@
6
6
  * Without an argument: fetch orgs and prompt the user to pick one.
7
7
  */
8
8
  import { defineCommand } from 'citty';
9
- import { resolveAuth } from '../utils/auth.js';
9
+ import { apiKeyOverride, resolveAuth } from '../utils/auth.js';
10
10
  import { CliError, logger } from '../utils/cli.js';
11
11
  import { readConfig, resolveApiUrl, writeConfig } from '../utils/config-store.js';
12
12
  import { fetchOrgs, pickOrg } from '../utils/orgs.js';
@@ -54,6 +54,10 @@ export const switchOrgCommand = defineCommand({
54
54
  current_org_name: chosen.name,
55
55
  });
56
56
  logger.log(ui.success(`Switched to ${colors.highlight(chosen.name)}`));
57
+ // The switch only changes the stored session; an exported key still wins.
58
+ if (apiKeyOverride() === 'DEVICE_CLOUD_API_KEY') {
59
+ 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.`));
60
+ }
57
61
  },
58
62
  });
59
63
  /**
@@ -7,6 +7,7 @@ import { uploadBinary, verifyAppZip } from '../methods.js';
7
7
  import { resolveAuth } from '../utils/auth.js';
8
8
  import { CliError, logger } from '../utils/cli.js';
9
9
  import { resolveApiUrl } from '../utils/config-store.js';
10
+ import { isEncryptionEnabled } from '../utils/envelope.js';
10
11
  import { downloadExpoUrl, extractTarGz, findAppBundle, isUrl } from '../utils/expo.js';
11
12
  import { colors, formatId } from '../utils/styling.js';
12
13
  import { ui } from '../utils/ui.js';
@@ -41,7 +42,10 @@ export const uploadCommand = defineCommand({
41
42
  const apiUrl = resolveApiUrl(args['api-url']);
42
43
  const appUrl = args['app-url'];
43
44
  const ignoreShaCheck = Boolean(args['ignore-sha-check']);
44
- const encryptBinary = Boolean(args['encrypt']);
45
+ // Same rule as `dcd cloud`: the flag turns encryption on, and without it
46
+ // DCD_ENCRYPT decides. Passing `false` here used to mean "explicitly
47
+ // off", which silently beat DCD_ENCRYPT=1 for every `dcd upload`.
48
+ const encryptBinary = isEncryptionEnabled(args['encrypt'] ? true : undefined);
45
49
  const debug = Boolean(args.debug);
46
50
  const positional = args.appFile;
47
51
  const auth = await resolveAuth({ apiKeyFlag });
@@ -1,2 +1,8 @@
1
- export declare const whoamiCommand: import("citty").CommandDef<import("citty").ArgsDef>;
1
+ export declare const whoamiCommand: import("citty").CommandDef<{
2
+ readonly 'api-key': {
3
+ readonly description: "An API key as you would pass it to other commands; whoami says that it takes precedence over the stored session";
4
+ readonly type: "string";
5
+ readonly alias: ["apiKey"];
6
+ };
7
+ }>;
2
8
  export default whoamiCommand;
@@ -1,7 +1,13 @@
1
1
  /**
2
2
  * `dcd whoami` — prints the logged-in user + active org from stored config.
3
+ *
4
+ * It reads only the stored `dcd login` session, while every other command
5
+ * prefers an API key from --api-key or DEVICE_CLOUD_API_KEY — so when one is
6
+ * present it says so, rather than implying the session is what they use.
3
7
  */
4
8
  import { defineCommand } from 'citty';
9
+ import { apiFlags } from '../config/flags/api.flags.js';
10
+ import { apiKeyOverride } from '../utils/auth.js';
5
11
  import { logger } from '../utils/cli.js';
6
12
  import { readConfig } from '../utils/config-store.js';
7
13
  import { colors, formatId } from '../utils/styling.js';
@@ -11,10 +17,22 @@ export const whoamiCommand = defineCommand({
11
17
  name: 'whoami',
12
18
  description: 'Show the logged-in user and active organization',
13
19
  },
14
- run() {
20
+ args: {
21
+ 'api-key': {
22
+ ...apiFlags['api-key'],
23
+ description: 'An API key as you would pass it to other commands; whoami says that it takes precedence over the stored session',
24
+ },
25
+ },
26
+ run({ args }) {
15
27
  const config = readConfig();
28
+ const keySource = apiKeyOverride(args['api-key']);
29
+ const keyName = colors.highlight(keySource ?? '');
16
30
  if (!config?.session) {
17
- logger.log(ui.info(`Not logged in. Run ${colors.highlight('dcd login')} or set ${colors.highlight('DEVICE_CLOUD_API_KEY')}.`));
31
+ logger.log(ui.info(keySource === 'DEVICE_CLOUD_API_KEY'
32
+ ? `Not logged in. ${keyName} is set, so dcd commands authenticate with that API key.`
33
+ : keySource === '--api-key'
34
+ ? `Not logged in. Commands given ${keyName} authenticate with that API key.`
35
+ : `Not logged in. Run ${colors.highlight('dcd login')} or set ${colors.highlight('DEVICE_CLOUD_API_KEY')}.`));
18
36
  return;
19
37
  }
20
38
  // Fallback for sessions stored before we persisted org name alongside id.
@@ -26,6 +44,12 @@ export const whoamiCommand = defineCommand({
26
44
  fields.push(['env', config.env]);
27
45
  logger.log(ui.section('devicecloud.dev'));
28
46
  logger.log(ui.branch(ui.fields(fields)));
47
+ if (keySource === 'DEVICE_CLOUD_API_KEY') {
48
+ logger.log(ui.warn(`${keyName} is set, so other dcd commands authenticate with that API key and its org, not this session. Unset it to use the session.`));
49
+ }
50
+ else if (keySource === '--api-key') {
51
+ logger.log(ui.warn(`Commands given ${keyName} authenticate with that API key and its org, not this session.`));
52
+ }
29
53
  },
30
54
  });
31
55
  export default whoamiCommand;
@@ -56,11 +56,11 @@ export declare const deviceFlags: {
56
56
  readonly 'android-no-snapshot': {
57
57
  readonly type: "boolean";
58
58
  readonly default: false;
59
- readonly description: "[Android only] Force cold boot instead of using snapshot boot. This is automatically enabled for API 35+ but can be used to force cold boot on older API levels.";
59
+ readonly description: "[Android only] Force cold boot instead of using snapshot boot. This is automatically enabled for API 34+ but can be used to force cold boot on older API levels.";
60
60
  };
61
61
  readonly 'disable-animations': {
62
62
  readonly type: "boolean";
63
- readonly default: false;
64
- readonly description: "Disable device animations during test execution. On Android, disables system animation scales. On iOS, enables Reduce Motion. Reduces CPU load and may improve test reliability.";
63
+ readonly description: "Disable device animations during test execution. On Android, disables system animation scales. On iOS, enables Reduce Motion. Reduces CPU load and may improve test reliability. Overrides platform.ios/android.disableAnimations in config.yaml.";
64
+ readonly negativeDescription: "Keep device animations on, even where config.yaml sets platform.ios/android.disableAnimations";
65
65
  };
66
66
  };
@@ -61,11 +61,13 @@ export const deviceFlags = {
61
61
  'android-no-snapshot': {
62
62
  type: 'boolean',
63
63
  default: false,
64
- description: '[Android only] Force cold boot instead of using snapshot boot. This is automatically enabled for API 35+ but can be used to force cold boot on older API levels.',
64
+ description: '[Android only] Force cold boot instead of using snapshot boot. This is automatically enabled for API 34+ but can be used to force cold boot on older API levels.',
65
65
  },
66
+ // No default: an unset flag must be distinguishable from --no-disable-animations
67
+ // so the platform's disableAnimations in config.yaml applies only when unset.
66
68
  'disable-animations': {
67
69
  type: 'boolean',
68
- default: false,
69
- description: 'Disable device animations during test execution. On Android, disables system animation scales. On iOS, enables Reduce Motion. Reduces CPU load and may improve test reliability.',
70
+ description: 'Disable device animations during test execution. On Android, disables system animation scales. On iOS, enables Reduce Motion. Reduces CPU load and may improve test reliability. Overrides platform.ios/android.disableAnimations in config.yaml.',
71
+ negativeDescription: 'Keep device animations on, even where config.yaml sets platform.ios/android.disableAnimations',
70
72
  },
71
73
  };
@@ -2,6 +2,11 @@
2
2
  * Test execution and flow management flags
3
3
  */
4
4
  export declare const executionFlags: {
5
+ readonly 'cancel-previous': {
6
+ readonly type: "boolean";
7
+ readonly default: false;
8
+ readonly description: "Cancel the still-queued tests of the previous run from the same CI context: the same repo and branch or PR (and check name, if one is set), read from the run metadata. Needs repo plus branch/PR metadata (--repo-name with --branch or --pr-number, or --metadata gh_repo=… with gh_branch=… or gh_pr_number=…); without it nothing is superseded. Tests already running are left to finish; cancelled tests are refunded at 75%.";
9
+ };
5
10
  readonly config: {
6
11
  readonly type: "string";
7
12
  readonly description: "Path to custom config.yaml file. If not provided, defaults to config.yaml in root flows folders.";
@@ -2,6 +2,11 @@
2
2
  * Test execution and flow management flags
3
3
  */
4
4
  export const executionFlags = {
5
+ 'cancel-previous': {
6
+ type: 'boolean',
7
+ default: false,
8
+ description: 'Cancel the still-queued tests of the previous run from the same CI context: the same repo and branch or PR (and check name, if one is set), read from the run metadata. Needs repo plus branch/PR metadata (--repo-name with --branch or --pr-number, or --metadata gh_repo=… with gh_branch=… or gh_pr_number=…); without it nothing is superseded. Tests already running are left to finish; cancelled tests are refunded at 75%.',
9
+ },
5
10
  config: {
6
11
  type: 'string',
7
12
  description: 'Path to custom config.yaml file. If not provided, defaults to config.yaml in root flows folders.',
@@ -16,7 +16,7 @@ export declare const outputFlags: {
16
16
  };
17
17
  readonly 'html-path': {
18
18
  readonly type: "string";
19
- readonly description: "Custom file path for downloaded HTML report (requires --report html, default: ./report.html)";
19
+ readonly description: "Custom file path for the downloaded HTML report, a ZIP of report.html and its assets (requires --report html or html-detailed, default: ./report.zip)";
20
20
  };
21
21
  readonly async: {
22
22
  readonly type: "boolean";
@@ -16,7 +16,7 @@ export const outputFlags = {
16
16
  },
17
17
  'html-path': {
18
18
  type: 'string',
19
- description: 'Custom file path for downloaded HTML report (requires --report html, default: ./report.html)',
19
+ description: 'Custom file path for the downloaded HTML report, a ZIP of report.html and its assets (requires --report html or html-detailed, default: ./report.zip)',
20
20
  },
21
21
  async: {
22
22
  type: 'boolean',
@@ -21,7 +21,7 @@ export declare const flags: {
21
21
  };
22
22
  readonly 'html-path': {
23
23
  readonly type: "string";
24
- readonly description: "Custom file path for downloaded HTML report (requires --report html, default: ./report.html)";
24
+ readonly description: "Custom file path for the downloaded HTML report, a ZIP of report.html and its assets (requires --report html or html-detailed, default: ./report.zip)";
25
25
  };
26
26
  readonly async: {
27
27
  readonly type: "boolean";
@@ -84,6 +84,11 @@ export declare const flags: {
84
84
  readonly type: "string";
85
85
  readonly description: "Pull request URL for this run (stored as gh_pr_url metadata)";
86
86
  };
87
+ readonly 'cancel-previous': {
88
+ readonly type: "boolean";
89
+ readonly default: false;
90
+ readonly description: "Cancel the still-queued tests of the previous run from the same CI context: the same repo and branch or PR (and check name, if one is set), read from the run metadata. Needs repo plus branch/PR metadata (--repo-name with --branch or --pr-number, or --metadata gh_repo=… with gh_branch=… or gh_pr_number=…); without it nothing is superseded. Tests already running are left to finish; cancelled tests are refunded at 75%.";
91
+ };
87
92
  readonly config: {
88
93
  readonly type: "string";
89
94
  readonly description: "Path to custom config.yaml file. If not provided, defaults to config.yaml in root flows folders.";
@@ -192,12 +197,12 @@ export declare const flags: {
192
197
  readonly 'android-no-snapshot': {
193
198
  readonly type: "boolean";
194
199
  readonly default: false;
195
- readonly description: "[Android only] Force cold boot instead of using snapshot boot. This is automatically enabled for API 35+ but can be used to force cold boot on older API levels.";
200
+ readonly description: "[Android only] Force cold boot instead of using snapshot boot. This is automatically enabled for API 34+ but can be used to force cold boot on older API levels.";
196
201
  };
197
202
  readonly 'disable-animations': {
198
203
  readonly type: "boolean";
199
- readonly default: false;
200
- readonly description: "Disable device animations during test execution. On Android, disables system animation scales. On iOS, enables Reduce Motion. Reduces CPU load and may improve test reliability.";
204
+ readonly description: "Disable device animations during test execution. On Android, disables system animation scales. On iOS, enables Reduce Motion. Reduces CPU load and may improve test reliability. Overrides platform.ios/android.disableAnimations in config.yaml.";
205
+ readonly negativeDescription: "Keep device animations on, even where config.yaml sets platform.ios/android.disableAnimations";
201
206
  };
202
207
  readonly 'app-binary-id': {
203
208
  readonly type: "string";
@@ -566,7 +566,8 @@ export const ApiGateway = {
566
566
  },
567
567
  html: {
568
568
  endpoint: `/results/${uploadId}/html-report`,
569
- defaultFilename: `report-${uploadId}.html`,
569
+ // A ZIP of report.html plus its assets, not a bare HTML file.
570
+ defaultFilename: `report-${uploadId}.zip`,
570
571
  notFoundMessage: `Upload ID '${uploadId}' not found or no HTML report available for this upload`,
571
572
  errorPrefix: 'Failed to download HTML report',
572
573
  },
@@ -8,6 +8,13 @@ export interface RealtimeResultsSubscription {
8
8
  isConnected(): boolean;
9
9
  /** Tear down the channel and close the socket. Best-effort, never throws. */
10
10
  unsubscribe(): Promise<void>;
11
+ /**
12
+ * Hand the socket a refreshed JWT. RLS authorises the channel with the token
13
+ * it joined with, and the server drops the channel once that expires — so a
14
+ * poll that outlives the token passes the new one on. Best-effort, never
15
+ * throws.
16
+ */
17
+ updateAccessToken(accessToken: string): void;
11
18
  }
12
19
  export interface RealtimeSubscribeOptions {
13
20
  /** Supabase JWT (AuthContext.accessToken) — authorises the socket for RLS. */
@@ -86,6 +86,13 @@ export class RealtimeResultsGateway {
86
86
  /* best effort — process is exiting anyway */
87
87
  }
88
88
  },
89
+ updateAccessToken(nextToken) {
90
+ // setAuth updates the join payload and pushes the token to joined
91
+ // channels. A failure only costs realtime; the backstop poll stays.
92
+ activeClient.realtime.setAuth(nextToken).catch((error) => {
93
+ dbg(`failed to update the socket's token: ${error instanceof Error ? error.message : String(error)}`);
94
+ });
95
+ },
89
96
  };
90
97
  }
91
98
  catch (error) {
@@ -97,7 +104,11 @@ export class RealtimeResultsGateway {
97
104
  catch {
98
105
  /* ignore */
99
106
  }
100
- return { isConnected: () => false, async unsubscribe() { } };
107
+ return {
108
+ isConnected: () => false,
109
+ async unsubscribe() { },
110
+ updateAccessToken() { },
111
+ };
101
112
  }
102
113
  }
103
114
  }
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Per-process MCP context: a single resolved AuthContext + API URL, plus the
3
- * stderr logger every tool must use.
2
+ * Per-process MCP context: the resolved AuthContext + API URL (re-resolved
3
+ * when a `dcd login` session nears expiry), plus the stderr logger every tool
4
+ * must use.
4
5
  *
5
6
  * stdio transport reserves **stdout** for the JSON-RPC frame stream — anything
6
7
  * a tool prints there corrupts the protocol. Tools therefore call the services
@@ -15,17 +16,27 @@ export interface McpContext {
15
16
  auth: AuthContext;
16
17
  }
17
18
  /**
18
- * Resolve auth + API URL once per process, lazily on first tool invocation.
19
+ * Resolve auth + API URL lazily on first tool invocation, and again whenever a
20
+ * `dcd login` session is near expiry.
19
21
  *
20
22
  * Lazy so `initialize` / `tools/list` succeed before the user has supplied a
21
23
  * credential (clients enumerate tools on connect), and so an auth failure
22
24
  * surfaces as a tool error rather than crashing the server at boot.
23
25
  *
26
+ * Re-resolved near expiry because a session's access token lasts about an
27
+ * hour while an MCP server can run for days: resolving once left every tool
28
+ * call failing after the first hour. resolveAuth refreshes the stored session
29
+ * (or picks up one another `dcd` process already refreshed). A failed
30
+ * refresh leaves the cache alone, so the next call retries — including after
31
+ * the user runs `dcd login` again. API keys never expire and are resolved once.
32
+ *
24
33
  * Precedence matches the CLI: `DEVICE_CLOUD_API_KEY` env > stored `dcd login`
25
34
  * session. The API URL honors `DCD_API_URL` (handy for pointing the server at
26
35
  * dev/staging), then the logged-in env, then the prod default.
27
36
  */
28
37
  export declare function getContext(): Promise<McpContext>;
38
+ /** Forget the resolved context. For tests. */
39
+ export declare function clearContextCache(): void;
29
40
  /**
30
41
  * Read-only mode hides the only state-changing / billable tool
31
42
  * (`dcd_run_cloud_test`). Recommended for autonomous or untrusted agents.
@@ -1,4 +1,4 @@
1
- import { resolveAuth } from '../utils/auth.js';
1
+ import { isAuthExpiring, resolveAuth } from '../utils/auth.js';
2
2
  import { resolveApiUrl } from '../utils/config-store.js';
3
3
  /** Write a line to stderr. Safe under stdio transport; stdout is reserved. */
4
4
  export function logStderr(message) {
@@ -6,24 +6,36 @@ export function logStderr(message) {
6
6
  }
7
7
  let cached = null;
8
8
  /**
9
- * Resolve auth + API URL once per process, lazily on first tool invocation.
9
+ * Resolve auth + API URL lazily on first tool invocation, and again whenever a
10
+ * `dcd login` session is near expiry.
10
11
  *
11
12
  * Lazy so `initialize` / `tools/list` succeed before the user has supplied a
12
13
  * credential (clients enumerate tools on connect), and so an auth failure
13
14
  * surfaces as a tool error rather than crashing the server at boot.
14
15
  *
16
+ * Re-resolved near expiry because a session's access token lasts about an
17
+ * hour while an MCP server can run for days: resolving once left every tool
18
+ * call failing after the first hour. resolveAuth refreshes the stored session
19
+ * (or picks up one another `dcd` process already refreshed). A failed
20
+ * refresh leaves the cache alone, so the next call retries — including after
21
+ * the user runs `dcd login` again. API keys never expire and are resolved once.
22
+ *
15
23
  * Precedence matches the CLI: `DEVICE_CLOUD_API_KEY` env > stored `dcd login`
16
24
  * session. The API URL honors `DCD_API_URL` (handy for pointing the server at
17
25
  * dev/staging), then the logged-in env, then the prod default.
18
26
  */
19
27
  export async function getContext() {
20
- if (cached)
28
+ if (cached && !isAuthExpiring(cached.auth))
21
29
  return cached;
22
30
  const auth = await resolveAuth({ apiKeyFlag: undefined });
23
31
  const apiUrl = resolveApiUrl(process.env.DCD_API_URL);
24
32
  cached = { apiUrl, auth };
25
33
  return cached;
26
34
  }
35
+ /** Forget the resolved context. For tests. */
36
+ export function clearContextCache() {
37
+ cached = null;
38
+ }
27
39
  /**
28
40
  * Read-only mode hides the only state-changing / billable tool
29
41
  * (`dcd_run_cloud_test`). Recommended for autonomous or untrusted agents.
@@ -34,7 +34,7 @@ export function registerDownloadArtifacts(server) {
34
34
  reportPath: z
35
35
  .string()
36
36
  .optional()
37
- .describe('Local path for the report file (defaults depend on report type)'),
37
+ .describe('Local path for the report file (default ./report.xml for junit, ./report.html for allure, ./report.zip for html, which is a ZIP of report.html and its assets)'),
38
38
  },
39
39
  annotations: { readOnlyHint: true, openWorldHint: true },
40
40
  }, async (args) => runTool('dcd_download_artifacts', async () => {
@@ -57,11 +57,14 @@ export function registerDownloadArtifacts(server) {
57
57
  });
58
58
  let reportPath;
59
59
  if (args.report) {
60
- reportPath = args.reportPath
61
- ? path.resolve(args.reportPath)
62
- : path.resolve(args.report === 'junit'
63
- ? 'report.xml'
64
- : 'report.html');
60
+ // The HTML report is a ZIP of report.html plus assets; Allure's is
61
+ // a single HTML file.
62
+ const defaultReportPath = {
63
+ allure: 'report.html',
64
+ html: 'report.zip',
65
+ junit: 'report.xml',
66
+ }[args.report];
67
+ reportPath = path.resolve(args.reportPath || defaultReportPath);
65
68
  await service.downloadReports({
66
69
  apiUrl,
67
70
  auth,
@@ -11,7 +11,7 @@ export function registerListDevices(server) {
11
11
  title: 'List available devices',
12
12
  description: 'List the iOS and Android devices, OS versions, and Maestro versions available on devicecloud.dev. ' +
13
13
  'Use this to discover valid device/version values before submitting a test run. ' +
14
- 'Returns: { ios, android, androidPlay, maestro }, where each platform maps OS version → supported device list.',
14
+ 'Returns: { ios, android, androidPlay, maestro }, where each platform maps a device slug to the OS versions it runs.',
15
15
  inputSchema: {},
16
16
  annotations: { readOnlyHint: true, openWorldHint: true },
17
17
  }, async () => runTool('dcd_list_devices', async () => {