@curl-runner/cli 1.16.0 → 1.16.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.
Files changed (40) hide show
  1. package/package.json +2 -2
  2. package/src/ci-exit.test.ts +0 -216
  3. package/src/cli.ts +0 -1351
  4. package/src/commands/upgrade.ts +0 -262
  5. package/src/diff/baseline-manager.test.ts +0 -181
  6. package/src/diff/baseline-manager.ts +0 -266
  7. package/src/diff/diff-formatter.ts +0 -316
  8. package/src/diff/index.ts +0 -3
  9. package/src/diff/response-differ.test.ts +0 -330
  10. package/src/diff/response-differ.ts +0 -489
  11. package/src/executor/max-concurrency.test.ts +0 -139
  12. package/src/executor/profile-executor.test.ts +0 -132
  13. package/src/executor/profile-executor.ts +0 -167
  14. package/src/executor/request-executor.ts +0 -663
  15. package/src/parser/yaml.test.ts +0 -480
  16. package/src/parser/yaml.ts +0 -271
  17. package/src/snapshot/index.ts +0 -3
  18. package/src/snapshot/snapshot-differ.test.ts +0 -358
  19. package/src/snapshot/snapshot-differ.ts +0 -296
  20. package/src/snapshot/snapshot-formatter.ts +0 -170
  21. package/src/snapshot/snapshot-manager.test.ts +0 -204
  22. package/src/snapshot/snapshot-manager.ts +0 -342
  23. package/src/types/bun-yaml.d.ts +0 -11
  24. package/src/types/config.ts +0 -638
  25. package/src/utils/colors.ts +0 -30
  26. package/src/utils/condition-evaluator.test.ts +0 -415
  27. package/src/utils/condition-evaluator.ts +0 -327
  28. package/src/utils/curl-builder.test.ts +0 -165
  29. package/src/utils/curl-builder.ts +0 -209
  30. package/src/utils/installation-detector.test.ts +0 -52
  31. package/src/utils/installation-detector.ts +0 -123
  32. package/src/utils/logger.ts +0 -856
  33. package/src/utils/response-store.test.ts +0 -213
  34. package/src/utils/response-store.ts +0 -108
  35. package/src/utils/stats.test.ts +0 -161
  36. package/src/utils/stats.ts +0 -151
  37. package/src/utils/version-checker.ts +0 -158
  38. package/src/version.ts +0 -43
  39. package/src/watcher/file-watcher.test.ts +0 -186
  40. package/src/watcher/file-watcher.ts +0 -140
@@ -1,327 +0,0 @@
1
- import type {
2
- ConditionExpression,
3
- ConditionOperator,
4
- ResponseStoreContext,
5
- WhenCondition,
6
- } from '../types/config';
7
- import { getValueByPath, valueToString } from './response-store';
8
-
9
- /**
10
- * Result of evaluating a condition.
11
- */
12
- export interface ConditionResult {
13
- shouldRun: boolean;
14
- reason?: string;
15
- }
16
-
17
- /**
18
- * Parses a string shorthand condition into a ConditionExpression.
19
- * Supports formats like:
20
- * - "store.status == 200"
21
- * - "store.userId exists"
22
- * - "store.body.type contains user"
23
- * - "store.version >= 2"
24
- *
25
- * @param condition - String condition to parse
26
- * @returns Parsed ConditionExpression or null if invalid
27
- */
28
- export function parseStringCondition(condition: string): ConditionExpression | null {
29
- const trimmed = condition.trim();
30
-
31
- // Check for exists/not-exists operators first (unary)
32
- const existsMatch = trimmed.match(/^(.+?)\s+(exists|not-exists)$/i);
33
- if (existsMatch) {
34
- return {
35
- left: existsMatch[1].trim(),
36
- operator: existsMatch[2].toLowerCase() as ConditionOperator,
37
- };
38
- }
39
-
40
- // Binary operators pattern
41
- const operatorPattern = /^(.+?)\s*(==|!=|>=|<=|>|<|contains|matches)\s*(.+)$/i;
42
- const match = trimmed.match(operatorPattern);
43
-
44
- if (!match) {
45
- return null;
46
- }
47
-
48
- const [, left, operator, right] = match;
49
- const rightValue = parseRightValue(right.trim());
50
-
51
- return {
52
- left: left.trim(),
53
- operator: operator.toLowerCase() as ConditionOperator,
54
- right: rightValue,
55
- };
56
- }
57
-
58
- /**
59
- * Parses the right-hand value, converting to appropriate type.
60
- */
61
- function parseRightValue(value: string): string | number | boolean {
62
- // Boolean
63
- if (value === 'true') {
64
- return true;
65
- }
66
- if (value === 'false') {
67
- return false;
68
- }
69
-
70
- // Number
71
- const num = Number(value);
72
- if (!Number.isNaN(num) && value !== '') {
73
- return num;
74
- }
75
-
76
- // String - remove quotes if present
77
- if (
78
- (value.startsWith('"') && value.endsWith('"')) ||
79
- (value.startsWith("'") && value.endsWith("'"))
80
- ) {
81
- return value.slice(1, -1);
82
- }
83
-
84
- return value;
85
- }
86
-
87
- /**
88
- * Gets a value from the store context using a path.
89
- * Handles "store.x" prefix by stripping it.
90
- */
91
- function getStoreValue(path: string, context: ResponseStoreContext): unknown {
92
- // Strip "store." prefix if present
93
- const normalizedPath = path.startsWith('store.') ? path.slice(6) : path;
94
-
95
- // First check if it's a direct key in context
96
- if (normalizedPath in context) {
97
- const value = context[normalizedPath];
98
- // Try to parse JSON if it looks like an object/array
99
- if (value.startsWith('{') || value.startsWith('[')) {
100
- try {
101
- return JSON.parse(value);
102
- } catch {
103
- return value;
104
- }
105
- }
106
- // Try to parse as number
107
- const num = Number(value);
108
- if (!Number.isNaN(num) && value !== '') {
109
- return num;
110
- }
111
- // Try to parse as boolean
112
- if (value === 'true') {
113
- return true;
114
- }
115
- if (value === 'false') {
116
- return false;
117
- }
118
- return value;
119
- }
120
-
121
- // Handle nested paths like "body.data.id" where context has "body" as JSON string
122
- const parts = normalizedPath.split('.');
123
- const rootKey = parts[0];
124
-
125
- if (rootKey in context) {
126
- const rootValue = context[rootKey];
127
- // Try to parse as JSON and navigate
128
- try {
129
- const parsed = JSON.parse(rootValue);
130
- return getValueByPath(parsed, parts.slice(1).join('.'));
131
- } catch {
132
- // Not JSON, can't navigate further
133
- return undefined;
134
- }
135
- }
136
-
137
- return undefined;
138
- }
139
-
140
- /**
141
- * Compares two values based on case sensitivity setting.
142
- */
143
- function compareStrings(a: string, b: string, caseSensitive: boolean): boolean {
144
- if (caseSensitive) {
145
- return a === b;
146
- }
147
- return a.toLowerCase() === b.toLowerCase();
148
- }
149
-
150
- /**
151
- * Evaluates a single condition expression against the store context.
152
- */
153
- export function evaluateExpression(
154
- expr: ConditionExpression,
155
- context: ResponseStoreContext,
156
- ): { passed: boolean; description: string } {
157
- const leftValue = getStoreValue(expr.left, context);
158
- const caseSensitive = expr.caseSensitive ?? false;
159
-
160
- const formatValue = (v: unknown): string => {
161
- if (v === undefined) {
162
- return 'undefined';
163
- }
164
- if (v === null) {
165
- return 'null';
166
- }
167
- if (typeof v === 'string') {
168
- return `"${v}"`;
169
- }
170
- return String(v);
171
- };
172
-
173
- const description = `${expr.left} ${expr.operator}${expr.right !== undefined ? ` ${formatValue(expr.right)}` : ''}`;
174
-
175
- switch (expr.operator) {
176
- case 'exists': {
177
- const passed = leftValue !== undefined && leftValue !== null && leftValue !== '';
178
- return { passed, description };
179
- }
180
-
181
- case 'not-exists': {
182
- const passed = leftValue === undefined || leftValue === null || leftValue === '';
183
- return { passed, description };
184
- }
185
-
186
- case '==': {
187
- let passed: boolean;
188
- if (typeof leftValue === 'string' && typeof expr.right === 'string') {
189
- passed = compareStrings(leftValue, expr.right, caseSensitive);
190
- } else {
191
- // biome-ignore lint/suspicious/noDoubleEquals: intentional loose equality for type coercion
192
- passed = leftValue == expr.right;
193
- }
194
- return { passed, description };
195
- }
196
-
197
- case '!=': {
198
- let passed: boolean;
199
- if (typeof leftValue === 'string' && typeof expr.right === 'string') {
200
- passed = !compareStrings(leftValue, expr.right, caseSensitive);
201
- } else {
202
- // biome-ignore lint/suspicious/noDoubleEquals: intentional loose equality for type coercion
203
- passed = leftValue != expr.right;
204
- }
205
- return { passed, description };
206
- }
207
-
208
- case '>': {
209
- const passed = Number(leftValue) > Number(expr.right);
210
- return { passed, description };
211
- }
212
-
213
- case '<': {
214
- const passed = Number(leftValue) < Number(expr.right);
215
- return { passed, description };
216
- }
217
-
218
- case '>=': {
219
- const passed = Number(leftValue) >= Number(expr.right);
220
- return { passed, description };
221
- }
222
-
223
- case '<=': {
224
- const passed = Number(leftValue) <= Number(expr.right);
225
- return { passed, description };
226
- }
227
-
228
- case 'contains': {
229
- const leftStr = valueToString(leftValue);
230
- const rightStr = String(expr.right ?? '');
231
- const passed = caseSensitive
232
- ? leftStr.includes(rightStr)
233
- : leftStr.toLowerCase().includes(rightStr.toLowerCase());
234
- return { passed, description };
235
- }
236
-
237
- case 'matches': {
238
- const leftStr = valueToString(leftValue);
239
- const pattern = String(expr.right ?? '');
240
- try {
241
- const flags = caseSensitive ? '' : 'i';
242
- const regex = new RegExp(pattern, flags);
243
- const passed = regex.test(leftStr);
244
- return { passed, description };
245
- } catch {
246
- // Invalid regex pattern
247
- return { passed: false, description: `${description} (invalid regex)` };
248
- }
249
- }
250
-
251
- default:
252
- return { passed: false, description: `unknown operator: ${expr.operator}` };
253
- }
254
- }
255
-
256
- /**
257
- * Evaluates a WhenCondition against the store context.
258
- *
259
- * @param condition - The condition to evaluate
260
- * @param context - The store context with values from previous requests
261
- * @returns Result indicating whether the request should run
262
- */
263
- export function evaluateCondition(
264
- condition: WhenCondition | string,
265
- context: ResponseStoreContext,
266
- ): ConditionResult {
267
- // Handle string shorthand
268
- if (typeof condition === 'string') {
269
- const parsed = parseStringCondition(condition);
270
- if (!parsed) {
271
- return { shouldRun: false, reason: `invalid condition syntax: "${condition}"` };
272
- }
273
- const result = evaluateExpression(parsed, context);
274
- return {
275
- shouldRun: result.passed,
276
- reason: result.passed ? undefined : `condition not met: ${result.description}`,
277
- };
278
- }
279
-
280
- // Handle compound "all" condition (AND logic with short-circuit)
281
- if (condition.all && condition.all.length > 0) {
282
- for (const expr of condition.all) {
283
- const result = evaluateExpression(expr, context);
284
- if (!result.passed) {
285
- return {
286
- shouldRun: false,
287
- reason: `condition not met: ${result.description}`,
288
- };
289
- }
290
- }
291
- return { shouldRun: true };
292
- }
293
-
294
- // Handle compound "any" condition (OR logic with short-circuit)
295
- if (condition.any && condition.any.length > 0) {
296
- const descriptions: string[] = [];
297
- for (const expr of condition.any) {
298
- const result = evaluateExpression(expr, context);
299
- if (result.passed) {
300
- return { shouldRun: true };
301
- }
302
- descriptions.push(result.description);
303
- }
304
- return {
305
- shouldRun: false,
306
- reason: `no conditions met: ${descriptions.join(', ')}`,
307
- };
308
- }
309
-
310
- // Handle single condition (inline left/operator/right)
311
- if (condition.left && condition.operator) {
312
- const expr: ConditionExpression = {
313
- left: condition.left,
314
- operator: condition.operator,
315
- right: condition.right,
316
- caseSensitive: condition.caseSensitive,
317
- };
318
- const result = evaluateExpression(expr, context);
319
- return {
320
- shouldRun: result.passed,
321
- reason: result.passed ? undefined : `condition not met: ${result.description}`,
322
- };
323
- }
324
-
325
- // No valid condition found - default to run
326
- return { shouldRun: true };
327
- }
@@ -1,165 +0,0 @@
1
- import { describe, expect, test } from 'bun:test';
2
- import { CurlBuilder } from './curl-builder';
3
-
4
- describe('CurlBuilder', () => {
5
- describe('buildCommand', () => {
6
- test('should build basic GET request', () => {
7
- const command = CurlBuilder.buildCommand({
8
- url: 'https://example.com/api',
9
- method: 'GET',
10
- });
11
-
12
- expect(command).toContain('curl');
13
- expect(command).toContain('-X GET');
14
- expect(command).toContain('"https://example.com/api"');
15
- });
16
-
17
- test('should build POST request with JSON body', () => {
18
- const command = CurlBuilder.buildCommand({
19
- url: 'https://example.com/api',
20
- method: 'POST',
21
- body: { name: 'test' },
22
- });
23
-
24
- expect(command).toContain('-X POST');
25
- expect(command).toContain('-d \'{"name":"test"}\'');
26
- expect(command).toContain('Content-Type: application/json');
27
- });
28
-
29
- test('should build POST request with form data', () => {
30
- const command = CurlBuilder.buildCommand({
31
- url: 'https://example.com/upload',
32
- method: 'POST',
33
- formData: {
34
- username: 'john',
35
- age: 30,
36
- },
37
- });
38
-
39
- expect(command).toContain('-X POST');
40
- expect(command).toContain("-F 'username=john'");
41
- expect(command).toContain("-F 'age=30'");
42
- expect(command).not.toContain('-d');
43
- });
44
-
45
- test('should build POST request with file attachment', () => {
46
- const command = CurlBuilder.buildCommand({
47
- url: 'https://example.com/upload',
48
- method: 'POST',
49
- formData: {
50
- document: {
51
- file: './test.pdf',
52
- },
53
- },
54
- });
55
-
56
- expect(command).toContain("-F 'document=@./test.pdf'");
57
- });
58
-
59
- test('should build POST request with file attachment and custom filename', () => {
60
- const command = CurlBuilder.buildCommand({
61
- url: 'https://example.com/upload',
62
- method: 'POST',
63
- formData: {
64
- document: {
65
- file: './test.pdf',
66
- filename: 'report.pdf',
67
- },
68
- },
69
- });
70
-
71
- expect(command).toContain("-F 'document=@./test.pdf;filename=report.pdf'");
72
- });
73
-
74
- test('should build POST request with file attachment and content type', () => {
75
- const command = CurlBuilder.buildCommand({
76
- url: 'https://example.com/upload',
77
- method: 'POST',
78
- formData: {
79
- data: {
80
- file: './data.json',
81
- contentType: 'application/json',
82
- },
83
- },
84
- });
85
-
86
- expect(command).toContain("-F 'data=@./data.json;type=application/json'");
87
- });
88
-
89
- test('should build POST request with file attachment including all options', () => {
90
- const command = CurlBuilder.buildCommand({
91
- url: 'https://example.com/upload',
92
- method: 'POST',
93
- formData: {
94
- document: {
95
- file: './report.pdf',
96
- filename: 'quarterly-report.pdf',
97
- contentType: 'application/pdf',
98
- },
99
- },
100
- });
101
-
102
- expect(command).toContain(
103
- "-F 'document=@./report.pdf;filename=quarterly-report.pdf;type=application/pdf'",
104
- );
105
- });
106
-
107
- test('should build POST request with mixed form data and files', () => {
108
- const command = CurlBuilder.buildCommand({
109
- url: 'https://example.com/upload',
110
- method: 'POST',
111
- formData: {
112
- title: 'My Document',
113
- description: 'Test upload',
114
- file: {
115
- file: './document.pdf',
116
- },
117
- },
118
- });
119
-
120
- expect(command).toContain("-F 'title=My Document'");
121
- expect(command).toContain("-F 'description=Test upload'");
122
- expect(command).toContain("-F 'file=@./document.pdf'");
123
- });
124
-
125
- test('should escape single quotes in form field values', () => {
126
- const command = CurlBuilder.buildCommand({
127
- url: 'https://example.com/upload',
128
- method: 'POST',
129
- formData: {
130
- message: "It's a test",
131
- },
132
- });
133
-
134
- expect(command).toContain("-F 'message=It'\\''s a test'");
135
- });
136
-
137
- test('should prefer formData over body when both are present', () => {
138
- const command = CurlBuilder.buildCommand({
139
- url: 'https://example.com/api',
140
- method: 'POST',
141
- formData: {
142
- field: 'value',
143
- },
144
- body: { name: 'test' },
145
- });
146
-
147
- expect(command).toContain("-F 'field=value'");
148
- expect(command).not.toContain('-d');
149
- });
150
-
151
- test('should handle boolean form field values', () => {
152
- const command = CurlBuilder.buildCommand({
153
- url: 'https://example.com/api',
154
- method: 'POST',
155
- formData: {
156
- active: true,
157
- disabled: false,
158
- },
159
- });
160
-
161
- expect(command).toContain("-F 'active=true'");
162
- expect(command).toContain("-F 'disabled=false'");
163
- });
164
- });
165
- });
@@ -1,209 +0,0 @@
1
- import type { FileAttachment, FormFieldValue, RequestConfig } from '../types/config';
2
-
3
- interface CurlMetrics {
4
- response_code?: number;
5
- http_code?: number;
6
- time_total?: number;
7
- size_download?: number;
8
- time_namelookup?: number;
9
- time_connect?: number;
10
- time_appconnect?: number;
11
- time_starttransfer?: number;
12
- }
13
-
14
- /**
15
- * Checks if a form field value is a file attachment.
16
- */
17
- function isFileAttachment(value: FormFieldValue): value is FileAttachment {
18
- return typeof value === 'object' && value !== null && 'file' in value;
19
- }
20
-
21
- /**
22
- * Escapes a string value for use in curl -F flag.
23
- */
24
- function escapeFormValue(value: string): string {
25
- return value.replace(/'/g, "'\\''");
26
- }
27
-
28
- // Using class for organization, but could be refactored to functions
29
- export class CurlBuilder {
30
- static buildCommand(config: RequestConfig): string {
31
- const parts: string[] = ['curl'];
32
-
33
- parts.push('-X', config.method || 'GET');
34
-
35
- parts.push('-w', '"\\n__CURL_METRICS_START__%{json}__CURL_METRICS_END__"');
36
-
37
- if (config.headers) {
38
- for (const [key, value] of Object.entries(config.headers)) {
39
- parts.push('-H', `"${key}: ${value}"`);
40
- }
41
- }
42
-
43
- if (config.auth) {
44
- if (config.auth.type === 'basic' && config.auth.username && config.auth.password) {
45
- parts.push('-u', `"${config.auth.username}:${config.auth.password}"`);
46
- } else if (config.auth.type === 'bearer' && config.auth.token) {
47
- parts.push('-H', `"Authorization: Bearer ${config.auth.token}"`);
48
- }
49
- }
50
-
51
- if (config.formData) {
52
- // Use -F flags for multipart/form-data
53
- for (const [fieldName, fieldValue] of Object.entries(config.formData)) {
54
- if (isFileAttachment(fieldValue)) {
55
- // File attachment: -F "field=@filepath;filename=name;type=mimetype"
56
- let fileSpec = `@${fieldValue.file}`;
57
- if (fieldValue.filename) {
58
- fileSpec += `;filename=${fieldValue.filename}`;
59
- }
60
- if (fieldValue.contentType) {
61
- fileSpec += `;type=${fieldValue.contentType}`;
62
- }
63
- parts.push('-F', `'${fieldName}=${escapeFormValue(fileSpec)}'`);
64
- } else {
65
- // Regular form field: -F "field=value"
66
- const strValue = String(fieldValue);
67
- parts.push('-F', `'${fieldName}=${escapeFormValue(strValue)}'`);
68
- }
69
- }
70
- } else if (config.body) {
71
- const bodyStr = typeof config.body === 'string' ? config.body : JSON.stringify(config.body);
72
- parts.push('-d', `'${bodyStr.replace(/'/g, "'\\''")}'`);
73
-
74
- if (!config.headers?.['Content-Type']) {
75
- parts.push('-H', '"Content-Type: application/json"');
76
- }
77
- }
78
-
79
- if (config.timeout) {
80
- parts.push('--max-time', config.timeout.toString());
81
- }
82
-
83
- if (config.followRedirects !== false) {
84
- parts.push('-L');
85
- if (config.maxRedirects) {
86
- parts.push('--max-redirs', config.maxRedirects.toString());
87
- }
88
- }
89
-
90
- if (config.proxy) {
91
- parts.push('-x', config.proxy);
92
- }
93
-
94
- // SSL/TLS configuration
95
- // insecure: true takes precedence (backwards compatibility)
96
- // ssl.verify: false is equivalent to insecure: true
97
- if (config.insecure || config.ssl?.verify === false) {
98
- parts.push('-k');
99
- }
100
-
101
- // SSL certificate options
102
- if (config.ssl) {
103
- if (config.ssl.ca) {
104
- parts.push('--cacert', `"${config.ssl.ca}"`);
105
- }
106
- if (config.ssl.cert) {
107
- parts.push('--cert', `"${config.ssl.cert}"`);
108
- }
109
- if (config.ssl.key) {
110
- parts.push('--key', `"${config.ssl.key}"`);
111
- }
112
- }
113
-
114
- if (config.output) {
115
- parts.push('-o', config.output);
116
- }
117
-
118
- parts.push('-s', '-S');
119
-
120
- let url = config.url;
121
- if (config.params && Object.keys(config.params).length > 0) {
122
- const queryString = new URLSearchParams(config.params).toString();
123
- url += (url.includes('?') ? '&' : '?') + queryString;
124
- }
125
-
126
- parts.push(`"${url}"`);
127
-
128
- return parts.join(' ');
129
- }
130
-
131
- static async executeCurl(command: string): Promise<{
132
- success: boolean;
133
- status?: number;
134
- headers?: Record<string, string>;
135
- body?: string;
136
- metrics?: {
137
- duration: number;
138
- size?: number;
139
- dnsLookup?: number;
140
- tcpConnection?: number;
141
- tlsHandshake?: number;
142
- firstByte?: number;
143
- download?: number;
144
- };
145
- error?: string;
146
- }> {
147
- try {
148
- const proc = Bun.spawn(['sh', '-c', command], {
149
- stdout: 'pipe',
150
- stderr: 'pipe',
151
- });
152
-
153
- const stdout = await new Response(proc.stdout).text();
154
- const stderr = await new Response(proc.stderr).text();
155
-
156
- await proc.exited;
157
-
158
- if (proc.exitCode !== 0 && !stdout) {
159
- return {
160
- success: false,
161
- error: stderr || `Command failed with exit code ${proc.exitCode}`,
162
- };
163
- }
164
-
165
- let responseBody = stdout;
166
- let metrics: CurlMetrics = {};
167
-
168
- const metricsMatch = stdout.match(/__CURL_METRICS_START__(.+?)__CURL_METRICS_END__/);
169
- if (metricsMatch) {
170
- responseBody = stdout.replace(/__CURL_METRICS_START__.+?__CURL_METRICS_END__/, '').trim();
171
- try {
172
- metrics = JSON.parse(metricsMatch[1]);
173
- } catch (_e) {}
174
- }
175
-
176
- const responseHeaders: Record<string, string> = {};
177
- if (metrics.response_code) {
178
- const headerLines = stderr.split('\n').filter((line) => line.includes(':'));
179
- for (const line of headerLines) {
180
- const [key, ...valueParts] = line.split(':');
181
- if (key && valueParts.length > 0) {
182
- responseHeaders[key.trim()] = valueParts.join(':').trim();
183
- }
184
- }
185
- }
186
-
187
- return {
188
- success: true,
189
- status: metrics.response_code || metrics.http_code,
190
- headers: responseHeaders,
191
- body: responseBody,
192
- metrics: {
193
- duration: (metrics.time_total || 0) * 1000,
194
- size: metrics.size_download,
195
- dnsLookup: (metrics.time_namelookup || 0) * 1000,
196
- tcpConnection: (metrics.time_connect || 0) * 1000,
197
- tlsHandshake: (metrics.time_appconnect || 0) * 1000,
198
- firstByte: (metrics.time_starttransfer || 0) * 1000,
199
- download: (metrics.time_total || 0) * 1000,
200
- },
201
- };
202
- } catch (error) {
203
- return {
204
- success: false,
205
- error: error instanceof Error ? error.message : String(error),
206
- };
207
- }
208
- }
209
- }