@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.
- package/README.md +75 -2
- package/dist/commands/artifacts.d.ts +47 -18
- package/dist/commands/artifacts.js +69 -64
- package/dist/commands/cloud.d.ts +228 -88
- package/dist/commands/cloud.js +430 -342
- package/dist/commands/list.d.ts +39 -38
- package/dist/commands/list.js +124 -131
- package/dist/commands/live.d.ts +2 -0
- package/dist/commands/live.js +520 -0
- package/dist/commands/login.d.ts +17 -0
- package/dist/commands/login.js +252 -0
- package/dist/commands/logout.d.ts +2 -0
- package/dist/commands/logout.js +30 -0
- package/dist/commands/status.d.ts +23 -42
- package/dist/commands/status.js +170 -179
- package/dist/commands/switch-org.d.ts +12 -0
- package/dist/commands/switch-org.js +76 -0
- package/dist/commands/upgrade.d.ts +2 -0
- package/dist/commands/upgrade.js +120 -0
- package/dist/commands/upload.d.ts +33 -18
- package/dist/commands/upload.js +72 -78
- package/dist/commands/whoami.d.ts +2 -0
- package/dist/commands/whoami.js +31 -0
- package/dist/config/environments.d.ts +31 -0
- package/dist/config/environments.js +52 -0
- package/dist/config/flags/api.flags.d.ts +10 -2
- package/dist/config/flags/api.flags.js +13 -14
- package/dist/config/flags/binary.flags.d.ts +17 -4
- package/dist/config/flags/binary.flags.js +14 -18
- package/dist/config/flags/device.flags.d.ts +49 -11
- package/dist/config/flags/device.flags.js +43 -38
- package/dist/config/flags/environment.flags.d.ts +27 -6
- package/dist/config/flags/environment.flags.js +24 -29
- package/dist/config/flags/execution.flags.d.ts +35 -8
- package/dist/config/flags/execution.flags.js +31 -41
- package/dist/config/flags/github.flags.d.ts +23 -5
- package/dist/config/flags/github.flags.js +19 -15
- package/dist/config/flags/output.flags.d.ts +57 -13
- package/dist/config/flags/output.flags.js +48 -47
- package/dist/constants.d.ts +218 -51
- package/dist/constants.js +17 -20
- package/dist/gateways/api-gateway.d.ts +72 -16
- package/dist/gateways/api-gateway.js +298 -104
- package/dist/gateways/cli-auth-gateway.d.ts +13 -0
- package/dist/gateways/cli-auth-gateway.js +54 -0
- package/dist/gateways/realtime-gateway.d.ts +32 -0
- package/dist/gateways/realtime-gateway.js +103 -0
- package/dist/gateways/supabase-gateway.d.ts +11 -11
- package/dist/gateways/supabase-gateway.js +20 -48
- package/dist/index.d.ts +2 -1
- package/dist/index.js +98 -4
- package/dist/mcp/context.d.ts +33 -0
- package/dist/mcp/context.js +33 -0
- package/dist/mcp/helpers.d.ts +16 -0
- package/dist/mcp/helpers.js +34 -0
- package/dist/mcp/index.d.ts +2 -0
- package/dist/mcp/index.js +24 -0
- package/dist/mcp/server.d.ts +7 -0
- package/dist/mcp/server.js +27 -0
- package/dist/mcp/tools/download-artifacts.d.ts +11 -0
- package/dist/mcp/tools/download-artifacts.js +84 -0
- package/dist/mcp/tools/get-status.d.ts +7 -0
- package/dist/mcp/tools/get-status.js +39 -0
- package/dist/mcp/tools/list-devices.d.ts +7 -0
- package/dist/mcp/tools/list-devices.js +27 -0
- package/dist/mcp/tools/list-runs.d.ts +3 -0
- package/dist/mcp/tools/list-runs.js +60 -0
- package/dist/mcp/tools/run-cloud-test.d.ts +14 -0
- package/dist/mcp/tools/run-cloud-test.js +233 -0
- package/dist/methods.d.ts +34 -5
- package/dist/methods.js +266 -215
- package/dist/services/device-validation.service.d.ts +9 -1
- package/dist/services/device-validation.service.js +56 -40
- package/dist/services/execution-plan.service.js +40 -31
- package/dist/services/execution-plan.utils.d.ts +3 -0
- package/dist/services/execution-plan.utils.js +25 -55
- package/dist/services/flow-paths.d.ts +17 -0
- package/dist/services/flow-paths.js +52 -0
- package/dist/services/metadata-extractor.service.d.ts +0 -2
- package/dist/services/metadata-extractor.service.js +75 -78
- package/dist/services/moropo.service.js +33 -34
- package/dist/services/report-download.service.d.ts +12 -1
- package/dist/services/report-download.service.js +34 -27
- package/dist/services/results-polling.service.d.ts +23 -9
- package/dist/services/results-polling.service.js +257 -123
- package/dist/services/telemetry.service.d.ts +49 -0
- package/dist/services/telemetry.service.js +252 -0
- package/dist/services/test-submission.service.d.ts +21 -4
- package/dist/services/test-submission.service.js +51 -33
- package/dist/services/version.service.d.ts +4 -3
- package/dist/services/version.service.js +28 -16
- package/dist/types/domain/auth.types.d.ts +20 -0
- package/dist/types/domain/auth.types.js +1 -0
- package/dist/types/domain/device.types.js +8 -11
- package/dist/types/domain/live.types.d.ts +76 -0
- package/dist/types/domain/live.types.js +3 -0
- package/dist/types/generated/schema.types.js +1 -2
- package/dist/types/index.d.ts +2 -2
- package/dist/types/index.js +2 -18
- package/dist/types.js +1 -2
- package/dist/utils/auth.d.ts +13 -0
- package/dist/utils/auth.js +141 -0
- package/dist/utils/ci.d.ts +12 -0
- package/dist/utils/ci.js +39 -0
- package/dist/utils/cli.d.ts +35 -0
- package/dist/utils/cli.js +118 -0
- package/dist/utils/compatibility.d.ts +2 -1
- package/dist/utils/compatibility.js +6 -8
- package/dist/utils/config-store.d.ts +35 -0
- package/dist/utils/config-store.js +115 -0
- package/dist/utils/connectivity.js +8 -7
- package/dist/utils/expo.js +29 -24
- package/dist/utils/orgs.d.ts +11 -0
- package/dist/utils/orgs.js +36 -0
- package/dist/utils/paths.d.ts +11 -0
- package/dist/utils/paths.js +21 -0
- package/dist/utils/progress.d.ts +13 -0
- package/dist/utils/progress.js +47 -0
- package/dist/utils/styling.d.ts +42 -36
- package/dist/utils/styling.js +78 -82
- package/dist/utils/ui.d.ts +41 -0
- package/dist/utils/ui.js +95 -0
- package/package.json +36 -45
- package/bin/dev.cmd +0 -3
- package/bin/dev.js +0 -6
- package/bin/run.cmd +0 -3
- package/bin/run.js +0 -7
- package/dist/types/schema.types.d.ts +0 -2702
- package/dist/types/schema.types.js +0 -3
- package/oclif.manifest.json +0 -884
|
@@ -1,17 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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(
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
throw error;
|
|
187
|
+
if (subscription) {
|
|
188
|
+
if (debug && logger) {
|
|
189
|
+
logger('[DEBUG] Closing realtime subscription');
|
|
71
190
|
}
|
|
72
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
248
|
+
table(results, {
|
|
132
249
|
duration: {
|
|
133
250
|
get(row) {
|
|
134
251
|
return row.duration_seconds
|
|
135
|
-
?
|
|
136
|
-
:
|
|
252
|
+
? colors.dim(formatDurationSeconds(Number(row.duration_seconds)))
|
|
253
|
+
: colors.dim('-');
|
|
137
254
|
},
|
|
138
255
|
},
|
|
139
256
|
status: {
|
|
140
257
|
get(row) {
|
|
141
|
-
const
|
|
142
|
-
|
|
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 ?
|
|
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
|
-
?
|
|
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(
|
|
185
|
-
logger(
|
|
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
|
|
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,
|
|
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
|
|
203
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
410
|
+
ux.action.start(colors.bold('Waiting for results'), colors.dim('Initializing'), {
|
|
287
411
|
stdout: true,
|
|
288
412
|
});
|
|
289
413
|
if (logger) {
|
|
290
|
-
logger(
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
316
|
-
|
|
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
|
|
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 {};
|