@devicecloud.dev/dcd 5.0.0-beta.0 → 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 +35 -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 +173 -186
- package/dist/commands/list.d.ts +22 -22
- package/dist/commands/list.js +36 -38
- 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 +45 -43
- package/dist/commands/switch-org.d.ts +7 -7
- package/dist/commands/switch-org.js +19 -21
- package/dist/commands/upgrade.js +29 -31
- 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 +125 -66
- 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 +195 -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 +1 -1
- package/dist/services/version.service.js +1 -5
- 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.js +18 -27
- 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.js +2 -5
- 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,15 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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 { ux } from '../utils/progress.js';
|
|
7
|
+
import { colors, formatTestSummary, statusPalette, table } from '../utils/styling.js';
|
|
8
|
+
import { ui } from '../utils/ui.js';
|
|
10
9
|
/**
|
|
11
10
|
* Custom error for run failures that includes the polling result
|
|
12
11
|
*/
|
|
13
|
-
class RunFailedError extends Error {
|
|
12
|
+
export class RunFailedError extends Error {
|
|
14
13
|
result;
|
|
15
14
|
constructor(result) {
|
|
16
15
|
super('RUN_FAILED');
|
|
@@ -18,18 +17,22 @@ class RunFailedError extends Error {
|
|
|
18
17
|
this.name = 'RunFailedError';
|
|
19
18
|
}
|
|
20
19
|
}
|
|
21
|
-
exports.RunFailedError = RunFailedError;
|
|
22
20
|
/**
|
|
23
21
|
* Service for polling test results from the API
|
|
24
22
|
*/
|
|
25
|
-
class ResultsPollingService {
|
|
23
|
+
export class ResultsPollingService {
|
|
26
24
|
// 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
|
-
//
|
|
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.
|
|
30
28
|
MAX_SEQUENTIAL_FAILURES = 30;
|
|
31
|
-
|
|
32
|
-
//
|
|
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;
|
|
33
36
|
MAX_ERROR_BACKOFF_MS = 30_000;
|
|
34
37
|
/**
|
|
35
38
|
* Poll for test results until all tests complete
|
|
@@ -46,7 +49,7 @@ class ResultsPollingService {
|
|
|
46
49
|
// displayFinalResults. Anything else aborts mid-poll with the spinner
|
|
47
50
|
// still live, which would corrupt the terminal under the error output.
|
|
48
51
|
if (!options.json && !(error instanceof RunFailedError)) {
|
|
49
|
-
|
|
52
|
+
ux.action.stop(colors.error('failed'));
|
|
50
53
|
}
|
|
51
54
|
throw error;
|
|
52
55
|
}
|
|
@@ -55,44 +58,137 @@ class ResultsPollingService {
|
|
|
55
58
|
const { apiUrl, auth, uploadId, consoleUrl, quiet = false, json = false, debug = false, logger } = options;
|
|
56
59
|
this.initializePollingDisplay(json, logger);
|
|
57
60
|
let sequentialPollFailures = 0;
|
|
58
|
-
|
|
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
|
+
}
|
|
59
130
|
if (debug && logger) {
|
|
60
131
|
logger(`[DEBUG] Starting polling loop for results`);
|
|
61
132
|
}
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
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);
|
|
168
|
+
}
|
|
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));
|
|
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);
|
|
84
181
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
if (ticker) {
|
|
185
|
+
clearInterval(ticker);
|
|
186
|
+
}
|
|
187
|
+
if (subscription) {
|
|
188
|
+
if (debug && logger) {
|
|
189
|
+
logger('[DEBUG] Closing realtime subscription');
|
|
89
190
|
}
|
|
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));
|
|
191
|
+
await subscription.unsubscribe();
|
|
96
192
|
}
|
|
97
193
|
}
|
|
98
194
|
}
|
|
@@ -129,7 +225,7 @@ class ResultsPollingService {
|
|
|
129
225
|
const running = statusCounts.RUNNING || 0;
|
|
130
226
|
const total = results.length;
|
|
131
227
|
const completed = passed + failed;
|
|
132
|
-
const summary =
|
|
228
|
+
const summary = formatTestSummary({
|
|
133
229
|
completed,
|
|
134
230
|
failed,
|
|
135
231
|
passed,
|
|
@@ -144,48 +240,29 @@ class ResultsPollingService {
|
|
|
144
240
|
if (json) {
|
|
145
241
|
return;
|
|
146
242
|
}
|
|
147
|
-
|
|
243
|
+
ux.action.stop(colors.success('completed'));
|
|
148
244
|
if (logger) {
|
|
149
245
|
logger('\n');
|
|
150
246
|
}
|
|
151
247
|
const hasFailedTests = results.some((result) => result.status === 'FAILED');
|
|
152
|
-
|
|
248
|
+
table(results, {
|
|
153
249
|
duration: {
|
|
154
250
|
get(row) {
|
|
155
251
|
return row.duration_seconds
|
|
156
|
-
?
|
|
157
|
-
:
|
|
252
|
+
? colors.dim(formatDurationSeconds(Number(row.duration_seconds)))
|
|
253
|
+
: colors.dim('-');
|
|
158
254
|
},
|
|
159
255
|
},
|
|
160
256
|
status: {
|
|
161
257
|
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
|
-
}
|
|
258
|
+
const { color } = statusPalette(row.status);
|
|
259
|
+
return color(row.status.toLowerCase());
|
|
183
260
|
},
|
|
184
261
|
},
|
|
185
262
|
test: {
|
|
186
263
|
get(row) {
|
|
187
264
|
const testName = row.test_file_name;
|
|
188
|
-
const retry = row.retry_of ?
|
|
265
|
+
const retry = row.retry_of ? colors.dim(' (retry)') : '';
|
|
189
266
|
return `${testName}${retry}`;
|
|
190
267
|
},
|
|
191
268
|
},
|
|
@@ -194,7 +271,7 @@ class ResultsPollingService {
|
|
|
194
271
|
fail_reason: {
|
|
195
272
|
get(row) {
|
|
196
273
|
return row.status === 'FAILED' && row.fail_reason
|
|
197
|
-
?
|
|
274
|
+
? colors.error(row.fail_reason)
|
|
198
275
|
: '';
|
|
199
276
|
},
|
|
200
277
|
},
|
|
@@ -202,8 +279,8 @@ class ResultsPollingService {
|
|
|
202
279
|
}, { printLine: logger });
|
|
203
280
|
if (logger) {
|
|
204
281
|
logger('\n');
|
|
205
|
-
logger(
|
|
206
|
-
logger(
|
|
282
|
+
logger(ui.success('Run completed'));
|
|
283
|
+
logger(ui.branch(ui.fields([['results', colors.url(consoleUrl)]])));
|
|
207
284
|
logger('\n');
|
|
208
285
|
}
|
|
209
286
|
}
|
|
@@ -220,7 +297,7 @@ class ResultsPollingService {
|
|
|
220
297
|
if (debug && logger) {
|
|
221
298
|
logger(`[DEBUG] Polling for results: ${uploadId}`);
|
|
222
299
|
}
|
|
223
|
-
const { results: updatedResults } = await
|
|
300
|
+
const { results: updatedResults } = await ApiGateway.getResultsForUpload(apiUrl, auth, uploadId);
|
|
224
301
|
// An empty array would otherwise read as "all complete, all passed".
|
|
225
302
|
if (!updatedResults || updatedResults.length === 0) {
|
|
226
303
|
throw new Error('no results');
|
|
@@ -298,7 +375,7 @@ class ResultsPollingService {
|
|
|
298
375
|
if (debug && logger) {
|
|
299
376
|
logger('[DEBUG] Checking internet connectivity...');
|
|
300
377
|
}
|
|
301
|
-
const connectivityCheck = await
|
|
378
|
+
const connectivityCheck = await checkInternetConnectivity();
|
|
302
379
|
if (debug && logger) {
|
|
303
380
|
logger(`[DEBUG] ${connectivityCheck.message}`);
|
|
304
381
|
for (const result of connectivityCheck.endpointResults) {
|
|
@@ -330,11 +407,11 @@ class ResultsPollingService {
|
|
|
330
407
|
*/
|
|
331
408
|
initializePollingDisplay(json, logger) {
|
|
332
409
|
if (!json) {
|
|
333
|
-
|
|
410
|
+
ux.action.start(colors.bold('Waiting for results'), colors.dim('Initializing'), {
|
|
334
411
|
stdout: true,
|
|
335
412
|
});
|
|
336
413
|
if (logger) {
|
|
337
|
-
logger(
|
|
414
|
+
logger(colors.dim('\nYou can safely close this terminal and the tests will continue\n'));
|
|
338
415
|
}
|
|
339
416
|
}
|
|
340
417
|
}
|
|
@@ -348,33 +425,43 @@ class ResultsPollingService {
|
|
|
348
425
|
setTimeout(resolve, ms);
|
|
349
426
|
});
|
|
350
427
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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) {
|
|
355
435
|
if (quiet) {
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
}
|
|
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…'));
|
|
360
460
|
}
|
|
361
461
|
else {
|
|
362
|
-
|
|
363
|
-
|
|
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}`;
|
|
375
|
-
}
|
|
462
|
+
const secondsLeft = Math.max(0, Math.ceil((nextPollAt - Date.now()) / 1000));
|
|
463
|
+
parts.push(colors.dim(`next refresh in ${secondsLeft}s`));
|
|
376
464
|
}
|
|
377
|
-
return
|
|
465
|
+
return parts.join(colors.dim(' · '));
|
|
378
466
|
}
|
|
379
467
|
}
|
|
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;
|