@vizzly-testing/cli 0.37.0 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/core.js +6 -0
- package/dist/cli.js +14 -0
- package/dist/commands/run.js +5 -1
- package/dist/commands/tdd-screenshots.js +417 -0
- package/dist/commands/tdd.js +5 -1
- package/dist/commands/upload.js +5 -1
- package/dist/test-runner/core.js +2 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/uploader/core.js +2 -0
- package/dist/utils/ci-env.js +43 -4
- package/dist/utils/git.js +46 -6
- package/package.json +1 -1
- package/skills/vizzly/references/cli-context.md +25 -0
package/dist/api/core.js
CHANGED
|
@@ -126,6 +126,12 @@ export function buildBuildPayload(options) {
|
|
|
126
126
|
if (options.message || options.commit_message) {
|
|
127
127
|
payload.commit_message = options.message || options.commit_message;
|
|
128
128
|
}
|
|
129
|
+
if (options.commit_author_name) {
|
|
130
|
+
payload.commit_author_name = options.commit_author_name;
|
|
131
|
+
}
|
|
132
|
+
if (options.commit_author_email) {
|
|
133
|
+
payload.commit_author_email = options.commit_author_email;
|
|
134
|
+
}
|
|
129
135
|
if (options.pullRequestNumber || options.github_pull_request_number) {
|
|
130
136
|
payload.github_pull_request_number = options.pullRequestNumber || options.github_pull_request_number;
|
|
131
137
|
}
|
package/dist/cli.js
CHANGED
|
@@ -22,6 +22,7 @@ import { runCommand, validateRunOptions } from './commands/run.js';
|
|
|
22
22
|
import { statusCommand, validateStatusOptions } from './commands/status.js';
|
|
23
23
|
import { tddCommand, validateTddOptions } from './commands/tdd.js';
|
|
24
24
|
import { runDaemonChild, tddListCommand, tddStartCommand, tddStatusCommand, tddStopCommand, validateTddStartOptions } from './commands/tdd-daemon.js';
|
|
25
|
+
import { acceptTddScreenshot, latestTddScreenshot, listTddScreenshots, showTddScreenshot } from './commands/tdd-screenshots.js';
|
|
25
26
|
import { uploadCommand, validateUploadOptions } from './commands/upload.js';
|
|
26
27
|
import { whoamiCommand } from './commands/whoami.js';
|
|
27
28
|
import { createPluginServices } from './plugin-api.js';
|
|
@@ -528,6 +529,19 @@ tddCmd.command('list').description('List all running TDD servers').action(async
|
|
|
528
529
|
let globalOptions = getGlobalOptions();
|
|
529
530
|
await tddListCommand(options, globalOptions);
|
|
530
531
|
});
|
|
532
|
+
let tddScreenshotsCmd = tddCmd.command('screenshots').description('Browse local TDD screenshots without a server');
|
|
533
|
+
tddScreenshotsCmd.command('list').description('List captured screenshots from the latest local TDD run').option('--page <number>', 'Page number', Number, 1).option('--page-size <number>', 'Screenshots per page (max 100)', Number, 20).option('--status <status>', 'Filter by comparison status').action(options => {
|
|
534
|
+
listTddScreenshots(options);
|
|
535
|
+
});
|
|
536
|
+
tddScreenshotsCmd.command('latest [name]').description('Print the latest captured screenshot path').action(name => {
|
|
537
|
+
latestTddScreenshot(name);
|
|
538
|
+
});
|
|
539
|
+
tddScreenshotsCmd.command('show <id-or-name>').description('Inspect a screenshot; print current image by default').option('--image <kind>', 'Print image path: current, baseline, diff, or all', 'current').action((idOrName, options) => {
|
|
540
|
+
showTddScreenshot(idOrName, options);
|
|
541
|
+
});
|
|
542
|
+
tddScreenshotsCmd.command('accept <id-or-name>').description('Accept a captured screenshot as its new local baseline').action(async idOrName => {
|
|
543
|
+
await acceptTddScreenshot(idOrName);
|
|
544
|
+
});
|
|
531
545
|
|
|
532
546
|
// TDD Run - One-off test run with ephemeral server (generates static report)
|
|
533
547
|
tddCmd.command('run <command>').description('Run tests once in TDD mode with local visual review').option('--port <port>', 'Port for TDD server').option('--branch <branch>', 'Git branch override').option('--environment <env>', 'Environment name', 'test').option('--threshold <number>', 'Comparison threshold', Number).option('--min-cluster-size <pixels>', 'Minimum changed-pixel cluster size', Number).option('--token <token>', 'API token override').option('--timeout <ms>', 'Server timeout in milliseconds', '30000').option('--baseline-build <id>', 'Use specific build as baseline').option('--baseline-comparison <id>', 'Use specific comparison as baseline').option('--set-baseline', 'Accept current screenshots as new baseline (overwrites existing)').option('--fail-on-diff', 'Fail tests when visual differences are detected').option('--no-open', 'Skip opening report in browser').action(async (command, options) => {
|
package/dist/commands/run.js
CHANGED
|
@@ -18,7 +18,7 @@ import { finalizeBuild as defaultFinalizeBuild, runTests as defaultRunTests } fr
|
|
|
18
18
|
import { createUploader as defaultCreateUploader } from '../uploader/index.js';
|
|
19
19
|
import { getAppBaseUrl } from '../utils/api-url.js';
|
|
20
20
|
import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
|
|
21
|
-
import { detectBranch as defaultDetectBranch, detectCommit as defaultDetectCommit, detectCommitMessage as defaultDetectCommitMessage, detectPullRequestNumber as defaultDetectPullRequestNumber, generateBuildNameWithGit as defaultGenerateBuildNameWithGit } from '../utils/git.js';
|
|
21
|
+
import { detectBranch as defaultDetectBranch, detectCommit as defaultDetectCommit, detectCommitAuthor as defaultDetectCommitAuthor, detectCommitMessage as defaultDetectCommitMessage, detectPullRequestNumber as defaultDetectPullRequestNumber, generateBuildNameWithGit as defaultGenerateBuildNameWithGit } from '../utils/git.js';
|
|
22
22
|
import * as defaultOutput from '../utils/output.js';
|
|
23
23
|
import { writeSession as defaultWriteSession } from '../utils/session.js';
|
|
24
24
|
function formatUploadCounts({
|
|
@@ -100,6 +100,7 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
|
|
|
100
100
|
runTests = defaultRunTests,
|
|
101
101
|
detectBranch = defaultDetectBranch,
|
|
102
102
|
detectCommit = defaultDetectCommit,
|
|
103
|
+
detectCommitAuthor = defaultDetectCommitAuthor,
|
|
103
104
|
detectCommitMessage = defaultDetectCommitMessage,
|
|
104
105
|
detectPullRequestNumber = defaultDetectPullRequestNumber,
|
|
105
106
|
generateBuildNameWithGit = defaultGenerateBuildNameWithGit,
|
|
@@ -210,6 +211,7 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
|
|
|
210
211
|
let branch = await detectBranch(options.branch || config.build.branch);
|
|
211
212
|
let commit = await detectCommit(options.commit || config.build.commit);
|
|
212
213
|
let message = options.message || config.build.message || (await detectCommitMessage());
|
|
214
|
+
let commitAuthor = await detectCommitAuthor();
|
|
213
215
|
let buildName = await generateBuildNameWithGit(options.buildName || configuredBuildName);
|
|
214
216
|
let pullRequestNumber = detectPullRequestNumber();
|
|
215
217
|
if (globalOptions.verbose) {
|
|
@@ -269,6 +271,8 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
|
|
|
269
271
|
branch,
|
|
270
272
|
commit,
|
|
271
273
|
message,
|
|
274
|
+
commitAuthorName: commitAuthor.name,
|
|
275
|
+
commitAuthorEmail: commitAuthor.email,
|
|
272
276
|
environment: config.build.environment,
|
|
273
277
|
threshold: config.comparison.threshold,
|
|
274
278
|
minClusterSize: config.comparison.minClusterSize,
|
|
@@ -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
|
+
}
|
package/dist/commands/tdd.js
CHANGED
|
@@ -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,
|
package/dist/commands/upload.js
CHANGED
|
@@ -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,
|
package/dist/test-runner/core.js
CHANGED
|
@@ -64,6 +64,8 @@ export function buildApiBuildPayload(options, comparisonConfig = null) {
|
|
|
64
64
|
environment: options.environment || 'test',
|
|
65
65
|
commit_sha: options.commit,
|
|
66
66
|
commit_message: options.message,
|
|
67
|
+
commit_author_name: options.commitAuthorName,
|
|
68
|
+
commit_author_email: options.commitAuthorEmail,
|
|
67
69
|
github_pull_request_number: options.pullRequestNumber,
|
|
68
70
|
parallel_id: options.parallelId
|
|
69
71
|
};
|
package/dist/types/index.d.ts
CHANGED
|
@@ -165,6 +165,8 @@ export interface UploadOptions {
|
|
|
165
165
|
branch?: string;
|
|
166
166
|
commit?: string;
|
|
167
167
|
message?: string;
|
|
168
|
+
commitAuthorName?: string;
|
|
169
|
+
commitAuthorEmail?: string;
|
|
168
170
|
environment?: string;
|
|
169
171
|
threshold?: number;
|
|
170
172
|
minClusterSize?: number;
|
|
@@ -517,6 +519,8 @@ export interface BuildOptions {
|
|
|
517
519
|
commit_sha?: string;
|
|
518
520
|
message?: string;
|
|
519
521
|
commit_message?: string;
|
|
522
|
+
commitAuthorName?: string;
|
|
523
|
+
commitAuthorEmail?: string;
|
|
520
524
|
environment?: string;
|
|
521
525
|
threshold?: number;
|
|
522
526
|
eager?: boolean;
|
package/dist/uploader/core.js
CHANGED
|
@@ -154,6 +154,8 @@ export function buildBuildInfo(options, defaultBranch = 'main') {
|
|
|
154
154
|
branch: options.branch || defaultBranch || 'main',
|
|
155
155
|
commit_sha: options.commit,
|
|
156
156
|
commit_message: options.message,
|
|
157
|
+
commit_author_name: options.commitAuthorName,
|
|
158
|
+
commit_author_email: options.commitAuthorEmail,
|
|
157
159
|
environment: options.environment || 'production',
|
|
158
160
|
threshold: options.threshold,
|
|
159
161
|
github_pull_request_number: options.pullRequestNumber,
|
package/dist/utils/ci-env.js
CHANGED
|
@@ -156,13 +156,23 @@ export function getCommit() {
|
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
/**
|
|
159
|
-
* Get the commit message from CI environment variables
|
|
159
|
+
* Get the commit message from CI environment variables.
|
|
160
|
+
*
|
|
161
|
+
* For GitHub Actions pull_request events, the checkout is a synthetic merge
|
|
162
|
+
* commit whose message is "Merge <sha> into <sha>", so the PR title from the
|
|
163
|
+
* event payload is used instead.
|
|
164
|
+
*
|
|
160
165
|
* @returns {string|null} Commit message or null if not available
|
|
161
166
|
*/
|
|
162
167
|
export function getCommitMessage() {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
168
|
+
if (process.env.VIZZLY_COMMIT_MESSAGE) {
|
|
169
|
+
return process.env.VIZZLY_COMMIT_MESSAGE;
|
|
170
|
+
}
|
|
171
|
+
if (process.env.GITHUB_ACTIONS) {
|
|
172
|
+
let title = getGitHubEvent().pull_request?.title;
|
|
173
|
+
if (title) return title;
|
|
174
|
+
}
|
|
175
|
+
return process.env.CI_COMMIT_MESSAGE ||
|
|
166
176
|
// GitLab CI
|
|
167
177
|
process.env.TRAVIS_COMMIT_MESSAGE ||
|
|
168
178
|
// Travis CI
|
|
@@ -176,6 +186,35 @@ export function getCommitMessage() {
|
|
|
176
186
|
// Generic
|
|
177
187
|
null;
|
|
178
188
|
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Parse a "Name <email>" author string (GitLab's CI_COMMIT_AUTHOR format)
|
|
192
|
+
* @param {string|undefined} value - Author string
|
|
193
|
+
* @returns {{ name: string|null, email: string|null }}
|
|
194
|
+
*/
|
|
195
|
+
function parseAuthorString(value) {
|
|
196
|
+
let match = value?.match(/^(.*?)\s*<([^>]*)>\s*$/);
|
|
197
|
+
if (!match) return {
|
|
198
|
+
name: value?.trim() || null,
|
|
199
|
+
email: null
|
|
200
|
+
};
|
|
201
|
+
return {
|
|
202
|
+
name: match[1] || null,
|
|
203
|
+
email: match[2] || null
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Get the commit author from CI environment variables
|
|
209
|
+
* @returns {{ name: string|null, email: string|null }} Author name and email
|
|
210
|
+
*/
|
|
211
|
+
export function getCommitAuthor() {
|
|
212
|
+
let gitlab = parseAuthorString(process.env.CI_COMMIT_AUTHOR);
|
|
213
|
+
return {
|
|
214
|
+
name: process.env.VIZZLY_COMMIT_AUTHOR_NAME || gitlab.name || null,
|
|
215
|
+
email: process.env.VIZZLY_COMMIT_AUTHOR_EMAIL || gitlab.email || null
|
|
216
|
+
};
|
|
217
|
+
}
|
|
179
218
|
function parsePullRequestNumber(value) {
|
|
180
219
|
if (!/^\d+$/.test(value)) {
|
|
181
220
|
return null;
|
package/dist/utils/git.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
|
-
import { getBranch as getCIBranch, getCommit as getCICommit, getCommitMessage as getCICommitMessage, getPullRequestNumber } from './ci-env.js';
|
|
3
|
+
import { getBranch as getCIBranch, getCommit as getCICommit, getCommitAuthor as getCICommitAuthor, getCommitMessage as getCICommitMessage, getPullRequestNumber } from './ci-env.js';
|
|
4
4
|
let execFileAsync = promisify(execFile);
|
|
5
5
|
async function runGit(args, cwd = process.cwd()) {
|
|
6
6
|
let {
|
|
@@ -124,8 +124,48 @@ export async function detectCommitMessage(override = null, cwd = process.cwd())
|
|
|
124
124
|
let ciCommitMessage = getCICommitMessage();
|
|
125
125
|
if (ciCommitMessage) return ciCommitMessage;
|
|
126
126
|
|
|
127
|
-
// Fallback to
|
|
128
|
-
return await
|
|
127
|
+
// Fallback to git, reading the same commit reported as commit_sha
|
|
128
|
+
return await readCommit('%B', cwd);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Read a formatted field from the detected build commit.
|
|
133
|
+
*
|
|
134
|
+
* Uses detectCommit() so CI checkouts (e.g. GitHub's synthetic PR merge
|
|
135
|
+
* commit) describe the PR head rather than HEAD. Falls back to HEAD when that
|
|
136
|
+
* commit is not in local history (shallow clones).
|
|
137
|
+
*
|
|
138
|
+
* @param {string} format - git log --format string
|
|
139
|
+
* @param {string} cwd - Working directory
|
|
140
|
+
* @returns {Promise<string|null>} Formatted output or null
|
|
141
|
+
*/
|
|
142
|
+
async function readCommit(format, cwd = process.cwd()) {
|
|
143
|
+
let sha = await detectCommit(null, cwd);
|
|
144
|
+
let refs = /^[0-9a-f]{4,64}$/i.test(sha || '') ? [sha, 'HEAD'] : ['HEAD'];
|
|
145
|
+
for (let ref of refs) {
|
|
146
|
+
try {
|
|
147
|
+
return await runGit(['log', '-1', `--format=${format}`, ref], cwd);
|
|
148
|
+
} catch {
|
|
149
|
+
// Commit not available locally, try the next ref
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Detect commit author with environment variable support, falling back to git
|
|
157
|
+
* @param {string} cwd - Working directory
|
|
158
|
+
* @returns {Promise<{ name: string|null, email: string|null }>}
|
|
159
|
+
*/
|
|
160
|
+
export async function detectCommitAuthor(cwd = process.cwd()) {
|
|
161
|
+
let author = getCICommitAuthor();
|
|
162
|
+
if (author.name && author.email) return author;
|
|
163
|
+
let stdout = await readCommit('%an%x00%ae', cwd);
|
|
164
|
+
let [gitName, gitEmail] = stdout ? stdout.split('\0') : [];
|
|
165
|
+
return {
|
|
166
|
+
name: author.name || gitName || null,
|
|
167
|
+
email: author.email || gitEmail || null
|
|
168
|
+
};
|
|
129
169
|
}
|
|
130
170
|
|
|
131
171
|
/**
|
|
@@ -206,9 +246,9 @@ export async function detectCommit(override = null, cwd = process.cwd()) {
|
|
|
206
246
|
*/
|
|
207
247
|
export async function generateBuildNameWithGit(override = null, cwd = process.cwd()) {
|
|
208
248
|
if (override) return override;
|
|
209
|
-
let branch = await
|
|
210
|
-
let shortSha = await
|
|
211
|
-
if (branch && shortSha) {
|
|
249
|
+
let branch = await detectBranch(null, cwd);
|
|
250
|
+
let shortSha = await detectCommit(null, cwd);
|
|
251
|
+
if (branch && branch !== 'unknown' && shortSha) {
|
|
212
252
|
let shortCommit = shortSha.substring(0, 7);
|
|
213
253
|
return `${branch}-${shortCommit}`;
|
|
214
254
|
}
|
package/package.json
CHANGED
|
@@ -86,6 +86,31 @@ vizzly tdd stop --json
|
|
|
86
86
|
`tdd run` and `tdd start` are alternatives. Stop only a daemon started for the
|
|
87
87
|
current task.
|
|
88
88
|
|
|
89
|
+
## Review A Local TDD Run
|
|
90
|
+
|
|
91
|
+
After `tdd run` exits, inspect captures directly from `.vizzly`. These
|
|
92
|
+
commands don't need a running TDD daemon or cloud credentials:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
vizzly tdd screenshots list --page 1 --page-size 20
|
|
96
|
+
vizzly tdd screenshots latest
|
|
97
|
+
vizzly tdd screenshots show <comparison-id>
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`latest` prints the current screenshot path for the most recent capture. Use
|
|
101
|
+
`list` to find a comparison ID when you need a specific screenshot or browser
|
|
102
|
+
variant. During UI iteration, inspect the current image first; request
|
|
103
|
+
`--image diff` or `--image all` when comparison diagnostics help answer the
|
|
104
|
+
question. Open the printed image paths with the available image viewer.
|
|
105
|
+
|
|
106
|
+
`accept` replaces one local baseline and updates that report entry. Use it only
|
|
107
|
+
when the task explicitly authorizes accepting a baseline; don't accept a change
|
|
108
|
+
just to make a diff disappear:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
vizzly tdd screenshots accept <comparison-id>
|
|
112
|
+
```
|
|
113
|
+
|
|
89
114
|
When a cloud build is in scope:
|
|
90
115
|
|
|
91
116
|
```bash
|