@devicecloud.dev/dcd 4.4.9 → 5.0.0-beta.0
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 +40 -2
- package/dist/commands/artifacts.d.ts +47 -18
- package/dist/commands/artifacts.js +68 -60
- package/dist/commands/cloud.d.ts +228 -88
- package/dist/commands/cloud.js +389 -288
- package/dist/commands/list.d.ts +39 -38
- package/dist/commands/list.js +122 -127
- package/dist/commands/live.d.ts +2 -0
- package/dist/commands/live.js +513 -0
- package/dist/commands/login.d.ts +17 -0
- package/dist/commands/login.js +250 -0
- package/dist/commands/logout.d.ts +2 -0
- package/dist/commands/logout.js +32 -0
- package/dist/commands/status.d.ts +23 -42
- package/dist/commands/status.js +162 -173
- package/dist/commands/switch-org.d.ts +12 -0
- package/dist/commands/switch-org.js +78 -0
- package/dist/commands/upgrade.d.ts +2 -0
- package/dist/commands/upgrade.js +122 -0
- package/dist/commands/upload.d.ts +33 -18
- package/dist/commands/upload.js +62 -67
- package/dist/commands/whoami.d.ts +2 -0
- package/dist/commands/whoami.js +34 -0
- package/dist/config/environments.d.ts +31 -0
- package/dist/config/environments.js +58 -0
- package/dist/config/flags/api.flags.d.ts +10 -2
- package/dist/config/flags/api.flags.js +12 -10
- package/dist/config/flags/binary.flags.d.ts +17 -4
- package/dist/config/flags/binary.flags.js +13 -14
- package/dist/config/flags/device.flags.d.ts +49 -11
- package/dist/config/flags/device.flags.js +41 -33
- package/dist/config/flags/environment.flags.d.ts +27 -6
- package/dist/config/flags/environment.flags.js +23 -25
- package/dist/config/flags/execution.flags.d.ts +35 -8
- package/dist/config/flags/execution.flags.js +30 -37
- package/dist/config/flags/github.flags.d.ts +23 -5
- package/dist/config/flags/github.flags.js +18 -11
- package/dist/config/flags/output.flags.d.ts +57 -13
- package/dist/config/flags/output.flags.js +47 -43
- package/dist/constants.d.ts +218 -51
- package/dist/constants.js +2 -2
- package/dist/gateways/api-gateway.d.ts +43 -12
- package/dist/gateways/api-gateway.js +240 -100
- package/dist/gateways/cli-auth-gateway.d.ts +13 -0
- package/dist/gateways/cli-auth-gateway.js +57 -0
- package/dist/gateways/supabase-gateway.d.ts +11 -11
- package/dist/gateways/supabase-gateway.js +15 -39
- package/dist/index.d.ts +2 -1
- package/dist/index.js +93 -2
- package/dist/methods.d.ts +3 -5
- package/dist/methods.js +170 -178
- package/dist/services/device-validation.service.d.ts +8 -0
- package/dist/services/device-validation.service.js +55 -35
- package/dist/services/execution-plan.service.js +27 -15
- package/dist/services/execution-plan.utils.d.ts +3 -0
- package/dist/services/execution-plan.utils.js +10 -32
- package/dist/services/metadata-extractor.service.d.ts +0 -2
- package/dist/services/metadata-extractor.service.js +57 -57
- package/dist/services/moropo.service.js +25 -24
- package/dist/services/report-download.service.d.ts +12 -1
- package/dist/services/report-download.service.js +31 -20
- package/dist/services/results-polling.service.d.ts +6 -7
- package/dist/services/results-polling.service.js +80 -33
- package/dist/services/telemetry.service.d.ts +40 -0
- package/dist/services/telemetry.service.js +230 -0
- package/dist/services/test-submission.service.js +2 -1
- package/dist/services/version.service.d.ts +3 -2
- package/dist/services/version.service.js +27 -11
- package/dist/types/domain/auth.types.d.ts +12 -0
- package/dist/types/{schema.types.js → domain/auth.types.js} +0 -1
- package/dist/types/domain/live.types.d.ts +76 -0
- package/dist/types/domain/live.types.js +4 -0
- package/dist/utils/auth.d.ts +13 -0
- package/dist/utils/auth.js +142 -0
- package/dist/utils/cli.d.ts +35 -0
- package/dist/utils/cli.js +127 -0
- package/dist/utils/compatibility.d.ts +2 -1
- package/dist/utils/compatibility.js +2 -2
- package/dist/utils/config-store.d.ts +35 -0
- package/dist/utils/config-store.js +125 -0
- package/dist/utils/connectivity.js +7 -3
- package/dist/utils/expo.js +14 -3
- package/dist/utils/orgs.d.ts +11 -0
- package/dist/utils/orgs.js +40 -0
- package/dist/utils/paths.d.ts +11 -0
- package/dist/utils/paths.js +24 -0
- package/dist/utils/progress.d.ts +13 -0
- package/dist/utils/progress.js +50 -0
- package/dist/utils/styling.d.ts +13 -5
- package/dist/utils/styling.js +37 -7
- package/package.json +26 -38
- 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/oclif.manifest.json +0 -884
|
@@ -13,12 +13,12 @@ class ReportDownloadService {
|
|
|
13
13
|
* @returns Promise that resolves when download is complete
|
|
14
14
|
*/
|
|
15
15
|
async downloadArtifacts(options) {
|
|
16
|
-
const { apiUrl,
|
|
16
|
+
const { apiUrl, auth, uploadId, downloadType, artifactsPath = './artifacts.zip', debug = false, logger, warnLogger, } = options;
|
|
17
17
|
try {
|
|
18
18
|
if (debug && logger) {
|
|
19
19
|
logger(`[DEBUG] Downloading artifacts: ${downloadType}`);
|
|
20
20
|
}
|
|
21
|
-
await api_gateway_1.ApiGateway.downloadArtifactsZip(apiUrl,
|
|
21
|
+
await api_gateway_1.ApiGateway.downloadArtifactsZip(apiUrl, auth, uploadId, downloadType, artifactsPath);
|
|
22
22
|
if (logger) {
|
|
23
23
|
logger('\n');
|
|
24
24
|
logger(`Test artifacts have been downloaded to ${artifactsPath}`);
|
|
@@ -28,9 +28,7 @@ class ReportDownloadService {
|
|
|
28
28
|
if (debug && logger) {
|
|
29
29
|
logger(`[DEBUG] Error downloading artifacts: ${error}`);
|
|
30
30
|
}
|
|
31
|
-
|
|
32
|
-
warnLogger('Failed to download artifacts');
|
|
33
|
-
}
|
|
31
|
+
this.warnDownloadFailure(warnLogger, 'artifacts', 'No artifacts found for this upload. Make sure your tests generated results.', error);
|
|
34
32
|
}
|
|
35
33
|
}
|
|
36
34
|
/**
|
|
@@ -81,12 +79,12 @@ class ReportDownloadService {
|
|
|
81
79
|
* @returns Promise that resolves when download is complete
|
|
82
80
|
*/
|
|
83
81
|
async downloadReport(type, filePath, options) {
|
|
84
|
-
const { apiUrl,
|
|
82
|
+
const { apiUrl, auth, uploadId, debug = false, logger, warnLogger } = options;
|
|
85
83
|
try {
|
|
86
84
|
if (debug && logger) {
|
|
87
85
|
logger(`[DEBUG] Downloading ${type.toUpperCase()} report`);
|
|
88
86
|
}
|
|
89
|
-
await api_gateway_1.ApiGateway.downloadReportGeneric(apiUrl,
|
|
87
|
+
await api_gateway_1.ApiGateway.downloadReportGeneric(apiUrl, auth, uploadId, type, filePath);
|
|
90
88
|
if (logger) {
|
|
91
89
|
logger(`${type.toUpperCase()} test report has been downloaded to ${filePath}`);
|
|
92
90
|
}
|
|
@@ -95,19 +93,32 @@ class ReportDownloadService {
|
|
|
95
93
|
if (debug && logger) {
|
|
96
94
|
logger(`[DEBUG] Error downloading ${type.toUpperCase()} report: ${error}`);
|
|
97
95
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
96
|
+
this.warnDownloadFailure(warnLogger, `${type.toUpperCase()} report`, `No ${type.toUpperCase()} reports found for this upload. Make sure your tests generated results.`, error);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Warn about a failed download with the underlying cause plus hints for
|
|
101
|
+
* common error classes (missing results, permissions, bad paths)
|
|
102
|
+
* @param warnLogger Warning logger, if configured
|
|
103
|
+
* @param subject What was being downloaded, e.g. 'artifacts' or 'JUNIT report'
|
|
104
|
+
* @param notFoundHint Message to show when the error looks like a 404
|
|
105
|
+
* @param error The error that occurred
|
|
106
|
+
* @returns void
|
|
107
|
+
*/
|
|
108
|
+
warnDownloadFailure(warnLogger, subject, notFoundHint, error) {
|
|
109
|
+
if (!warnLogger) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
113
|
+
warnLogger(`Failed to download ${subject}: ${errorMessage}`);
|
|
114
|
+
if (errorMessage.includes('404')) {
|
|
115
|
+
warnLogger(notFoundHint);
|
|
116
|
+
}
|
|
117
|
+
else if (errorMessage.includes('EACCES') || errorMessage.includes('EPERM')) {
|
|
118
|
+
warnLogger('Permission denied. Check write permissions for the current directory.');
|
|
119
|
+
}
|
|
120
|
+
else if (errorMessage.includes('ENOENT')) {
|
|
121
|
+
warnLogger('Directory does not exist. Make sure you have write access to the current directory.');
|
|
111
122
|
}
|
|
112
123
|
}
|
|
113
124
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
type TestResult = paths['/results/{uploadId}']['get']['responses']['200']['content']['application/json']['results'][number];
|
|
1
|
+
import type { AuthContext } from '../types/domain/auth.types';
|
|
3
2
|
/**
|
|
4
3
|
* Custom error for run failures that includes the polling result
|
|
5
4
|
*/
|
|
@@ -8,7 +7,7 @@ export declare class RunFailedError extends Error {
|
|
|
8
7
|
constructor(result: PollingResult);
|
|
9
8
|
}
|
|
10
9
|
export interface PollingOptions {
|
|
11
|
-
|
|
10
|
+
auth: AuthContext;
|
|
12
11
|
apiUrl: string;
|
|
13
12
|
consoleUrl: string;
|
|
14
13
|
debug?: boolean;
|
|
@@ -48,21 +47,22 @@ export interface PollingResult {
|
|
|
48
47
|
export declare class ResultsPollingService {
|
|
49
48
|
private readonly MAX_SEQUENTIAL_FAILURES;
|
|
50
49
|
private readonly POLL_INTERVAL_MS;
|
|
50
|
+
private readonly MAX_ERROR_BACKOFF_MS;
|
|
51
51
|
/**
|
|
52
52
|
* Poll for test results until all tests complete
|
|
53
|
-
* @param results Initial test results from submission
|
|
54
53
|
* @param options Polling configuration
|
|
55
54
|
* @param testMetadata Optional metadata map for each test (flowName, tags)
|
|
56
55
|
* @returns Promise that resolves with final test results or rejects if tests fail
|
|
57
56
|
*/
|
|
58
|
-
pollUntilComplete(
|
|
57
|
+
pollUntilComplete(options: PollingOptions, testMetadata?: Record<string, TestMetadata>): Promise<PollingResult>;
|
|
58
|
+
private pollLoop;
|
|
59
59
|
private buildPollingResult;
|
|
60
60
|
private calculateStatusSummary;
|
|
61
61
|
private displayFinalResults;
|
|
62
62
|
/**
|
|
63
63
|
* Fetch results from API and log debug information
|
|
64
64
|
* @param apiUrl API base URL
|
|
65
|
-
* @param
|
|
65
|
+
* @param auth AuthContext carrying request headers
|
|
66
66
|
* @param uploadId Upload ID to fetch results for
|
|
67
67
|
* @param debug Whether debug logging is enabled
|
|
68
68
|
* @param logger Optional logger function
|
|
@@ -93,4 +93,3 @@ export declare class ResultsPollingService {
|
|
|
93
93
|
private sleep;
|
|
94
94
|
private updateDisplayStatus;
|
|
95
95
|
}
|
|
96
|
-
export {};
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
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
4
|
const path = require("node:path");
|
|
7
5
|
const api_gateway_1 = require("../gateways/api-gateway");
|
|
8
6
|
const methods_1 = require("../methods");
|
|
9
7
|
const connectivity_1 = require("../utils/connectivity");
|
|
8
|
+
const progress_1 = require("../utils/progress");
|
|
10
9
|
const styling_1 = require("../utils/styling");
|
|
11
10
|
/**
|
|
12
11
|
* Custom error for run failures that includes the polling result
|
|
@@ -24,17 +23,36 @@ exports.RunFailedError = RunFailedError;
|
|
|
24
23
|
* Service for polling test results from the API
|
|
25
24
|
*/
|
|
26
25
|
class ResultsPollingService {
|
|
27
|
-
|
|
26
|
+
// The run keeps executing in the cloud regardless of whether the CLI can
|
|
27
|
+
// poll, so tolerate a long stretch of transient API/network blips (~5 min at
|
|
28
|
+
// the 10s base interval) before giving up. Losing a run to a brief hiccup is
|
|
29
|
+
// far more costly than waiting a bit longer.
|
|
30
|
+
MAX_SEQUENTIAL_FAILURES = 30;
|
|
28
31
|
POLL_INTERVAL_MS = 10_000;
|
|
32
|
+
// Cap for the backoff applied between failed polls.
|
|
33
|
+
MAX_ERROR_BACKOFF_MS = 30_000;
|
|
29
34
|
/**
|
|
30
35
|
* Poll for test results until all tests complete
|
|
31
|
-
* @param results Initial test results from submission
|
|
32
36
|
* @param options Polling configuration
|
|
33
37
|
* @param testMetadata Optional metadata map for each test (flowName, tags)
|
|
34
38
|
* @returns Promise that resolves with final test results or rejects if tests fail
|
|
35
39
|
*/
|
|
36
|
-
async pollUntilComplete(
|
|
37
|
-
|
|
40
|
+
async pollUntilComplete(options, testMetadata) {
|
|
41
|
+
try {
|
|
42
|
+
return await this.pollLoop(options, testMetadata);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
// RunFailedError is a completed run — the spinner was already stopped by
|
|
46
|
+
// displayFinalResults. Anything else aborts mid-poll with the spinner
|
|
47
|
+
// still live, which would corrupt the terminal under the error output.
|
|
48
|
+
if (!options.json && !(error instanceof RunFailedError)) {
|
|
49
|
+
progress_1.ux.action.stop(styling_1.colors.error('failed'));
|
|
50
|
+
}
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async pollLoop(options, testMetadata) {
|
|
55
|
+
const { apiUrl, auth, uploadId, consoleUrl, quiet = false, json = false, debug = false, logger } = options;
|
|
38
56
|
this.initializePollingDisplay(json, logger);
|
|
39
57
|
let sequentialPollFailures = 0;
|
|
40
58
|
let previousSummary = '';
|
|
@@ -45,7 +63,7 @@ class ResultsPollingService {
|
|
|
45
63
|
// eslint-disable-next-line no-constant-condition
|
|
46
64
|
while (true) {
|
|
47
65
|
try {
|
|
48
|
-
const updatedResults = await this.fetchAndLogResults(apiUrl,
|
|
66
|
+
const updatedResults = await this.fetchAndLogResults(apiUrl, auth, uploadId, debug, logger);
|
|
49
67
|
const { summary } = this.calculateStatusSummary(updatedResults);
|
|
50
68
|
previousSummary = this.updateDisplayStatus(updatedResults, quiet, json, summary, previousSummary);
|
|
51
69
|
const allComplete = updatedResults.every((result) => !['PENDING', 'QUEUED', 'RUNNING'].includes(result.status));
|
|
@@ -71,9 +89,10 @@ class ResultsPollingService {
|
|
|
71
89
|
}
|
|
72
90
|
sequentialPollFailures++;
|
|
73
91
|
// Handle polling errors (network issues, etc.)
|
|
74
|
-
await this.handlePollingError(error, sequentialPollFailures, debug, logger);
|
|
75
|
-
//
|
|
76
|
-
|
|
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));
|
|
77
96
|
}
|
|
78
97
|
}
|
|
79
98
|
}
|
|
@@ -81,11 +100,13 @@ class ResultsPollingService {
|
|
|
81
100
|
const resultsWithoutEarlierTries = this.filterLatestResults(results);
|
|
82
101
|
return {
|
|
83
102
|
consoleUrl,
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
103
|
+
// Anything other than an explicit pass (CANCELLED, ERROR, a status we
|
|
104
|
+
// don't know about yet) must fail the run — this gates CI exit codes.
|
|
105
|
+
status: resultsWithoutEarlierTries.every((result) => result.status === 'PASSED')
|
|
106
|
+
? 'PASSED'
|
|
107
|
+
: 'FAILED',
|
|
87
108
|
tests: resultsWithoutEarlierTries.map((r) => ({
|
|
88
|
-
durationSeconds: r.duration_seconds,
|
|
109
|
+
durationSeconds: r.duration_seconds ?? null,
|
|
89
110
|
failReason: r.status === 'FAILED' ? r.fail_reason || 'No reason provided' : undefined,
|
|
90
111
|
fileName: r.test_file_name,
|
|
91
112
|
flowName: testMetadata?.[r.test_file_name]?.flowName || path.parse(r.test_file_name).name,
|
|
@@ -123,12 +144,12 @@ class ResultsPollingService {
|
|
|
123
144
|
if (json) {
|
|
124
145
|
return;
|
|
125
146
|
}
|
|
126
|
-
|
|
147
|
+
progress_1.ux.action.stop(styling_1.colors.success('completed'));
|
|
127
148
|
if (logger) {
|
|
128
149
|
logger('\n');
|
|
129
150
|
}
|
|
130
151
|
const hasFailedTests = results.some((result) => result.status === 'FAILED');
|
|
131
|
-
(0,
|
|
152
|
+
(0, styling_1.table)(results, {
|
|
132
153
|
duration: {
|
|
133
154
|
get(row) {
|
|
134
155
|
return row.duration_seconds
|
|
@@ -189,18 +210,19 @@ class ResultsPollingService {
|
|
|
189
210
|
/**
|
|
190
211
|
* Fetch results from API and log debug information
|
|
191
212
|
* @param apiUrl API base URL
|
|
192
|
-
* @param
|
|
213
|
+
* @param auth AuthContext carrying request headers
|
|
193
214
|
* @param uploadId Upload ID to fetch results for
|
|
194
215
|
* @param debug Whether debug logging is enabled
|
|
195
216
|
* @param logger Optional logger function
|
|
196
217
|
* @returns Promise resolving to test results
|
|
197
218
|
*/
|
|
198
|
-
async fetchAndLogResults(apiUrl,
|
|
219
|
+
async fetchAndLogResults(apiUrl, auth, uploadId, debug, logger) {
|
|
199
220
|
if (debug && logger) {
|
|
200
221
|
logger(`[DEBUG] Polling for results: ${uploadId}`);
|
|
201
222
|
}
|
|
202
|
-
const { results: updatedResults } = await api_gateway_1.ApiGateway.getResultsForUpload(apiUrl,
|
|
203
|
-
|
|
223
|
+
const { results: updatedResults } = await api_gateway_1.ApiGateway.getResultsForUpload(apiUrl, auth, uploadId);
|
|
224
|
+
// An empty array would otherwise read as "all complete, all passed".
|
|
225
|
+
if (!updatedResults || updatedResults.length === 0) {
|
|
204
226
|
throw new Error('no results');
|
|
205
227
|
}
|
|
206
228
|
if (debug && logger) {
|
|
@@ -212,11 +234,31 @@ class ResultsPollingService {
|
|
|
212
234
|
return updatedResults;
|
|
213
235
|
}
|
|
214
236
|
filterLatestResults(results) {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
237
|
+
// Resolve each row to the root of its retry chain (a retry's `retry_of`
|
|
238
|
+
// may point at the previous retry rather than the original attempt), then
|
|
239
|
+
// keep only the newest row per root.
|
|
240
|
+
const byId = new Map(results.map((result) => [result.id, result]));
|
|
241
|
+
const rootIdOf = (result) => {
|
|
242
|
+
let current = result;
|
|
243
|
+
const seen = new Set([current.id]);
|
|
244
|
+
while (current.retry_of) {
|
|
245
|
+
const parent = byId.get(current.retry_of);
|
|
246
|
+
if (!parent || seen.has(parent.id))
|
|
247
|
+
return current.retry_of;
|
|
248
|
+
seen.add(parent.id);
|
|
249
|
+
current = parent;
|
|
250
|
+
}
|
|
251
|
+
return current.id;
|
|
252
|
+
};
|
|
253
|
+
const latestByRoot = new Map();
|
|
254
|
+
for (const result of results) {
|
|
255
|
+
const rootId = rootIdOf(result);
|
|
256
|
+
const existing = latestByRoot.get(rootId);
|
|
257
|
+
if (!existing || result.id > existing.id) {
|
|
258
|
+
latestByRoot.set(rootId, result);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return results.filter((result) => latestByRoot.get(rootIdOf(result)) === result);
|
|
220
262
|
}
|
|
221
263
|
/**
|
|
222
264
|
* Handle completed tests and return final result
|
|
@@ -242,12 +284,17 @@ class ResultsPollingService {
|
|
|
242
284
|
}
|
|
243
285
|
return output;
|
|
244
286
|
}
|
|
245
|
-
async handlePollingError(error, sequentialPollFailures, debug, logger) {
|
|
287
|
+
async handlePollingError(error, sequentialPollFailures, debug, logger, uploadId) {
|
|
288
|
+
// The run is unaffected by our inability to poll — always point the user at
|
|
289
|
+
// how to reconnect to it rather than leaving them thinking it died.
|
|
290
|
+
const resumeHint = uploadId
|
|
291
|
+
? `\n\nThe test is still running in the cloud. Reconnect with:\n dcd status --upload-id ${uploadId}`
|
|
292
|
+
: '';
|
|
246
293
|
if (debug && logger) {
|
|
247
294
|
logger(`[DEBUG] Error polling for results: ${error}`);
|
|
248
295
|
logger(`[DEBUG] Sequential poll failures: ${sequentialPollFailures}`);
|
|
249
296
|
}
|
|
250
|
-
if (sequentialPollFailures
|
|
297
|
+
if (sequentialPollFailures >= this.MAX_SEQUENTIAL_FAILURES) {
|
|
251
298
|
if (debug && logger) {
|
|
252
299
|
logger('[DEBUG] Checking internet connectivity...');
|
|
253
300
|
}
|
|
@@ -267,9 +314,9 @@ class ResultsPollingService {
|
|
|
267
314
|
const endpointDetails = connectivityCheck.endpointResults
|
|
268
315
|
.map((r) => ` - ${r.endpoint}: ${r.error} (${r.latencyMs}ms)`)
|
|
269
316
|
.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
|
|
317
|
+
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
318
|
}
|
|
272
|
-
throw new Error(`unable to fetch results after ${this.MAX_SEQUENTIAL_FAILURES} attempts`);
|
|
319
|
+
throw new Error(`unable to fetch results after ${this.MAX_SEQUENTIAL_FAILURES} attempts${resumeHint}`);
|
|
273
320
|
}
|
|
274
321
|
if (logger) {
|
|
275
322
|
logger('unable to fetch results, trying again...');
|
|
@@ -283,7 +330,7 @@ class ResultsPollingService {
|
|
|
283
330
|
*/
|
|
284
331
|
initializePollingDisplay(json, logger) {
|
|
285
332
|
if (!json) {
|
|
286
|
-
|
|
333
|
+
progress_1.ux.action.start(styling_1.colors.bold('Waiting for results'), styling_1.colors.dim('Initializing'), {
|
|
287
334
|
stdout: true,
|
|
288
335
|
});
|
|
289
336
|
if (logger) {
|
|
@@ -307,12 +354,12 @@ class ResultsPollingService {
|
|
|
307
354
|
}
|
|
308
355
|
if (quiet) {
|
|
309
356
|
if (summary !== previousSummary) {
|
|
310
|
-
|
|
357
|
+
progress_1.ux.action.status = summary;
|
|
311
358
|
return summary;
|
|
312
359
|
}
|
|
313
360
|
}
|
|
314
361
|
else {
|
|
315
|
-
|
|
362
|
+
progress_1.ux.action.status = styling_1.colors.dim('\nStatus Test\n─────────── ───────────────');
|
|
316
363
|
for (const { retry_of: isRetry, status, test_file_name: test, } of results) {
|
|
317
364
|
const statusFormatted = status.toUpperCase() === 'PASSED'
|
|
318
365
|
? styling_1.colors.success(status.padEnd(10, ' '))
|
|
@@ -324,7 +371,7 @@ class ResultsPollingService {
|
|
|
324
371
|
? styling_1.colors.dim(status.padEnd(10, ' '))
|
|
325
372
|
: styling_1.colors.warning(status.padEnd(10, ' '));
|
|
326
373
|
const retryText = isRetry ? styling_1.colors.dim(' (retry)') : '';
|
|
327
|
-
|
|
374
|
+
progress_1.ux.action.status += `\n${statusFormatted} ${test}${retryText}`;
|
|
328
375
|
}
|
|
329
376
|
}
|
|
330
377
|
return previousSummary;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { AuthContext } from '../types/domain/auth.types';
|
|
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
|
+
recordCommandStart(): void;
|
|
23
|
+
recordCommandSuccess(): void;
|
|
24
|
+
recordCommandFailure(opts: {
|
|
25
|
+
error: Error | string;
|
|
26
|
+
exitCode: number;
|
|
27
|
+
}): void;
|
|
28
|
+
private enqueue;
|
|
29
|
+
flush(): Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* Synchronous flush via `curl`. Used right before `process.exit` (which
|
|
32
|
+
* bypasses `beforeExit`, so async `fetch` would be killed mid-flight).
|
|
33
|
+
* Node has no built-in sync HTTP and `curl` ships with macOS, Linux, and
|
|
34
|
+
* Windows ≥ 1803 — that's the supported surface for the CLI.
|
|
35
|
+
*/
|
|
36
|
+
flushSync(): void;
|
|
37
|
+
private buildMeta;
|
|
38
|
+
}
|
|
39
|
+
export declare const telemetry: Telemetry;
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.telemetry = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Ships CLI lifecycle + error events to Axiom via the API's `/cli/logs`
|
|
6
|
+
* proxy (forwards to `cli-dev` / `cli-prod`). The proxy authenticates with
|
|
7
|
+
* the same `auth.headers` every other gateway uses (`x-app-api-key` or
|
|
8
|
+
* `Authorization: Bearer ...` + `x-dcd-org`), so we never need the Axiom
|
|
9
|
+
* token client-side.
|
|
10
|
+
*
|
|
11
|
+
* Two flush paths:
|
|
12
|
+
* - `flush()` — async, used after `runMain` returns naturally
|
|
13
|
+
* - `flushSync()` — sync via `curl`, used inside `logger.error` right before
|
|
14
|
+
* `process.exit` (no other way to send HTTP after exit is called)
|
|
15
|
+
*
|
|
16
|
+
* Opt out: `DCD_TELEMETRY_DISABLED=1` in the environment.
|
|
17
|
+
*/
|
|
18
|
+
const node_child_process_1 = require("node:child_process");
|
|
19
|
+
const node_crypto_1 = require("node:crypto");
|
|
20
|
+
const node_fs_1 = require("node:fs");
|
|
21
|
+
const node_os_1 = require("node:os");
|
|
22
|
+
const node_path_1 = require("node:path");
|
|
23
|
+
const cli_1 = require("../utils/cli");
|
|
24
|
+
const DEFAULT_API_URL = 'https://api.devicecloud.dev';
|
|
25
|
+
class Telemetry {
|
|
26
|
+
buffer = [];
|
|
27
|
+
config = null;
|
|
28
|
+
command = inferCommandFromArgv();
|
|
29
|
+
sessionId = (0, node_crypto_1.randomUUID)();
|
|
30
|
+
startedAt = Date.now();
|
|
31
|
+
disabled = !!process.env.DCD_TELEMETRY_DISABLED;
|
|
32
|
+
release = (0, cli_1.getCliVersion)();
|
|
33
|
+
/**
|
|
34
|
+
* Called once per invocation by `resolveAuth` after the credential check
|
|
35
|
+
* succeeds. Before this is called, lifecycle events are buffered but cannot
|
|
36
|
+
* be sent. Commands that never reach `resolveAuth` (`--help`, `--version`,
|
|
37
|
+
* `dcd login` before sign-in completes) will skip telemetry entirely — by
|
|
38
|
+
* design, since those flows have no identity to attach.
|
|
39
|
+
*/
|
|
40
|
+
configure(opts) {
|
|
41
|
+
if (this.disabled)
|
|
42
|
+
return;
|
|
43
|
+
this.config = {
|
|
44
|
+
apiUrl: opts.apiUrl ?? inferApiUrlFromArgv(),
|
|
45
|
+
auth: opts.auth,
|
|
46
|
+
command: this.command,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
recordCommandStart() {
|
|
50
|
+
this.startedAt = Date.now();
|
|
51
|
+
this.enqueue('info', 'cli.lifecycle', 'command started', {
|
|
52
|
+
argv: scrubArgv(process.argv.slice(2)),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
recordCommandSuccess() {
|
|
56
|
+
this.enqueue('info', 'cli.lifecycle', 'command completed', {
|
|
57
|
+
duration_ms: Date.now() - this.startedAt,
|
|
58
|
+
exit_code: 0,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
recordCommandFailure(opts) {
|
|
62
|
+
const message = opts.error instanceof Error ? opts.error.message : String(opts.error);
|
|
63
|
+
this.enqueue('error', 'cli.lifecycle', 'command failed', {
|
|
64
|
+
duration_ms: Date.now() - this.startedAt,
|
|
65
|
+
exit_code: opts.exitCode,
|
|
66
|
+
error_message: message,
|
|
67
|
+
error_name: opts.error instanceof Error ? opts.error.name : 'CliError',
|
|
68
|
+
error_stack: opts.error instanceof Error ? opts.error.stack : undefined,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
enqueue(level, context, message, extra) {
|
|
72
|
+
if (this.disabled)
|
|
73
|
+
return;
|
|
74
|
+
this.buffer.push({
|
|
75
|
+
timestamp: new Date().toISOString(),
|
|
76
|
+
level,
|
|
77
|
+
context,
|
|
78
|
+
message,
|
|
79
|
+
extra,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
async flush() {
|
|
83
|
+
if (this.disabled || !this.config || this.buffer.length === 0)
|
|
84
|
+
return;
|
|
85
|
+
const events = this.buffer.splice(0, this.buffer.length);
|
|
86
|
+
const body = JSON.stringify({ events, meta: this.buildMeta() });
|
|
87
|
+
try {
|
|
88
|
+
await fetch(`${this.config.apiUrl}/cli/logs`, {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: {
|
|
91
|
+
'content-type': 'application/json',
|
|
92
|
+
...this.config.auth.headers,
|
|
93
|
+
},
|
|
94
|
+
body,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// Telemetry failures must never surface — silently drop.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Synchronous flush via `curl`. Used right before `process.exit` (which
|
|
103
|
+
* bypasses `beforeExit`, so async `fetch` would be killed mid-flight).
|
|
104
|
+
* Node has no built-in sync HTTP and `curl` ships with macOS, Linux, and
|
|
105
|
+
* Windows ≥ 1803 — that's the supported surface for the CLI.
|
|
106
|
+
*/
|
|
107
|
+
flushSync() {
|
|
108
|
+
if (this.disabled || !this.config || this.buffer.length === 0)
|
|
109
|
+
return;
|
|
110
|
+
const events = this.buffer.splice(0, this.buffer.length);
|
|
111
|
+
const body = JSON.stringify({ events, meta: this.buildMeta() });
|
|
112
|
+
// Headers carry the API key / Bearer token, so they must not appear in
|
|
113
|
+
// curl's argv (world-readable via ps//proc while curl runs). They go in a
|
|
114
|
+
// 0600 config file inside a fresh 0700 temp dir instead; stdin carries the
|
|
115
|
+
// body, so it can't double as the config channel.
|
|
116
|
+
let configDir;
|
|
117
|
+
try {
|
|
118
|
+
configDir = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'dcd-telemetry-'));
|
|
119
|
+
const configPath = (0, node_path_1.join)(configDir, 'curl.cfg');
|
|
120
|
+
const headerLines = ['header = "content-type: application/json"'];
|
|
121
|
+
for (const [k, v] of Object.entries(this.config.auth.headers)) {
|
|
122
|
+
headerLines.push(`header = "${k}: ${v}"`);
|
|
123
|
+
}
|
|
124
|
+
(0, node_fs_1.writeFileSync)(configPath, headerLines.join('\n'), { mode: 0o600 });
|
|
125
|
+
(0, node_child_process_1.execFileSync)('curl', [
|
|
126
|
+
'-sS',
|
|
127
|
+
'-m',
|
|
128
|
+
'3',
|
|
129
|
+
'-X',
|
|
130
|
+
'POST',
|
|
131
|
+
'-K',
|
|
132
|
+
configPath,
|
|
133
|
+
'--data-binary',
|
|
134
|
+
'@-',
|
|
135
|
+
`${this.config.apiUrl}/cli/logs`,
|
|
136
|
+
], { input: body, stdio: ['pipe', 'ignore', 'ignore'] });
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// Telemetry failures must never surface — silently drop.
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
if (configDir)
|
|
143
|
+
(0, node_fs_1.rmSync)(configDir, { recursive: true, force: true });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
buildMeta() {
|
|
147
|
+
if (!this.config) {
|
|
148
|
+
throw new Error('telemetry not configured');
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
release: this.release,
|
|
152
|
+
command: this.command,
|
|
153
|
+
sessionId: this.sessionId,
|
|
154
|
+
authMode: this.config.auth.mode,
|
|
155
|
+
installMethod: (0, cli_1.getInstallMethod)(),
|
|
156
|
+
nodeVersion: process.versions.node,
|
|
157
|
+
platform: process.platform,
|
|
158
|
+
arch: process.arch,
|
|
159
|
+
userEmail: this.config.auth.userEmail,
|
|
160
|
+
orgId: this.config.auth.orgId,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// Flags whose values are credential material (API keys, signed URLs) or
|
|
165
|
+
// user-provided env pairs that routinely carry test-account secrets. Their
|
|
166
|
+
// values must never reach the telemetry backend.
|
|
167
|
+
const SENSITIVE_FLAG_NAMES = new Set([
|
|
168
|
+
'--api-key',
|
|
169
|
+
'--apiKey',
|
|
170
|
+
'--moropo-v1-api-key',
|
|
171
|
+
'--app-url',
|
|
172
|
+
'--appUrl',
|
|
173
|
+
'-e',
|
|
174
|
+
'--env',
|
|
175
|
+
]);
|
|
176
|
+
function scrubArgv(args) {
|
|
177
|
+
const scrubbed = [];
|
|
178
|
+
for (let i = 0; i < args.length; i++) {
|
|
179
|
+
const arg = args[i];
|
|
180
|
+
const eqIndex = arg.indexOf('=');
|
|
181
|
+
const flagName = eqIndex === -1 ? arg : arg.slice(0, eqIndex);
|
|
182
|
+
if (arg.startsWith('-') && SENSITIVE_FLAG_NAMES.has(flagName)) {
|
|
183
|
+
if (eqIndex === -1) {
|
|
184
|
+
scrubbed.push(arg);
|
|
185
|
+
if (i + 1 < args.length) {
|
|
186
|
+
scrubbed.push('<redacted>');
|
|
187
|
+
i++;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
scrubbed.push(`${flagName}=<redacted>`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
scrubbed.push(arg);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return scrubbed;
|
|
199
|
+
}
|
|
200
|
+
// argv layout: node|tsx, script, command, ...flags. The first non-flag after
|
|
201
|
+
// the script is the subcommand. Falls back to 'help' for `--help`/`--version`
|
|
202
|
+
// invocations and to 'unknown' if we can't decide.
|
|
203
|
+
function inferCommandFromArgv() {
|
|
204
|
+
const args = process.argv.slice(2);
|
|
205
|
+
for (const arg of args) {
|
|
206
|
+
if (arg.startsWith('-'))
|
|
207
|
+
continue;
|
|
208
|
+
return arg;
|
|
209
|
+
}
|
|
210
|
+
if (args.some((a) => a === '--help' || a === '-h'))
|
|
211
|
+
return 'help';
|
|
212
|
+
if (args.some((a) => a === '--version' || a === '-v'))
|
|
213
|
+
return 'version';
|
|
214
|
+
return 'unknown';
|
|
215
|
+
}
|
|
216
|
+
const API_URL_FLAGS = ['--api-url', '--apiURL', '--apiUrl'];
|
|
217
|
+
function inferApiUrlFromArgv() {
|
|
218
|
+
const args = process.argv.slice(2);
|
|
219
|
+
for (let i = 0; i < args.length; i++) {
|
|
220
|
+
const arg = args[i];
|
|
221
|
+
for (const flag of API_URL_FLAGS) {
|
|
222
|
+
if (arg === flag && args[i + 1])
|
|
223
|
+
return args[i + 1];
|
|
224
|
+
if (arg.startsWith(`${flag}=`))
|
|
225
|
+
return arg.slice(flag.length + 1);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return DEFAULT_API_URL;
|
|
229
|
+
}
|
|
230
|
+
exports.telemetry = new Telemetry();
|
|
@@ -4,6 +4,7 @@ exports.TestSubmissionService = void 0;
|
|
|
4
4
|
const node_crypto_1 = require("node:crypto");
|
|
5
5
|
const path = require("node:path");
|
|
6
6
|
const methods_1 = require("../methods");
|
|
7
|
+
const paths_1 = require("../utils/paths");
|
|
7
8
|
const mimeTypeLookupByExtension = {
|
|
8
9
|
zip: 'application/zip',
|
|
9
10
|
};
|
|
@@ -116,7 +117,7 @@ class TestSubmissionService {
|
|
|
116
117
|
}
|
|
117
118
|
}
|
|
118
119
|
normalizeFilePath(filePath, commonRoot) {
|
|
119
|
-
return
|
|
120
|
+
return (0, paths_1.toPortableRelativePath)(filePath, commonRoot);
|
|
120
121
|
}
|
|
121
122
|
normalizePathMap(map, commonRoot) {
|
|
122
123
|
return Object.fromEntries(Object.entries(map).map(([key, value]) => [
|