@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.
@@ -3,9 +3,10 @@
3
3
  * Handles authentication endpoints (device flow login, logout, status)
4
4
  */
5
5
 
6
+ import { buildTokens, normalizeDeviceFlowResponse } from '../../auth/core.js';
6
7
  import * as output from '../../utils/output.js';
7
8
  import { parseJsonBody } from '../middleware/json-parser.js';
8
- import { sendError, sendServiceUnavailable, sendSuccess } from '../middleware/response.js';
9
+ import { sendError, sendJson, sendServiceUnavailable, sendSuccess } from '../middleware/response.js';
9
10
 
10
11
  /**
11
12
  * Create auth router
@@ -50,22 +51,20 @@ export function createAuthRouter({
50
51
  // Initiate device flow login
51
52
  if (req.method === 'POST' && pathname === '/api/auth/login') {
52
53
  try {
53
- const deviceFlow = await authService.initiateDeviceFlow();
54
-
55
- // Transform snake_case to camelCase for frontend
56
- const response = {
57
- deviceCode: deviceFlow.device_code,
58
- userCode: deviceFlow.user_code,
59
- verificationUri: deviceFlow.verification_uri,
60
- verificationUriComplete: deviceFlow.verification_uri_complete,
61
- expiresIn: deviceFlow.expires_in,
54
+ let deviceFlow = await authService.initiateDeviceFlow();
55
+ let flow = normalizeDeviceFlowResponse(deviceFlow);
56
+ sendSuccess(res, {
57
+ deviceCode: flow.deviceCode,
58
+ userCode: flow.userCode,
59
+ verificationUri: flow.verificationUri,
60
+ verificationUriComplete: flow.urlWithCode,
61
+ expiresIn: deviceFlow.expires_in ?? deviceFlow.expiresIn,
62
62
  interval: deviceFlow.interval
63
- };
64
- sendSuccess(res, response);
63
+ });
65
64
  return true;
66
65
  } catch (error) {
67
66
  output.error('Error initiating device flow:', error);
68
- sendError(res, 500, error.message);
67
+ sendAuthError(res, error);
69
68
  return true;
70
69
  }
71
70
  }
@@ -81,45 +80,25 @@ export function createAuthRouter({
81
80
  sendError(res, 400, 'deviceCode is required');
82
81
  return true;
83
82
  }
84
- let result;
85
- try {
86
- result = await authService.pollDeviceAuthorization(deviceCode);
87
- } catch (error) {
88
- // Handle "Authorization pending" as a valid response
89
- if (error.message?.includes('Authorization pending')) {
90
- sendSuccess(res, {
91
- status: 'pending'
92
- });
93
- return true;
94
- }
95
- throw error;
96
- }
97
-
98
- // Check if authorization is complete by looking for tokens
99
- if (result.tokens?.accessToken) {
100
- const tokensData = result.tokens;
101
- const tokenExpiresIn = tokensData.expiresIn || tokensData.expires_in;
102
- const tokenExpiresAt = tokenExpiresIn ? new Date(Date.now() + tokenExpiresIn * 1000).toISOString() : result.expires_at || result.expiresAt;
103
- const tokens = {
104
- accessToken: tokensData.accessToken || tokensData.access_token,
105
- refreshToken: tokensData.refreshToken || tokensData.refresh_token,
106
- expiresAt: tokenExpiresAt,
107
- user: result.user
108
- };
83
+ let result = await authService.pollDeviceAuthorization(deviceCode);
84
+ if (result.tokens?.accessToken || result.tokens?.access_token) {
85
+ let tokens = buildTokens(result);
109
86
  await authService.completeDeviceFlow(tokens);
110
87
  sendSuccess(res, {
111
88
  status: 'complete',
112
89
  user: result.user
113
90
  });
114
- } else {
91
+ } else if (['pending', 'expired', 'denied', 'invalid'].includes(result.status)) {
115
92
  sendSuccess(res, {
116
- status: 'pending'
93
+ status: result.status
117
94
  });
95
+ } else {
96
+ throw new Error('Unexpected response from authorization server');
118
97
  }
119
98
  return true;
120
99
  } catch (error) {
121
100
  output.error('Error polling device authorization:', error);
122
- sendError(res, 500, error.message);
101
+ sendAuthError(res, error);
123
102
  return true;
124
103
  }
125
104
  }
@@ -135,10 +114,21 @@ export function createAuthRouter({
135
114
  return true;
136
115
  } catch (error) {
137
116
  output.error('Error logging out:', error);
138
- sendError(res, 500, error.message);
117
+ sendAuthError(res, error);
139
118
  return true;
140
119
  }
141
120
  }
142
121
  return false;
143
122
  };
123
+ }
124
+ function sendAuthError(res, error) {
125
+ let retryAfter = error.context?.retryAfter;
126
+ if (retryAfter) res.setHeader('Retry-After', String(retryAfter));
127
+ sendJson(res, error.context?.status || 500, {
128
+ error: error.message,
129
+ code: error.context?.serverCode || error.code,
130
+ ...(retryAfter ? {
131
+ retryAfter
132
+ } : {})
133
+ });
144
134
  }
@@ -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,
@@ -52,7 +52,13 @@ export async function openBrowser(url) {
52
52
  args = [url];
53
53
  break;
54
54
  }
55
- execFile(command, args, error => {
55
+
56
+ // Some launchers stay in the foreground. Bound the attempt so login and
57
+ // other CLI workflows can continue using the displayed URL.
58
+ execFile(command, args, {
59
+ timeout: 5000,
60
+ killSignal: 'SIGKILL'
61
+ }, error => {
56
62
  if (error) {
57
63
  // Browser opening failed, but don't throw - user can manually open
58
64
  resolve(false);
@@ -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
  }
@@ -0,0 +1,50 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
2
+
3
+ /** Wait for the next check, with optional terminal input and a live countdown. */
4
+ export async function waitForLoginPoll({
5
+ milliseconds,
6
+ manualAllowed = true,
7
+ earliestCheckAt = 0,
8
+ stdin = process.stdin,
9
+ now = Date.now,
10
+ sleep = delay,
11
+ onTick
12
+ }) {
13
+ if (!stdin.isTTY) {
14
+ onTick(milliseconds, false);
15
+ await sleep(milliseconds);
16
+ return;
17
+ }
18
+ let deadline = now() + milliseconds;
19
+ let requested = false;
20
+ let timer;
21
+ let wasFlowing = stdin.readableFlowing === true;
22
+ let onData = data => {
23
+ if (manualAllowed && /[\r\n]/.test(data.toString())) {
24
+ requested = true;
25
+ timer?.abort();
26
+ }
27
+ };
28
+ if (manualAllowed) {
29
+ stdin.on('data', onData);
30
+ stdin.resume();
31
+ }
32
+ try {
33
+ while (true) {
34
+ let remaining = Math.min(deadline, requested ? earliestCheckAt : deadline) - now();
35
+ if (remaining <= 0) return;
36
+ onTick(remaining, manualAllowed && !requested);
37
+ timer = new AbortController();
38
+ try {
39
+ await sleep(Math.min(remaining, 1000), undefined, {
40
+ signal: timer.signal
41
+ });
42
+ } catch (error) {
43
+ if (!timer.signal.aborted) throw error;
44
+ }
45
+ }
46
+ } finally {
47
+ stdin.removeListener('data', onData);
48
+ if (manualAllowed && !wasFlowing) stdin.pause();
49
+ }
50
+ }
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.1",
4
4
  "description": "Visual regression testing from your terminal",
5
5
  "keywords": [
6
6
  "llm-context",
@@ -87,24 +87,24 @@
87
87
  "fix": "pnpm run format && pnpm run lint:fix"
88
88
  },
89
89
  "engines": {
90
- "node": ">=22.0.0"
90
+ "node": "^22.18.0 || >=24.0.0"
91
91
  },
92
92
  "publishConfig": {
93
93
  "access": "public",
94
94
  "registry": "https://registry.npmjs.org/"
95
95
  },
96
96
  "dependencies": {
97
- "@vizzly-testing/bear-den": "0.1.4",
97
+ "@vizzly-testing/bear-den": "0.1.6",
98
98
  "@vizzly-testing/honeydiff": "^0.17.0",
99
- "ansis": "^4.2.0",
99
+ "ansis": "^4.4.0",
100
100
  "commander": "^15.0.0",
101
- "cosmiconfig": "^9.0.0",
102
- "dotenv": "^17.2.1",
103
- "form-data": "^4.0.0",
104
- "glob": "^13.0.0",
105
- "react": "^19.1.1",
106
- "react-dom": "^19.1.1",
107
- "zod": "^4.1.12"
101
+ "cosmiconfig": "^10.0.1",
102
+ "dotenv": "^17.4.2",
103
+ "form-data": "^4.0.6",
104
+ "glob": "^13.0.6",
105
+ "react": "^19.3.0",
106
+ "react-dom": "^19.3.0",
107
+ "zod": "^4.6.5"
108
108
  },
109
109
  "tsd": {
110
110
  "directory": "test-d",
@@ -117,29 +117,29 @@
117
117
  }
118
118
  },
119
119
  "devDependencies": {
120
- "@babel/cli": "^8.0.1",
121
- "@babel/core": "^8.0.1",
122
- "@babel/preset-env": "^8.0.2",
120
+ "@babel/cli": "^8.0.6",
121
+ "@babel/core": "^8.0.6",
122
+ "@babel/preset-env": "^8.0.6",
123
123
  "@babel/preset-react": "^8.0.1",
124
124
  "@babel/preset-typescript": "^8.0.1",
125
- "@biomejs/biome": "^2.5.10",
125
+ "@biomejs/biome": "^2.5.15",
126
126
  "@heroicons/react": "^2.2.0",
127
- "@modelcontextprotocol/sdk": "^1.0.4",
128
- "@playwright/test": "^1.55.1",
129
- "@tailwindcss/postcss": "^4.1.13",
130
- "@tanstack/react-query": "^5.90.11",
131
- "@types/node": "^26.0.0",
132
- "@vitejs/plugin-react": "^6.0.1",
133
- "autoprefixer": "^10.4.21",
127
+ "@modelcontextprotocol/sdk": "^1.31.0",
128
+ "@playwright/test": "^1.63.0",
129
+ "@tailwindcss/postcss": "^4.3.3",
130
+ "@tanstack/react-query": "^5.104.0",
131
+ "@types/node": "^26.6.3",
132
+ "@vitejs/plugin-react": "^6.1.2",
133
+ "autoprefixer": "^10.6.1",
134
134
  "babel-plugin-react-compiler": "^1.0.0",
135
135
  "babel-plugin-transform-remove-console": "^6.9.4",
136
- "postcss": "^8.5.6",
137
- "rimraf": "^6.0.1",
138
- "tailwindcss": "^4.1.13",
136
+ "postcss": "^8.5.29",
137
+ "rimraf": "^6.1.3",
138
+ "tailwindcss": "^4.3.3",
139
139
  "tsd": "^0.33.0",
140
140
  "tui-driver": "github:vizzly-testing/tui-driver",
141
141
  "typescript": "^7.0.2",
142
- "vite": "^8.2.2",
143
- "wouter": "^3.7.1"
142
+ "vite": "^8.3.1",
143
+ "wouter": "^3.11.1"
144
144
  }
145
145
  }
@@ -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