@devicecloud.dev/dcd 4.1.9-beta.0 → 4.2.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.
@@ -99,9 +99,9 @@ class Cloud extends core_1.Command {
99
99
  // Store debug flag for use in catch block
100
100
  debugFlag = debug === true;
101
101
  jsonFile = flags['json-file'] === true;
102
+ this.log(`CLI Version: ${this.config.version}`);
102
103
  if (debug) {
103
104
  this.log('[DEBUG] Starting command execution with debug logging enabled');
104
- this.log(`[DEBUG] CLI Version: ${this.config.version}`);
105
105
  this.log(`[DEBUG] Node version: ${process.versions.node}`);
106
106
  this.log(`[DEBUG] OS: ${process.platform} ${process.arch}`);
107
107
  }
@@ -176,7 +176,7 @@ class Cloud extends core_1.Command {
176
176
  this.log(styling_1.colors.info('ℹ') + ' ' + styling_1.colors.dim('Note: runnerType m1 is experimental and currently supports Android (Pixel 7, API Level 34) only.'));
177
177
  }
178
178
  if (runnerType === 'gpu1') {
179
- this.log(styling_1.colors.info('ℹ') + ' ' + styling_1.colors.dim('Note: runnerType gpu1 is Android-only (Pixel 7, API Level 34 or 35), available to all users.'));
179
+ this.log(styling_1.colors.info('ℹ') + ' ' + styling_1.colors.dim('Note: runnerType gpu1 is Android-only (all devices, API Level 34 or 35), available to all users.'));
180
180
  }
181
181
  const { firstFile, secondFile } = args;
182
182
  let finalBinaryId = appBinaryId;
@@ -0,0 +1,38 @@
1
+ import { Command } from '@oclif/core';
2
+ type UploadListItem = {
3
+ consoleUrl: string;
4
+ created_at: string;
5
+ id: string;
6
+ name: null | string;
7
+ };
8
+ type ListResponse = {
9
+ limit: number;
10
+ offset: number;
11
+ total: number;
12
+ uploads: UploadListItem[];
13
+ };
14
+ export default class List extends Command {
15
+ static description: string;
16
+ static enableJsonFlag: boolean;
17
+ static examples: string[];
18
+ static flags: {
19
+ apiKey: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
20
+ apiUrl: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
21
+ from: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
22
+ json: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
23
+ limit: import("@oclif/core/lib/interfaces").OptionFlag<number, import("@oclif/core/lib/interfaces").CustomOptions>;
24
+ name: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
25
+ offset: import("@oclif/core/lib/interfaces").OptionFlag<number, import("@oclif/core/lib/interfaces").CustomOptions>;
26
+ to: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
27
+ };
28
+ run(): Promise<ListResponse | void>;
29
+ /**
30
+ * Detects if the provided name parameter likely underwent shell expansion
31
+ * Warns the user if shell expansion is detected
32
+ * @param name - The name parameter to check for shell expansion
33
+ * @returns void
34
+ */
35
+ private detectShellExpansion;
36
+ private displayResults;
37
+ }
38
+ export {};
@@ -0,0 +1,143 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const core_1 = require("@oclif/core");
4
+ const constants_1 = require("../constants");
5
+ const api_gateway_1 = require("../gateways/api-gateway");
6
+ const styling_1 = require("../utils/styling");
7
+ class List extends core_1.Command {
8
+ static description = 'List recent flow uploads for your organization';
9
+ static enableJsonFlag = true;
10
+ static examples = [
11
+ '<%= config.bin %> <%= command.id %>',
12
+ '<%= config.bin %> <%= command.id %> --limit 10',
13
+ '<%= config.bin %> <%= command.id %> --name "nightly-*" # Quote wildcards to prevent shell expansion!',
14
+ '<%= config.bin %> <%= command.id %> --from 2024-01-01 --to 2024-01-31',
15
+ '<%= config.bin %> <%= command.id %> --json',
16
+ ];
17
+ static flags = {
18
+ apiKey: constants_1.flags.apiKey,
19
+ apiUrl: constants_1.flags.apiUrl,
20
+ from: core_1.Flags.string({
21
+ description: 'Filter uploads created on or after this date (ISO 8601 format, e.g., 2024-01-01)',
22
+ }),
23
+ json: core_1.Flags.boolean({
24
+ description: 'Output in JSON format',
25
+ }),
26
+ limit: core_1.Flags.integer({
27
+ default: 20,
28
+ description: 'Maximum number of uploads to return',
29
+ }),
30
+ name: core_1.Flags.string({
31
+ description: 'Filter by upload name (supports * wildcard, e.g., "nightly-*"). IMPORTANT: Always quote wildcards to prevent shell expansion!',
32
+ }),
33
+ offset: core_1.Flags.integer({
34
+ default: 0,
35
+ description: 'Number of uploads to skip (for pagination)',
36
+ }),
37
+ to: core_1.Flags.string({
38
+ description: 'Filter uploads created on or before this date (ISO 8601 format, e.g., 2024-01-31)',
39
+ }),
40
+ };
41
+ async run() {
42
+ const { flags } = await this.parse(List);
43
+ const { apiKey: apiKeyFlag, apiUrl, from, json, limit, name, offset, to, } = flags;
44
+ const apiKey = apiKeyFlag || process.env.DEVICE_CLOUD_API_KEY;
45
+ if (!apiKey) {
46
+ this.error('API key is required. Please provide it via --api-key flag or DEVICE_CLOUD_API_KEY environment variable.');
47
+ return;
48
+ }
49
+ // Validate date formats if provided
50
+ if (from && Number.isNaN(Date.parse(from))) {
51
+ this.error('Invalid --from date format. Please use ISO 8601 format (e.g., 2024-01-01).');
52
+ return;
53
+ }
54
+ if (to && Number.isNaN(Date.parse(to))) {
55
+ this.error('Invalid --to date format. Please use ISO 8601 format (e.g., 2024-01-31).');
56
+ return;
57
+ }
58
+ // Detect potential shell expansion of wildcards
59
+ if (name) {
60
+ this.detectShellExpansion(name);
61
+ }
62
+ try {
63
+ const response = await api_gateway_1.ApiGateway.listUploads(apiUrl, apiKey, {
64
+ from,
65
+ limit,
66
+ name,
67
+ offset,
68
+ to,
69
+ });
70
+ if (json) {
71
+ return response;
72
+ }
73
+ this.displayResults(response);
74
+ }
75
+ catch (error) {
76
+ this.error(`Failed to list uploads: ${error.message}`);
77
+ }
78
+ }
79
+ /**
80
+ * Detects if the provided name parameter likely underwent shell expansion
81
+ * Warns the user if shell expansion is detected
82
+ * @param name - The name parameter to check for shell expansion
83
+ * @returns void
84
+ */
85
+ detectShellExpansion(name) {
86
+ const shellExpansionIndicators = [
87
+ // Contains file path separators (likely expanded to file paths)
88
+ name.includes('/') || name.includes('\\'),
89
+ // Contains file extensions (likely expanded to filenames)
90
+ /\.(yaml|yml|json|txt|md|ts|js|py|sh)$/i.test(name),
91
+ // Looks like multiple space-separated filenames (shell expanded glob to multiple files)
92
+ name.includes(' ') && !name.includes('*') && !name.includes('?'),
93
+ ];
94
+ if (shellExpansionIndicators.some(Boolean)) {
95
+ this.warn(`\nThe --name parameter appears to have been expanded by your shell: "${name}"\n` +
96
+ 'Wildcards like * should be quoted to prevent shell expansion.\n' +
97
+ 'Examples:\n' +
98
+ ' ✓ Correct: dcd list --name "nightly-*"\n' +
99
+ ' ✓ Correct: dcd list --name \'nightly-*\'\n' +
100
+ ' ✗ Incorrect: dcd list --name nightly-*\n');
101
+ }
102
+ }
103
+ displayResults(response) {
104
+ const { uploads, total, limit, offset } = response;
105
+ if (uploads.length === 0) {
106
+ this.log('\nNo uploads found matching your criteria.\n');
107
+ return;
108
+ }
109
+ this.log((0, styling_1.sectionHeader)('Recent Uploads'));
110
+ this.log(` ${styling_1.colors.dim('Showing')} ${uploads.length} ${styling_1.colors.dim('of')} ${total} ${styling_1.colors.dim('uploads')}`);
111
+ if (offset > 0) {
112
+ this.log(` ${styling_1.colors.dim('(offset:')} ${offset}${styling_1.colors.dim(')')}`);
113
+ }
114
+ this.log('');
115
+ for (const upload of uploads) {
116
+ const date = new Date(upload.created_at);
117
+ const formattedDate = date.toLocaleDateString('en-US', {
118
+ day: 'numeric',
119
+ hour: '2-digit',
120
+ minute: '2-digit',
121
+ month: 'short',
122
+ year: 'numeric',
123
+ });
124
+ // Upload name
125
+ const displayName = upload.name || styling_1.colors.dim('(unnamed)');
126
+ this.log(` ${styling_1.colors.bold(displayName)}`);
127
+ // Upload ID and date
128
+ this.log(` ${styling_1.colors.dim('ID:')} ${(0, styling_1.formatId)(upload.id)}`);
129
+ this.log(` ${styling_1.colors.dim('Created:')} ${formattedDate}`);
130
+ // Console URL
131
+ this.log(` ${styling_1.colors.dim('Console:')} ${(0, styling_1.formatUrl)(upload.consoleUrl)}`);
132
+ this.log('');
133
+ }
134
+ // Pagination hint
135
+ if (total > offset + uploads.length) {
136
+ const remaining = total - (offset + uploads.length);
137
+ this.log(` ${styling_1.colors.dim('Use')} --offset ${offset + limit} ${styling_1.colors.dim('to see the next')} ${Math.min(remaining, limit)} ${styling_1.colors.dim('uploads')}\n`);
138
+ }
139
+ // Hint about getting detailed status
140
+ this.log(` ${styling_1.colors.dim('Tip: Use')} dcd status --upload-id <id> ${styling_1.colors.dim('for detailed test results')}\n`);
141
+ }
142
+ }
143
+ exports.default = List;
@@ -9,9 +9,12 @@ type StatusResponse = {
9
9
  message: string;
10
10
  };
11
11
  consoleUrl?: string;
12
+ createdAt?: string;
12
13
  error?: string;
14
+ name?: string;
13
15
  status: 'CANCELLED' | 'FAILED' | 'PASSED' | 'PENDING' | 'RUNNING';
14
16
  tests: {
17
+ createdAt?: string;
15
18
  durationSeconds?: number;
16
19
  failReason?: string;
17
20
  name: string;
@@ -31,5 +34,11 @@ export default class Status extends Command {
31
34
  'upload-id': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
32
35
  };
33
36
  run(): Promise<StatusResponse | void>;
37
+ /**
38
+ * Format an ISO date string to a human-readable local date/time
39
+ * @param isoString - ISO 8601 date string
40
+ * @returns Formatted local date/time string
41
+ */
42
+ private formatDateTime;
34
43
  }
35
44
  export {};
@@ -47,8 +47,10 @@ class Status extends core_1.Command {
47
47
  }
48
48
  let lastError = null;
49
49
  let status = null;
50
+ let attemptsMade = 0;
50
51
  for (let attempt = 1; attempt <= 5; attempt++) {
51
52
  try {
53
+ attemptsMade = attempt;
52
54
  status = (await api_gateway_1.ApiGateway.getUploadStatus(apiUrl, apiKey, {
53
55
  name,
54
56
  uploadId,
@@ -57,57 +59,109 @@ class Status extends core_1.Command {
57
59
  }
58
60
  catch (error) {
59
61
  lastError = error;
60
- if (attempt < 5) {
62
+ // Check if this is a retryable error (network/timeout issues)
63
+ // Non-retryable errors: 4xx client errors (bad request, not found, unauthorized, forbidden)
64
+ const isNetworkError = lastError.name === 'NetworkError' ||
65
+ (error instanceof TypeError && lastError.message === 'fetch failed');
66
+ const isClientError = lastError.message.includes('Invalid request:') ||
67
+ lastError.message.includes('Resource not found') ||
68
+ lastError.message.includes('Authentication failed') ||
69
+ lastError.message.includes('Access denied') ||
70
+ lastError.message.includes('Invalid API key') ||
71
+ lastError.message.includes('Rate limit exceeded');
72
+ // Don't retry client errors - they won't succeed on retry
73
+ if (isClientError) {
74
+ break;
75
+ }
76
+ // Only retry network errors
77
+ if (attempt < 5 && isNetworkError) {
61
78
  this.log(`Network error on attempt ${attempt}/5. Retrying...`);
62
79
  await new Promise((resolve) => {
63
80
  setTimeout(resolve, 1000 * attempt);
64
81
  });
65
82
  }
83
+ else if (attempt < 5) {
84
+ // For other errors (server errors), retry but with different message
85
+ this.log(`Request failed on attempt ${attempt}/5. Retrying...`);
86
+ await new Promise((resolve) => {
87
+ setTimeout(resolve, 1000 * attempt);
88
+ });
89
+ }
66
90
  }
67
91
  }
68
92
  if (!status) {
93
+ // Check if this was a client error (non-retryable)
94
+ const isClientError = lastError && (lastError.message.includes('Invalid request:') ||
95
+ lastError.message.includes('Resource not found') ||
96
+ lastError.message.includes('Authentication failed') ||
97
+ lastError.message.includes('Access denied') ||
98
+ lastError.message.includes('Invalid API key') ||
99
+ lastError.message.includes('Rate limit exceeded'));
100
+ if (isClientError) {
101
+ // For client errors, show the error immediately without connectivity check
102
+ const errorMessage = lastError?.message || 'Unknown error';
103
+ if (json) {
104
+ return {
105
+ status: 'FAILED',
106
+ error: errorMessage,
107
+ attempts: attemptsMade,
108
+ tests: [],
109
+ };
110
+ }
111
+ this.error(errorMessage);
112
+ }
69
113
  // Check if the failure is due to internet connectivity issues
70
114
  const connectivityCheck = await (0, connectivity_1.checkInternetConnectivity)();
71
115
  let errorMessage;
72
116
  if (connectivityCheck.connected) {
73
- errorMessage = `Failed to get status after 5 attempts. Internet appears functional but unable to reach API. Last error: ${lastError?.message || 'Unknown error'}`;
117
+ errorMessage = `Failed to get status after ${attemptsMade} attempt${attemptsMade > 1 ? 's' : ''}. Internet appears functional but unable to reach API. Last error: ${lastError?.message || 'Unknown error'}`;
74
118
  }
75
119
  else {
76
120
  // Build detailed error message with endpoint diagnostics
77
121
  const endpointDetails = connectivityCheck.endpointResults
78
122
  .map((r) => ` - ${r.endpoint}: ${r.error} (${r.latencyMs}ms)`)
79
123
  .join('\n');
80
- errorMessage = `Failed to get status after 5 attempts.\n\nInternet connectivity check failed - all test endpoints unreachable:\n${endpointDetails}\n\nPlease verify your network connection and DNS resolution.\nLast API error: ${lastError?.message || 'Unknown error'}`;
124
+ errorMessage = `Failed to get status after ${attemptsMade} attempt${attemptsMade > 1 ? 's' : ''}.\n\nInternet connectivity check failed - all test endpoints unreachable:\n${endpointDetails}\n\nPlease verify your network connection and DNS resolution.\nLast API error: ${lastError?.message || 'Unknown error'}`;
81
125
  }
82
126
  if (json) {
83
127
  return {
84
- attempts: 5,
128
+ status: 'FAILED',
129
+ error: errorMessage,
130
+ attempts: attemptsMade,
85
131
  connectivityCheck: {
86
132
  connected: connectivityCheck.connected,
87
133
  endpointResults: connectivityCheck.endpointResults,
88
134
  message: connectivityCheck.message,
89
135
  },
90
- error: errorMessage,
91
- status: 'FAILED',
92
136
  tests: [],
93
137
  };
94
138
  }
95
139
  this.error(errorMessage);
96
- return;
97
140
  }
98
141
  try {
99
142
  if (json) {
100
- return status;
143
+ // Reconstruct object to ensure tests appears at the bottom
144
+ const { tests, ...rest } = status;
145
+ return {
146
+ ...rest,
147
+ tests,
148
+ };
101
149
  }
102
150
  this.log((0, styling_1.sectionHeader)('Upload Status'));
103
151
  // Display overall status
104
152
  this.log(` ${(0, styling_1.formatStatus)(status.status)}`);
153
+ if (status.name) {
154
+ this.log(` ${styling_1.colors.dim('Name:')} ${styling_1.colors.bold(status.name)}`);
155
+ }
105
156
  if (status.uploadId) {
106
157
  this.log(` ${styling_1.colors.dim('Upload ID:')} ${(0, styling_1.formatId)(status.uploadId)}`);
107
158
  }
108
159
  if (status.appBinaryId) {
109
160
  this.log(` ${styling_1.colors.dim('Binary ID:')} ${(0, styling_1.formatId)(status.appBinaryId)}`);
110
161
  }
162
+ if (status.createdAt) {
163
+ this.log(` ${styling_1.colors.dim('Created:')} ${this.formatDateTime(status.createdAt)}`);
164
+ }
111
165
  if (status.consoleUrl) {
112
166
  this.log(` ${styling_1.colors.dim('Console:')} ${(0, styling_1.formatUrl)(status.consoleUrl)}`);
113
167
  }
@@ -121,6 +175,9 @@ class Status extends core_1.Command {
121
175
  if (item.durationSeconds) {
122
176
  this.log(` ${styling_1.colors.dim('Duration:')} ${(0, methods_1.formatDurationSeconds)(item.durationSeconds)}`);
123
177
  }
178
+ if (item.createdAt) {
179
+ this.log(` ${styling_1.colors.dim('Created:')} ${this.formatDateTime(item.createdAt)}`);
180
+ }
124
181
  this.log('');
125
182
  }
126
183
  }
@@ -129,5 +186,19 @@ class Status extends core_1.Command {
129
186
  this.error(`Failed to get status: ${error.message}`);
130
187
  }
131
188
  }
189
+ /**
190
+ * Format an ISO date string to a human-readable local date/time
191
+ * @param isoString - ISO 8601 date string
192
+ * @returns Formatted local date/time string
193
+ */
194
+ formatDateTime(isoString) {
195
+ try {
196
+ const date = new Date(isoString);
197
+ return date.toLocaleString();
198
+ }
199
+ catch {
200
+ return isoString;
201
+ }
202
+ }
132
203
  }
133
204
  exports.default = Status;
@@ -30,7 +30,7 @@ exports.outputFlags = {
30
30
  description: 'Enable detailed debug logging for troubleshooting issues',
31
31
  }),
32
32
  'download-artifacts': core_1.Flags.string({
33
- description: 'Download a zip containing the logs, screenshots and videos for each result in this run. You will be debited a $0.01 egress fee for each result. Options: ALL (everything), FAILED (failures only).',
33
+ description: 'Download a zip containing the logs, screenshots and videos for each result in this run. Options: ALL (everything), FAILED (failures only).',
34
34
  options: ['ALL', 'FAILED'],
35
35
  }),
36
36
  'dry-run': core_1.Flags.boolean({
@@ -1,5 +1,12 @@
1
1
  import { TAppMetadata } from '../types';
2
2
  export declare const ApiGateway: {
3
+ /**
4
+ * Enhances generic "fetch failed" errors with more specific diagnostic information
5
+ * @param error - The original TypeError from fetch
6
+ * @param url - The URL that was being fetched
7
+ * @returns Enhanced error with diagnostic information
8
+ */
9
+ enhanceFetchError(error: TypeError, url: string): Error;
3
10
  /**
4
11
  * Standardized error handling for API responses
5
12
  * @param res - The fetch response object
@@ -38,14 +45,34 @@ export declare const ApiGateway: {
38
45
  name?: string;
39
46
  uploadId?: string;
40
47
  }): Promise<{
48
+ createdAt?: string;
49
+ name?: string;
41
50
  status: "CANCELLED" | "FAILED" | "PASSED" | "PENDING";
42
51
  tests: Array<{
52
+ createdAt?: string;
43
53
  durationSeconds?: number;
44
54
  failReason?: string;
45
55
  name: string;
46
56
  status: "CANCELLED" | "FAILED" | "PASSED" | "PENDING";
47
57
  }>;
48
58
  }>;
59
+ listUploads(baseUrl: string, apiKey: string, options?: {
60
+ from?: string;
61
+ limit?: number;
62
+ name?: string;
63
+ offset?: number;
64
+ to?: string;
65
+ }): Promise<{
66
+ limit: number;
67
+ offset: number;
68
+ total: number;
69
+ uploads: Array<{
70
+ consoleUrl: string;
71
+ created_at: string;
72
+ id: string;
73
+ name: null | string;
74
+ }>;
75
+ }>;
49
76
  uploadFlow(baseUrl: string, apiKey: string, testFormData: FormData): Promise<{
50
77
  message?: string;
51
78
  results?: import("../types/generated/schema.types").components["schemas"]["IDBResult"][];