@devicecloud.dev/dcd 5.0.0-beta.0 → 5.0.0-beta.2

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 (104) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +52 -0
  3. package/dist/commands/artifacts.d.ts +28 -28
  4. package/dist/commands/artifacts.js +20 -23
  5. package/dist/commands/cloud.d.ts +57 -57
  6. package/dist/commands/cloud.js +224 -192
  7. package/dist/commands/list.d.ts +22 -22
  8. package/dist/commands/list.js +43 -40
  9. package/dist/commands/live.js +134 -127
  10. package/dist/commands/login.d.ts +11 -11
  11. package/dist/commands/login.js +46 -44
  12. package/dist/commands/logout.js +16 -18
  13. package/dist/commands/status.d.ts +11 -11
  14. package/dist/commands/status.js +53 -44
  15. package/dist/commands/switch-org.d.ts +7 -7
  16. package/dist/commands/switch-org.js +19 -21
  17. package/dist/commands/upgrade.js +41 -33
  18. package/dist/commands/upload.d.ts +10 -10
  19. package/dist/commands/upload.js +42 -43
  20. package/dist/commands/whoami.js +17 -20
  21. package/dist/config/environments.js +6 -12
  22. package/dist/config/flags/api.flags.js +1 -4
  23. package/dist/config/flags/binary.flags.js +1 -4
  24. package/dist/config/flags/device.flags.js +6 -9
  25. package/dist/config/flags/environment.flags.js +1 -4
  26. package/dist/config/flags/execution.flags.js +1 -4
  27. package/dist/config/flags/github.flags.js +1 -4
  28. package/dist/config/flags/output.flags.js +1 -4
  29. package/dist/constants.js +15 -18
  30. package/dist/gateways/api-gateway.d.ts +31 -6
  31. package/dist/gateways/api-gateway.js +70 -16
  32. package/dist/gateways/cli-auth-gateway.d.ts +1 -1
  33. package/dist/gateways/cli-auth-gateway.js +3 -6
  34. package/dist/gateways/realtime-gateway.d.ts +32 -0
  35. package/dist/gateways/realtime-gateway.js +103 -0
  36. package/dist/gateways/supabase-gateway.d.ts +1 -1
  37. package/dist/gateways/supabase-gateway.js +10 -14
  38. package/dist/index.js +41 -38
  39. package/dist/mcp/context.d.ts +33 -0
  40. package/dist/mcp/context.js +33 -0
  41. package/dist/mcp/helpers.d.ts +16 -0
  42. package/dist/mcp/helpers.js +34 -0
  43. package/dist/mcp/index.d.ts +2 -0
  44. package/dist/mcp/index.js +24 -0
  45. package/dist/mcp/server.d.ts +7 -0
  46. package/dist/mcp/server.js +27 -0
  47. package/dist/mcp/tools/download-artifacts.d.ts +11 -0
  48. package/dist/mcp/tools/download-artifacts.js +84 -0
  49. package/dist/mcp/tools/get-status.d.ts +7 -0
  50. package/dist/mcp/tools/get-status.js +39 -0
  51. package/dist/mcp/tools/list-devices.d.ts +7 -0
  52. package/dist/mcp/tools/list-devices.js +27 -0
  53. package/dist/mcp/tools/list-runs.d.ts +3 -0
  54. package/dist/mcp/tools/list-runs.js +60 -0
  55. package/dist/mcp/tools/run-cloud-test.d.ts +14 -0
  56. package/dist/mcp/tools/run-cloud-test.js +233 -0
  57. package/dist/methods.d.ts +32 -1
  58. package/dist/methods.js +133 -79
  59. package/dist/services/device-validation.service.d.ts +1 -1
  60. package/dist/services/device-validation.service.js +1 -5
  61. package/dist/services/execution-plan.service.js +14 -17
  62. package/dist/services/execution-plan.utils.js +15 -23
  63. package/dist/services/flow-paths.d.ts +17 -0
  64. package/dist/services/flow-paths.js +52 -0
  65. package/dist/services/metadata-extractor.service.js +22 -25
  66. package/dist/services/moropo.service.js +18 -20
  67. package/dist/services/report-download.service.d.ts +1 -1
  68. package/dist/services/report-download.service.js +5 -9
  69. package/dist/services/results-polling.service.d.ts +18 -3
  70. package/dist/services/results-polling.service.js +211 -108
  71. package/dist/services/telemetry.service.d.ts +10 -1
  72. package/dist/services/telemetry.service.js +40 -18
  73. package/dist/services/test-submission.service.d.ts +21 -4
  74. package/dist/services/test-submission.service.js +51 -34
  75. package/dist/services/version.service.d.ts +30 -7
  76. package/dist/services/version.service.js +88 -32
  77. package/dist/types/domain/auth.types.d.ts +8 -0
  78. package/dist/types/domain/auth.types.js +1 -2
  79. package/dist/types/domain/device.types.js +8 -11
  80. package/dist/types/domain/live.types.js +1 -2
  81. package/dist/types/generated/schema.types.js +1 -2
  82. package/dist/types/index.d.ts +2 -2
  83. package/dist/types/index.js +2 -18
  84. package/dist/types.js +1 -2
  85. package/dist/utils/auth.d.ts +1 -1
  86. package/dist/utils/auth.js +27 -28
  87. package/dist/utils/ci.d.ts +12 -0
  88. package/dist/utils/ci.js +39 -0
  89. package/dist/utils/cli.d.ts +16 -2
  90. package/dist/utils/cli.js +57 -29
  91. package/dist/utils/compatibility.d.ts +1 -1
  92. package/dist/utils/compatibility.js +5 -7
  93. package/dist/utils/config-store.js +33 -43
  94. package/dist/utils/connectivity.js +1 -4
  95. package/dist/utils/expo.js +15 -21
  96. package/dist/utils/orgs.js +8 -12
  97. package/dist/utils/paths.js +2 -5
  98. package/dist/utils/progress.d.ts +3 -0
  99. package/dist/utils/progress.js +47 -8
  100. package/dist/utils/styling.d.ts +35 -37
  101. package/dist/utils/styling.js +52 -86
  102. package/dist/utils/ui.d.ts +41 -0
  103. package/dist/utils/ui.js +95 -0
  104. package/package.json +27 -24
@@ -1,16 +1,16 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ResultsPollingService = exports.RunFailedError = void 0;
4
- const path = require("node:path");
5
- const api_gateway_1 = require("../gateways/api-gateway");
6
- const methods_1 = require("../methods");
7
- const connectivity_1 = require("../utils/connectivity");
8
- const progress_1 = require("../utils/progress");
9
- const styling_1 = require("../utils/styling");
1
+ import * as path from 'node:path';
2
+ import { ApiGateway } from '../gateways/api-gateway.js';
3
+ import { RealtimeResultsGateway, } from '../gateways/realtime-gateway.js';
4
+ import { formatDurationSeconds } from '../methods.js';
5
+ import { checkInternetConnectivity } from '../utils/connectivity.js';
6
+ import { isCI } from '../utils/ci.js';
7
+ import { ux } from '../utils/progress.js';
8
+ import { colors, formatTestSummary, statusPalette, table } from '../utils/styling.js';
9
+ import { ui } from '../utils/ui.js';
10
10
  /**
11
11
  * Custom error for run failures that includes the polling result
12
12
  */
13
- class RunFailedError extends Error {
13
+ export class RunFailedError extends Error {
14
14
  result;
15
15
  constructor(result) {
16
16
  super('RUN_FAILED');
@@ -18,18 +18,22 @@ class RunFailedError extends Error {
18
18
  this.name = 'RunFailedError';
19
19
  }
20
20
  }
21
- exports.RunFailedError = RunFailedError;
22
21
  /**
23
22
  * Service for polling test results from the API
24
23
  */
25
- class ResultsPollingService {
24
+ export class ResultsPollingService {
26
25
  // The run keeps executing in the cloud regardless of whether the CLI can
27
- // poll, so tolerate a long stretch of transient API/network blips (~5 min at
28
- // the 10s base interval) before giving up. Losing a run to a brief hiccup is
29
- // far more costly than waiting a bit longer.
26
+ // poll, so tolerate a long stretch of transient API/network blips before
27
+ // giving up. Losing a run to a brief hiccup is far more costly than waiting a
28
+ // bit longer.
30
29
  MAX_SEQUENTIAL_FAILURES = 30;
31
- POLL_INTERVAL_MS = 10_000;
32
- // Cap for the backoff applied between failed polls.
30
+ // Backstop poll cadence. Logged-in (bearer) users also get realtime pushes
31
+ // (see RealtimeResultsGateway), so they only need an occasional reconciling
32
+ // poll; api-key users have no realtime and rely on the faster interval.
33
+ BEARER_POLL_INTERVAL_MS = 60_000;
34
+ APIKEY_POLL_INTERVAL_MS = 20_000;
35
+ // Base unit for the backoff applied between *failed* polls, and its cap.
36
+ ERROR_BACKOFF_BASE_MS = 10_000;
33
37
  MAX_ERROR_BACKOFF_MS = 30_000;
34
38
  /**
35
39
  * Poll for test results until all tests complete
@@ -46,7 +50,7 @@ class ResultsPollingService {
46
50
  // displayFinalResults. Anything else aborts mid-poll with the spinner
47
51
  // still live, which would corrupt the terminal under the error output.
48
52
  if (!options.json && !(error instanceof RunFailedError)) {
49
- progress_1.ux.action.stop(styling_1.colors.error('failed'));
53
+ ux.action.stop(colors.error('failed'));
50
54
  }
51
55
  throw error;
52
56
  }
@@ -55,44 +59,148 @@ class ResultsPollingService {
55
59
  const { apiUrl, auth, uploadId, consoleUrl, quiet = false, json = false, debug = false, logger } = options;
56
60
  this.initializePollingDisplay(json, logger);
57
61
  let sequentialPollFailures = 0;
58
- let previousSummary = '';
62
+ const pollIntervalMs = auth.mode === 'bearer'
63
+ ? this.BEARER_POLL_INTERVAL_MS
64
+ : this.APIKEY_POLL_INTERVAL_MS;
65
+ // "Poke" mechanism: a realtime change resolves the current inter-poll wait
66
+ // early. If a poke lands while we're mid-fetch (not waiting) it's latched
67
+ // and consumed by the next wait, so events are never silently dropped.
68
+ let resolveWake = null;
69
+ let pendingPoke = false;
70
+ const poke = () => {
71
+ if (resolveWake) {
72
+ const r = resolveWake;
73
+ resolveWake = null;
74
+ r();
75
+ }
76
+ else {
77
+ pendingPoke = true;
78
+ }
79
+ };
80
+ const waitForNextPoll = (ms) => {
81
+ if (pendingPoke) {
82
+ pendingPoke = false;
83
+ return Promise.resolve();
84
+ }
85
+ return new Promise((resolve) => {
86
+ const timer = setTimeout(() => {
87
+ resolveWake = null;
88
+ resolve();
89
+ }, ms);
90
+ resolveWake = () => {
91
+ clearTimeout(timer);
92
+ resolve();
93
+ };
94
+ });
95
+ };
96
+ // Live display state, recomposed by renderStatus() so the footer (countdown
97
+ // to the next backstop poll + realtime connection state) stays current
98
+ // between polls without re-fetching. `nextPollAt` is an epoch-ms deadline
99
+ // while waiting, or null while a fetch is in flight.
100
+ let subscription;
101
+ let realtimeEnabled = false;
102
+ let statusBody = '';
103
+ let nextPollAt = null;
104
+ // The animated footer/countdown only makes sense on a TTY. In CI/pipes it
105
+ // would flood logs (a fresh line per frame), so we drop it and let the
106
+ // progress adapter print one line per distinct status change instead.
107
+ const interactive = !json && !isCI();
108
+ const renderStatus = () => {
109
+ if (json)
110
+ return;
111
+ if (!interactive) {
112
+ ux.action.status = statusBody;
113
+ return;
114
+ }
115
+ const footer = this.buildStatusFooter(realtimeEnabled, subscription?.isConnected() ?? false, nextPollAt, quiet);
116
+ ux.action.status = footer ? `${statusBody}\n${footer}` : statusBody;
117
+ };
118
+ // Realtime is a latency optimisation over the backstop poll; only logged-in
119
+ // (bearer) users can authenticate the socket under RLS. Any failure inside
120
+ // the gateway degrades silently to pure polling.
121
+ if (auth.mode === 'bearer' && auth.accessToken && auth.orgId && auth.env) {
122
+ realtimeEnabled = true;
123
+ subscription = RealtimeResultsGateway.subscribe({
124
+ accessToken: auth.accessToken,
125
+ debug,
126
+ env: auth.env,
127
+ log: logger,
128
+ onChange: poke,
129
+ // Reflect connect/disconnect in the live footer immediately rather than
130
+ // waiting for the next ticker frame.
131
+ onConnectionChange: renderStatus,
132
+ orgId: auth.orgId,
133
+ uploadId,
134
+ });
135
+ if (debug && logger) {
136
+ logger(`[DEBUG] Realtime enabled; backstop poll every ${pollIntervalMs / 1000}s`);
137
+ }
138
+ }
59
139
  if (debug && logger) {
60
140
  logger(`[DEBUG] Starting polling loop for results`);
61
141
  }
62
- // Poll in a loop until all tests complete
63
- // eslint-disable-next-line no-constant-condition
64
- while (true) {
65
- try {
66
- const updatedResults = await this.fetchAndLogResults(apiUrl, auth, uploadId, debug, logger);
67
- const { summary } = this.calculateStatusSummary(updatedResults);
68
- previousSummary = this.updateDisplayStatus(updatedResults, quiet, json, summary, previousSummary);
69
- const allComplete = updatedResults.every((result) => !['PENDING', 'QUEUED', 'RUNNING'].includes(result.status));
70
- if (allComplete) {
71
- return await this.handleCompletedTests(updatedResults, {
72
- consoleUrl,
73
- debug,
74
- json,
75
- logger,
76
- testMetadata,
77
- uploadId,
78
- });
142
+ // Tick the live footer once a second so the countdown actually counts down
143
+ // (the spinner's own frames don't recompute our message). Unref'd so it
144
+ // never keeps the process alive on its own. Only on an interactive TTY —
145
+ // a 1s ticker in CI would reprint the status every second.
146
+ const ticker = interactive
147
+ ? setInterval(renderStatus, 1000)
148
+ : null;
149
+ ticker?.unref?.();
150
+ try {
151
+ // Poll in a loop until all tests complete
152
+ // eslint-disable-next-line no-constant-condition
153
+ while (true) {
154
+ try {
155
+ nextPollAt = null;
156
+ renderStatus();
157
+ const updatedResults = await this.fetchAndLogResults(apiUrl, auth, uploadId, debug, logger);
158
+ const { summary } = this.calculateStatusSummary(updatedResults);
159
+ if (!json) {
160
+ statusBody = this.buildStatusBody(updatedResults, quiet, summary);
161
+ }
162
+ const allComplete = updatedResults.every((result) => !['PENDING', 'QUEUED', 'RUNNING'].includes(result.status));
163
+ if (allComplete) {
164
+ return await this.handleCompletedTests(updatedResults, {
165
+ consoleUrl,
166
+ debug,
167
+ json,
168
+ logger,
169
+ testMetadata,
170
+ uploadId,
171
+ });
172
+ }
173
+ // Reset failure counter on successful poll
174
+ sequentialPollFailures = 0;
175
+ // Wait for the next backstop poll, or a realtime poke, whichever comes
176
+ // first, while the footer counts down to the deadline.
177
+ nextPollAt = Date.now() + pollIntervalMs;
178
+ renderStatus();
179
+ await waitForNextPoll(pollIntervalMs);
79
180
  }
80
- // Reset failure counter on successful poll
81
- sequentialPollFailures = 0;
82
- // Wait before next poll
83
- await this.sleep(this.POLL_INTERVAL_MS);
181
+ catch (error) {
182
+ // Re-throw RunFailedError immediately (test failures, not polling errors)
183
+ if (error instanceof RunFailedError) {
184
+ throw error;
185
+ }
186
+ sequentialPollFailures++;
187
+ // Handle polling errors (network issues, etc.)
188
+ await this.handlePollingError(error, sequentialPollFailures, debug, logger, uploadId);
189
+ // Back off (capped) before retrying so a flaky API gets some breathing
190
+ // room instead of being hammered on every failure.
191
+ await this.sleep(Math.min(this.ERROR_BACKOFF_BASE_MS * sequentialPollFailures, this.MAX_ERROR_BACKOFF_MS));
192
+ }
193
+ }
194
+ }
195
+ finally {
196
+ if (ticker) {
197
+ clearInterval(ticker);
84
198
  }
85
- catch (error) {
86
- // Re-throw RunFailedError immediately (test failures, not polling errors)
87
- if (error instanceof RunFailedError) {
88
- throw error;
199
+ if (subscription) {
200
+ if (debug && logger) {
201
+ logger('[DEBUG] Closing realtime subscription');
89
202
  }
90
- sequentialPollFailures++;
91
- // Handle polling errors (network issues, etc.)
92
- await this.handlePollingError(error, sequentialPollFailures, debug, logger, uploadId);
93
- // Back off (capped) before retrying so a flaky API gets some breathing
94
- // room instead of being hammered every 10s.
95
- await this.sleep(Math.min(this.POLL_INTERVAL_MS * sequentialPollFailures, this.MAX_ERROR_BACKOFF_MS));
203
+ await subscription.unsubscribe();
96
204
  }
97
205
  }
98
206
  }
@@ -129,7 +237,7 @@ class ResultsPollingService {
129
237
  const running = statusCounts.RUNNING || 0;
130
238
  const total = results.length;
131
239
  const completed = passed + failed;
132
- const summary = (0, styling_1.formatTestSummary)({
240
+ const summary = formatTestSummary({
133
241
  completed,
134
242
  failed,
135
243
  passed,
@@ -144,48 +252,29 @@ class ResultsPollingService {
144
252
  if (json) {
145
253
  return;
146
254
  }
147
- progress_1.ux.action.stop(styling_1.colors.success('completed'));
255
+ ux.action.stop(colors.success('completed'));
148
256
  if (logger) {
149
257
  logger('\n');
150
258
  }
151
259
  const hasFailedTests = results.some((result) => result.status === 'FAILED');
152
- (0, styling_1.table)(results, {
260
+ table(results, {
153
261
  duration: {
154
262
  get(row) {
155
263
  return row.duration_seconds
156
- ? styling_1.colors.dim((0, methods_1.formatDurationSeconds)(Number(row.duration_seconds)))
157
- : styling_1.colors.dim('-');
264
+ ? colors.dim(formatDurationSeconds(Number(row.duration_seconds)))
265
+ : colors.dim('-');
158
266
  },
159
267
  },
160
268
  status: {
161
269
  get(row) {
162
- const statusUpper = row.status.toUpperCase();
163
- switch (statusUpper) {
164
- case 'PASSED': {
165
- return styling_1.colors.success(row.status);
166
- }
167
- case 'FAILED': {
168
- return styling_1.colors.error(row.status);
169
- }
170
- case 'RUNNING': {
171
- return styling_1.colors.info(row.status);
172
- }
173
- case 'PENDING': {
174
- return styling_1.colors.warning(row.status);
175
- }
176
- case 'QUEUED': {
177
- return styling_1.colors.dim(row.status);
178
- }
179
- default: {
180
- return styling_1.colors.dim(row.status);
181
- }
182
- }
270
+ const { color } = statusPalette(row.status);
271
+ return color(row.status.toLowerCase());
183
272
  },
184
273
  },
185
274
  test: {
186
275
  get(row) {
187
276
  const testName = row.test_file_name;
188
- const retry = row.retry_of ? styling_1.colors.dim(' (retry)') : '';
277
+ const retry = row.retry_of ? colors.dim(' (retry)') : '';
189
278
  return `${testName}${retry}`;
190
279
  },
191
280
  },
@@ -194,7 +283,7 @@ class ResultsPollingService {
194
283
  fail_reason: {
195
284
  get(row) {
196
285
  return row.status === 'FAILED' && row.fail_reason
197
- ? styling_1.colors.error(row.fail_reason)
286
+ ? colors.error(row.fail_reason)
198
287
  : '';
199
288
  },
200
289
  },
@@ -202,8 +291,8 @@ class ResultsPollingService {
202
291
  }, { printLine: logger });
203
292
  if (logger) {
204
293
  logger('\n');
205
- logger(styling_1.colors.bold('Run completed') + styling_1.colors.dim(', you can access the results at:'));
206
- logger(styling_1.colors.url(consoleUrl));
294
+ logger(ui.success('Run completed'));
295
+ logger(ui.branch(ui.fields([['results', colors.url(consoleUrl)]])));
207
296
  logger('\n');
208
297
  }
209
298
  }
@@ -220,7 +309,7 @@ class ResultsPollingService {
220
309
  if (debug && logger) {
221
310
  logger(`[DEBUG] Polling for results: ${uploadId}`);
222
311
  }
223
- const { results: updatedResults } = await api_gateway_1.ApiGateway.getResultsForUpload(apiUrl, auth, uploadId);
312
+ const { results: updatedResults } = await ApiGateway.getResultsForUpload(apiUrl, auth, uploadId);
224
313
  // An empty array would otherwise read as "all complete, all passed".
225
314
  if (!updatedResults || updatedResults.length === 0) {
226
315
  throw new Error('no results');
@@ -298,7 +387,7 @@ class ResultsPollingService {
298
387
  if (debug && logger) {
299
388
  logger('[DEBUG] Checking internet connectivity...');
300
389
  }
301
- const connectivityCheck = await (0, connectivity_1.checkInternetConnectivity)();
390
+ const connectivityCheck = await checkInternetConnectivity();
302
391
  if (debug && logger) {
303
392
  logger(`[DEBUG] ${connectivityCheck.message}`);
304
393
  for (const result of connectivityCheck.endpointResults) {
@@ -330,11 +419,11 @@ class ResultsPollingService {
330
419
  */
331
420
  initializePollingDisplay(json, logger) {
332
421
  if (!json) {
333
- progress_1.ux.action.start(styling_1.colors.bold('Waiting for results'), styling_1.colors.dim('Initializing'), {
422
+ ux.action.start(colors.bold('Waiting for results'), colors.dim('Initializing'), {
334
423
  stdout: true,
335
424
  });
336
425
  if (logger) {
337
- logger(styling_1.colors.dim('\nYou can safely close this terminal and the tests will continue\n'));
426
+ logger(colors.dim('\nYou can safely close this terminal and the tests will continue\n'));
338
427
  }
339
428
  }
340
429
  }
@@ -348,33 +437,47 @@ class ResultsPollingService {
348
437
  setTimeout(resolve, ms);
349
438
  });
350
439
  }
351
- updateDisplayStatus(results, quiet, json, summary, previousSummary) {
352
- if (json) {
353
- return previousSummary;
354
- }
440
+ /**
441
+ * Build the body of the live status display (the per-test table, or just the
442
+ * one-line summary in quiet mode). The footer (countdown + realtime state) is
443
+ * appended separately by {@link buildStatusFooter} so it can re-render on a
444
+ * timer without re-fetching.
445
+ */
446
+ buildStatusBody(results, quiet, summary) {
355
447
  if (quiet) {
356
- if (summary !== previousSummary) {
357
- progress_1.ux.action.status = summary;
358
- return summary;
359
- }
448
+ return summary;
360
449
  }
361
- else {
362
- progress_1.ux.action.status = styling_1.colors.dim('\nStatus Test\n─────────── ───────────────');
363
- for (const { retry_of: isRetry, status, test_file_name: test, } of results) {
364
- const statusFormatted = status.toUpperCase() === 'PASSED'
365
- ? styling_1.colors.success(status.padEnd(10, ' '))
366
- : status.toUpperCase() === 'FAILED'
367
- ? styling_1.colors.error(status.padEnd(10, ' '))
368
- : status.toUpperCase() === 'RUNNING'
369
- ? styling_1.colors.info(status.padEnd(10, ' '))
370
- : status.toUpperCase() === 'QUEUED'
371
- ? styling_1.colors.dim(status.padEnd(10, ' '))
372
- : styling_1.colors.warning(status.padEnd(10, ' '));
373
- const retryText = isRetry ? styling_1.colors.dim(' (retry)') : '';
374
- progress_1.ux.action.status += `\n${statusFormatted} ${test}${retryText}`;
450
+ const rows = results.map(({ retry_of: isRetry, status, test_file_name: test }) => {
451
+ const label = `${ui.statusSymbol(status)} ${ui.statusWord(status)}`;
452
+ const retryText = isRetry ? colors.dim(' (retry)') : '';
453
+ return [label, `${test}${retryText}`];
454
+ });
455
+ return `\n${ui.branch(ui.fields(rows))}`;
456
+ }
457
+ /**
458
+ * Build the live footer shown under the status display: whether realtime
459
+ * updates are connected (for logged-in users) and how long until the next
460
+ * backstop poll. While a fetch is in flight (`nextPollAt` is null) the
461
+ * countdown reads "refreshing…". In quiet mode the countdown is omitted.
462
+ */
463
+ buildStatusFooter(realtimeEnabled, realtimeConnected, nextPollAt, quiet) {
464
+ const parts = [];
465
+ if (realtimeEnabled) {
466
+ parts.push(realtimeConnected
467
+ ? colors.success('● realtime connected')
468
+ : colors.warning('○ realtime connecting…'));
469
+ }
470
+ // The countdown to the next backstop poll is noise in quiet mode (geared at
471
+ // CI), so suppress it there while keeping the realtime indicator.
472
+ if (!quiet) {
473
+ if (nextPollAt === null) {
474
+ parts.push(colors.dim('refreshing…'));
475
+ }
476
+ else {
477
+ const secondsLeft = Math.max(0, Math.ceil((nextPollAt - Date.now()) / 1000));
478
+ parts.push(colors.dim(`next refresh in ${secondsLeft}s`));
375
479
  }
376
480
  }
377
- return previousSummary;
481
+ return parts.join(colors.dim(' · '));
378
482
  }
379
483
  }
380
- exports.ResultsPollingService = ResultsPollingService;
@@ -1,4 +1,4 @@
1
- import type { AuthContext } from '../types/domain/auth.types';
1
+ import type { AuthContext } from '../types/domain/auth.types.js';
2
2
  export type TelemetryLevel = 'log' | 'info' | 'warn' | 'error';
3
3
  declare class Telemetry {
4
4
  private buffer;
@@ -19,7 +19,16 @@ declare class Telemetry {
19
19
  auth: AuthContext;
20
20
  apiUrl?: string;
21
21
  }): void;
22
+ /**
23
+ * Override the command label attached to telemetry meta. The MCP server is
24
+ * long-lived and isn't a citty subcommand, so `inferCommandFromArgv` can't
25
+ * name it — `src/mcp/index.ts` calls this at boot.
26
+ */
27
+ setCommand(command: string): void;
22
28
  recordCommandStart(): void;
29
+ recordMcpToolStart(tool: string): void;
30
+ recordMcpToolSuccess(tool: string, durationMs: number): void;
31
+ recordMcpToolFailure(tool: string, error: unknown, durationMs: number): void;
23
32
  recordCommandSuccess(): void;
24
33
  recordCommandFailure(opts: {
25
34
  error: Error | string;
@@ -1,6 +1,3 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.telemetry = void 0;
4
1
  /**
5
2
  * Ships CLI lifecycle + error events to Axiom via the API's `/cli/logs`
6
3
  * proxy (forwards to `cli-dev` / `cli-prod`). The proxy authenticates with
@@ -15,21 +12,21 @@ exports.telemetry = void 0;
15
12
  *
16
13
  * Opt out: `DCD_TELEMETRY_DISABLED=1` in the environment.
17
14
  */
18
- const node_child_process_1 = require("node:child_process");
19
- const node_crypto_1 = require("node:crypto");
20
- const node_fs_1 = require("node:fs");
21
- const node_os_1 = require("node:os");
22
- const node_path_1 = require("node:path");
23
- const cli_1 = require("../utils/cli");
15
+ import { execFileSync } from 'node:child_process';
16
+ import { randomUUID } from 'node:crypto';
17
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import { join } from 'node:path';
20
+ import { getCliVersion, getInstallMethod } from '../utils/cli.js';
24
21
  const DEFAULT_API_URL = 'https://api.devicecloud.dev';
25
22
  class Telemetry {
26
23
  buffer = [];
27
24
  config = null;
28
25
  command = inferCommandFromArgv();
29
- sessionId = (0, node_crypto_1.randomUUID)();
26
+ sessionId = randomUUID();
30
27
  startedAt = Date.now();
31
28
  disabled = !!process.env.DCD_TELEMETRY_DISABLED;
32
- release = (0, cli_1.getCliVersion)();
29
+ release = getCliVersion();
33
30
  /**
34
31
  * Called once per invocation by `resolveAuth` after the credential check
35
32
  * succeeds. Before this is called, lifecycle events are buffered but cannot
@@ -46,12 +43,37 @@ class Telemetry {
46
43
  command: this.command,
47
44
  };
48
45
  }
46
+ /**
47
+ * Override the command label attached to telemetry meta. The MCP server is
48
+ * long-lived and isn't a citty subcommand, so `inferCommandFromArgv` can't
49
+ * name it — `src/mcp/index.ts` calls this at boot.
50
+ */
51
+ setCommand(command) {
52
+ this.command = command;
53
+ }
49
54
  recordCommandStart() {
50
55
  this.startedAt = Date.now();
51
56
  this.enqueue('info', 'cli.lifecycle', 'command started', {
52
57
  argv: scrubArgv(process.argv.slice(2)),
53
58
  });
54
59
  }
60
+ recordMcpToolStart(tool) {
61
+ this.enqueue('info', 'cli.mcp', 'mcp tool invoked', { tool });
62
+ }
63
+ recordMcpToolSuccess(tool, durationMs) {
64
+ this.enqueue('info', 'cli.mcp', 'mcp tool completed', {
65
+ tool,
66
+ duration_ms: durationMs,
67
+ });
68
+ }
69
+ recordMcpToolFailure(tool, error, durationMs) {
70
+ this.enqueue('error', 'cli.mcp', 'mcp tool failed', {
71
+ tool,
72
+ duration_ms: durationMs,
73
+ error_message: error instanceof Error ? error.message : String(error),
74
+ error_name: error instanceof Error ? error.name : 'Error',
75
+ });
76
+ }
55
77
  recordCommandSuccess() {
56
78
  this.enqueue('info', 'cli.lifecycle', 'command completed', {
57
79
  duration_ms: Date.now() - this.startedAt,
@@ -115,14 +137,14 @@ class Telemetry {
115
137
  // body, so it can't double as the config channel.
116
138
  let configDir;
117
139
  try {
118
- configDir = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'dcd-telemetry-'));
119
- const configPath = (0, node_path_1.join)(configDir, 'curl.cfg');
140
+ configDir = mkdtempSync(join(tmpdir(), 'dcd-telemetry-'));
141
+ const configPath = join(configDir, 'curl.cfg');
120
142
  const headerLines = ['header = "content-type: application/json"'];
121
143
  for (const [k, v] of Object.entries(this.config.auth.headers)) {
122
144
  headerLines.push(`header = "${k}: ${v}"`);
123
145
  }
124
- (0, node_fs_1.writeFileSync)(configPath, headerLines.join('\n'), { mode: 0o600 });
125
- (0, node_child_process_1.execFileSync)('curl', [
146
+ writeFileSync(configPath, headerLines.join('\n'), { mode: 0o600 });
147
+ execFileSync('curl', [
126
148
  '-sS',
127
149
  '-m',
128
150
  '3',
@@ -140,7 +162,7 @@ class Telemetry {
140
162
  }
141
163
  finally {
142
164
  if (configDir)
143
- (0, node_fs_1.rmSync)(configDir, { recursive: true, force: true });
165
+ rmSync(configDir, { recursive: true, force: true });
144
166
  }
145
167
  }
146
168
  buildMeta() {
@@ -152,7 +174,7 @@ class Telemetry {
152
174
  command: this.command,
153
175
  sessionId: this.sessionId,
154
176
  authMode: this.config.auth.mode,
155
- installMethod: (0, cli_1.getInstallMethod)(),
177
+ installMethod: getInstallMethod(),
156
178
  nodeVersion: process.versions.node,
157
179
  platform: process.platform,
158
180
  arch: process.arch,
@@ -227,4 +249,4 @@ function inferApiUrlFromArgv() {
227
249
  }
228
250
  return DEFAULT_API_URL;
229
251
  }
230
- exports.telemetry = new Telemetry();
252
+ export const telemetry = new Telemetry();
@@ -1,4 +1,4 @@
1
- import { IExecutionPlan } from './execution-plan.service';
1
+ import { IExecutionPlan } from './execution-plan.service.js';
2
2
  export interface TestSubmissionConfig {
3
3
  androidApiLevel?: string;
4
4
  androidDevice?: string;
@@ -35,11 +35,28 @@ export interface TestSubmissionConfig {
35
35
  */
36
36
  export declare class TestSubmissionService {
37
37
  /**
38
- * Build FormData for test submission
38
+ * Build the test-submission payload: the compressed flow zip plus every
39
+ * non-`file` field, each encoded exactly as it is sent today. The same
40
+ * `fields` feed both the new JSON `submitFlowTest` body and the legacy
41
+ * multipart `buildFormData`, guaranteeing byte-identical field encoding
42
+ * across both paths.
39
43
  * @param config Test submission configuration
40
- * @returns FormData ready to be submitted to the API
44
+ * @returns The flow zip buffer, its SHA-256, and the string-encoded fields
41
45
  */
42
- buildTestFormData(config: TestSubmissionConfig): Promise<FormData>;
46
+ buildTestPayload(config: TestSubmissionConfig): Promise<{
47
+ buffer: Buffer;
48
+ fields: Record<string, string>;
49
+ sha: string;
50
+ }>;
51
+ /**
52
+ * Wraps the payload fields and flow zip into multipart FormData for the
53
+ * legacy `POST /uploads/flow` fallback. `file` is set first to preserve the
54
+ * exact part ordering the old code produced.
55
+ * @param fields String-encoded fields from {@link buildTestPayload}
56
+ * @param buffer The compressed flow zip
57
+ * @returns FormData ready to be submitted to the multipart API
58
+ */
59
+ buildFormData(fields: Record<string, string>, buffer: Buffer): FormData;
43
60
  private logDebug;
44
61
  private normalizeFilePath;
45
62
  private normalizePathMap;