@vizzly-testing/cli 0.18.0 → 0.19.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.
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
 
@@ -12,13 +12,11 @@ import { sendError, sendServiceUnavailable, sendSuccess } from '../middleware/re
12
12
  * @param {Object} context - Router context
13
13
  * @param {Object} context.screenshotHandler - Screenshot handler
14
14
  * @param {Object} context.tddService - TDD service for baseline downloads
15
- * @param {Object} context.authService - Auth service for OAuth requests
16
15
  * @returns {Function} Route handler
17
16
  */
18
17
  export function createBaselineRouter({
19
18
  screenshotHandler,
20
- tddService,
21
- authService
19
+ tddService
22
20
  }) {
23
21
  return async function handleBaselineRoute(req, res, pathname) {
24
22
  // Accept a single screenshot as baseline
@@ -96,49 +94,16 @@ export function createBaselineRouter({
96
94
  return true;
97
95
  }
98
96
  try {
99
- const body = await parseJsonBody(req);
100
- const {
101
- buildId,
102
- organizationSlug,
103
- projectSlug
97
+ let body = await parseJsonBody(req);
98
+ let {
99
+ buildId
104
100
  } = body;
105
101
  if (!buildId) {
106
102
  sendError(res, 400, 'buildId is required');
107
103
  return true;
108
104
  }
109
105
  output.info(`Downloading baselines from build ${buildId}...`);
110
-
111
- // If organizationSlug and projectSlug are provided, use OAuth-based download
112
- if (organizationSlug && projectSlug && authService) {
113
- try {
114
- const result = await tddService.downloadBaselinesWithAuth(buildId, organizationSlug, projectSlug, authService);
115
- sendSuccess(res, {
116
- success: true,
117
- message: `Baselines downloaded from build ${buildId}`,
118
- ...result
119
- });
120
- return true;
121
- } catch (authError) {
122
- // Log the OAuth error with details
123
- output.warn(`OAuth download failed (org=${organizationSlug}, project=${projectSlug}): ${authError.message}`);
124
-
125
- // If the error is a 404, it's likely the build doesn't belong to the project
126
- // or the project/org is incorrect - provide a helpful error
127
- if (authError.message?.includes('404')) {
128
- sendError(res, 404, `Build not found or does not belong to project "${projectSlug}" in organization "${organizationSlug}". ` + `Please verify the build exists and you have access to it.`);
129
- return true;
130
- }
131
-
132
- // For auth errors, try API token fallback
133
- if (!authError.message?.includes('401')) {
134
- // For other errors, don't fall through - report them directly
135
- throw authError;
136
- }
137
- }
138
- }
139
-
140
- // Fall back to API token-based download (when no OAuth info or OAuth auth failed)
141
- const result = await tddService.downloadBaselines('test',
106
+ let result = await tddService.downloadBaselines('test',
142
107
  // environment
143
108
  null,
144
109
  // branch (not needed when buildId is specified)
@@ -122,6 +122,16 @@ export class ApiService {
122
122
  return this.request(endpoint);
123
123
  }
124
124
 
125
+ /**
126
+ * Get TDD baselines for a build
127
+ * Returns screenshots with pre-computed filenames for baseline download
128
+ * @param {string} buildId - Build ID
129
+ * @returns {Promise<Object>} { build, screenshots, signatureProperties }
130
+ */
131
+ async getTddBaselines(buildId) {
132
+ return this.request(`/api/sdk/builds/${buildId}/tdd-baselines`);
133
+ }
134
+
125
135
  /**
126
136
  * Get comparison information
127
137
  * @param {string} comparisonId - Comparison ID
@@ -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
  /**
@@ -153,13 +178,8 @@ export class TddService {
153
178
  try {
154
179
  let baselineBuild;
155
180
  if (buildId) {
156
- // Use specific build ID - get it with screenshots in one call
157
- const apiResponse = await this.api.getBuild(buildId, 'screenshots');
158
-
159
- // API response available in verbose mode
160
- output.debug('tdd', 'fetched baseline build', {
161
- id: apiResponse?.build?.id || apiResponse?.id
162
- });
181
+ // Use the tdd-baselines endpoint which returns pre-computed filenames
182
+ let apiResponse = await this.api.getTddBaselines(buildId);
163
183
  if (!apiResponse) {
164
184
  throw new Error(`Build ${buildId} not found or API returned null`);
165
185
  }
@@ -171,23 +191,19 @@ export class TddService {
171
191
  output.info(`Using custom signature properties: ${this.signatureProperties.join(', ')}`);
172
192
  }
173
193
  }
174
-
175
- // Handle wrapped response format
176
- baselineBuild = apiResponse.build || apiResponse;
177
- if (!baselineBuild.id) {
178
- output.warn(`⚠️ Build response structure: ${JSON.stringify(Object.keys(apiResponse))}`);
179
- output.warn(`⚠️ Extracted build keys: ${JSON.stringify(Object.keys(baselineBuild))}`);
180
- }
194
+ baselineBuild = apiResponse.build;
181
195
 
182
196
  // Check build status and warn if it's not successful
183
197
  if (baselineBuild.status === 'failed') {
184
198
  output.warn(`⚠️ Build ${buildId} is marked as FAILED - falling back to local baselines`);
185
199
  output.info(`💡 To use remote baselines, specify a successful build ID instead`);
186
- // Fall back to local baseline logic
187
200
  return await this.handleLocalBaselines();
188
201
  } else if (baselineBuild.status !== 'completed') {
189
202
  output.warn(`⚠️ Build ${buildId} has status: ${baselineBuild.status} (expected: completed)`);
190
203
  }
204
+
205
+ // Attach screenshots to build for unified processing below
206
+ baselineBuild.screenshots = apiResponse.screenshots;
191
207
  } else if (comparisonId) {
192
208
  // Use specific comparison ID - download only this comparison's baseline screenshot
193
209
  output.info(`Using comparison: ${comparisonId}`);
@@ -237,6 +253,11 @@ export class TddService {
237
253
  }
238
254
  output.info(`📊 Extracted properties for signature: ${JSON.stringify(screenshotProperties)}`);
239
255
 
256
+ // Generate filename locally for comparison path (we don't have API-provided filename)
257
+ const screenshotName = comparison.baseline_name || comparison.current_name;
258
+ const signature = generateScreenshotSignature(screenshotName, screenshotProperties, this.signatureProperties);
259
+ const filename = generateBaselineFilename(screenshotName, signature);
260
+
240
261
  // For a specific comparison, we only download that one baseline screenshot
241
262
  // Create a mock build structure with just this one screenshot
242
263
  baselineBuild = {
@@ -244,10 +265,11 @@ export class TddService {
244
265
  name: `Comparison ${comparisonId.substring(0, 8)}`,
245
266
  screenshots: [{
246
267
  id: comparison.baseline_screenshot.id,
247
- name: comparison.baseline_name || comparison.current_name,
268
+ name: screenshotName,
248
269
  original_url: baselineUrl,
249
270
  metadata: screenshotProperties,
250
- properties: screenshotProperties
271
+ properties: screenshotProperties,
272
+ filename: filename // Generated locally for comparison path
251
273
  }]
252
274
  };
253
275
  } else {
@@ -263,18 +285,27 @@ export class TddService {
263
285
  output.info('💡 Run a build in normal mode first to create baselines');
264
286
  return null;
265
287
  }
266
- baselineBuild = builds.data[0];
288
+
289
+ // Use getTddBaselines to get screenshots with pre-computed filenames
290
+ const apiResponse = await this.api.getTddBaselines(builds.data[0].id);
291
+ if (!apiResponse) {
292
+ throw new Error(`Build ${builds.data[0].id} not found or API returned null`);
293
+ }
294
+
295
+ // Extract signature properties from API response (for variant support)
296
+ if (apiResponse.signatureProperties && Array.isArray(apiResponse.signatureProperties)) {
297
+ this.signatureProperties = apiResponse.signatureProperties;
298
+ if (this.signatureProperties.length > 0) {
299
+ output.info(`Using custom signature properties: ${this.signatureProperties.join(', ')}`);
300
+ }
301
+ }
302
+ baselineBuild = apiResponse.build;
303
+ baselineBuild.screenshots = apiResponse.screenshots;
267
304
  }
268
305
 
269
- // For specific buildId, we already have screenshots
306
+ // For both buildId and getBuilds paths, we now have screenshots with filenames
270
307
  // For comparisonId, we created a mock build with just the one screenshot
271
- // Otherwise, get build details with screenshots
272
308
  let buildDetails = baselineBuild;
273
- if (!buildId && !comparisonId) {
274
- // Get build details with screenshots for non-buildId/non-comparisonId cases
275
- const actualBuildId = baselineBuild.id;
276
- buildDetails = await this.api.getBuild(actualBuildId, 'screenshots');
277
- }
278
309
  if (!buildDetails.screenshots || buildDetails.screenshots.length === 0) {
279
310
  output.warn('⚠️ No screenshots found in baseline build');
280
311
  return null;
@@ -283,12 +314,12 @@ export class TddService {
283
314
  output.info(`Checking ${colors.cyan(buildDetails.screenshots.length)} baseline screenshots...`);
284
315
 
285
316
  // Check existing baseline metadata for efficient SHA comparison
286
- const existingBaseline = await this.loadBaseline();
287
- const existingShaMap = new Map();
317
+ let existingBaseline = await this.loadBaseline();
318
+ let existingShaMap = new Map();
288
319
  if (existingBaseline) {
289
320
  existingBaseline.screenshots.forEach(s => {
290
- if (s.sha256 && s.signature) {
291
- existingShaMap.set(s.signature, s.sha256);
321
+ if (s.sha256 && s.filename) {
322
+ existingShaMap.set(s.filename, s.sha256);
292
323
  }
293
324
  });
294
325
  }
@@ -313,23 +344,21 @@ export class TddService {
313
344
  continue;
314
345
  }
315
346
 
316
- // Generate signature for baseline matching (same as compareScreenshot)
317
- // Build properties object with top-level viewport_width and browser
318
- // These are returned as top-level fields from the API, not inside metadata
319
- const properties = validateScreenshotProperties({
320
- viewport_width: screenshot.viewport_width,
321
- browser: screenshot.browser,
322
- ...(screenshot.metadata || screenshot.properties || {})
323
- });
324
- const signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
325
- const filename = signatureToFilename(signature);
326
- const imagePath = safePath(this.baselinePath, `${filename}.png`);
347
+ // Use API-provided filename (required from tdd-baselines endpoint)
348
+ // This ensures filenames match between cloud and local TDD
349
+ let filename = screenshot.filename;
350
+ if (!filename) {
351
+ output.warn(`⚠️ Screenshot ${sanitizedName} has no filename from API - skipping`);
352
+ errorCount++;
353
+ continue;
354
+ }
355
+ let imagePath = safePath(this.baselinePath, filename);
327
356
 
328
- // Check if we already have this file with the same SHA (using metadata)
357
+ // Check if we already have this file with the same SHA
329
358
  if (existsSync(imagePath) && screenshot.sha256) {
330
- const storedSha = existingShaMap.get(signature);
359
+ let storedSha = existingShaMap.get(filename);
331
360
  if (storedSha === screenshot.sha256) {
332
- downloadedCount++; // Count as "downloaded" since we have it
361
+ downloadedCount++;
333
362
  skippedCount++;
334
363
  continue;
335
364
  }
@@ -347,9 +376,7 @@ export class TddService {
347
376
  sanitizedName,
348
377
  imagePath,
349
378
  downloadUrl,
350
- signature,
351
- filename,
352
- properties
379
+ filename
353
380
  });
354
381
  }
355
382
 
@@ -430,41 +457,23 @@ export class TddService {
430
457
  approvalStatus: baselineBuild.approval_status,
431
458
  completedAt: baselineBuild.completed_at
432
459
  },
433
- screenshots: buildDetails.screenshots.map(s => {
434
- let sanitizedName;
435
- try {
436
- sanitizedName = sanitizeScreenshotName(s.name);
437
- } catch (error) {
438
- output.warn(`Screenshot name sanitization failed for '${s.name}': ${error.message}`);
439
- return null; // Skip invalid screenshots
460
+ screenshots: buildDetails.screenshots.filter(s => s.filename) // Only include screenshots with filenames
461
+ .map(s => ({
462
+ name: sanitizeScreenshotName(s.name),
463
+ originalName: s.name,
464
+ sha256: s.sha256,
465
+ id: s.id,
466
+ filename: s.filename,
467
+ path: safePath(this.baselinePath, s.filename),
468
+ browser: s.browser,
469
+ viewport_width: s.viewport_width,
470
+ originalUrl: s.original_url,
471
+ fileSize: s.file_size_bytes,
472
+ dimensions: {
473
+ width: s.width,
474
+ height: s.height
440
475
  }
441
-
442
- // Build properties object with top-level viewport_width and browser
443
- // These are returned as top-level fields from the API, not inside metadata
444
- const properties = validateScreenshotProperties({
445
- viewport_width: s.viewport_width,
446
- browser: s.browser,
447
- ...(s.metadata || s.properties || {})
448
- });
449
- const signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
450
- const filename = signatureToFilename(signature);
451
- return {
452
- name: sanitizedName,
453
- originalName: s.name,
454
- sha256: s.sha256,
455
- // Store remote SHA for quick comparison
456
- id: s.id,
457
- properties: properties,
458
- path: safePath(this.baselinePath, `${filename}.png`),
459
- signature: signature,
460
- originalUrl: s.original_url,
461
- fileSize: s.file_size_bytes,
462
- dimensions: {
463
- width: s.width,
464
- height: s.height
465
- }
466
- };
467
- }).filter(Boolean) // Remove null entries from invalid screenshots
476
+ }))
468
477
  };
469
478
  const metadataPath = join(this.baselinePath, 'metadata.json');
470
479
  writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
@@ -657,179 +666,6 @@ export class TddService {
657
666
  };
658
667
  }
659
668
 
660
- /**
661
- * Download baselines using OAuth authentication
662
- * Used when user is logged in via device flow but no API token is configured
663
- * @param {string} buildId - Build ID to download from
664
- * @param {string} organizationSlug - Organization slug
665
- * @param {string} projectSlug - Project slug
666
- * @param {Object} authService - Auth service for OAuth requests
667
- * @returns {Promise<Object>} Download result
668
- */
669
- async downloadBaselinesWithAuth(buildId, organizationSlug, projectSlug, authService) {
670
- output.info(`Downloading baselines using OAuth from build ${buildId}...`);
671
- try {
672
- // Fetch build with screenshots via OAuth endpoint
673
- const endpoint = `/api/build/${projectSlug}/${buildId}/tdd-baselines`;
674
- const response = await authService.authenticatedRequest(endpoint, {
675
- method: 'GET',
676
- headers: {
677
- 'X-Organization': organizationSlug
678
- }
679
- });
680
- const {
681
- build,
682
- screenshots,
683
- signatureProperties
684
- } = response;
685
-
686
- // Extract signature properties from API response (for variant support)
687
- if (signatureProperties && Array.isArray(signatureProperties)) {
688
- this.signatureProperties = signatureProperties;
689
- if (this.signatureProperties.length > 0) {
690
- output.info(`Using custom signature properties: ${this.signatureProperties.join(', ')}`);
691
- }
692
- }
693
- if (!screenshots || screenshots.length === 0) {
694
- output.warn('⚠️ No screenshots found in build');
695
- return {
696
- downloadedCount: 0,
697
- skippedCount: 0,
698
- errorCount: 0
699
- };
700
- }
701
- output.info(`Using baseline from build: ${colors.cyan(build.name || 'Unknown')} (${build.id})`);
702
- output.info(`Checking ${colors.cyan(screenshots.length)} baseline screenshots...`);
703
-
704
- // Load existing baseline metadata for SHA comparison
705
- const existingBaseline = await this.loadBaseline();
706
- const existingShaMap = new Map();
707
- if (existingBaseline) {
708
- existingBaseline.screenshots.forEach(s => {
709
- if (s.sha256 && s.signature) {
710
- existingShaMap.set(s.signature, s.sha256);
711
- }
712
- });
713
- }
714
-
715
- // Process and download screenshots
716
- let downloadedCount = 0;
717
- let skippedCount = 0;
718
- let errorCount = 0;
719
- const downloadedScreenshots = [];
720
- for (const screenshot of screenshots) {
721
- let sanitizedName;
722
- try {
723
- sanitizedName = sanitizeScreenshotName(screenshot.name);
724
- } catch (error) {
725
- output.warn(`Screenshot name sanitization failed for '${screenshot.name}': ${error.message}`);
726
- errorCount++;
727
- continue;
728
- }
729
-
730
- // Build properties object with top-level viewport_width and browser
731
- // These are returned as top-level fields from the API, not inside metadata
732
- const properties = validateScreenshotProperties({
733
- viewport_width: screenshot.viewport_width,
734
- browser: screenshot.browser,
735
- ...screenshot.metadata
736
- });
737
- const signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
738
- const filename = signatureToFilename(signature);
739
- const filePath = safePath(this.baselinePath, `${filename}.png`);
740
-
741
- // Check if we can skip via SHA comparison
742
- if (screenshot.sha256 && existingShaMap.get(signature) === screenshot.sha256) {
743
- skippedCount++;
744
- downloadedScreenshots.push({
745
- name: sanitizedName,
746
- sha256: screenshot.sha256,
747
- signature,
748
- path: filePath,
749
- properties
750
- });
751
- continue;
752
- }
753
-
754
- // Download the screenshot
755
- const downloadUrl = screenshot.original_url;
756
- if (!downloadUrl) {
757
- output.warn(`⚠️ No download URL for screenshot: ${sanitizedName}`);
758
- errorCount++;
759
- continue;
760
- }
761
- try {
762
- const imageResponse = await fetchWithTimeout(downloadUrl, {}, 30000);
763
- if (!imageResponse.ok) {
764
- throw new Error(`HTTP ${imageResponse.status}`);
765
- }
766
- const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
767
-
768
- // Calculate SHA256 of downloaded content
769
- const sha256 = crypto.createHash('sha256').update(imageBuffer).digest('hex');
770
- writeFileSync(filePath, imageBuffer);
771
- downloadedCount++;
772
- downloadedScreenshots.push({
773
- name: sanitizedName,
774
- sha256,
775
- signature,
776
- path: filePath,
777
- properties,
778
- originalUrl: downloadUrl
779
- });
780
- } catch (error) {
781
- output.warn(`⚠️ Failed to download ${sanitizedName}: ${error.message}`);
782
- errorCount++;
783
- }
784
- }
785
-
786
- // Store baseline metadata
787
- this.baselineData = {
788
- buildId: build.id,
789
- buildName: build.name,
790
- branch: build.branch,
791
- threshold: this.threshold,
792
- signatureProperties: this.signatureProperties,
793
- // Store for TDD comparison
794
- screenshots: downloadedScreenshots
795
- };
796
- const metadataPath = join(this.baselinePath, 'metadata.json');
797
- writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
798
-
799
- // Save baseline build metadata
800
- const baselineMetadataPath = safePath(this.workingDir, '.vizzly', 'baseline-metadata.json');
801
- writeFileSync(baselineMetadataPath, JSON.stringify({
802
- buildId: build.id,
803
- buildName: build.name,
804
- branch: build.branch,
805
- commitSha: build.commit_sha,
806
- downloadedAt: new Date().toISOString()
807
- }, null, 2));
808
-
809
- // Summary
810
- if (skippedCount > 0 && downloadedCount === 0) {
811
- output.info(`✅ All ${skippedCount} baselines up-to-date (matching local SHA)`);
812
- } else if (skippedCount > 0) {
813
- output.info(`✅ Downloaded ${downloadedCount} new screenshots, ${skippedCount} already up-to-date`);
814
- } else {
815
- output.info(`✅ Downloaded ${downloadedCount}/${screenshots.length} screenshots successfully`);
816
- }
817
- if (errorCount > 0) {
818
- output.warn(`⚠️ ${errorCount} screenshots failed to download`);
819
- }
820
- return {
821
- downloadedCount,
822
- skippedCount,
823
- errorCount,
824
- buildId: build.id,
825
- buildName: build.name
826
- };
827
- } catch (error) {
828
- output.error(`❌ OAuth download failed: ${error.message} (org=${organizationSlug}, project=${projectSlug}, build=${buildId})`);
829
- throw error;
830
- }
831
- }
832
-
833
669
  /**
834
670
  * Handle local baseline logic (either load existing or prepare for new baselines)
835
671
  * @returns {Promise<Object|null>} Baseline data or null if no local baselines exist
@@ -900,6 +736,12 @@ export class TddService {
900
736
  validatedProperties = {};
901
737
  }
902
738
 
739
+ // Preserve metadata object through validation (validateScreenshotProperties strips non-primitives)
740
+ // This is needed because signature generation checks properties.metadata.* for custom properties
741
+ if (properties.metadata && typeof properties.metadata === 'object') {
742
+ validatedProperties.metadata = properties.metadata;
743
+ }
744
+
903
745
  // Normalize properties to match backend format (viewport_width at top level)
904
746
  // This ensures signature generation matches backend's screenshot-identity.js
905
747
  if (validatedProperties.viewport?.width && !validatedProperties.viewport_width) {
@@ -908,10 +750,11 @@ export class TddService {
908
750
 
909
751
  // Generate signature for baseline matching (name + viewport_width + browser + custom props)
910
752
  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`);
753
+ // Use hash-based filename for reliable matching (matches cloud format)
754
+ const filename = generateBaselineFilename(sanitizedName, signature);
755
+ const currentImagePath = safePath(this.currentPath, filename);
756
+ const baselineImagePath = safePath(this.baselinePath, filename);
757
+ const diffImagePath = safePath(this.diffPath, filename);
915
758
 
916
759
  // Save current screenshot
917
760
  writeFileSync(currentImagePath, imageBuffer);
@@ -1303,8 +1146,8 @@ export class TddService {
1303
1146
  }
1304
1147
  const validatedProperties = validateScreenshotProperties(comparison.properties || {});
1305
1148
  const signature = generateScreenshotSignature(sanitizedName, validatedProperties, this.signatureProperties);
1306
- const filename = signatureToFilename(signature);
1307
- const baselineImagePath = safePath(this.baselinePath, `${filename}.png`);
1149
+ const filename = generateBaselineFilename(sanitizedName, signature);
1150
+ const baselineImagePath = safePath(this.baselinePath, filename);
1308
1151
  try {
1309
1152
  // Copy current screenshot to baseline
1310
1153
  const currentBuffer = readFileSync(current);
@@ -1479,10 +1322,10 @@ export class TddService {
1479
1322
  const sanitizedName = comparison.name;
1480
1323
  const properties = comparison.properties || {};
1481
1324
  const signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
1482
- const filename = signatureToFilename(signature);
1325
+ const filename = generateBaselineFilename(sanitizedName, signature);
1483
1326
 
1484
1327
  // Find the current screenshot file
1485
- const currentImagePath = safePath(this.currentPath, `${filename}.png`);
1328
+ const currentImagePath = safePath(this.currentPath, filename);
1486
1329
  if (!existsSync(currentImagePath)) {
1487
1330
  output.error(`Current screenshot not found at: ${currentImagePath}`);
1488
1331
  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.1",
4
4
  "description": "Visual review platform for UI developers and designers",
5
5
  "keywords": [
6
6
  "visual-testing",