@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.
- package/LICENSE +21 -0
- package/README.md +52 -0
- package/dist/commands/artifacts.d.ts +28 -28
- package/dist/commands/artifacts.js +20 -23
- package/dist/commands/cloud.d.ts +57 -57
- package/dist/commands/cloud.js +224 -192
- package/dist/commands/list.d.ts +22 -22
- package/dist/commands/list.js +43 -40
- package/dist/commands/live.js +134 -127
- package/dist/commands/login.d.ts +11 -11
- package/dist/commands/login.js +46 -44
- package/dist/commands/logout.js +16 -18
- package/dist/commands/status.d.ts +11 -11
- package/dist/commands/status.js +53 -44
- package/dist/commands/switch-org.d.ts +7 -7
- package/dist/commands/switch-org.js +19 -21
- package/dist/commands/upgrade.js +41 -33
- package/dist/commands/upload.d.ts +10 -10
- package/dist/commands/upload.js +42 -43
- package/dist/commands/whoami.js +17 -20
- package/dist/config/environments.js +6 -12
- package/dist/config/flags/api.flags.js +1 -4
- package/dist/config/flags/binary.flags.js +1 -4
- package/dist/config/flags/device.flags.js +6 -9
- package/dist/config/flags/environment.flags.js +1 -4
- package/dist/config/flags/execution.flags.js +1 -4
- package/dist/config/flags/github.flags.js +1 -4
- package/dist/config/flags/output.flags.js +1 -4
- package/dist/constants.js +15 -18
- package/dist/gateways/api-gateway.d.ts +31 -6
- package/dist/gateways/api-gateway.js +70 -16
- package/dist/gateways/cli-auth-gateway.d.ts +1 -1
- package/dist/gateways/cli-auth-gateway.js +3 -6
- package/dist/gateways/realtime-gateway.d.ts +32 -0
- package/dist/gateways/realtime-gateway.js +103 -0
- package/dist/gateways/supabase-gateway.d.ts +1 -1
- package/dist/gateways/supabase-gateway.js +10 -14
- package/dist/index.js +41 -38
- 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 +32 -1
- package/dist/methods.js +133 -79
- package/dist/services/device-validation.service.d.ts +1 -1
- package/dist/services/device-validation.service.js +1 -5
- package/dist/services/execution-plan.service.js +14 -17
- package/dist/services/execution-plan.utils.js +15 -23
- package/dist/services/flow-paths.d.ts +17 -0
- package/dist/services/flow-paths.js +52 -0
- package/dist/services/metadata-extractor.service.js +22 -25
- package/dist/services/moropo.service.js +18 -20
- package/dist/services/report-download.service.d.ts +1 -1
- package/dist/services/report-download.service.js +5 -9
- package/dist/services/results-polling.service.d.ts +18 -3
- package/dist/services/results-polling.service.js +211 -108
- package/dist/services/telemetry.service.d.ts +10 -1
- package/dist/services/telemetry.service.js +40 -18
- package/dist/services/test-submission.service.d.ts +21 -4
- package/dist/services/test-submission.service.js +51 -34
- package/dist/services/version.service.d.ts +30 -7
- package/dist/services/version.service.js +88 -32
- package/dist/types/domain/auth.types.d.ts +8 -0
- package/dist/types/domain/auth.types.js +1 -2
- package/dist/types/domain/device.types.js +8 -11
- package/dist/types/domain/live.types.js +1 -2
- 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 +1 -1
- package/dist/utils/auth.js +27 -28
- package/dist/utils/ci.d.ts +12 -0
- package/dist/utils/ci.js +39 -0
- package/dist/utils/cli.d.ts +16 -2
- package/dist/utils/cli.js +57 -29
- package/dist/utils/compatibility.d.ts +1 -1
- package/dist/utils/compatibility.js +5 -7
- package/dist/utils/config-store.js +33 -43
- package/dist/utils/connectivity.js +1 -4
- package/dist/utils/expo.js +15 -21
- package/dist/utils/orgs.js +8 -12
- package/dist/utils/paths.js +2 -5
- package/dist/utils/progress.d.ts +3 -0
- package/dist/utils/progress.js +47 -8
- package/dist/utils/styling.d.ts +35 -37
- package/dist/utils/styling.js +52 -86
- package/dist/utils/ui.d.ts +41 -0
- package/dist/utils/ui.js +95 -0
- package/package.json +27 -24
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
28
|
-
//
|
|
29
|
-
//
|
|
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
|
-
|
|
32
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
throw error;
|
|
199
|
+
if (subscription) {
|
|
200
|
+
if (debug && logger) {
|
|
201
|
+
logger('[DEBUG] Closing realtime subscription');
|
|
89
202
|
}
|
|
90
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
260
|
+
table(results, {
|
|
153
261
|
duration: {
|
|
154
262
|
get(row) {
|
|
155
263
|
return row.duration_seconds
|
|
156
|
-
?
|
|
157
|
-
:
|
|
264
|
+
? colors.dim(formatDurationSeconds(Number(row.duration_seconds)))
|
|
265
|
+
: colors.dim('-');
|
|
158
266
|
},
|
|
159
267
|
},
|
|
160
268
|
status: {
|
|
161
269
|
get(row) {
|
|
162
|
-
const
|
|
163
|
-
|
|
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 ?
|
|
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
|
-
?
|
|
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(
|
|
206
|
-
logger(
|
|
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
|
|
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
|
|
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
|
-
|
|
422
|
+
ux.action.start(colors.bold('Waiting for results'), colors.dim('Initializing'), {
|
|
334
423
|
stdout: true,
|
|
335
424
|
});
|
|
336
425
|
if (logger) {
|
|
337
|
-
logger(
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
357
|
-
progress_1.ux.action.status = summary;
|
|
358
|
-
return summary;
|
|
359
|
-
}
|
|
448
|
+
return summary;
|
|
360
449
|
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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 =
|
|
26
|
+
sessionId = randomUUID();
|
|
30
27
|
startedAt = Date.now();
|
|
31
28
|
disabled = !!process.env.DCD_TELEMETRY_DISABLED;
|
|
32
|
-
release =
|
|
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 =
|
|
119
|
-
const configPath =
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
44
|
+
* @returns The flow zip buffer, its SHA-256, and the string-encoded fields
|
|
41
45
|
*/
|
|
42
|
-
|
|
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;
|