@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 +3 -1
- package/dist/plugin-api.js +43 -0
- package/dist/server/handlers/tdd-handler.js +11 -10
- package/dist/server/routers/baseline.js +5 -40
- package/dist/services/api-service.js +10 -0
- package/dist/services/tdd-service.js +119 -276
- package/dist/types/index.d.ts +84 -0
- package/docs/plugins.md +60 -25
- package/package.json +1 -1
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
|
|
249
|
-
// This ensures signature generation works correctly
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
//
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
*
|
|
66
|
-
*
|
|
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
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
157
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
287
|
-
|
|
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.
|
|
291
|
-
existingShaMap.set(s.
|
|
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
|
-
//
|
|
317
|
-
//
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
}
|
|
324
|
-
|
|
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
|
|
357
|
+
// Check if we already have this file with the same SHA
|
|
329
358
|
if (existsSync(imagePath) && screenshot.sha256) {
|
|
330
|
-
|
|
359
|
+
let storedSha = existingShaMap.get(filename);
|
|
331
360
|
if (storedSha === screenshot.sha256) {
|
|
332
|
-
downloadedCount++;
|
|
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
|
-
|
|
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.
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
912
|
-
const
|
|
913
|
-
const
|
|
914
|
-
const
|
|
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 =
|
|
1307
|
-
const baselineImagePath = safePath(this.baselinePath,
|
|
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 =
|
|
1325
|
+
const filename = generateBaselineFilename(sanitizedName, signature);
|
|
1483
1326
|
|
|
1484
1327
|
// Find the current screenshot file
|
|
1485
|
-
const currentImagePath = safePath(this.currentPath,
|
|
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})`);
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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
|
|
108
|
-
let 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
154
|
-
.
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
294
|
-
let
|
|
295
|
-
output.info(`
|
|
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
|
-
|
|
428
|
+
// Capture screenshots (uses vizzlyScreenshot internally)
|
|
429
|
+
await crawlStorybook(path, {
|
|
394
430
|
viewports: options.viewports.split(','),
|
|
395
431
|
});
|
|
396
432
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
});
|