@vizzly-testing/cli 0.37.0 → 0.38.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.
@@ -0,0 +1,417 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { basename, join, resolve } from 'node:path';
3
+ import { generateScreenshotSignature } from '../tdd/core/signature.js';
4
+ import { TddService as defaultTddService } from '../tdd/tdd-service.js';
5
+ import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
6
+ import * as output from '../utils/output.js';
7
+ import { safePath } from '../utils/security.js';
8
+ let DEFAULT_PAGE_SIZE = 20;
9
+ let MAX_PAGE_SIZE = 100;
10
+ let TDD_SCREENSHOT_STATUSES = new Set(['baseline-created', 'baseline-updated', 'error', 'failed', 'new', 'passed', 'rejected']);
11
+ let TDD_SCREENSHOT_IMAGES = new Set(['current', 'baseline', 'diff', 'all']);
12
+ function readLocalReport(workingDir) {
13
+ let projectDir = resolve(workingDir);
14
+ let vizzlyDir = join(projectDir, '.vizzly');
15
+ let reportPath = join(vizzlyDir, 'report-data.json');
16
+ if (!existsSync(reportPath)) {
17
+ return {
18
+ error: `No local TDD screenshots found. Run "vizzly tdd run <command>" ` + `first. Expected ${reportPath}`
19
+ };
20
+ }
21
+ try {
22
+ let report = JSON.parse(readFileSync(reportPath, 'utf8'));
23
+ if (!report || !Array.isArray(report.comparisons)) {
24
+ return {
25
+ error: `Local TDD report has no comparisons: ${reportPath}`
26
+ };
27
+ }
28
+ if (report.comparisons.some(comparison => !comparison || typeof comparison !== 'object' || Array.isArray(comparison))) {
29
+ return {
30
+ error: `Local TDD report has invalid comparisons: ${reportPath}`
31
+ };
32
+ }
33
+ return {
34
+ report,
35
+ projectDir,
36
+ vizzlyDir
37
+ };
38
+ } catch (error) {
39
+ return {
40
+ error: `Could not read local TDD report ${reportPath}: ${error.message}`
41
+ };
42
+ }
43
+ }
44
+ function resolveImagePath(imageUrl, vizzlyDir) {
45
+ if (typeof imageUrl !== 'string' || imageUrl.length === 0) return null;
46
+ let relativePath;
47
+ if (imageUrl.startsWith('/images/')) {
48
+ relativePath = imageUrl.slice('/images/'.length);
49
+ } else if (imageUrl.startsWith('.vizzly/')) {
50
+ relativePath = imageUrl.slice('.vizzly/'.length);
51
+ } else {
52
+ return null;
53
+ }
54
+ try {
55
+ return safePath(vizzlyDir, relativePath);
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+ function readComparisonDetails(vizzlyDir) {
61
+ let detailsPath = join(vizzlyDir, 'comparison-details.json');
62
+ if (!existsSync(detailsPath)) return {};
63
+ try {
64
+ let details = JSON.parse(readFileSync(detailsPath, 'utf8'));
65
+ return details && typeof details === 'object' && !Array.isArray(details) ? details : {};
66
+ } catch {
67
+ return {};
68
+ }
69
+ }
70
+ function getViewport(properties) {
71
+ return {
72
+ width: properties?.viewport_width ?? properties?.viewport?.width ?? null,
73
+ height: properties?.viewport_height ?? properties?.viewport?.height ?? null
74
+ };
75
+ }
76
+ function comparisonSummary(comparison, vizzlyDir) {
77
+ let currentPath = resolveImagePath(comparison.current, vizzlyDir);
78
+ return {
79
+ id: comparison.id,
80
+ name: comparison.name,
81
+ status: comparison.status,
82
+ diffPercentage: comparison.diffPercentage ?? null,
83
+ browser: comparison.properties?.browser ?? null,
84
+ viewport: getViewport(comparison.properties),
85
+ currentImage: currentPath ? {
86
+ path: currentPath,
87
+ exists: existsSync(currentPath)
88
+ } : null
89
+ };
90
+ }
91
+ function getPaginationOptions(options = {}) {
92
+ let page = options.page ?? 1;
93
+ let pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE;
94
+ let errors = [];
95
+ if (!Number.isInteger(page) || page < 1) {
96
+ errors.push('--page must be a positive integer');
97
+ }
98
+ if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > MAX_PAGE_SIZE) {
99
+ errors.push(`--page-size must be an integer between 1 and ${MAX_PAGE_SIZE}`);
100
+ }
101
+ if (options.status && !TDD_SCREENSHOT_STATUSES.has(options.status)) {
102
+ errors.push(`--status must be one of: ${[...TDD_SCREENSHOT_STATUSES].join(', ')}`);
103
+ }
104
+ return {
105
+ page,
106
+ pageSize,
107
+ errors
108
+ };
109
+ }
110
+ function reportError(message, commandOutput = output) {
111
+ commandOutput.error(message);
112
+ process.exitCode = 1;
113
+ }
114
+ function getImageKinds(image = 'current') {
115
+ if (!TDD_SCREENSHOT_IMAGES.has(image)) {
116
+ return {
117
+ error: `--image must be one of: ${[...TDD_SCREENSHOT_IMAGES].join(', ')}`
118
+ };
119
+ }
120
+ return {
121
+ kinds: image === 'all' ? ['current', 'baseline', 'diff'] : [image]
122
+ };
123
+ }
124
+ function imageReferences(comparison, vizzlyDir, kinds) {
125
+ return Object.fromEntries(kinds.map(kind => {
126
+ let path = resolveImagePath(comparison[kind], vizzlyDir);
127
+ return [kind, path ? {
128
+ path,
129
+ exists: existsSync(path)
130
+ } : null];
131
+ }));
132
+ }
133
+ function readLocalComparison(idOrName, workingDir) {
134
+ if (!idOrName?.trim()) {
135
+ return {
136
+ error: 'A screenshot name or comparison ID is required.'
137
+ };
138
+ }
139
+ let local = readLocalReport(workingDir);
140
+ if (local.error) return local;
141
+ let found = findComparison(local.report.comparisons, idOrName);
142
+ if (found.error) return found;
143
+ return {
144
+ local,
145
+ comparison: found.comparison
146
+ };
147
+ }
148
+ export function listTddScreenshots(options = {}, workingDir = process.cwd()) {
149
+ let {
150
+ page,
151
+ pageSize,
152
+ errors
153
+ } = getPaginationOptions(options);
154
+ if (errors.length > 0) {
155
+ reportError(errors.join('\n'));
156
+ return null;
157
+ }
158
+ let local = readLocalReport(workingDir);
159
+ if (local.error) {
160
+ reportError(local.error);
161
+ return null;
162
+ }
163
+ let comparisons = options.status ? local.report.comparisons.filter(item => item.status === options.status) : local.report.comparisons;
164
+ let total = comparisons.length;
165
+ let totalPages = Math.ceil(total / pageSize);
166
+ let start = (page - 1) * pageSize;
167
+ let items = comparisons.slice(start, start + pageSize).map(comparison => comparisonSummary(comparison, local.vizzlyDir));
168
+ let data = {
169
+ page,
170
+ pageSize,
171
+ total,
172
+ totalPages,
173
+ hasPrevious: page > 1 && total > 0,
174
+ hasNext: start + items.length < total,
175
+ screenshots: items
176
+ };
177
+ if (output.isJson()) {
178
+ output.data(data);
179
+ return data;
180
+ }
181
+ let pageLabel = totalPages ? ` of ${totalPages}` : '';
182
+ output.print(`TDD screenshots · ${total} comparison${total === 1 ? '' : 's'} · ` + `page ${page}${pageLabel}`);
183
+ if (options.status) output.print(`Filtered by status: ${options.status}`);
184
+ if (items.length === 0) {
185
+ output.print('No screenshots on this page.');
186
+ }
187
+ for (let [index, item] of items.entries()) {
188
+ let row = start + index + 1;
189
+ let diff = !Number.isFinite(item.diffPercentage) ? '' : ` · ${item.diffPercentage.toFixed(2)}% diff`;
190
+ output.print(`${row}. ${item.status} · ${item.name} · ${item.id}${diff}`);
191
+ }
192
+ if (data.hasNext) {
193
+ output.blank();
194
+ let nextPageCommand = `Next page: vizzly tdd screenshots list --page ${page + 1} ` + `--page-size ${pageSize}`;
195
+ if (options.status) nextPageCommand += ` --status ${options.status}`;
196
+ output.print(nextPageCommand);
197
+ }
198
+ if (items.length > 0) {
199
+ output.blank();
200
+ output.print('Inspect one: vizzly tdd screenshots show <id>');
201
+ }
202
+ return data;
203
+ }
204
+ export function latestTddScreenshot(name = null, workingDir = process.cwd()) {
205
+ let local = readLocalReport(workingDir);
206
+ if (local.error) {
207
+ reportError(local.error);
208
+ return null;
209
+ }
210
+ let matches = local.report.comparisons.map((comparison, index) => ({
211
+ comparison,
212
+ index
213
+ })).filter(({
214
+ comparison
215
+ }) => !name || comparison.name === name).sort((left, right) => {
216
+ let leftTimestamp = Number(left.comparison.timestamp) || 0;
217
+ let rightTimestamp = Number(right.comparison.timestamp) || 0;
218
+ return rightTimestamp - leftTimestamp || right.index - left.index;
219
+ });
220
+ if (matches.length === 0) {
221
+ reportError(name ? `No local TDD screenshot found for "${name}".` : 'No local TDD screenshots were captured.');
222
+ return null;
223
+ }
224
+ let {
225
+ comparison
226
+ } = matches[0];
227
+ let image = imageReferences(comparison, local.vizzlyDir, ['current']).current;
228
+ let data = {
229
+ id: comparison.id,
230
+ name: comparison.name,
231
+ status: comparison.status,
232
+ timestamp: comparison.timestamp ?? null,
233
+ image
234
+ };
235
+ if (!image?.exists) {
236
+ reportError(`The latest screenshot is missing for "${comparison.name}".`);
237
+ return null;
238
+ }
239
+ if (output.isJson()) {
240
+ output.data(data);
241
+ return data;
242
+ }
243
+ output.print(image.path);
244
+ return data;
245
+ }
246
+ function findComparison(comparisons, idOrName) {
247
+ let byId = comparisons.find(item => item.id === idOrName);
248
+ if (byId) return {
249
+ comparison: byId
250
+ };
251
+ let bySignature = comparisons.find(item => item.signature === idOrName);
252
+ if (bySignature) return {
253
+ comparison: bySignature
254
+ };
255
+ let byName = comparisons.filter(item => item.name === idOrName);
256
+ if (byName.length === 1) return {
257
+ comparison: byName[0]
258
+ };
259
+ if (byName.length > 1) {
260
+ return {
261
+ error: `More than one screenshot is named "${idOrName}". ` + `Use a comparison ID: ${byName.map(item => item.id).join(', ')}`
262
+ };
263
+ }
264
+ let prefixMatches = comparisons.filter(item => String(item.id || '').startsWith(idOrName));
265
+ if (prefixMatches.length === 1) return {
266
+ comparison: prefixMatches[0]
267
+ };
268
+ if (prefixMatches.length > 1) {
269
+ return {
270
+ error: `Comparison ID prefix "${idOrName}" is ambiguous. Use a full ID.`
271
+ };
272
+ }
273
+ return {
274
+ error: `No local TDD screenshot found for "${idOrName}".`
275
+ };
276
+ }
277
+ export function showTddScreenshot(idOrName, options = {}, workingDir = process.cwd()) {
278
+ let selected = readLocalComparison(idOrName, workingDir);
279
+ if (selected.error) {
280
+ reportError(selected.error);
281
+ return null;
282
+ }
283
+ let {
284
+ kinds,
285
+ error
286
+ } = getImageKinds(options.image);
287
+ if (error) {
288
+ reportError(error);
289
+ return null;
290
+ }
291
+ let {
292
+ local,
293
+ comparison
294
+ } = selected;
295
+ let comparisonFacts = {
296
+ ...comparison
297
+ };
298
+ delete comparisonFacts.baseline;
299
+ delete comparisonFacts.current;
300
+ delete comparisonFacts.diff;
301
+ let detail = {
302
+ comparison: comparisonFacts,
303
+ images: imageReferences(comparison, local.vizzlyDir, kinds),
304
+ details: readComparisonDetails(local.vizzlyDir)[comparison.id] || {}
305
+ };
306
+ if (output.isJson()) {
307
+ output.data(detail);
308
+ return detail;
309
+ }
310
+ let {
311
+ properties = {}
312
+ } = comparison;
313
+ let viewport = getViewport(properties);
314
+ output.print(`${comparison.name} · ${comparison.status}`);
315
+ output.print(`ID: ${comparison.id}`);
316
+ if (comparison.signature) output.print(`Signature: ${comparison.signature}`);
317
+ if (Number.isFinite(comparison.diffPercentage)) {
318
+ output.print(`Difference: ${comparison.diffPercentage.toFixed(2)}%`);
319
+ }
320
+ if (properties.browser) output.print(`Browser: ${properties.browser}`);
321
+ if (viewport.width && viewport.height) {
322
+ output.print(`Viewport: ${viewport.width}×${viewport.height}`);
323
+ }
324
+ if (comparison.error) output.print(`Error: ${comparison.error}`);
325
+ if (comparison.reason) output.print(`Reason: ${comparison.reason}`);
326
+ for (let kind of kinds) {
327
+ let image = detail.images[kind];
328
+ let imageLabel = image ? `${image.path}${image.exists ? '' : ' (missing)'}` : 'not available';
329
+ output.print(`${kind}: ${imageLabel}`);
330
+ }
331
+ return detail;
332
+ }
333
+ function removeComparisonDetails(comparison, vizzlyDir, writeFile) {
334
+ let detailsPath = join(vizzlyDir, 'comparison-details.json');
335
+ if (!existsSync(detailsPath)) return;
336
+ let details;
337
+ try {
338
+ details = JSON.parse(readFileSync(detailsPath, 'utf8'));
339
+ } catch {
340
+ return;
341
+ }
342
+ if (!details || typeof details !== 'object' || Array.isArray(details) || !Object.hasOwn(details, comparison.id)) {
343
+ return;
344
+ }
345
+ delete details[comparison.id];
346
+ writeFile(detailsPath, JSON.stringify(details));
347
+ }
348
+ function updateReportAfterAccept(report, comparison, baselinePath) {
349
+ let timestamp = Date.now();
350
+ let updatedComparison = {
351
+ ...comparison,
352
+ status: 'passed',
353
+ baseline: baselinePath ? `/images/baselines/${basename(baselinePath)}` : comparison.baseline,
354
+ diffPercentage: 0,
355
+ diff: null
356
+ };
357
+ let comparisons = report.comparisons.map(item => item.id === comparison.id ? {
358
+ ...updatedComparison,
359
+ initialStatus: item.initialStatus || item.status
360
+ } : item);
361
+ return {
362
+ ...report,
363
+ timestamp,
364
+ comparisons,
365
+ summary: {
366
+ total: comparisons.length,
367
+ passed: comparisons.filter(item => ['passed', 'baseline-created', 'new'].includes(item.status)).length,
368
+ failed: comparisons.filter(item => item.status === 'failed').length,
369
+ rejected: comparisons.filter(item => item.status === 'rejected').length,
370
+ errors: comparisons.filter(item => item.status === 'error').length
371
+ }
372
+ };
373
+ }
374
+ export async function acceptTddScreenshot(idOrName, workingDir = process.cwd(), deps = {}) {
375
+ let {
376
+ loadConfig = defaultLoadConfig,
377
+ TddService = defaultTddService,
378
+ writeFile = writeFileSync,
379
+ output: commandOutput = output
380
+ } = deps;
381
+ let selected = readLocalComparison(idOrName, workingDir);
382
+ if (selected.error) {
383
+ reportError(selected.error, commandOutput);
384
+ return null;
385
+ }
386
+ let {
387
+ local,
388
+ comparison
389
+ } = selected;
390
+ try {
391
+ let config = await loadConfig();
392
+ let service = new TddService(config, local.projectDir);
393
+ await service.loadBaseline();
394
+ let accepted = await service.acceptBaseline(comparison);
395
+ let signature = generateScreenshotSignature(comparison.name, comparison.properties || {}, service.signatureProperties);
396
+ let baseline = service.baselineData?.screenshots?.find(item => item.signature === signature);
397
+ writeFile(join(local.vizzlyDir, 'report-data.json'), JSON.stringify(updateReportAfterAccept(local.report, comparison, baseline?.path)));
398
+ removeComparisonDetails(comparison, local.vizzlyDir, writeFile);
399
+ let data = {
400
+ id: comparison.id,
401
+ name: comparison.name,
402
+ status: accepted.status,
403
+ baseline: baseline?.path ?? null
404
+ };
405
+ if (commandOutput.isJson()) {
406
+ commandOutput.data(data);
407
+ } else {
408
+ commandOutput.success(`Accepted ${comparison.name} as the local baseline`, {
409
+ baseline: data.baseline
410
+ });
411
+ }
412
+ return data;
413
+ } catch (error) {
414
+ reportError(`Failed to accept ${comparison.name}: ${error.message}`, commandOutput);
415
+ return null;
416
+ }
417
+ }
@@ -14,7 +14,7 @@ import { createConfigService as defaultCreateConfigService } from '../services/c
14
14
  import { createProjectService as defaultCreateProjectService } from '../services/project-service.js';
15
15
  import { initializeDaemon as defaultInitializeDaemon, runTests as defaultRunTests } from '../test-runner/index.js';
16
16
  import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
17
- import { detectBranch as defaultDetectBranch, detectCommit as defaultDetectCommit, detectCommitMessage as defaultDetectCommitMessage, detectPullRequestNumber as defaultDetectPullRequestNumber, generateBuildNameWithGit as defaultGenerateBuildNameWithGit } from '../utils/git.js';
17
+ import { detectBranch as defaultDetectBranch, detectCommit as defaultDetectCommit, detectCommitAuthor as defaultDetectCommitAuthor, detectCommitMessage as defaultDetectCommitMessage, detectPullRequestNumber as defaultDetectPullRequestNumber, generateBuildNameWithGit as defaultGenerateBuildNameWithGit } from '../utils/git.js';
18
18
  import * as defaultOutput from '../utils/output.js';
19
19
 
20
20
  /**
@@ -58,6 +58,7 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {},
58
58
  runTests = defaultRunTests,
59
59
  detectBranch = defaultDetectBranch,
60
60
  detectCommit = defaultDetectCommit,
61
+ detectCommitAuthor = defaultDetectCommitAuthor,
61
62
  detectCommitMessage = defaultDetectCommitMessage,
62
63
  detectPullRequestNumber = defaultDetectPullRequestNumber,
63
64
  generateBuildNameWithGit = defaultGenerateBuildNameWithGit,
@@ -107,6 +108,7 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {},
107
108
  let branch = await detectBranch(options.branch || config.build.branch);
108
109
  let commit = await detectCommit(options.commit || config.build.commit);
109
110
  let message = options.message || config.build.message || (await detectCommitMessage());
111
+ let commitAuthor = await detectCommitAuthor();
110
112
  let buildName = await generateBuildNameWithGit(options.buildName || configuredBuildName);
111
113
  let pullRequestNumber = detectPullRequestNumber();
112
114
 
@@ -160,6 +162,8 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {},
160
162
  branch,
161
163
  commit,
162
164
  message,
165
+ commitAuthorName: commitAuthor.name,
166
+ commitAuthorEmail: commitAuthor.email,
163
167
  environment: config.build.environment,
164
168
  pullRequestNumber,
165
169
  parallelId: config.parallelId,
@@ -3,7 +3,7 @@ import { CONFIG_DEFAULTS } from '../config/core.js';
3
3
  import { createUploader as defaultCreateUploader } from '../uploader/index.js';
4
4
  import { getAppBaseUrl } from '../utils/api-url.js';
5
5
  import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
6
- import { detectBranch as defaultDetectBranch, detectCommit as defaultDetectCommit, detectCommitMessage as defaultDetectCommitMessage, detectPullRequestNumber as defaultDetectPullRequestNumber, generateBuildNameWithGit as defaultGenerateBuildNameWithGit } from '../utils/git.js';
6
+ import { detectBranch as defaultDetectBranch, detectCommit as defaultDetectCommit, detectCommitAuthor as defaultDetectCommitAuthor, detectCommitMessage as defaultDetectCommitMessage, detectPullRequestNumber as defaultDetectPullRequestNumber, generateBuildNameWithGit as defaultGenerateBuildNameWithGit } from '../utils/git.js';
7
7
  import * as defaultOutput from '../utils/output.js';
8
8
  import { writeSession as defaultWriteSession } from '../utils/session.js';
9
9
 
@@ -59,6 +59,7 @@ export async function uploadCommand(screenshotsPath, options = {}, globalOptions
59
59
  createUploader = defaultCreateUploader,
60
60
  detectBranch = defaultDetectBranch,
61
61
  detectCommit = defaultDetectCommit,
62
+ detectCommitAuthor = defaultDetectCommitAuthor,
62
63
  detectCommitMessage = defaultDetectCommitMessage,
63
64
  detectPullRequestNumber = defaultDetectPullRequestNumber,
64
65
  generateBuildNameWithGit = defaultGenerateBuildNameWithGit,
@@ -100,6 +101,7 @@ export async function uploadCommand(screenshotsPath, options = {}, globalOptions
100
101
  let branch = await detectBranch(options.branch || config.build.branch);
101
102
  let commit = await detectCommit(options.commit || config.build.commit);
102
103
  let message = options.message || config.build.message || (await detectCommitMessage());
104
+ let commitAuthor = await detectCommitAuthor();
103
105
  let buildName = await generateBuildNameWithGit(options.buildName || configuredBuildName);
104
106
  let pullRequestNumber = detectPullRequestNumber();
105
107
  output.info(`Uploading screenshots from: ${screenshotsPath}`);
@@ -127,6 +129,8 @@ export async function uploadCommand(screenshotsPath, options = {}, globalOptions
127
129
  branch,
128
130
  commit,
129
131
  message,
132
+ commitAuthorName: commitAuthor.name,
133
+ commitAuthorEmail: commitAuthor.email,
130
134
  environment: config.build.environment,
131
135
  threshold: config.comparison.threshold,
132
136
  minClusterSize: config.comparison.minClusterSize,