@devicecloud.dev/dcd 4.4.9 → 5.0.0-beta.1

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 (130) hide show
  1. package/README.md +75 -2
  2. package/dist/commands/artifacts.d.ts +47 -18
  3. package/dist/commands/artifacts.js +69 -64
  4. package/dist/commands/cloud.d.ts +228 -88
  5. package/dist/commands/cloud.js +430 -342
  6. package/dist/commands/list.d.ts +39 -38
  7. package/dist/commands/list.js +124 -131
  8. package/dist/commands/live.d.ts +2 -0
  9. package/dist/commands/live.js +520 -0
  10. package/dist/commands/login.d.ts +17 -0
  11. package/dist/commands/login.js +252 -0
  12. package/dist/commands/logout.d.ts +2 -0
  13. package/dist/commands/logout.js +30 -0
  14. package/dist/commands/status.d.ts +23 -42
  15. package/dist/commands/status.js +170 -179
  16. package/dist/commands/switch-org.d.ts +12 -0
  17. package/dist/commands/switch-org.js +76 -0
  18. package/dist/commands/upgrade.d.ts +2 -0
  19. package/dist/commands/upgrade.js +120 -0
  20. package/dist/commands/upload.d.ts +33 -18
  21. package/dist/commands/upload.js +72 -78
  22. package/dist/commands/whoami.d.ts +2 -0
  23. package/dist/commands/whoami.js +31 -0
  24. package/dist/config/environments.d.ts +31 -0
  25. package/dist/config/environments.js +52 -0
  26. package/dist/config/flags/api.flags.d.ts +10 -2
  27. package/dist/config/flags/api.flags.js +13 -14
  28. package/dist/config/flags/binary.flags.d.ts +17 -4
  29. package/dist/config/flags/binary.flags.js +14 -18
  30. package/dist/config/flags/device.flags.d.ts +49 -11
  31. package/dist/config/flags/device.flags.js +43 -38
  32. package/dist/config/flags/environment.flags.d.ts +27 -6
  33. package/dist/config/flags/environment.flags.js +24 -29
  34. package/dist/config/flags/execution.flags.d.ts +35 -8
  35. package/dist/config/flags/execution.flags.js +31 -41
  36. package/dist/config/flags/github.flags.d.ts +23 -5
  37. package/dist/config/flags/github.flags.js +19 -15
  38. package/dist/config/flags/output.flags.d.ts +57 -13
  39. package/dist/config/flags/output.flags.js +48 -47
  40. package/dist/constants.d.ts +218 -51
  41. package/dist/constants.js +17 -20
  42. package/dist/gateways/api-gateway.d.ts +72 -16
  43. package/dist/gateways/api-gateway.js +298 -104
  44. package/dist/gateways/cli-auth-gateway.d.ts +13 -0
  45. package/dist/gateways/cli-auth-gateway.js +54 -0
  46. package/dist/gateways/realtime-gateway.d.ts +32 -0
  47. package/dist/gateways/realtime-gateway.js +103 -0
  48. package/dist/gateways/supabase-gateway.d.ts +11 -11
  49. package/dist/gateways/supabase-gateway.js +20 -48
  50. package/dist/index.d.ts +2 -1
  51. package/dist/index.js +98 -4
  52. package/dist/mcp/context.d.ts +33 -0
  53. package/dist/mcp/context.js +33 -0
  54. package/dist/mcp/helpers.d.ts +16 -0
  55. package/dist/mcp/helpers.js +34 -0
  56. package/dist/mcp/index.d.ts +2 -0
  57. package/dist/mcp/index.js +24 -0
  58. package/dist/mcp/server.d.ts +7 -0
  59. package/dist/mcp/server.js +27 -0
  60. package/dist/mcp/tools/download-artifacts.d.ts +11 -0
  61. package/dist/mcp/tools/download-artifacts.js +84 -0
  62. package/dist/mcp/tools/get-status.d.ts +7 -0
  63. package/dist/mcp/tools/get-status.js +39 -0
  64. package/dist/mcp/tools/list-devices.d.ts +7 -0
  65. package/dist/mcp/tools/list-devices.js +27 -0
  66. package/dist/mcp/tools/list-runs.d.ts +3 -0
  67. package/dist/mcp/tools/list-runs.js +60 -0
  68. package/dist/mcp/tools/run-cloud-test.d.ts +14 -0
  69. package/dist/mcp/tools/run-cloud-test.js +233 -0
  70. package/dist/methods.d.ts +34 -5
  71. package/dist/methods.js +266 -215
  72. package/dist/services/device-validation.service.d.ts +9 -1
  73. package/dist/services/device-validation.service.js +56 -40
  74. package/dist/services/execution-plan.service.js +40 -31
  75. package/dist/services/execution-plan.utils.d.ts +3 -0
  76. package/dist/services/execution-plan.utils.js +25 -55
  77. package/dist/services/flow-paths.d.ts +17 -0
  78. package/dist/services/flow-paths.js +52 -0
  79. package/dist/services/metadata-extractor.service.d.ts +0 -2
  80. package/dist/services/metadata-extractor.service.js +75 -78
  81. package/dist/services/moropo.service.js +33 -34
  82. package/dist/services/report-download.service.d.ts +12 -1
  83. package/dist/services/report-download.service.js +34 -27
  84. package/dist/services/results-polling.service.d.ts +23 -9
  85. package/dist/services/results-polling.service.js +257 -123
  86. package/dist/services/telemetry.service.d.ts +49 -0
  87. package/dist/services/telemetry.service.js +252 -0
  88. package/dist/services/test-submission.service.d.ts +21 -4
  89. package/dist/services/test-submission.service.js +51 -33
  90. package/dist/services/version.service.d.ts +4 -3
  91. package/dist/services/version.service.js +28 -16
  92. package/dist/types/domain/auth.types.d.ts +20 -0
  93. package/dist/types/domain/auth.types.js +1 -0
  94. package/dist/types/domain/device.types.js +8 -11
  95. package/dist/types/domain/live.types.d.ts +76 -0
  96. package/dist/types/domain/live.types.js +3 -0
  97. package/dist/types/generated/schema.types.js +1 -2
  98. package/dist/types/index.d.ts +2 -2
  99. package/dist/types/index.js +2 -18
  100. package/dist/types.js +1 -2
  101. package/dist/utils/auth.d.ts +13 -0
  102. package/dist/utils/auth.js +141 -0
  103. package/dist/utils/ci.d.ts +12 -0
  104. package/dist/utils/ci.js +39 -0
  105. package/dist/utils/cli.d.ts +35 -0
  106. package/dist/utils/cli.js +118 -0
  107. package/dist/utils/compatibility.d.ts +2 -1
  108. package/dist/utils/compatibility.js +6 -8
  109. package/dist/utils/config-store.d.ts +35 -0
  110. package/dist/utils/config-store.js +115 -0
  111. package/dist/utils/connectivity.js +8 -7
  112. package/dist/utils/expo.js +29 -24
  113. package/dist/utils/orgs.d.ts +11 -0
  114. package/dist/utils/orgs.js +36 -0
  115. package/dist/utils/paths.d.ts +11 -0
  116. package/dist/utils/paths.js +21 -0
  117. package/dist/utils/progress.d.ts +13 -0
  118. package/dist/utils/progress.js +47 -0
  119. package/dist/utils/styling.d.ts +42 -36
  120. package/dist/utils/styling.js +78 -82
  121. package/dist/utils/ui.d.ts +41 -0
  122. package/dist/utils/ui.js +95 -0
  123. package/package.json +36 -45
  124. package/bin/dev.cmd +0 -3
  125. package/bin/dev.js +0 -6
  126. package/bin/run.cmd +0 -3
  127. package/bin/run.js +0 -7
  128. package/dist/types/schema.types.d.ts +0 -2702
  129. package/dist/types/schema.types.js +0 -3
  130. package/oclif.manifest.json +0 -884
@@ -1,17 +1,15 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ResultsPollingService = exports.RunFailedError = void 0;
4
- const core_1 = require("@oclif/core");
5
- const cli_ux_1 = require("@oclif/core/lib/cli-ux");
6
- const path = require("node:path");
7
- const api_gateway_1 = require("../gateways/api-gateway");
8
- const methods_1 = require("../methods");
9
- const connectivity_1 = require("../utils/connectivity");
10
- 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 { ux } from '../utils/progress.js';
7
+ import { colors, formatTestSummary, statusPalette, table } from '../utils/styling.js';
8
+ import { ui } from '../utils/ui.js';
11
9
  /**
12
10
  * Custom error for run failures that includes the polling result
13
11
  */
14
- class RunFailedError extends Error {
12
+ export class RunFailedError extends Error {
15
13
  result;
16
14
  constructor(result) {
17
15
  super('RUN_FAILED');
@@ -19,61 +17,178 @@ class RunFailedError extends Error {
19
17
  this.name = 'RunFailedError';
20
18
  }
21
19
  }
22
- exports.RunFailedError = RunFailedError;
23
20
  /**
24
21
  * Service for polling test results from the API
25
22
  */
26
- class ResultsPollingService {
27
- MAX_SEQUENTIAL_FAILURES = 10;
28
- POLL_INTERVAL_MS = 10_000;
23
+ export class ResultsPollingService {
24
+ // The run keeps executing in the cloud regardless of whether the CLI can
25
+ // poll, so tolerate a long stretch of transient API/network blips before
26
+ // giving up. Losing a run to a brief hiccup is far more costly than waiting a
27
+ // bit longer.
28
+ MAX_SEQUENTIAL_FAILURES = 30;
29
+ // Backstop poll cadence. Logged-in (bearer) users also get realtime pushes
30
+ // (see RealtimeResultsGateway), so they only need an occasional reconciling
31
+ // poll; api-key users have no realtime and rely on the faster interval.
32
+ BEARER_POLL_INTERVAL_MS = 60_000;
33
+ APIKEY_POLL_INTERVAL_MS = 20_000;
34
+ // Base unit for the backoff applied between *failed* polls, and its cap.
35
+ ERROR_BACKOFF_BASE_MS = 10_000;
36
+ MAX_ERROR_BACKOFF_MS = 30_000;
29
37
  /**
30
38
  * Poll for test results until all tests complete
31
- * @param results Initial test results from submission
32
39
  * @param options Polling configuration
33
40
  * @param testMetadata Optional metadata map for each test (flowName, tags)
34
41
  * @returns Promise that resolves with final test results or rejects if tests fail
35
42
  */
36
- async pollUntilComplete(results, options, testMetadata) {
37
- const { apiUrl, apiKey, uploadId, consoleUrl, quiet = false, json = false, debug = false, logger } = options;
43
+ async pollUntilComplete(options, testMetadata) {
44
+ try {
45
+ return await this.pollLoop(options, testMetadata);
46
+ }
47
+ catch (error) {
48
+ // RunFailedError is a completed run — the spinner was already stopped by
49
+ // displayFinalResults. Anything else aborts mid-poll with the spinner
50
+ // still live, which would corrupt the terminal under the error output.
51
+ if (!options.json && !(error instanceof RunFailedError)) {
52
+ ux.action.stop(colors.error('failed'));
53
+ }
54
+ throw error;
55
+ }
56
+ }
57
+ async pollLoop(options, testMetadata) {
58
+ const { apiUrl, auth, uploadId, consoleUrl, quiet = false, json = false, debug = false, logger } = options;
38
59
  this.initializePollingDisplay(json, logger);
39
60
  let sequentialPollFailures = 0;
40
- let previousSummary = '';
61
+ const pollIntervalMs = auth.mode === 'bearer'
62
+ ? this.BEARER_POLL_INTERVAL_MS
63
+ : this.APIKEY_POLL_INTERVAL_MS;
64
+ // "Poke" mechanism: a realtime change resolves the current inter-poll wait
65
+ // early. If a poke lands while we're mid-fetch (not waiting) it's latched
66
+ // and consumed by the next wait, so events are never silently dropped.
67
+ let resolveWake = null;
68
+ let pendingPoke = false;
69
+ const poke = () => {
70
+ if (resolveWake) {
71
+ const r = resolveWake;
72
+ resolveWake = null;
73
+ r();
74
+ }
75
+ else {
76
+ pendingPoke = true;
77
+ }
78
+ };
79
+ const waitForNextPoll = (ms) => {
80
+ if (pendingPoke) {
81
+ pendingPoke = false;
82
+ return Promise.resolve();
83
+ }
84
+ return new Promise((resolve) => {
85
+ const timer = setTimeout(() => {
86
+ resolveWake = null;
87
+ resolve();
88
+ }, ms);
89
+ resolveWake = () => {
90
+ clearTimeout(timer);
91
+ resolve();
92
+ };
93
+ });
94
+ };
95
+ // Live display state, recomposed by renderStatus() so the footer (countdown
96
+ // to the next backstop poll + realtime connection state) stays current
97
+ // between polls without re-fetching. `nextPollAt` is an epoch-ms deadline
98
+ // while waiting, or null while a fetch is in flight.
99
+ let subscription;
100
+ let realtimeEnabled = false;
101
+ let statusBody = '';
102
+ let nextPollAt = null;
103
+ const renderStatus = () => {
104
+ if (json)
105
+ return;
106
+ const footer = this.buildStatusFooter(realtimeEnabled, subscription?.isConnected() ?? false, nextPollAt);
107
+ ux.action.status = footer ? `${statusBody}\n${footer}` : statusBody;
108
+ };
109
+ // Realtime is a latency optimisation over the backstop poll; only logged-in
110
+ // (bearer) users can authenticate the socket under RLS. Any failure inside
111
+ // the gateway degrades silently to pure polling.
112
+ if (auth.mode === 'bearer' && auth.accessToken && auth.orgId && auth.env) {
113
+ realtimeEnabled = true;
114
+ subscription = RealtimeResultsGateway.subscribe({
115
+ accessToken: auth.accessToken,
116
+ debug,
117
+ env: auth.env,
118
+ log: logger,
119
+ onChange: poke,
120
+ // Reflect connect/disconnect in the live footer immediately rather than
121
+ // waiting for the next ticker frame.
122
+ onConnectionChange: renderStatus,
123
+ orgId: auth.orgId,
124
+ uploadId,
125
+ });
126
+ if (debug && logger) {
127
+ logger(`[DEBUG] Realtime enabled; backstop poll every ${pollIntervalMs / 1000}s`);
128
+ }
129
+ }
41
130
  if (debug && logger) {
42
131
  logger(`[DEBUG] Starting polling loop for results`);
43
132
  }
44
- // Poll in a loop until all tests complete
45
- // eslint-disable-next-line no-constant-condition
46
- while (true) {
47
- try {
48
- const updatedResults = await this.fetchAndLogResults(apiUrl, apiKey, uploadId, debug, logger);
49
- const { summary } = this.calculateStatusSummary(updatedResults);
50
- previousSummary = this.updateDisplayStatus(updatedResults, quiet, json, summary, previousSummary);
51
- const allComplete = updatedResults.every((result) => !['PENDING', 'QUEUED', 'RUNNING'].includes(result.status));
52
- if (allComplete) {
53
- return await this.handleCompletedTests(updatedResults, {
54
- consoleUrl,
55
- debug,
56
- json,
57
- logger,
58
- testMetadata,
59
- uploadId,
60
- });
133
+ // Tick the live footer once a second so the countdown actually counts down
134
+ // (the spinner's own frames don't recompute our message). Unref'd so it
135
+ // never keeps the process alive on its own.
136
+ const ticker = json ? null : setInterval(renderStatus, 1000);
137
+ ticker?.unref?.();
138
+ try {
139
+ // Poll in a loop until all tests complete
140
+ // eslint-disable-next-line no-constant-condition
141
+ while (true) {
142
+ try {
143
+ nextPollAt = null;
144
+ renderStatus();
145
+ const updatedResults = await this.fetchAndLogResults(apiUrl, auth, uploadId, debug, logger);
146
+ const { summary } = this.calculateStatusSummary(updatedResults);
147
+ if (!json) {
148
+ statusBody = this.buildStatusBody(updatedResults, quiet, summary);
149
+ }
150
+ const allComplete = updatedResults.every((result) => !['PENDING', 'QUEUED', 'RUNNING'].includes(result.status));
151
+ if (allComplete) {
152
+ return await this.handleCompletedTests(updatedResults, {
153
+ consoleUrl,
154
+ debug,
155
+ json,
156
+ logger,
157
+ testMetadata,
158
+ uploadId,
159
+ });
160
+ }
161
+ // Reset failure counter on successful poll
162
+ sequentialPollFailures = 0;
163
+ // Wait for the next backstop poll, or a realtime poke, whichever comes
164
+ // first, while the footer counts down to the deadline.
165
+ nextPollAt = Date.now() + pollIntervalMs;
166
+ renderStatus();
167
+ await waitForNextPoll(pollIntervalMs);
61
168
  }
62
- // Reset failure counter on successful poll
63
- sequentialPollFailures = 0;
64
- // Wait before next poll
65
- await this.sleep(this.POLL_INTERVAL_MS);
169
+ catch (error) {
170
+ // Re-throw RunFailedError immediately (test failures, not polling errors)
171
+ if (error instanceof RunFailedError) {
172
+ throw error;
173
+ }
174
+ sequentialPollFailures++;
175
+ // Handle polling errors (network issues, etc.)
176
+ await this.handlePollingError(error, sequentialPollFailures, debug, logger, uploadId);
177
+ // Back off (capped) before retrying so a flaky API gets some breathing
178
+ // room instead of being hammered on every failure.
179
+ await this.sleep(Math.min(this.ERROR_BACKOFF_BASE_MS * sequentialPollFailures, this.MAX_ERROR_BACKOFF_MS));
180
+ }
181
+ }
182
+ }
183
+ finally {
184
+ if (ticker) {
185
+ clearInterval(ticker);
66
186
  }
67
- catch (error) {
68
- // Re-throw RunFailedError immediately (test failures, not polling errors)
69
- if (error instanceof RunFailedError) {
70
- throw error;
187
+ if (subscription) {
188
+ if (debug && logger) {
189
+ logger('[DEBUG] Closing realtime subscription');
71
190
  }
72
- sequentialPollFailures++;
73
- // Handle polling errors (network issues, etc.)
74
- await this.handlePollingError(error, sequentialPollFailures, debug, logger);
75
- // Wait before retrying after an error
76
- await this.sleep(this.POLL_INTERVAL_MS);
191
+ await subscription.unsubscribe();
77
192
  }
78
193
  }
79
194
  }
@@ -81,11 +196,13 @@ class ResultsPollingService {
81
196
  const resultsWithoutEarlierTries = this.filterLatestResults(results);
82
197
  return {
83
198
  consoleUrl,
84
- status: resultsWithoutEarlierTries.some((result) => result.status === 'FAILED')
85
- ? 'FAILED'
86
- : 'PASSED',
199
+ // Anything other than an explicit pass (CANCELLED, ERROR, a status we
200
+ // don't know about yet) must fail the run — this gates CI exit codes.
201
+ status: resultsWithoutEarlierTries.every((result) => result.status === 'PASSED')
202
+ ? 'PASSED'
203
+ : 'FAILED',
87
204
  tests: resultsWithoutEarlierTries.map((r) => ({
88
- durationSeconds: r.duration_seconds,
205
+ durationSeconds: r.duration_seconds ?? null,
89
206
  failReason: r.status === 'FAILED' ? r.fail_reason || 'No reason provided' : undefined,
90
207
  fileName: r.test_file_name,
91
208
  flowName: testMetadata?.[r.test_file_name]?.flowName || path.parse(r.test_file_name).name,
@@ -108,7 +225,7 @@ class ResultsPollingService {
108
225
  const running = statusCounts.RUNNING || 0;
109
226
  const total = results.length;
110
227
  const completed = passed + failed;
111
- const summary = (0, styling_1.formatTestSummary)({
228
+ const summary = formatTestSummary({
112
229
  completed,
113
230
  failed,
114
231
  passed,
@@ -123,48 +240,29 @@ class ResultsPollingService {
123
240
  if (json) {
124
241
  return;
125
242
  }
126
- core_1.ux.action.stop(styling_1.colors.success('completed'));
243
+ ux.action.stop(colors.success('completed'));
127
244
  if (logger) {
128
245
  logger('\n');
129
246
  }
130
247
  const hasFailedTests = results.some((result) => result.status === 'FAILED');
131
- (0, cli_ux_1.table)(results, {
248
+ table(results, {
132
249
  duration: {
133
250
  get(row) {
134
251
  return row.duration_seconds
135
- ? styling_1.colors.dim((0, methods_1.formatDurationSeconds)(Number(row.duration_seconds)))
136
- : styling_1.colors.dim('-');
252
+ ? colors.dim(formatDurationSeconds(Number(row.duration_seconds)))
253
+ : colors.dim('-');
137
254
  },
138
255
  },
139
256
  status: {
140
257
  get(row) {
141
- const statusUpper = row.status.toUpperCase();
142
- switch (statusUpper) {
143
- case 'PASSED': {
144
- return styling_1.colors.success(row.status);
145
- }
146
- case 'FAILED': {
147
- return styling_1.colors.error(row.status);
148
- }
149
- case 'RUNNING': {
150
- return styling_1.colors.info(row.status);
151
- }
152
- case 'PENDING': {
153
- return styling_1.colors.warning(row.status);
154
- }
155
- case 'QUEUED': {
156
- return styling_1.colors.dim(row.status);
157
- }
158
- default: {
159
- return styling_1.colors.dim(row.status);
160
- }
161
- }
258
+ const { color } = statusPalette(row.status);
259
+ return color(row.status.toLowerCase());
162
260
  },
163
261
  },
164
262
  test: {
165
263
  get(row) {
166
264
  const testName = row.test_file_name;
167
- const retry = row.retry_of ? styling_1.colors.dim(' (retry)') : '';
265
+ const retry = row.retry_of ? colors.dim(' (retry)') : '';
168
266
  return `${testName}${retry}`;
169
267
  },
170
268
  },
@@ -173,7 +271,7 @@ class ResultsPollingService {
173
271
  fail_reason: {
174
272
  get(row) {
175
273
  return row.status === 'FAILED' && row.fail_reason
176
- ? styling_1.colors.error(row.fail_reason)
274
+ ? colors.error(row.fail_reason)
177
275
  : '';
178
276
  },
179
277
  },
@@ -181,26 +279,27 @@ class ResultsPollingService {
181
279
  }, { printLine: logger });
182
280
  if (logger) {
183
281
  logger('\n');
184
- logger(styling_1.colors.bold('Run completed') + styling_1.colors.dim(', you can access the results at:'));
185
- logger(styling_1.colors.url(consoleUrl));
282
+ logger(ui.success('Run completed'));
283
+ logger(ui.branch(ui.fields([['results', colors.url(consoleUrl)]])));
186
284
  logger('\n');
187
285
  }
188
286
  }
189
287
  /**
190
288
  * Fetch results from API and log debug information
191
289
  * @param apiUrl API base URL
192
- * @param apiKey API authentication key
290
+ * @param auth AuthContext carrying request headers
193
291
  * @param uploadId Upload ID to fetch results for
194
292
  * @param debug Whether debug logging is enabled
195
293
  * @param logger Optional logger function
196
294
  * @returns Promise resolving to test results
197
295
  */
198
- async fetchAndLogResults(apiUrl, apiKey, uploadId, debug, logger) {
296
+ async fetchAndLogResults(apiUrl, auth, uploadId, debug, logger) {
199
297
  if (debug && logger) {
200
298
  logger(`[DEBUG] Polling for results: ${uploadId}`);
201
299
  }
202
- const { results: updatedResults } = await api_gateway_1.ApiGateway.getResultsForUpload(apiUrl, apiKey, uploadId);
203
- if (!updatedResults) {
300
+ const { results: updatedResults } = await ApiGateway.getResultsForUpload(apiUrl, auth, uploadId);
301
+ // An empty array would otherwise read as "all complete, all passed".
302
+ if (!updatedResults || updatedResults.length === 0) {
204
303
  throw new Error('no results');
205
304
  }
206
305
  if (debug && logger) {
@@ -212,11 +311,31 @@ class ResultsPollingService {
212
311
  return updatedResults;
213
312
  }
214
313
  filterLatestResults(results) {
215
- return results.filter((result) => {
216
- const originalTryId = result.retry_of || result.id;
217
- const tries = results.filter((r) => r.retry_of === originalTryId || r.id === originalTryId);
218
- return result.id === Math.max(...tries.map((t) => t.id));
219
- });
314
+ // Resolve each row to the root of its retry chain (a retry's `retry_of`
315
+ // may point at the previous retry rather than the original attempt), then
316
+ // keep only the newest row per root.
317
+ const byId = new Map(results.map((result) => [result.id, result]));
318
+ const rootIdOf = (result) => {
319
+ let current = result;
320
+ const seen = new Set([current.id]);
321
+ while (current.retry_of) {
322
+ const parent = byId.get(current.retry_of);
323
+ if (!parent || seen.has(parent.id))
324
+ return current.retry_of;
325
+ seen.add(parent.id);
326
+ current = parent;
327
+ }
328
+ return current.id;
329
+ };
330
+ const latestByRoot = new Map();
331
+ for (const result of results) {
332
+ const rootId = rootIdOf(result);
333
+ const existing = latestByRoot.get(rootId);
334
+ if (!existing || result.id > existing.id) {
335
+ latestByRoot.set(rootId, result);
336
+ }
337
+ }
338
+ return results.filter((result) => latestByRoot.get(rootIdOf(result)) === result);
220
339
  }
221
340
  /**
222
341
  * Handle completed tests and return final result
@@ -242,16 +361,21 @@ class ResultsPollingService {
242
361
  }
243
362
  return output;
244
363
  }
245
- async handlePollingError(error, sequentialPollFailures, debug, logger) {
364
+ async handlePollingError(error, sequentialPollFailures, debug, logger, uploadId) {
365
+ // The run is unaffected by our inability to poll — always point the user at
366
+ // how to reconnect to it rather than leaving them thinking it died.
367
+ const resumeHint = uploadId
368
+ ? `\n\nThe test is still running in the cloud. Reconnect with:\n dcd status --upload-id ${uploadId}`
369
+ : '';
246
370
  if (debug && logger) {
247
371
  logger(`[DEBUG] Error polling for results: ${error}`);
248
372
  logger(`[DEBUG] Sequential poll failures: ${sequentialPollFailures}`);
249
373
  }
250
- if (sequentialPollFailures > this.MAX_SEQUENTIAL_FAILURES) {
374
+ if (sequentialPollFailures >= this.MAX_SEQUENTIAL_FAILURES) {
251
375
  if (debug && logger) {
252
376
  logger('[DEBUG] Checking internet connectivity...');
253
377
  }
254
- const connectivityCheck = await (0, connectivity_1.checkInternetConnectivity)();
378
+ const connectivityCheck = await checkInternetConnectivity();
255
379
  if (debug && logger) {
256
380
  logger(`[DEBUG] ${connectivityCheck.message}`);
257
381
  for (const result of connectivityCheck.endpointResults) {
@@ -267,9 +391,9 @@ class ResultsPollingService {
267
391
  const endpointDetails = connectivityCheck.endpointResults
268
392
  .map((r) => ` - ${r.endpoint}: ${r.error} (${r.latencyMs}ms)`)
269
393
  .join('\n');
270
- throw new Error(`Unable to fetch results after ${this.MAX_SEQUENTIAL_FAILURES} attempts.\n\nInternet connectivity check failed - all test endpoints unreachable:\n${endpointDetails}\n\nPlease verify your network connection and DNS resolution.`);
394
+ throw new Error(`Unable to fetch results after ${this.MAX_SEQUENTIAL_FAILURES} attempts.\n\nInternet connectivity check failed - all test endpoints unreachable:\n${endpointDetails}\n\nPlease verify your network connection and DNS resolution.${resumeHint}`);
271
395
  }
272
- throw new Error(`unable to fetch results after ${this.MAX_SEQUENTIAL_FAILURES} attempts`);
396
+ throw new Error(`unable to fetch results after ${this.MAX_SEQUENTIAL_FAILURES} attempts${resumeHint}`);
273
397
  }
274
398
  if (logger) {
275
399
  logger('unable to fetch results, trying again...');
@@ -283,11 +407,11 @@ class ResultsPollingService {
283
407
  */
284
408
  initializePollingDisplay(json, logger) {
285
409
  if (!json) {
286
- core_1.ux.action.start(styling_1.colors.bold('Waiting for results'), styling_1.colors.dim('Initializing'), {
410
+ ux.action.start(colors.bold('Waiting for results'), colors.dim('Initializing'), {
287
411
  stdout: true,
288
412
  });
289
413
  if (logger) {
290
- logger(styling_1.colors.dim('\nYou can safely close this terminal and the tests will continue\n'));
414
+ logger(colors.dim('\nYou can safely close this terminal and the tests will continue\n'));
291
415
  }
292
416
  }
293
417
  }
@@ -301,33 +425,43 @@ class ResultsPollingService {
301
425
  setTimeout(resolve, ms);
302
426
  });
303
427
  }
304
- updateDisplayStatus(results, quiet, json, summary, previousSummary) {
305
- if (json) {
306
- return previousSummary;
307
- }
428
+ /**
429
+ * Build the body of the live status display (the per-test table, or just the
430
+ * one-line summary in quiet mode). The footer (countdown + realtime state) is
431
+ * appended separately by {@link buildStatusFooter} so it can re-render on a
432
+ * timer without re-fetching.
433
+ */
434
+ buildStatusBody(results, quiet, summary) {
308
435
  if (quiet) {
309
- if (summary !== previousSummary) {
310
- core_1.ux.action.status = summary;
311
- return summary;
312
- }
436
+ return summary;
437
+ }
438
+ const rows = results.map(({ retry_of: isRetry, status, test_file_name: test }) => {
439
+ const label = `${ui.statusSymbol(status)} ${ui.statusWord(status)}`;
440
+ const retryText = isRetry ? colors.dim(' (retry)') : '';
441
+ return [label, `${test}${retryText}`];
442
+ });
443
+ return `\n${ui.branch(ui.fields(rows))}`;
444
+ }
445
+ /**
446
+ * Build the live footer shown under the status display: whether realtime
447
+ * updates are connected (for logged-in users) and how long until the next
448
+ * backstop poll. While a fetch is in flight (`nextPollAt` is null) the
449
+ * countdown reads "refreshing…".
450
+ */
451
+ buildStatusFooter(realtimeEnabled, realtimeConnected, nextPollAt) {
452
+ const parts = [];
453
+ if (realtimeEnabled) {
454
+ parts.push(realtimeConnected
455
+ ? colors.success('● realtime connected')
456
+ : colors.warning('○ realtime connecting…'));
457
+ }
458
+ if (nextPollAt === null) {
459
+ parts.push(colors.dim('refreshing…'));
313
460
  }
314
461
  else {
315
- core_1.ux.action.status = styling_1.colors.dim('\nStatus Test\n─────────── ───────────────');
316
- for (const { retry_of: isRetry, status, test_file_name: test, } of results) {
317
- const statusFormatted = status.toUpperCase() === 'PASSED'
318
- ? styling_1.colors.success(status.padEnd(10, ' '))
319
- : status.toUpperCase() === 'FAILED'
320
- ? styling_1.colors.error(status.padEnd(10, ' '))
321
- : status.toUpperCase() === 'RUNNING'
322
- ? styling_1.colors.info(status.padEnd(10, ' '))
323
- : status.toUpperCase() === 'QUEUED'
324
- ? styling_1.colors.dim(status.padEnd(10, ' '))
325
- : styling_1.colors.warning(status.padEnd(10, ' '));
326
- const retryText = isRetry ? styling_1.colors.dim(' (retry)') : '';
327
- core_1.ux.action.status += `\n${statusFormatted} ${test}${retryText}`;
328
- }
462
+ const secondsLeft = Math.max(0, Math.ceil((nextPollAt - Date.now()) / 1000));
463
+ parts.push(colors.dim(`next refresh in ${secondsLeft}s`));
329
464
  }
330
- return previousSummary;
465
+ return parts.join(colors.dim(' · '));
331
466
  }
332
467
  }
333
- exports.ResultsPollingService = ResultsPollingService;
@@ -0,0 +1,49 @@
1
+ import type { AuthContext } from '../types/domain/auth.types.js';
2
+ export type TelemetryLevel = 'log' | 'info' | 'warn' | 'error';
3
+ declare class Telemetry {
4
+ private buffer;
5
+ private config;
6
+ private command;
7
+ private sessionId;
8
+ private startedAt;
9
+ private readonly disabled;
10
+ private readonly release;
11
+ /**
12
+ * Called once per invocation by `resolveAuth` after the credential check
13
+ * succeeds. Before this is called, lifecycle events are buffered but cannot
14
+ * be sent. Commands that never reach `resolveAuth` (`--help`, `--version`,
15
+ * `dcd login` before sign-in completes) will skip telemetry entirely — by
16
+ * design, since those flows have no identity to attach.
17
+ */
18
+ configure(opts: {
19
+ auth: AuthContext;
20
+ apiUrl?: string;
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;
28
+ recordCommandStart(): void;
29
+ recordMcpToolStart(tool: string): void;
30
+ recordMcpToolSuccess(tool: string, durationMs: number): void;
31
+ recordMcpToolFailure(tool: string, error: unknown, durationMs: number): void;
32
+ recordCommandSuccess(): void;
33
+ recordCommandFailure(opts: {
34
+ error: Error | string;
35
+ exitCode: number;
36
+ }): void;
37
+ private enqueue;
38
+ flush(): Promise<void>;
39
+ /**
40
+ * Synchronous flush via `curl`. Used right before `process.exit` (which
41
+ * bypasses `beforeExit`, so async `fetch` would be killed mid-flight).
42
+ * Node has no built-in sync HTTP and `curl` ships with macOS, Linux, and
43
+ * Windows ≥ 1803 — that's the supported surface for the CLI.
44
+ */
45
+ flushSync(): void;
46
+ private buildMeta;
47
+ }
48
+ export declare const telemetry: Telemetry;
49
+ export {};