@vizzly-testing/cli 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -14,6 +14,7 @@ import { runDaemonChild, tddStartCommand, tddStatusCommand, tddStopCommand } fro
14
14
  import { uploadCommand, validateUploadOptions } from './commands/upload.js';
15
15
  import { validateWhoamiOptions, whoamiCommand } from './commands/whoami.js';
16
16
  import { loadPlugins } from './plugin-loader.js';
17
+ import { createPluginServices } from './plugin-api.js';
17
18
  import { createServices } from './services/index.js';
18
19
  import { loadConfig } from './utils/config-loader.js';
19
20
  import * as output from './utils/output.js';
@@ -47,6 +48,7 @@ output.configure({
47
48
  });
48
49
  const config = await loadConfig(configPath, {});
49
50
  const services = createServices(config);
51
+ const pluginServices = createPluginServices(services);
50
52
  let plugins = [];
51
53
  try {
52
54
  plugins = await loadPlugins(configPath, config);
@@ -55,7 +57,7 @@ try {
55
57
  // Add timeout protection for plugin registration (5 seconds)
56
58
  const registerPromise = plugin.register(program, {
57
59
  config,
58
- services,
60
+ services: pluginServices,
59
61
  output,
60
62
  // Backwards compatibility alias for plugins using old API
61
63
  logger: output
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Plugin API - Stable interface for Vizzly plugins
3
+ *
4
+ * This module defines the stable API contract for plugins. Only methods
5
+ * exposed here are considered part of the public API and are guaranteed
6
+ * to not break between minor versions.
7
+ *
8
+ * Internal services (apiService, uploader, buildManager, etc.) are NOT
9
+ * exposed to plugins to prevent coupling to implementation details.
10
+ */
11
+
12
+ /**
13
+ * Creates a stable plugin services object from the internal services
14
+ *
15
+ * Only exposes:
16
+ * - testRunner: Build lifecycle management (createBuild, finalizeBuild, events)
17
+ * - serverManager: Screenshot server control (start, stop)
18
+ *
19
+ * @param {Object} services - Internal services from createServices()
20
+ * @returns {Object} Frozen plugin services object
21
+ */
22
+ export function createPluginServices(services) {
23
+ let {
24
+ testRunner,
25
+ serverManager
26
+ } = services;
27
+ return Object.freeze({
28
+ testRunner: Object.freeze({
29
+ // EventEmitter methods for build lifecycle events
30
+ once: testRunner.once.bind(testRunner),
31
+ on: testRunner.on.bind(testRunner),
32
+ off: testRunner.off.bind(testRunner),
33
+ // Build lifecycle
34
+ createBuild: testRunner.createBuild.bind(testRunner),
35
+ finalizeBuild: testRunner.finalizeBuild.bind(testRunner)
36
+ }),
37
+ serverManager: Object.freeze({
38
+ // Server lifecycle
39
+ start: serverManager.start.bind(serverManager),
40
+ stop: serverManager.stop.bind(serverManager)
41
+ })
42
+ });
43
+ }
@@ -245,17 +245,18 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
245
245
  };
246
246
  }
247
247
 
248
- // Extract viewport/browser to top-level properties (matching cloud API behavior)
249
- // This ensures signature generation works correctly with: name|viewport_width|browser
248
+ // Extract ALL properties to top-level (matching cloud API behavior)
249
+ // This ensures signature generation works correctly for custom properties like theme, device, etc.
250
+ // Spread all validated properties first, then normalize viewport/browser for cloud format
250
251
  const extractedProperties = {
251
- viewport_width: validatedProperties.viewport?.width || null,
252
- viewport_height: validatedProperties.viewport?.height || null,
253
- browser: validatedProperties.browser || null,
254
- device: validatedProperties.device || null,
255
- url: validatedProperties.url || null,
256
- selector: validatedProperties.selector || null,
257
- threshold: validatedProperties.threshold,
258
- // Preserve full nested structure in metadata for compatibility
252
+ ...validatedProperties,
253
+ // Normalize viewport to top-level viewport_width/height (cloud format)
254
+ // Use nullish coalescing to preserve any existing top-level values
255
+ viewport_width: validatedProperties.viewport?.width ?? validatedProperties.viewport_width ?? null,
256
+ viewport_height: validatedProperties.viewport?.height ?? validatedProperties.viewport_height ?? null,
257
+ browser: validatedProperties.browser ?? null,
258
+ // Preserve nested structure in metadata for backward compatibility
259
+ // Signature generation checks multiple locations: top-level, metadata.*, metadata.properties.*
259
260
  metadata: validatedProperties
260
261
  };
261
262
 
@@ -1,3 +1,25 @@
1
+ /**
2
+ * TDD Service - Local Visual Testing
3
+ *
4
+ * ⚠️ CRITICAL: Signature/filename generation MUST stay in sync with the cloud!
5
+ *
6
+ * Cloud counterpart: vizzly/src/utils/screenshot-identity.js
7
+ * - generateScreenshotSignature()
8
+ * - generateBaselineFilename()
9
+ *
10
+ * Contract tests: Both repos have golden tests that must produce identical values:
11
+ * - Cloud: tests/contracts/signature-parity.test.js
12
+ * - CLI: tests/contracts/signature-parity.spec.js
13
+ *
14
+ * If you modify signature or filename generation here, you MUST:
15
+ * 1. Make the same change in the cloud repo
16
+ * 2. Update golden test values in BOTH repos
17
+ * 3. Run contract tests in both repos to verify parity
18
+ *
19
+ * The signature format is: name|viewport_width|browser|custom1|custom2|...
20
+ * The filename format is: {sanitized-name}_{12-char-sha256-hash}.png
21
+ */
22
+
1
23
  import crypto from 'node:crypto';
2
24
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
25
  import { join } from 'node:path';
@@ -13,13 +35,10 @@ import { HtmlReportGenerator } from './html-report-generator.js';
13
35
 
14
36
  /**
15
37
  * Generate a screenshot signature for baseline matching
16
- * Uses same logic as screenshot-identity.js: name + viewport_width + browser + custom properties
17
38
  *
18
- * Matches backend signature generation which uses:
19
- * - screenshot.name
20
- * - screenshot.viewport_width (top-level property)
21
- * - screenshot.browser (top-level property)
22
- * - custom properties from project's baseline_signature_properties setting
39
+ * ⚠️ SYNC WITH: vizzly/src/utils/screenshot-identity.js - generateScreenshotSignature()
40
+ *
41
+ * Uses same logic as cloud: name + viewport_width + browser + custom properties
23
42
  *
24
43
  * @param {string} name - Screenshot name
25
44
  * @param {Object} properties - Screenshot properties (viewport, browser, metadata, etc.)
@@ -62,17 +81,23 @@ function generateScreenshotSignature(name, properties = {}, customProperties = [
62
81
  }
63
82
 
64
83
  /**
65
- * Create a safe filename from signature
66
- * Handles custom property values that may contain spaces or special characters
84
+ * Generate a stable, filesystem-safe filename for a screenshot baseline
85
+ * Uses a hash of the signature to avoid character encoding issues
86
+ * Matches the cloud's generateBaselineFilename implementation exactly
67
87
  *
68
- * IMPORTANT: Does NOT collapse multiple underscores because empty signature
69
- * positions (e.g., null browser) result in `||` which becomes `__` and must
70
- * be preserved for cloud compatibility.
88
+ * @param {string} name - Screenshot name
89
+ * @param {string} signature - Full signature string
90
+ * @returns {string} Filename like "homepage_a1b2c3d4e5f6.png"
71
91
  */
72
- function signatureToFilename(signature) {
73
- return signature.replace(/\|/g, '_') // pipes to underscores
74
- .replace(/\s+/g, '-') // spaces to hyphens (not underscores, to distinguish from position separators)
75
- .replace(/[/\\:*?"<>]/g, ''); // remove unsafe filesystem chars
92
+ function generateBaselineFilename(name, signature) {
93
+ const hash = crypto.createHash('sha256').update(signature).digest('hex').slice(0, 12);
94
+
95
+ // Sanitize the name for filesystem safety
96
+ const safeName = name.replace(/[/\\:*?"<>|]/g, '') // Remove unsafe chars
97
+ .replace(/\s+/g, '-') // Spaces to hyphens
98
+ .slice(0, 50); // Limit length
99
+
100
+ return `${safeName}_${hash}.png`;
76
101
  }
77
102
 
78
103
  /**
@@ -322,8 +347,11 @@ export class TddService {
322
347
  ...(screenshot.metadata || screenshot.properties || {})
323
348
  });
324
349
  const signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
325
- const filename = signatureToFilename(signature);
326
- const imagePath = safePath(this.baselinePath, `${filename}.png`);
350
+
351
+ // Use API-provided filename if available, otherwise generate hash-based filename
352
+ // Both return the full filename with .png extension
353
+ const filename = screenshot.filename || generateBaselineFilename(sanitizedName, signature);
354
+ const imagePath = safePath(this.baselinePath, filename);
327
355
 
328
356
  // Check if we already have this file with the same SHA (using metadata)
329
357
  if (existsSync(imagePath) && screenshot.sha256) {
@@ -447,7 +475,7 @@ export class TddService {
447
475
  ...(s.metadata || s.properties || {})
448
476
  });
449
477
  const signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
450
- const filename = signatureToFilename(signature);
478
+ const filename = generateBaselineFilename(sanitizedName, signature);
451
479
  return {
452
480
  name: sanitizedName,
453
481
  originalName: s.name,
@@ -455,7 +483,7 @@ export class TddService {
455
483
  // Store remote SHA for quick comparison
456
484
  id: s.id,
457
485
  properties: properties,
458
- path: safePath(this.baselinePath, `${filename}.png`),
486
+ path: safePath(this.baselinePath, filename),
459
487
  signature: signature,
460
488
  originalUrl: s.original_url,
461
489
  fileSize: s.file_size_bytes,
@@ -735,8 +763,9 @@ export class TddService {
735
763
  ...screenshot.metadata
736
764
  });
737
765
  const signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
738
- const filename = signatureToFilename(signature);
739
- const filePath = safePath(this.baselinePath, `${filename}.png`);
766
+ // Use API-provided filename if available, otherwise generate hash-based filename
767
+ const filename = screenshot.filename || generateBaselineFilename(sanitizedName, signature);
768
+ const filePath = safePath(this.baselinePath, filename);
740
769
 
741
770
  // Check if we can skip via SHA comparison
742
771
  if (screenshot.sha256 && existingShaMap.get(signature) === screenshot.sha256) {
@@ -900,6 +929,12 @@ export class TddService {
900
929
  validatedProperties = {};
901
930
  }
902
931
 
932
+ // Preserve metadata object through validation (validateScreenshotProperties strips non-primitives)
933
+ // This is needed because signature generation checks properties.metadata.* for custom properties
934
+ if (properties.metadata && typeof properties.metadata === 'object') {
935
+ validatedProperties.metadata = properties.metadata;
936
+ }
937
+
903
938
  // Normalize properties to match backend format (viewport_width at top level)
904
939
  // This ensures signature generation matches backend's screenshot-identity.js
905
940
  if (validatedProperties.viewport?.width && !validatedProperties.viewport_width) {
@@ -908,10 +943,11 @@ export class TddService {
908
943
 
909
944
  // Generate signature for baseline matching (name + viewport_width + browser + custom props)
910
945
  const signature = generateScreenshotSignature(sanitizedName, validatedProperties, this.signatureProperties);
911
- const filename = signatureToFilename(signature);
912
- const currentImagePath = safePath(this.currentPath, `${filename}.png`);
913
- const baselineImagePath = safePath(this.baselinePath, `${filename}.png`);
914
- const diffImagePath = safePath(this.diffPath, `${filename}.png`);
946
+ // Use hash-based filename for reliable matching (matches cloud format)
947
+ const filename = generateBaselineFilename(sanitizedName, signature);
948
+ const currentImagePath = safePath(this.currentPath, filename);
949
+ const baselineImagePath = safePath(this.baselinePath, filename);
950
+ const diffImagePath = safePath(this.diffPath, filename);
915
951
 
916
952
  // Save current screenshot
917
953
  writeFileSync(currentImagePath, imageBuffer);
@@ -1303,8 +1339,8 @@ export class TddService {
1303
1339
  }
1304
1340
  const validatedProperties = validateScreenshotProperties(comparison.properties || {});
1305
1341
  const signature = generateScreenshotSignature(sanitizedName, validatedProperties, this.signatureProperties);
1306
- const filename = signatureToFilename(signature);
1307
- const baselineImagePath = safePath(this.baselinePath, `${filename}.png`);
1342
+ const filename = generateBaselineFilename(sanitizedName, signature);
1343
+ const baselineImagePath = safePath(this.baselinePath, filename);
1308
1344
  try {
1309
1345
  // Copy current screenshot to baseline
1310
1346
  const currentBuffer = readFileSync(current);
@@ -1479,10 +1515,10 @@ export class TddService {
1479
1515
  const sanitizedName = comparison.name;
1480
1516
  const properties = comparison.properties || {};
1481
1517
  const signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
1482
- const filename = signatureToFilename(signature);
1518
+ const filename = generateBaselineFilename(sanitizedName, signature);
1483
1519
 
1484
1520
  // Find the current screenshot file
1485
- const currentImagePath = safePath(this.currentPath, `${filename}.png`);
1521
+ const currentImagePath = safePath(this.currentPath, filename);
1486
1522
  if (!existsSync(currentImagePath)) {
1487
1523
  output.error(`Current screenshot not found at: ${currentImagePath}`);
1488
1524
  throw new Error(`Current screenshot not found: ${sanitizedName} (looked at ${currentImagePath})`);
@@ -351,6 +351,90 @@ export interface Services {
351
351
  testRunner: unknown;
352
352
  }
353
353
 
354
+ // ============================================================================
355
+ // Plugin API Types (Stable Contract)
356
+ // ============================================================================
357
+
358
+ /**
359
+ * Stable TestRunner interface for plugins.
360
+ * Only these methods are guaranteed to remain stable across minor versions.
361
+ */
362
+ export interface PluginTestRunner {
363
+ /** Listen for a single event emission */
364
+ once(event: string, callback: (...args: unknown[]) => void): void;
365
+ /** Subscribe to events */
366
+ on(event: string, callback: (...args: unknown[]) => void): void;
367
+ /** Unsubscribe from events */
368
+ off(event: string, callback: (...args: unknown[]) => void): void;
369
+ /** Create a new build and return the build ID */
370
+ createBuild(options: BuildOptions, isTddMode: boolean): Promise<string>;
371
+ /** Finalize a build after all screenshots are captured */
372
+ finalizeBuild(
373
+ buildId: string,
374
+ isTddMode: boolean,
375
+ success: boolean,
376
+ executionTime: number
377
+ ): Promise<void>;
378
+ }
379
+
380
+ /**
381
+ * Stable ServerManager interface for plugins.
382
+ * Only these methods are guaranteed to remain stable across minor versions.
383
+ */
384
+ export interface PluginServerManager {
385
+ /** Start the screenshot server */
386
+ start(buildId: string, tddMode: boolean, setBaseline: boolean): Promise<void>;
387
+ /** Stop the screenshot server */
388
+ stop(): Promise<void>;
389
+ }
390
+
391
+ /**
392
+ * Stable services interface for plugins.
393
+ * This is the public API contract - internal services are NOT exposed.
394
+ */
395
+ export interface PluginServices {
396
+ testRunner: PluginTestRunner;
397
+ serverManager: PluginServerManager;
398
+ }
399
+
400
+ /**
401
+ * Build options for createBuild()
402
+ */
403
+ export interface BuildOptions {
404
+ port?: number;
405
+ timeout?: number;
406
+ buildName?: string;
407
+ branch?: string;
408
+ commit?: string;
409
+ message?: string;
410
+ environment?: string;
411
+ threshold?: number;
412
+ eager?: boolean;
413
+ allowNoToken?: boolean;
414
+ wait?: boolean;
415
+ uploadAll?: boolean;
416
+ pullRequestNumber?: string;
417
+ parallelId?: string;
418
+ }
419
+
420
+ /**
421
+ * Context object passed to plugin register() function.
422
+ * This is the stable plugin API contract.
423
+ */
424
+ export interface PluginContext {
425
+ /** Merged Vizzly configuration */
426
+ config: VizzlyConfig;
427
+ /** Stable services for plugins */
428
+ services: PluginServices;
429
+ /** Output utilities for logging */
430
+ output: OutputUtils;
431
+ /** @deprecated Use output instead. Alias for backwards compatibility. */
432
+ logger: OutputUtils;
433
+ }
434
+
435
+ /** Create stable plugin services from internal services */
436
+ export function createPluginServices(services: Services): PluginServices;
437
+
354
438
  // ============================================================================
355
439
  // Output Utilities
356
440
  // ============================================================================
package/docs/plugins.md CHANGED
@@ -104,8 +104,8 @@ export default {
104
104
  .action(async (arg, options) => {
105
105
  output.info(`Running my-command with ${arg}`);
106
106
 
107
- // Access shared services if needed
108
- let apiService = await services.get('apiService');
107
+ // Access shared services directly
108
+ let apiService = services.apiService;
109
109
 
110
110
  // Your command logic here
111
111
  });
@@ -134,26 +134,55 @@ The `register` function receives two arguments:
134
134
  - `output` - Unified output module with `.debug()`, `.info()`, `.warn()`, `.error()`, `.success()` methods
135
135
  - `services` - Service container with access to internal Vizzly services
136
136
 
137
- ### Available Services
137
+ ### Available Services (Stable API)
138
138
 
139
- Plugins can access these services from the container:
139
+ The `services` object provides a stable API for plugins. Only these services and methods are
140
+ guaranteed to remain stable across minor versions:
140
141
 
141
- - **`apiService`** - Vizzly API client for interacting with the platform
142
- - **`uploader`** - Screenshot upload service
143
- - **`buildManager`** - Build lifecycle management
144
- - **`serverManager`** - Screenshot server management
145
- - **`tddService`** - TDD mode services
146
- - **`testRunner`** - Test execution service
142
+ #### `services.testRunner`
147
143
 
148
- Example accessing a service:
144
+ Manages build lifecycle and emits events:
145
+
146
+ - **`once(event, callback)`** - Listen for a single event emission
147
+ - **`on(event, callback)`** - Subscribe to events
148
+ - **`off(event, callback)`** - Unsubscribe from events
149
+ - **`createBuild(options, isTddMode)`** - Create a new build, returns `Promise<buildId>`
150
+ - **`finalizeBuild(buildId, isTddMode, success, executionTime)`** - Finalize a build
151
+
152
+ Events emitted:
153
+ - `build-created` - Emitted with `{ url }` when a build is created
154
+
155
+ #### `services.serverManager`
156
+
157
+ Controls the screenshot capture server:
158
+
159
+ - **`start(buildId, tddMode, setBaseline)`** - Start the screenshot server
160
+ - **`stop()`** - Stop the screenshot server
161
+
162
+ Example accessing services:
149
163
 
150
164
  ```javascript
151
165
  register(program, { config, output, services }) {
152
166
  program
153
- .command('upload-screenshots <dir>')
154
- .action(async (dir) => {
155
- let uploader = await services.get('uploader');
156
- await uploader.uploadScreenshots(screenshots);
167
+ .command('capture')
168
+ .description('Capture screenshots with custom workflow')
169
+ .action(async () => {
170
+ let { testRunner, serverManager } = services;
171
+
172
+ // Listen for build creation
173
+ testRunner.once('build-created', ({ url }) => {
174
+ output.info(`Build created: ${url}`);
175
+ });
176
+
177
+ // Create build and start server
178
+ let buildId = await testRunner.createBuild({ buildName: 'Custom' }, false);
179
+ await serverManager.start(buildId, false, false);
180
+
181
+ // ... capture screenshots ...
182
+
183
+ // Finalize and cleanup
184
+ await testRunner.finalizeBuild(buildId, false, true, Date.now());
185
+ await serverManager.stop();
157
186
  });
158
187
  }
159
188
  ```
@@ -290,9 +319,9 @@ Use async/await for asynchronous operations:
290
319
 
291
320
  ```javascript
292
321
  .action(async (options) => {
293
- let service = await services.get('apiService');
294
- let result = await service.doSomething();
295
- output.info(`Result: ${result}`);
322
+ let { testRunner } = services;
323
+ let buildId = await testRunner.createBuild({ buildName: 'Test' }, false);
324
+ output.info(`Created build: ${buildId}`);
296
325
  });
297
326
  ```
298
327
 
@@ -384,21 +413,27 @@ export default {
384
413
  .description('Capture screenshots from Storybook build')
385
414
  .option('--viewports <list>', 'Comma-separated viewports', '1280x720')
386
415
  .action(async (path, options) => {
416
+ let { testRunner, serverManager } = services;
417
+ let startTime = Date.now();
418
+
419
+ // Create build and start server
420
+ let buildId = await testRunner.createBuild({ buildName: 'Storybook' }, false);
421
+ await serverManager.start(buildId, false, false);
422
+
387
423
  output.info(`Crawling Storybook at ${path}`);
388
424
 
389
425
  // Import dependencies lazily
390
426
  let { crawlStorybook } = await import('./crawler.js');
391
427
 
392
- // Capture screenshots
393
- let screenshots = await crawlStorybook(path, {
428
+ // Capture screenshots (uses vizzlyScreenshot internally)
429
+ await crawlStorybook(path, {
394
430
  viewports: options.viewports.split(','),
395
431
  });
396
432
 
397
- output.info(`Captured ${screenshots.length} screenshots`);
398
-
399
- // Upload using Vizzly's uploader service
400
- let uploader = await services.get('uploader');
401
- await uploader.uploadScreenshots(screenshots);
433
+ // Finalize build
434
+ let executionTime = Date.now() - startTime;
435
+ await testRunner.finalizeBuild(buildId, false, true, executionTime);
436
+ await serverManager.stop();
402
437
 
403
438
  output.success('Upload complete!');
404
439
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vizzly-testing/cli",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Visual review platform for UI developers and designers",
5
5
  "keywords": [
6
6
  "visual-testing",