@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 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) => {
@@ -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
+ }
@@ -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,
@@ -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
  };
@@ -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;
@@ -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,
@@ -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
- return process.env.VIZZLY_COMMIT_MESSAGE ||
164
- // Vizzly override
165
- process.env.CI_COMMIT_MESSAGE ||
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 regular git log
128
- return await getCommitMessage(cwd);
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 getCurrentBranch(cwd);
210
- let shortSha = await getCurrentCommitSha(cwd);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vizzly-testing/cli",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "description": "Visual regression testing from your terminal",
5
5
  "keywords": [
6
6
  "llm-context",
@@ -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