@vizzly-testing/cli 0.34.0 → 0.35.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +67 -7
  2. package/dist/api/core.js +18 -4
  3. package/dist/api/endpoints.js +38 -15
  4. package/dist/api/index.js +1 -1
  5. package/dist/cli.js +270 -194
  6. package/dist/client/index.js +62 -21
  7. package/dist/commands/api.js +9 -3
  8. package/dist/commands/preview.js +30 -26
  9. package/dist/commands/project.js +1 -1
  10. package/dist/commands/run.js +25 -10
  11. package/dist/commands/status.js +14 -1
  12. package/dist/commands/tdd-daemon-process.js +5 -1
  13. package/dist/commands/tdd-daemon.js +217 -139
  14. package/dist/commands/tdd.js +26 -5
  15. package/dist/commands/upload.js +31 -13
  16. package/dist/reporter/reporter-bundle.iife.js +6 -6
  17. package/dist/sdk/index.js +23 -9
  18. package/dist/server/handlers/api-handler.js +3 -2
  19. package/dist/server/handlers/tdd-handler.js +5 -1
  20. package/dist/server/routers/screenshot.js +11 -4
  21. package/dist/services/build-manager.js +6 -1
  22. package/dist/tdd/tdd-service.js +6 -2
  23. package/dist/test-runner/core.js +8 -3
  24. package/dist/test-runner/operations.js +19 -3
  25. package/dist/types/client.d.ts +32 -5
  26. package/dist/types/index.d.ts +33 -5
  27. package/dist/uploader/core.js +41 -9
  28. package/dist/uploader/operations.js +19 -17
  29. package/dist/utils/config-loader.js +13 -6
  30. package/dist/utils/context.js +46 -76
  31. package/dist/utils/environment-config.js +20 -0
  32. package/dist/utils/output.js +8 -5
  33. package/dist/utils/screenshot-options.js +60 -10
  34. package/package.json +1 -1
  35. package/skills/vizzly/SKILL.md +12 -4
  36. package/skills/vizzly/references/cli-context.md +8 -2
  37. package/skills/vizzly/references/setup-ci.md +3 -1
@@ -8,22 +8,27 @@ import { request as httpRequest } from 'node:http';
8
8
  import { request as httpsRequest } from 'node:https';
9
9
  import { dirname, join, parse } from 'node:path';
10
10
  import { getBuildId, getServerUrl, isTddMode, setVizzlyEnabled } from '../utils/environment-config.js';
11
- import { createScreenshotProperties } from '../utils/screenshot-options.js';
11
+ import { normalizeScreenshotOptions } from '../utils/screenshot-options.js';
12
12
 
13
13
  // Internal client state
14
14
  let currentClient = null;
15
+ let currentServerUrl = null;
15
16
  let isDisabled = false;
17
+ let currentFailOnDiff = false;
16
18
 
17
19
  // Default timeout for screenshot requests (30 seconds)
18
20
  let DEFAULT_TIMEOUT_MS = 30000;
19
21
 
20
22
  // Log levels for client SDK output control
21
- export let LOG_LEVELS = {
23
+ export let LOG_LEVELS = Object.freeze({
22
24
  debug: 0,
23
25
  info: 1,
24
26
  warn: 2,
25
27
  error: 3
26
- };
28
+ });
29
+ function getEnvFailOnDiff(env = process.env) {
30
+ return env.VIZZLY_FAIL_ON_DIFF === 'true' || env.VIZZLY_FAIL_ON_DIFF === '1';
31
+ }
27
32
 
28
33
  /**
29
34
  * Check if client should log at the given level
@@ -56,6 +61,7 @@ function isVizzlyDisabled() {
56
61
  function disableVizzly() {
57
62
  isDisabled = true;
58
63
  currentClient = null;
64
+ currentServerUrl = null;
59
65
  }
60
66
 
61
67
  /**
@@ -67,7 +73,8 @@ function disableVizzly() {
67
73
  export function autoDiscoverTddServer(startDir, deps = {}) {
68
74
  let {
69
75
  exists = existsSync,
70
- readFile = readFileSync
76
+ readFile = readFileSync,
77
+ env = process.env
71
78
  } = deps;
72
79
  try {
73
80
  // Look for .vizzly/server.json in current directory and parent directories
@@ -80,6 +87,7 @@ export function autoDiscoverTddServer(startDir, deps = {}) {
80
87
  let serverInfo = JSON.parse(readFile(serverJsonPath, 'utf8'));
81
88
  if (serverInfo.port) {
82
89
  let url = `http://localhost:${serverInfo.port}`;
90
+ currentFailOnDiff = getEnvFailOnDiff(env) || Boolean(serverInfo.failOnDiff);
83
91
  return url;
84
92
  }
85
93
  } catch {
@@ -104,6 +112,7 @@ function getClient() {
104
112
  }
105
113
  if (!currentClient) {
106
114
  let serverUrl = getServerUrl();
115
+ currentFailOnDiff = getEnvFailOnDiff();
107
116
 
108
117
  // Auto-detect local TDD server and enable Vizzly if TDD server is found
109
118
  if (!serverUrl) {
@@ -116,7 +125,10 @@ function getClient() {
116
125
 
117
126
  // If we have a server URL, create the client (regardless of initial enabled state)
118
127
  if (serverUrl) {
119
- currentClient = createSimpleClient(serverUrl);
128
+ currentServerUrl = serverUrl;
129
+ currentClient = createSimpleClient(serverUrl, {
130
+ failOnDiff: currentFailOnDiff
131
+ });
120
132
  }
121
133
  }
122
134
  return currentClient;
@@ -183,28 +195,38 @@ function httpPost(url, body, timeoutMs) {
183
195
  * Create a simple HTTP client for screenshots
184
196
  * @private
185
197
  */
186
- function createSimpleClient(serverUrl) {
198
+ function createSimpleClient(serverUrl, clientOptions = {}) {
199
+ let {
200
+ failOnDiff = false
201
+ } = clientOptions;
187
202
  return {
188
203
  async screenshot(name, imageBuffer, options = {}) {
204
+ let normalizedOptions = normalizeScreenshotOptions(options);
205
+ let requestTimeout = normalizedOptions.requestTimeout || DEFAULT_TIMEOUT_MS;
206
+ for (let warning of normalizedOptions.warnings) {
207
+ console.warn(`[vizzly] ${warning.message}`);
208
+ }
189
209
  try {
190
210
  // If it's a string, assume it's a file path and send directly
191
211
  // Otherwise it's a Buffer, so convert to base64
192
212
  let isFilePath = typeof imageBuffer === 'string';
193
213
  let image = isFilePath ? imageBuffer : imageBuffer.toString('base64');
194
214
  let type = isFilePath ? 'file-path' : 'base64';
195
- let requestTimeout = options.requestTimeout || DEFAULT_TIMEOUT_MS;
196
- let properties = createScreenshotProperties(options);
215
+ let screenshotData = {
216
+ buildId: normalizedOptions.buildId ?? getBuildId(),
217
+ name,
218
+ image,
219
+ type,
220
+ properties: normalizedOptions.properties
221
+ };
222
+ if (normalizedOptions.warnings.length > 0) {
223
+ screenshotData.warnings = normalizedOptions.warnings;
224
+ }
197
225
  let httpStart = Date.now();
198
226
  let {
199
227
  status,
200
228
  json
201
- } = await httpPost(`${serverUrl}/screenshot`, {
202
- buildId: getBuildId(),
203
- name,
204
- image,
205
- type,
206
- properties
207
- }, requestTimeout);
229
+ } = await httpPost(`${serverUrl}/screenshot`, screenshotData, requestTimeout);
208
230
  let httpMs = Date.now() - httpStart;
209
231
  if (shouldLogClient('debug')) {
210
232
  console.debug(`[vizzly-client] ${name} HTTP completed in ${httpMs}ms`);
@@ -215,6 +237,9 @@ function createSimpleClient(serverUrl) {
215
237
  // The summary will show all failures at the end
216
238
  if (status === 422 && json.tddMode && json.comparison) {
217
239
  let comp = json.comparison;
240
+ if (failOnDiff) {
241
+ throw new Error(`Visual diff detected for "${comp.name}" (${comp.diffPercentage ?? 0}% difference)`);
242
+ }
218
243
 
219
244
  // Return success so test continues and captures remaining screenshots
220
245
  // Visual diff details will be shown in the summary after tests complete
@@ -227,12 +252,15 @@ function createSimpleClient(serverUrl) {
227
252
  }
228
253
  throw new Error(`Screenshot failed: ${status} - ${json.error || 'Unknown error'}`);
229
254
  }
255
+ if (failOnDiff && json?.tddMode && ['diff', 'failed'].includes(json.status)) {
256
+ throw new Error(`Visual diff detected for "${json.name || name}" (${json.diffPercentage ?? 0}% difference)`);
257
+ }
230
258
  return json;
231
259
  } catch (error) {
232
260
  // Handle timeout
233
261
  if (error.message === 'Request timeout') {
234
262
  if (shouldLogClient('error')) {
235
- console.error(`[vizzly] Screenshot timed out for "${name}" after ${DEFAULT_TIMEOUT_MS / 1000}s`);
263
+ console.error(`[vizzly] Screenshot timed out for "${name}" after ${requestTimeout / 1000}s`);
236
264
  }
237
265
  disableVizzly();
238
266
  return null;
@@ -308,7 +336,7 @@ function createSimpleClient(serverUrl) {
308
336
  * await vizzlyScreenshot('checkout-form', screenshot, {
309
337
  * properties: {
310
338
  * browser: 'chrome',
311
- * viewport: '1920x1080'
339
+ * viewport: { width: 1920, height: 1080 }
312
340
  * },
313
341
  * threshold: 5
314
342
  * });
@@ -366,8 +394,20 @@ export function isVizzlyReady() {
366
394
  * @param {boolean} [config.enabled] - Enable/disable screenshots
367
395
  */
368
396
  export function configure(config = {}) {
397
+ if ('failOnDiff' in config) {
398
+ currentFailOnDiff = config.failOnDiff === true;
399
+ } else if ('serverUrl' in config) {
400
+ currentFailOnDiff = getEnvFailOnDiff();
401
+ }
369
402
  if ('serverUrl' in config) {
370
- currentClient = config.serverUrl ? createSimpleClient(config.serverUrl) : null;
403
+ currentServerUrl = config.serverUrl || null;
404
+ currentClient = config.serverUrl ? createSimpleClient(config.serverUrl, {
405
+ failOnDiff: currentFailOnDiff
406
+ }) : null;
407
+ } else if ('failOnDiff' in config && currentClient && currentServerUrl) {
408
+ currentClient = createSimpleClient(currentServerUrl, {
409
+ failOnDiff: currentFailOnDiff
410
+ });
371
411
  }
372
412
  if (typeof config.enabled === 'boolean') {
373
413
  setVizzlyEnabled(config.enabled);
@@ -397,10 +437,11 @@ export function getVizzlyInfo() {
397
437
  let client = getClient();
398
438
  return {
399
439
  enabled: !isVizzlyDisabled(),
400
- serverUrl: getServerUrl(),
440
+ serverUrl: currentServerUrl || getServerUrl() || null,
401
441
  ready: !isVizzlyDisabled() && client !== null,
402
- buildId: getBuildId(),
442
+ buildId: getBuildId() || null,
403
443
  tddMode: isTddMode(),
404
- disabled: isVizzlyDisabled()
444
+ disabled: isVizzlyDisabled(),
445
+ failOnDiff: currentFailOnDiff
405
446
  };
406
447
  }
@@ -65,7 +65,8 @@ export function appendApiQuery(endpoint, queryOption) {
65
65
  }
66
66
  export function validateApiRequest({
67
67
  endpoint,
68
- method
68
+ method,
69
+ hasData = false
69
70
  }) {
70
71
  let errors = [];
71
72
  if (method !== 'GET' && method !== 'POST') {
@@ -75,6 +76,9 @@ export function validateApiRequest({
75
76
  if (method === 'POST' && !isAllowedPostEndpoint(endpoint)) {
76
77
  errors.push(`POST not allowed for ${endpoint}. Only approve, reject, and comment endpoints support POST.`);
77
78
  }
79
+ if (hasData && method !== 'POST') {
80
+ errors.push('Request data requires --method POST.');
81
+ }
78
82
  return errors;
79
83
  }
80
84
  export function buildApiRequest({
@@ -85,7 +89,8 @@ export function buildApiRequest({
85
89
  let method = normalizeApiMethod(options.method || 'GET');
86
90
  let errors = validateApiRequest({
87
91
  endpoint: normalizedEndpoint,
88
- method
92
+ method,
93
+ hasData: options.data !== undefined
89
94
  });
90
95
  if (errors.length > 0) {
91
96
  return {
@@ -239,7 +244,8 @@ export function validateApiOptions(endpoint, options = {}) {
239
244
  let method = normalizeApiMethod(options.method || 'GET');
240
245
  errors.push(...validateApiRequest({
241
246
  endpoint: normalizedEndpoint,
242
- method
247
+ method,
248
+ hasData: options.data !== undefined
243
249
  }));
244
250
  return errors;
245
251
  }
@@ -259,6 +259,7 @@ export async function previewCommand(path, options = {}, globalOptions = {}, dep
259
259
  formatSessionAge = defaultFormatSessionAge,
260
260
  detectBranch = defaultDetectBranch,
261
261
  openBrowser = defaultOpenBrowser,
262
+ createZipWithSystem: createZip = createZipWithSystem,
262
263
  output = defaultOutput,
263
264
  exit = code => process.exit(code)
264
265
  } = deps;
@@ -268,39 +269,42 @@ export async function previewCommand(path, options = {}, globalOptions = {}, dep
268
269
  color: !globalOptions.noColor
269
270
  });
270
271
  try {
271
- // Load configuration
272
- let allOptions = {
273
- ...globalOptions,
274
- ...options
275
- };
276
- let config = await loadConfig(globalOptions.config, allOptions);
272
+ let uploadPath = options.base ? resolve(path, options.base) : path;
277
273
 
278
- // Validate API token (skip for dry-run)
279
- if (!options.dryRun && !config.apiKey) {
280
- output.error('API token required. Use --token or set VIZZLY_TOKEN environment variable');
274
+ // Validate path exists and is a directory before auth so local input
275
+ // mistakes are visible without requiring credentials.
276
+ if (!existsSync(uploadPath)) {
277
+ output.error(`Path does not exist: ${uploadPath}`);
281
278
  exit(1);
282
279
  return {
283
280
  success: false,
284
- reason: 'no-api-key'
281
+ reason: 'path-not-found'
285
282
  };
286
283
  }
287
-
288
- // Validate path exists and is a directory
289
- if (!existsSync(path)) {
290
- output.error(`Path does not exist: ${path}`);
284
+ let pathStat = statSync(uploadPath);
285
+ if (!pathStat.isDirectory()) {
286
+ output.error(`Path is not a directory: ${uploadPath}`);
291
287
  exit(1);
292
288
  return {
293
289
  success: false,
294
- reason: 'path-not-found'
290
+ reason: 'not-a-directory'
295
291
  };
296
292
  }
297
- let pathStat = statSync(path);
298
- if (!pathStat.isDirectory()) {
299
- output.error(`Path is not a directory: ${path}`);
293
+
294
+ // Load configuration
295
+ let allOptions = {
296
+ ...globalOptions,
297
+ ...options
298
+ };
299
+ let config = await loadConfig(globalOptions.config, allOptions);
300
+
301
+ // Validate API token (skip for dry-run)
302
+ if (!options.dryRun && !config.apiKey) {
303
+ output.error('API token required. Use --token or set VIZZLY_TOKEN environment variable');
300
304
  exit(1);
301
305
  return {
302
306
  success: false,
303
- reason: 'not-a-directory'
307
+ reason: 'no-api-key'
304
308
  };
305
309
  }
306
310
 
@@ -455,14 +459,14 @@ export async function previewCommand(path, options = {}, globalOptions = {}, dep
455
459
  count: fileCount,
456
460
  totalSize,
457
461
  files
458
- } = await countFiles(path, {
462
+ } = await countFiles(uploadPath, {
459
463
  collectPaths: options.dryRun,
460
464
  excludedDirs,
461
465
  excludedFiles
462
466
  });
463
467
  if (fileCount === 0) {
464
468
  output.stopSpinner();
465
- output.error(`No files found in ${path}`);
469
+ output.error(`No files found in ${uploadPath}`);
466
470
  exit(1);
467
471
  return {
468
472
  success: false,
@@ -477,7 +481,7 @@ export async function previewCommand(path, options = {}, globalOptions = {}, dep
477
481
  if (globalOptions.json) {
478
482
  output.data({
479
483
  dryRun: true,
480
- path: resolve(path),
484
+ path: resolve(uploadPath),
481
485
  fileCount,
482
486
  totalSize,
483
487
  excludedDirs,
@@ -490,7 +494,7 @@ export async function previewCommand(path, options = {}, globalOptions = {}, dep
490
494
  } else {
491
495
  output.info(`Dry run - would upload ${fileCount} files (${formatBytes(totalSize)})`);
492
496
  output.blank();
493
- output.print(` ${colors.brand.textTertiary('Source')} ${resolve(path)}`);
497
+ output.print(` ${colors.brand.textTertiary('Source')} ${resolve(uploadPath)}`);
494
498
  output.print(` ${colors.brand.textTertiary('Files')} ${fileCount}`);
495
499
  output.print(` ${colors.brand.textTertiary('Total size')} ${formatBytes(totalSize)}`);
496
500
  output.blank();
@@ -538,7 +542,7 @@ export async function previewCommand(path, options = {}, globalOptions = {}, dep
538
542
  let zipPath = join(tmpdir(), `vizzly-preview-${Date.now()}-${randomSuffix}.zip`);
539
543
  let zipBuffer;
540
544
  try {
541
- await createZipWithSystem(path, zipPath, exclusions);
545
+ await createZip(uploadPath, zipPath, exclusions);
542
546
  zipBuffer = await readFile(zipPath);
543
547
  } catch (zipError) {
544
548
  output.stopSpinner();
@@ -569,10 +573,10 @@ export async function previewCommand(path, options = {}, globalOptions = {}, dep
569
573
  success: true,
570
574
  buildId,
571
575
  previewUrl: result.previewUrl,
572
- files: result.uploaded || fileCount,
576
+ files: result.uploaded ?? fileCount,
573
577
  bytes: totalSize,
574
578
  compressedBytes: zipBuffer.length,
575
- compressionRatio,
579
+ compressionRatio: `${compressionPercent}%`,
576
580
  newBytes: result.newBytes,
577
581
  reusedBlobs: result.reusedBlobs || 0,
578
582
  deduplicationRatio: result.deduplicationRatio,
@@ -56,7 +56,7 @@ export async function projectLinkCommand(selector, options = {}, globalOptions =
56
56
  } = parseProjectSelector(selector, options);
57
57
  try {
58
58
  let config = await loadConfig(globalOptions.config, globalOptions);
59
- let userToken = config.userToken || (await getAccessToken());
59
+ let userToken = config.userToken || globalOptions.token || (await getAccessToken());
60
60
  if (!userToken) {
61
61
  output.error('Login required before linking a project');
62
62
  output.hint('Run "vizzly login" first, then try project link again');
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { spawn as defaultSpawn } from 'node:child_process';
7
7
  import { createBuild as defaultCreateApiBuild, createApiClient as defaultCreateApiClient, finalizeBuild as defaultFinalizeApiBuild, getBuild as defaultGetBuild, getTokenContext as defaultGetTokenContext } from '../api/index.js';
8
+ import { CONFIG_DEFAULTS } from '../config/core.js';
8
9
  import { VizzlyError } from '../errors/vizzly-error.js';
9
10
  import { createServerManager as defaultCreateServerManager } from '../server-manager/index.js';
10
11
  import { createBuildObject as defaultCreateBuildObject } from '../services/build-manager.js';
@@ -174,10 +175,11 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
174
175
  }
175
176
 
176
177
  // Collect git metadata and build info
177
- let branch = await detectBranch(options.branch);
178
- let commit = await detectCommit(options.commit);
179
- let message = options.message || (await detectCommitMessage());
180
- let buildName = await generateBuildNameWithGit(options.buildName);
178
+ let configuredBuildName = config.build.name && config.build.name !== CONFIG_DEFAULTS.build.name ? config.build.name : undefined;
179
+ let branch = await detectBranch(options.branch || config.build.branch);
180
+ let commit = await detectCommit(options.commit || config.build.commit);
181
+ let message = options.message || config.build.message || (await detectCommitMessage());
182
+ let buildName = await generateBuildNameWithGit(options.buildName || configuredBuildName);
181
183
  let pullRequestNumber = detectPullRequestNumber();
182
184
  if (globalOptions.verbose) {
183
185
  output.info('Configuration loaded');
@@ -244,7 +246,8 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
244
246
  wait: config.wait || options.wait || false,
245
247
  uploadAll: options.uploadAll || false,
246
248
  pullRequestNumber,
247
- parallelId: config.parallelId
249
+ parallelId: config.parallelId,
250
+ json: globalOptions.json
248
251
  };
249
252
 
250
253
  // Start test run
@@ -305,7 +308,7 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
305
308
  }
306
309
 
307
310
  // JSON output mode - output structured data and exit
308
- if (globalOptions.json) {
311
+ if (globalOptions.json && !runOptions.wait) {
309
312
  let executionTimeMs = Date.now() - startTime;
310
313
  let displayUrl = await resolveBuildDisplayUrl({
311
314
  result,
@@ -334,10 +337,12 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
334
337
  result
335
338
  };
336
339
  }
337
- output.complete('Test run completed');
340
+ if (!globalOptions.json) {
341
+ output.complete('Test run completed');
342
+ }
338
343
 
339
344
  // Show Vizzly summary with link to results
340
- if (result.buildId) {
345
+ if (result.buildId && !globalOptions.json) {
341
346
  output.blank();
342
347
  let colors = output.getColors();
343
348
  output.print(` ${colors.brand.textTertiary('Screenshots')} ${colors.white(result.screenshotsCaptured)}`);
@@ -384,7 +389,7 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
384
389
  });
385
390
  output.cleanup();
386
391
  } else {
387
- output.error('Test run failed');
392
+ output.error('Test run failed', error);
388
393
  }
389
394
  return {
390
395
  success: false,
@@ -490,7 +495,17 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
490
495
  // Don't fail CI for Vizzly infrastructure issues (5xx errors)
491
496
  let status = error.context?.status;
492
497
  if (status >= 500) {
493
- output.warn('Vizzly API unavailable - visual tests skipped. Your tests still ran.');
498
+ if (globalOptions.json) {
499
+ output.data({
500
+ buildId: null,
501
+ status: 'skipped',
502
+ message: 'Vizzly API unavailable - visual tests skipped',
503
+ executionTimeMs: Date.now() - (startTime || Date.now())
504
+ });
505
+ } else {
506
+ output.warn('Vizzly API unavailable - visual tests skipped. Your tests still ran.');
507
+ }
508
+ output.cleanup();
494
509
  output.debug('api', 'API error details:', {
495
510
  error: error.message
496
511
  });
@@ -36,6 +36,19 @@ function createStatusClient({
36
36
  command: 'status'
37
37
  });
38
38
  }
39
+ function isMissingPreviewError(error) {
40
+ return error.context?.status === 404;
41
+ }
42
+ async function fetchOptionalPreviewInfo(getPreviewInfo, client, buildId) {
43
+ try {
44
+ return await getPreviewInfo(client, buildId);
45
+ } catch (error) {
46
+ if (isMissingPreviewError(error)) {
47
+ return null;
48
+ }
49
+ throw error;
50
+ }
51
+ }
39
52
  export function normalizeBuildStatus(buildStatus) {
40
53
  return buildStatus.build || buildStatus;
41
54
  }
@@ -251,7 +264,7 @@ export async function statusCommand(buildId, options = {}, globalOptions = {}, d
251
264
  let buildStatus = await getBuild(client, buildId);
252
265
 
253
266
  // Also fetch preview info (if exists)
254
- let previewInfo = await getPreviewInfo(client, buildId);
267
+ let previewInfo = await fetchOptionalPreviewInfo(getPreviewInfo, client, buildId);
255
268
  output.stopSpinner();
256
269
 
257
270
  // Extract build data from API response
@@ -18,7 +18,7 @@ function isProcessRunning(pid) {
18
18
  return false;
19
19
  }
20
20
  }
21
- function parsePositiveInteger(value) {
21
+ export function parsePositiveInteger(value) {
22
22
  let text = String(value).trim();
23
23
  if (!/^\d+$/.test(text)) {
24
24
  return null;
@@ -71,12 +71,16 @@ export async function resolveDaemonPid({
71
71
  pidFile = join(process.cwd(), '.vizzly', 'server.pid'),
72
72
  readPid = readDaemonPidFile,
73
73
  findByPort = findDaemonPidByPort,
74
+ allowPortFallback = false,
74
75
  fileDeps = {}
75
76
  } = {}) {
76
77
  let pid = readPid(pidFile, fileDeps);
77
78
  if (pid) {
78
79
  return pid;
79
80
  }
81
+ if (!allowPortFallback) {
82
+ return null;
83
+ }
80
84
  return await findByPort(port);
81
85
  }
82
86
  export async function waitForProcessExit(pid, {