@vizzly-testing/cli 0.30.0 → 0.32.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.
Files changed (79) hide show
  1. package/README.md +9 -9
  2. package/dist/api/client.js +6 -3
  3. package/dist/cli.js +58 -29
  4. package/dist/client/index.js +21 -29
  5. package/dist/commands/api.js +145 -90
  6. package/dist/commands/baselines.js +78 -47
  7. package/dist/commands/builds.js +9 -8
  8. package/dist/commands/comparisons.js +7 -8
  9. package/dist/commands/config-cmd.js +15 -7
  10. package/dist/commands/context.js +329 -18
  11. package/dist/commands/doctor.js +116 -81
  12. package/dist/commands/init.js +202 -201
  13. package/dist/commands/login.js +134 -65
  14. package/dist/commands/logout.js +17 -6
  15. package/dist/commands/preview.js +29 -57
  16. package/dist/commands/project.js +120 -0
  17. package/dist/commands/projects.js +9 -2
  18. package/dist/commands/review.js +236 -202
  19. package/dist/commands/run.js +75 -72
  20. package/dist/commands/status.js +228 -150
  21. package/dist/commands/tdd-daemon-process.js +98 -0
  22. package/dist/commands/tdd-daemon.js +318 -212
  23. package/dist/commands/tdd.js +23 -8
  24. package/dist/commands/upload.js +10 -9
  25. package/dist/commands/whoami.js +63 -25
  26. package/dist/config/core.js +36 -35
  27. package/dist/context/local-workspace-provider.js +132 -12
  28. package/dist/context/provider-resolver.js +5 -1
  29. package/dist/index.js +5 -4
  30. package/dist/plugin-loader.js +55 -49
  31. package/dist/project/core.js +2 -2
  32. package/dist/project/operations.js +6 -3
  33. package/dist/reporter/reporter-bundle.css +1 -1
  34. package/dist/reporter/reporter-bundle.iife.js +7 -7
  35. package/dist/sdk/index.js +162 -107
  36. package/dist/server/handlers/tdd-handler.js +30 -54
  37. package/dist/server/routers/baseline.js +195 -178
  38. package/dist/server/routers/events.js +212 -174
  39. package/dist/server/routers/projects.js +14 -5
  40. package/dist/server/routers/screenshot.js +1 -31
  41. package/dist/services/build-manager.js +19 -97
  42. package/dist/services/config-service.js +26 -34
  43. package/dist/services/index.js +2 -4
  44. package/dist/services/static-report-generator.js +11 -12
  45. package/dist/services/test-runner.js +21 -27
  46. package/dist/tdd/core/region-coverage.js +109 -21
  47. package/dist/tdd/index.js +1 -1
  48. package/dist/tdd/metadata/region-metadata.js +0 -37
  49. package/dist/tdd/runtime-context.js +78 -0
  50. package/dist/tdd/server-registry.js +38 -17
  51. package/dist/tdd/services/baseline-manager.js +23 -0
  52. package/dist/tdd/services/comparison-service.js +10 -1
  53. package/dist/tdd/services/hotspot-service.js +0 -13
  54. package/dist/tdd/tdd-service.js +144 -138
  55. package/dist/types/client.d.ts +36 -1
  56. package/dist/types/index.d.ts +114 -22
  57. package/dist/types/sdk.d.ts +40 -0
  58. package/dist/uploader/core.js +5 -4
  59. package/dist/uploader/index.js +79 -2
  60. package/dist/uploader/operations.js +40 -5
  61. package/dist/utils/api-url.js +14 -0
  62. package/dist/utils/async-utils.js +26 -0
  63. package/dist/utils/ci-env.js +19 -12
  64. package/dist/utils/config-loader.js +39 -65
  65. package/dist/utils/config-schema.js +19 -55
  66. package/dist/utils/fetch-utils.js +12 -5
  67. package/dist/utils/git.js +35 -72
  68. package/dist/utils/global-config.js +20 -1
  69. package/dist/utils/output.js +19 -7
  70. package/dist/utils/patterns.js +14 -0
  71. package/dist/utils/project-link-store.js +105 -0
  72. package/dist/utils/screenshot-options.js +28 -0
  73. package/dist/utils/secret-store.js +65 -0
  74. package/package.json +14 -11
  75. package/dist/services/screenshot-server.js +0 -65
  76. package/dist/services/server-manager.js +0 -81
  77. package/dist/services/uploader.js +0 -100
  78. package/dist/tdd/services/region-service.js +0 -75
  79. package/dist/utils/build-history.js +0 -103
@@ -77,6 +77,7 @@ export interface ScreenshotOptions {
77
77
  minClusterSize?: number;
78
78
  fullPage?: boolean;
79
79
  buildId?: string;
80
+ [key: string]: unknown;
80
81
  }
81
82
 
82
83
  export interface ScreenshotResult {
@@ -84,6 +85,7 @@ export interface ScreenshotResult {
84
85
  status?: 'passed' | 'failed' | 'new';
85
86
  name?: string;
86
87
  diffPercentage?: number;
88
+ [key: string]: unknown;
87
89
  }
88
90
 
89
91
  // ============================================================================
@@ -262,6 +264,12 @@ export class ValidationError extends VizzlyError {
262
264
  export interface VizzlySDKInstance extends EventEmitter {
263
265
  config: VizzlyConfig;
264
266
 
267
+ /** Reload file config and re-apply current in-memory overrides */
268
+ init(): Promise<VizzlyConfig>;
269
+
270
+ /** Merge new config values into the active SDK config */
271
+ updateConfig(newConfig: Partial<VizzlyConfig>): VizzlyConfig;
272
+
265
273
  /** Start the Vizzly server */
266
274
  start(): Promise<{ port: number; url: string }>;
267
275
 
@@ -286,6 +294,28 @@ export interface VizzlySDKInstance extends EventEmitter {
286
294
  name: string,
287
295
  imageBuffer: Buffer | string
288
296
  ): Promise<ComparisonResult>;
297
+
298
+ /** Create an uploader using the SDK config */
299
+ createUploader(options?: {
300
+ upload?: UploadConfig;
301
+ signal?: AbortSignal;
302
+ batchSize?: number;
303
+ timeout?: number;
304
+ }): Uploader;
305
+
306
+ /** Create a local TDD service using the SDK config */
307
+ createTDDService(options?: {
308
+ workingDir?: string;
309
+ setBaseline?: boolean;
310
+ authService?: unknown;
311
+ }): TddService;
312
+
313
+ /** Start a local TDD service */
314
+ startTDD(options?: {
315
+ workingDir?: string;
316
+ setBaseline?: boolean;
317
+ authService?: unknown;
318
+ }): Promise<unknown>;
289
319
  }
290
320
 
291
321
  export class VizzlySDK extends EventEmitter implements VizzlySDKInstance {
@@ -293,6 +323,8 @@ export class VizzlySDK extends EventEmitter implements VizzlySDKInstance {
293
323
 
294
324
  constructor(config: VizzlyConfig, services: unknown);
295
325
 
326
+ init(): Promise<VizzlyConfig>;
327
+ updateConfig(newConfig: Partial<VizzlyConfig>): VizzlyConfig;
296
328
  start(): Promise<{ port: number; url: string }>;
297
329
  stop(): Promise<void>;
298
330
  getConfig(): VizzlyConfig;
@@ -306,6 +338,22 @@ export class VizzlySDK extends EventEmitter implements VizzlySDKInstance {
306
338
  name: string,
307
339
  imageBuffer: Buffer | string
308
340
  ): Promise<ComparisonResult>;
341
+ createUploader(options?: {
342
+ upload?: UploadConfig;
343
+ signal?: AbortSignal;
344
+ batchSize?: number;
345
+ timeout?: number;
346
+ }): Uploader;
347
+ createTDDService(options?: {
348
+ workingDir?: string;
349
+ setBaseline?: boolean;
350
+ authService?: unknown;
351
+ }): TddService;
352
+ startTDD(options?: {
353
+ workingDir?: string;
354
+ setBaseline?: boolean;
355
+ authService?: unknown;
356
+ }): Promise<unknown>;
309
357
  }
310
358
 
311
359
  // ============================================================================
@@ -344,15 +392,8 @@ export interface TddService {
344
392
  }
345
393
 
346
394
  export interface Services {
347
- apiService: unknown;
348
- authService: unknown;
349
- configService: unknown;
350
- projectService: unknown;
351
- uploader: Uploader;
352
- buildManager: unknown;
353
- serverManager: unknown;
354
- tddService: TddService;
355
- testRunner: unknown;
395
+ serverManager: ServerManager;
396
+ testRunner: TestRunnerService;
356
397
  }
357
398
 
358
399
  // ============================================================================
@@ -392,11 +433,47 @@ export interface PluginServerManager {
392
433
  stop(): Promise<void>;
393
434
  }
394
435
 
436
+ /**
437
+ * Internal server manager returned by createServices().
438
+ */
439
+ export interface ServerManager extends PluginServerManager {
440
+ /** Get current TDD results from the local server handler */
441
+ getTddResults(): Promise<TddResults | null>;
442
+ /** Current HTTP server facade, if running */
443
+ readonly server: unknown;
444
+ }
445
+
446
+ /**
447
+ * Internal TestRunner returned by createServices().
448
+ */
449
+ export interface TestRunnerService extends PluginTestRunner {
450
+ /** Initialize daemon mode without running tests */
451
+ initialize(options: Record<string, unknown>): Promise<void>;
452
+ /** Run tests through the configured test command */
453
+ run(options: Record<string, unknown>): Promise<unknown>;
454
+ /** Cancel the active test process and stop the server */
455
+ cancel(): Promise<void>;
456
+ }
457
+
458
+ export interface PluginGitInfo {
459
+ branch: string;
460
+ commit: string | null;
461
+ message: string | null;
462
+ prNumber: number | null;
463
+ buildName: string;
464
+ }
465
+
466
+ export interface PluginGit {
467
+ /** Detect branch, commit, commit message, PR number, and build name */
468
+ detect(options?: { buildPrefix?: string }): Promise<PluginGitInfo>;
469
+ }
470
+
395
471
  /**
396
472
  * Stable services interface for plugins.
397
473
  * This is the public API contract - internal services are NOT exposed.
398
474
  */
399
475
  export interface PluginServices {
476
+ git: PluginGit;
400
477
  testRunner: PluginTestRunner;
401
478
  serverManager: PluginServerManager;
402
479
  }
@@ -432,8 +509,6 @@ export interface PluginContext {
432
509
  services: PluginServices;
433
510
  /** Output utilities for logging */
434
511
  output: OutputUtils;
435
- /** @deprecated Use output instead. Alias for backwards compatibility. */
436
- logger: OutputUtils;
437
512
  }
438
513
 
439
514
  /** Create stable plugin services from internal services */
@@ -444,12 +519,31 @@ export function createPluginServices(services: Services): PluginServices;
444
519
  // ============================================================================
445
520
 
446
521
  export interface OutputUtils {
447
- info(message: string): void;
448
- warn(message: string): void;
449
- error(message: string): void;
450
- success(message: string): void;
451
- debug(category: string, ...args: unknown[]): void;
452
- configure(options: { verbose?: boolean }): void;
522
+ info(message: string, data?: Record<string, unknown>): void;
523
+ warn(message: string, data?: Record<string, unknown>): void;
524
+ error(
525
+ message: string,
526
+ error?: Error | null,
527
+ data?: Record<string, unknown>
528
+ ): void;
529
+ success(message: string, data?: Record<string, unknown>): void;
530
+ debug(
531
+ component: string,
532
+ message: string,
533
+ data?: Record<string, unknown>
534
+ ): void;
535
+ configure(options: OutputConfigureOptions): void;
536
+ }
537
+
538
+ export interface OutputConfigureOptions {
539
+ json?: boolean | string;
540
+ jsonFields?: string[] | null;
541
+ logLevel?: 'debug' | 'info' | 'warn' | 'error';
542
+ verbose?: boolean;
543
+ color?: boolean;
544
+ silent?: boolean;
545
+ logFile?: string | null;
546
+ resetTimer?: boolean;
453
547
  }
454
548
 
455
549
  // ============================================================================
@@ -471,8 +565,9 @@ export function vizzlyScreenshot(
471
565
  threshold?: number;
472
566
  minClusterSize?: number;
473
567
  fullPage?: boolean;
568
+ [key: string]: unknown;
474
569
  }
475
- ): Promise<void>;
570
+ ): Promise<ScreenshotResult | null>;
476
571
 
477
572
  /** Configure the Vizzly client */
478
573
  export function configure(config?: {
@@ -510,10 +605,7 @@ export function createTDDService(
510
605
  ): TddService;
511
606
 
512
607
  /** Create all services with dependencies */
513
- export function createServices(
514
- config: VizzlyConfig,
515
- command?: string
516
- ): Services;
608
+ export function createServices(config: VizzlyConfig): Services;
517
609
 
518
610
  /** Load configuration from file and environment */
519
611
  export function loadConfig(options?: { cwd?: string }): Promise<VizzlyConfig>;
@@ -41,6 +41,18 @@ export class VizzlySDK extends EventEmitter {
41
41
 
42
42
  constructor(config: import('./index').VizzlyConfig, services: unknown);
43
43
 
44
+ /**
45
+ * Reload file config and re-apply current in-memory overrides.
46
+ */
47
+ init(): Promise<import('./index').VizzlyConfig>;
48
+
49
+ /**
50
+ * Merge new config values into the active SDK config.
51
+ */
52
+ updateConfig(
53
+ newConfig: Partial<import('./index').VizzlyConfig>
54
+ ): import('./index').VizzlyConfig;
55
+
44
56
  /**
45
57
  * Start the Vizzly server
46
58
  * @returns Server information including port and URL
@@ -90,6 +102,34 @@ export class VizzlySDK extends EventEmitter {
90
102
  name: string,
91
103
  imageBuffer: Buffer | string
92
104
  ): Promise<import('./index').ComparisonResult>;
105
+
106
+ /**
107
+ * Create an uploader using the SDK config.
108
+ */
109
+ createUploader(options?: {
110
+ upload?: import('./index').UploadConfig;
111
+ signal?: AbortSignal;
112
+ batchSize?: number;
113
+ timeout?: number;
114
+ }): import('./index').Uploader;
115
+
116
+ /**
117
+ * Create a local TDD service using the SDK config.
118
+ */
119
+ createTDDService(options?: {
120
+ workingDir?: string;
121
+ setBaseline?: boolean;
122
+ authService?: unknown;
123
+ }): import('./index').TddService;
124
+
125
+ /**
126
+ * Start a local TDD service.
127
+ */
128
+ startTDD(options?: {
129
+ workingDir?: string;
130
+ setBaseline?: boolean;
131
+ authService?: unknown;
132
+ }): Promise<unknown>;
93
133
  }
94
134
 
95
135
  /**
@@ -14,6 +14,7 @@ import { basename } from 'node:path';
14
14
  export const DEFAULT_BATCH_SIZE = 50;
15
15
  export const DEFAULT_SHA_CHECK_BATCH_SIZE = 100;
16
16
  export const DEFAULT_TIMEOUT = 30000; // 30 seconds
17
+ export const DEFAULT_BUILD_POLL_INTERVAL = 1000; // 1 second
17
18
 
18
19
  // ============================================================================
19
20
  // Validation
@@ -363,8 +364,8 @@ export function resolveTimeout(options, uploadConfig) {
363
364
  * @param {number} timeout - Timeout in ms
364
365
  * @returns {boolean} True if timed out
365
366
  */
366
- export function isTimedOut(startTime, timeout) {
367
- return Date.now() - startTime >= timeout;
367
+ export function isTimedOut(startTime, timeout, now = Date.now()) {
368
+ return now - startTime >= timeout;
368
369
  }
369
370
 
370
371
  /**
@@ -372,8 +373,8 @@ export function isTimedOut(startTime, timeout) {
372
373
  * @param {number} startTime - Start timestamp
373
374
  * @returns {number} Elapsed time in ms
374
375
  */
375
- export function getElapsedTime(startTime) {
376
- return Date.now() - startTime;
376
+ export function getElapsedTime(startTime, now = Date.now()) {
377
+ return now - startTime;
377
378
  }
378
379
 
379
380
  /**
@@ -1,11 +1,88 @@
1
1
  /**
2
2
  * Uploader Module
3
3
  *
4
- * Exports pure functions (core) and I/O operations for screenshot uploading.
4
+ * Exports pure functions (core), I/O operations, and the public uploader
5
+ * factory for screenshot uploading.
5
6
  */
6
7
 
8
+ import { readFile, stat } from 'node:fs/promises';
9
+ import { glob } from 'glob';
10
+ import { checkShas, createApiClient, createBuild } from '../api/index.js';
11
+ import { TimeoutError, UploadError, ValidationError } from '../errors/vizzly-error.js';
12
+ import { getDefaultBranch } from '../utils/git.js';
13
+ import * as output from '../utils/output.js';
14
+ import { resolveBatchSize, resolveTimeout } from './core.js';
15
+ import { upload as uploadOperation, waitForBuild as waitForBuildOperation } from './operations.js';
16
+ export function createUploader({
17
+ apiKey,
18
+ apiUrl,
19
+ userAgent,
20
+ command,
21
+ upload: uploadConfig = {}
22
+ } = {}, options = {}) {
23
+ let signal = options.signal || new AbortController().signal;
24
+ let client = createApiClient({
25
+ baseUrl: apiUrl,
26
+ token: apiKey,
27
+ command: command || 'upload',
28
+ sdkUserAgent: userAgent,
29
+ allowNoToken: true
30
+ });
31
+ let batchSize = resolveBatchSize(options, uploadConfig);
32
+ let timeout = resolveTimeout(options, uploadConfig);
33
+ let deps = options.deps || {
34
+ client,
35
+ createBuild,
36
+ getDefaultBranch,
37
+ glob,
38
+ readFile,
39
+ stat,
40
+ checkShas,
41
+ createError: (message, code, context) => {
42
+ let error = new UploadError(message, context);
43
+ error.code = code;
44
+ return error;
45
+ },
46
+ createValidationError: (message, context) => new ValidationError(message, context),
47
+ createUploadError: (message, context) => new UploadError(message, context),
48
+ createTimeoutError: (message, context) => new TimeoutError(message, context),
49
+ output
50
+ };
51
+ async function upload(uploadOptions) {
52
+ return uploadOperation({
53
+ uploadOptions,
54
+ config: {
55
+ apiKey,
56
+ apiUrl
57
+ },
58
+ signal,
59
+ batchSize,
60
+ deps: {
61
+ ...deps,
62
+ client: deps.client || client
63
+ }
64
+ });
65
+ }
66
+ async function waitForBuild(buildId, waitTimeout = timeout) {
67
+ return waitForBuildOperation({
68
+ buildId,
69
+ timeout: waitTimeout,
70
+ signal,
71
+ client: deps.client || client,
72
+ deps: {
73
+ createError: deps.createError,
74
+ createTimeoutError: deps.createTimeoutError
75
+ }
76
+ });
77
+ }
78
+ return {
79
+ upload,
80
+ waitForBuild
81
+ };
82
+ }
83
+
7
84
  // Core - pure functions
8
- export { buildBuildInfo, buildCompletedProgress, buildDeduplicationProgress, buildFileMetadata, buildProcessingProgress, buildScanningProgress, buildScreenshotPattern, buildUploadingProgress, buildUploadResult, buildWaitResult, computeSha256, DEFAULT_BATCH_SIZE, DEFAULT_SHA_CHECK_BATCH_SIZE, DEFAULT_TIMEOUT, extractBrowserFromFilename, extractStatusCodeFromError, fileToScreenshotFormat, getElapsedTime, isTimedOut, partitionFilesByExistence, resolveBatchSize, resolveTimeout, validateApiKey, validateDirectoryStats, validateFilesFound, validateScreenshotsDir } from './core.js';
85
+ export { buildBuildInfo, buildCompletedProgress, buildDeduplicationProgress, buildFileMetadata, buildProcessingProgress, buildScanningProgress, buildScreenshotPattern, buildUploadingProgress, buildUploadResult, buildWaitResult, computeSha256, DEFAULT_BATCH_SIZE, DEFAULT_BUILD_POLL_INTERVAL, DEFAULT_SHA_CHECK_BATCH_SIZE, DEFAULT_TIMEOUT, extractBrowserFromFilename, extractStatusCodeFromError, fileToScreenshotFormat, getElapsedTime, isTimedOut, partitionFilesByExistence, resolveBatchSize, resolveTimeout, validateApiKey, validateDirectoryStats, validateFilesFound, validateScreenshotsDir } from './core.js';
9
86
 
10
87
  // Operations - I/O with dependency injection
11
88
  export { checkExistingFiles, findScreenshots, processFiles, upload, uploadFiles, waitForBuild } from './operations.js';
@@ -4,7 +4,34 @@
4
4
  * Each operation takes its dependencies as parameters for testability.
5
5
  */
6
6
 
7
- import { buildBuildInfo, buildCompletedProgress, buildDeduplicationProgress, buildFileMetadata, buildProcessingProgress, buildScanningProgress, buildScreenshotPattern, buildUploadingProgress, buildUploadResult, buildWaitResult, DEFAULT_SHA_CHECK_BATCH_SIZE, extractStatusCodeFromError, fileToScreenshotFormat, getElapsedTime, isTimedOut, partitionFilesByExistence, validateApiKey, validateDirectoryStats, validateFilesFound, validateScreenshotsDir } from './core.js';
7
+ import { buildBuildInfo, buildCompletedProgress, buildDeduplicationProgress, buildFileMetadata, buildProcessingProgress, buildScanningProgress, buildScreenshotPattern, buildUploadingProgress, buildUploadResult, buildWaitResult, DEFAULT_BUILD_POLL_INTERVAL, DEFAULT_SHA_CHECK_BATCH_SIZE, extractStatusCodeFromError, fileToScreenshotFormat, getElapsedTime, isTimedOut, partitionFilesByExistence, validateApiKey, validateDirectoryStats, validateFilesFound, validateScreenshotsDir } from './core.js';
8
+ function delay(ms, signal) {
9
+ if (ms <= 0) {
10
+ return Promise.resolve();
11
+ }
12
+ return new Promise((resolve, reject) => {
13
+ let timeoutId;
14
+ function cleanup() {
15
+ clearTimeout(timeoutId);
16
+ signal?.removeEventListener?.('abort', handleAbort);
17
+ }
18
+ function handleAbort() {
19
+ cleanup();
20
+ reject(new Error('Operation cancelled'));
21
+ }
22
+ timeoutId = setTimeout(() => {
23
+ cleanup();
24
+ resolve();
25
+ }, ms);
26
+ if (signal?.aborted) {
27
+ handleAbort();
28
+ return;
29
+ }
30
+ signal?.addEventListener?.('abort', handleAbort, {
31
+ once: true
32
+ });
33
+ });
34
+ }
8
35
 
9
36
  // ============================================================================
10
37
  // File Discovery
@@ -228,10 +255,13 @@ export async function waitForBuild({
228
255
  }) {
229
256
  let {
230
257
  createError,
231
- createTimeoutError
258
+ createTimeoutError,
259
+ now = () => Date.now(),
260
+ pollInterval = DEFAULT_BUILD_POLL_INTERVAL,
261
+ wait = delay
232
262
  } = deps;
233
- let startTime = Date.now();
234
- while (!isTimedOut(startTime, timeout)) {
263
+ let startTime = now();
264
+ while (!isTimedOut(startTime, timeout, now())) {
235
265
  if (signal.aborted) {
236
266
  throw createError('Operation cancelled', 'UPLOAD_CANCELLED', {
237
267
  buildId
@@ -253,11 +283,16 @@ export async function waitForBuild({
253
283
  if (build.status === 'failed') {
254
284
  throw createError(`Build failed: ${build.error || 'Unknown error'}`, 'BUILD_FAILED');
255
285
  }
286
+ let remaining = timeout - getElapsedTime(startTime, now());
287
+ if (remaining <= 0) {
288
+ break;
289
+ }
290
+ await wait(Math.min(pollInterval, remaining), signal);
256
291
  }
257
292
  throw createTimeoutError(`Build timed out after ${timeout}ms`, {
258
293
  buildId,
259
294
  timeout,
260
- elapsed: getElapsedTime(startTime)
295
+ elapsed: getElapsedTime(startTime, now())
261
296
  });
262
297
  }
263
298
 
@@ -0,0 +1,14 @@
1
+ export function getAppBaseUrl(apiUrl) {
2
+ try {
3
+ let url = new URL(apiUrl);
4
+ let apiPathIndex = url.pathname.indexOf('/api');
5
+ if (apiPathIndex !== -1) {
6
+ url.pathname = url.pathname.slice(0, apiPathIndex) || '/';
7
+ url.search = '';
8
+ url.hash = '';
9
+ }
10
+ return url.toString().replace(/\/$/, '');
11
+ } catch {
12
+ return apiUrl.replace(/\/api(?:\/.*)?$/, '');
13
+ }
14
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Race an operation against a timeout and always clear the timer when either
3
+ * side settles.
4
+ *
5
+ * @param {Promise<unknown>} operation - Operation promise to wait for.
6
+ * @param {number} ms - Timeout in milliseconds.
7
+ * @param {string} message - Error message when the timeout wins.
8
+ * @param {Object} [timers] - Timer implementation for deterministic tests.
9
+ * @returns {Promise<unknown>} The operation result.
10
+ */
11
+ export async function withTimeout(operation, ms, message, timers = {
12
+ setTimeout,
13
+ clearTimeout
14
+ }) {
15
+ let timeoutId;
16
+ let timeoutPromise = new Promise((_, reject) => {
17
+ timeoutId = timers.setTimeout(() => reject(new Error(message)), ms);
18
+ });
19
+ try {
20
+ return await Promise.race([operation, timeoutPromise]);
21
+ } finally {
22
+ if (timeoutId !== undefined) {
23
+ timers.clearTimeout(timeoutId);
24
+ }
25
+ }
26
+ }
@@ -176,6 +176,13 @@ export function getCommitMessage() {
176
176
  // Generic
177
177
  null;
178
178
  }
179
+ function parsePullRequestNumber(value) {
180
+ if (!/^\d+$/.test(value)) {
181
+ return null;
182
+ }
183
+ let number = Number(value);
184
+ return Number.isSafeInteger(number) && number > 0 ? number : null;
185
+ }
179
186
 
180
187
  /**
181
188
  * Get the pull request number from CI environment variables
@@ -184,54 +191,54 @@ export function getCommitMessage() {
184
191
  export function getPullRequestNumber() {
185
192
  // Check VIZZLY override first
186
193
  if (process.env.VIZZLY_PR_NUMBER) {
187
- return parseInt(process.env.VIZZLY_PR_NUMBER, 10);
194
+ return parsePullRequestNumber(process.env.VIZZLY_PR_NUMBER);
188
195
  }
189
196
 
190
197
  // GitHub Actions - extract from GITHUB_REF
191
198
  if (process.env.GITHUB_ACTIONS && process.env.GITHUB_EVENT_NAME === 'pull_request') {
192
- const prMatch = process.env.GITHUB_REF?.match(/refs\/pull\/(\d+)\/merge/);
193
- if (prMatch?.[1]) return parseInt(prMatch[1], 10);
199
+ let prMatch = process.env.GITHUB_REF?.match(/refs\/pull\/(\d+)\/merge/);
200
+ if (prMatch?.[1]) return parsePullRequestNumber(prMatch[1]);
194
201
  }
195
202
 
196
203
  // GitLab CI
197
204
  if (process.env.CI_MERGE_REQUEST_ID) {
198
- return parseInt(process.env.CI_MERGE_REQUEST_ID, 10);
205
+ return parsePullRequestNumber(process.env.CI_MERGE_REQUEST_ID);
199
206
  }
200
207
 
201
208
  // CircleCI - extract from PR URL
202
209
  if (process.env.CIRCLE_PULL_REQUEST) {
203
- const prMatch = process.env.CIRCLE_PULL_REQUEST.match(/\/pull\/(\d+)$/);
204
- if (prMatch?.[1]) return parseInt(prMatch[1], 10);
210
+ let prMatch = process.env.CIRCLE_PULL_REQUEST.match(/\/pull\/(\d+)$/);
211
+ if (prMatch?.[1]) return parsePullRequestNumber(prMatch[1]);
205
212
  }
206
213
 
207
214
  // Travis CI
208
215
  if (process.env.TRAVIS_PULL_REQUEST && process.env.TRAVIS_PULL_REQUEST !== 'false') {
209
- return parseInt(process.env.TRAVIS_PULL_REQUEST, 10);
216
+ return parsePullRequestNumber(process.env.TRAVIS_PULL_REQUEST);
210
217
  }
211
218
 
212
219
  // Buildkite
213
220
  if (process.env.BUILDKITE_PULL_REQUEST && process.env.BUILDKITE_PULL_REQUEST !== 'false') {
214
- return parseInt(process.env.BUILDKITE_PULL_REQUEST, 10);
221
+ return parsePullRequestNumber(process.env.BUILDKITE_PULL_REQUEST);
215
222
  }
216
223
 
217
224
  // Drone CI
218
225
  if (process.env.DRONE_PULL_REQUEST) {
219
- return parseInt(process.env.DRONE_PULL_REQUEST, 10);
226
+ return parsePullRequestNumber(process.env.DRONE_PULL_REQUEST);
220
227
  }
221
228
 
222
229
  // Jenkins (GitHub Pull Request Builder plugin)
223
230
  if (process.env.ghprbPullId) {
224
- return parseInt(process.env.ghprbPullId, 10);
231
+ return parsePullRequestNumber(process.env.ghprbPullId);
225
232
  }
226
233
 
227
234
  // Azure DevOps
228
235
  if (process.env.SYSTEM_PULLREQUEST_PULLREQUESTID) {
229
- return parseInt(process.env.SYSTEM_PULLREQUEST_PULLREQUESTID, 10);
236
+ return parsePullRequestNumber(process.env.SYSTEM_PULLREQUEST_PULLREQUESTID);
230
237
  }
231
238
 
232
239
  // AppVeyor
233
240
  if (process.env.APPVEYOR_PULL_REQUEST_NUMBER) {
234
- return parseInt(process.env.APPVEYOR_PULL_REQUEST_NUMBER, 10);
241
+ return parsePullRequestNumber(process.env.APPVEYOR_PULL_REQUEST_NUMBER);
235
242
  }
236
243
  return null;
237
244
  }