@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.
- package/README.md +14 -1
- package/dist/api/core.js +6 -0
- package/dist/auth/client.js +4 -1
- package/dist/auth/core.js +64 -3
- package/dist/auth/operations.js +21 -11
- package/dist/cli.js +14 -0
- package/dist/commands/login.js +71 -137
- 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/reporter/reporter-bundle.css +1 -1
- package/dist/reporter/reporter-bundle.iife.js +39 -104
- package/dist/server/routers/auth.js +32 -42
- 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/browser.js +7 -1
- package/dist/utils/ci-env.js +43 -4
- package/dist/utils/git.js +46 -6
- package/dist/utils/login-poll-wait.js +50 -0
- package/package.json +27 -27
- package/skills/vizzly/references/cli-context.md +25 -0
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ build or diff data for scripts, automation, and coding agents.
|
|
|
20
20
|
|
|
21
21
|
## Quick Start
|
|
22
22
|
|
|
23
|
-
You need Node.js 22+.
|
|
23
|
+
You need Node.js 22.18 or newer within Node 22, or Node.js 24+.
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
26
|
pnpm install -g @vizzly-testing/cli
|
|
@@ -43,6 +43,19 @@ existing Vizzly config.
|
|
|
43
43
|
Use `vizzly init --agent-skill` to install or refresh only the local skill, or
|
|
44
44
|
`vizzly init --skip-agent-skill` when you want config without the agent prompt.
|
|
45
45
|
|
|
46
|
+
### TypeScript Configuration
|
|
47
|
+
|
|
48
|
+
Vizzly loads `vizzly.config.ts` with Node's native type stripping. Type
|
|
49
|
+
annotations, interfaces, and `satisfies` work without installing TypeScript.
|
|
50
|
+
Use `import type` for type-only imports and include file extensions when
|
|
51
|
+
importing local helpers, such as `./helpers.ts`.
|
|
52
|
+
|
|
53
|
+
Enums, namespaces containing runtime code, and constructor parameter properties
|
|
54
|
+
aren't supported. Replace them with ordinary JavaScript values and assignments.
|
|
55
|
+
Node doesn't apply `tsconfig.json` path aliases when loading your config.
|
|
56
|
+
These restrictions apply to the config and its imports; they don't change how
|
|
57
|
+
your application or tests compile.
|
|
58
|
+
|
|
46
59
|
### Start Local TDD
|
|
47
60
|
|
|
48
61
|
Start the TDD server, run your tests, and open the dashboard at the URL the
|
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/auth/client.js
CHANGED
|
@@ -56,7 +56,10 @@ export function createAuthClient(options = {}) {
|
|
|
56
56
|
});
|
|
57
57
|
if (!response.ok) {
|
|
58
58
|
let body = await parseResponseBody(response);
|
|
59
|
-
|
|
59
|
+
let error = parseAuthError(response.status, body, endpoint);
|
|
60
|
+
let retryAfter = Number(response.headers?.get('retry-after'));
|
|
61
|
+
if (retryAfter > 0) error.context.retryAfter = retryAfter;
|
|
62
|
+
throw error;
|
|
60
63
|
}
|
|
61
64
|
return response.json();
|
|
62
65
|
}
|
package/dist/auth/core.js
CHANGED
|
@@ -70,13 +70,19 @@ export function parseAuthError(status, body, _endpoint) {
|
|
|
70
70
|
} else if (typeof body === 'string') {
|
|
71
71
|
errorText = body;
|
|
72
72
|
}
|
|
73
|
+
let retryAfter = Number(body?.retryAfter);
|
|
74
|
+
let context = {
|
|
75
|
+
status,
|
|
76
|
+
serverCode: body?.code,
|
|
77
|
+
retryAfter: retryAfter > 0 ? retryAfter : undefined
|
|
78
|
+
};
|
|
73
79
|
if (status === 401) {
|
|
74
|
-
return new AuthError(errorText || 'Invalid credentials. Please check your email/username and password.');
|
|
80
|
+
return new AuthError(errorText || 'Invalid credentials. Please check your email/username and password.', context);
|
|
75
81
|
}
|
|
76
82
|
if (status === 429) {
|
|
77
|
-
return new VizzlyError('Too many login attempts. Please try again later.', 'RATE_LIMIT_ERROR');
|
|
83
|
+
return new VizzlyError('Too many login attempts. Please try again later.', 'RATE_LIMIT_ERROR', context);
|
|
78
84
|
}
|
|
79
|
-
return new VizzlyError(`Authentication request failed: ${status}${errorText ? ` - ${errorText}` : ''}`, 'AUTH_REQUEST_ERROR');
|
|
85
|
+
return new VizzlyError(`Authentication request failed: ${status}${errorText ? ` - ${errorText}` : ''}`, 'AUTH_REQUEST_ERROR', context);
|
|
80
86
|
}
|
|
81
87
|
|
|
82
88
|
/**
|
|
@@ -173,4 +179,59 @@ export function validateTokens(auth, requiredField = 'accessToken') {
|
|
|
173
179
|
valid: true,
|
|
174
180
|
error: null
|
|
175
181
|
};
|
|
182
|
+
}
|
|
183
|
+
export function normalizeDeviceFlowResponse(deviceFlow) {
|
|
184
|
+
let verificationUri = deviceFlow.verification_uri || deviceFlow.verificationUri;
|
|
185
|
+
let userCode = deviceFlow.user_code || deviceFlow.userCode;
|
|
186
|
+
let deviceCode = deviceFlow.device_code || deviceFlow.deviceCode;
|
|
187
|
+
if (!verificationUri || !userCode || !deviceCode) {
|
|
188
|
+
throw new Error('Invalid device flow response from server');
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
verificationUri,
|
|
192
|
+
userCode,
|
|
193
|
+
deviceCode,
|
|
194
|
+
urlWithCode: deviceFlow.verification_uri_complete || deviceFlow.verificationUriComplete || `${verificationUri}?code=${encodeURIComponent(userCode)}`
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
export function resolveAuthorizedTokenData(pollResponse) {
|
|
198
|
+
let accessToken = pollResponse.tokens?.accessToken || pollResponse.tokens?.access_token;
|
|
199
|
+
if (accessToken) {
|
|
200
|
+
return pollResponse;
|
|
201
|
+
}
|
|
202
|
+
if (pollResponse.status === 'pending') {
|
|
203
|
+
throw new Error('Authorization not complete yet. Please complete the authorization in your browser and try running "vizzly login" again.');
|
|
204
|
+
}
|
|
205
|
+
if (pollResponse.status === 'expired') {
|
|
206
|
+
throw new Error('Device code expired. Please try logging in again.');
|
|
207
|
+
}
|
|
208
|
+
if (pollResponse.status === 'invalid') {
|
|
209
|
+
throw new Error('Device code is invalid or expired. Please try logging in again.');
|
|
210
|
+
}
|
|
211
|
+
if (pollResponse.status === 'denied') {
|
|
212
|
+
throw new Error('Authorization denied. Please try logging in again.');
|
|
213
|
+
}
|
|
214
|
+
throw new Error('Unexpected response from authorization server. Please try logging in again.');
|
|
215
|
+
}
|
|
216
|
+
export function buildTokens(tokenData, now = Date.now()) {
|
|
217
|
+
let tokensData = tokenData.tokens || tokenData;
|
|
218
|
+
let tokenExpiresIn = tokensData.expiresIn || tokensData.expires_in;
|
|
219
|
+
let tokenExpiresAt = tokenExpiresIn ? new Date(now + tokenExpiresIn * 1000).toISOString() : tokenData.expires_at || tokenData.expiresAt;
|
|
220
|
+
return {
|
|
221
|
+
accessToken: tokensData.accessToken || tokensData.access_token,
|
|
222
|
+
refreshToken: tokensData.refreshToken || tokensData.refresh_token,
|
|
223
|
+
expiresAt: tokenExpiresAt,
|
|
224
|
+
user: tokenData.user,
|
|
225
|
+
organizations: tokenData.organizations
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Map protocol codes without depending on the server's human-readable text. */
|
|
230
|
+
export function deviceAuthorizationStatus(error) {
|
|
231
|
+
return {
|
|
232
|
+
AUTHORIZATION_PENDING: 'pending',
|
|
233
|
+
ACCESS_DENIED: 'denied',
|
|
234
|
+
EXPIRED_TOKEN: 'expired',
|
|
235
|
+
INVALID_DEVICE_CODE: 'invalid'
|
|
236
|
+
}[error.context?.serverCode];
|
|
176
237
|
}
|
package/dist/auth/operations.js
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* This makes them trivially testable without mocking modules.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import
|
|
11
|
+
import * as output from '../utils/output.js';
|
|
12
|
+
import { buildDevicePollPayload, buildLogoutPayload, buildRefreshPayload, buildTokenData, deviceAuthorizationStatus, validateTokens } from './core.js';
|
|
12
13
|
|
|
13
14
|
// ============================================================================
|
|
14
15
|
// Device Flow Operations
|
|
@@ -34,14 +35,23 @@ export async function initiateDeviceFlow(httpClient) {
|
|
|
34
35
|
* @param {string} deviceCode - Device code from initiate
|
|
35
36
|
* @returns {Promise<Object>} Token data or pending status
|
|
36
37
|
*/
|
|
37
|
-
export async function pollDeviceAuthorization(httpClient, deviceCode) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
38
|
+
export async function pollDeviceAuthorization(httpClient, deviceCode, options = {}) {
|
|
39
|
+
try {
|
|
40
|
+
return await httpClient.request('/api/auth/cli/device/poll', {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: {
|
|
43
|
+
'Content-Type': 'application/json'
|
|
44
|
+
},
|
|
45
|
+
body: JSON.stringify(buildDevicePollPayload(deviceCode)),
|
|
46
|
+
signal: options.signal
|
|
47
|
+
});
|
|
48
|
+
} catch (error) {
|
|
49
|
+
let status = deviceAuthorizationStatus(error);
|
|
50
|
+
if (status) return {
|
|
51
|
+
status
|
|
52
|
+
};
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
45
55
|
}
|
|
46
56
|
|
|
47
57
|
/**
|
|
@@ -98,7 +108,7 @@ export async function logout(httpClient, tokenStore) {
|
|
|
98
108
|
let auth = await tokenStore.getTokens();
|
|
99
109
|
if (auth?.refreshToken) {
|
|
100
110
|
try {
|
|
101
|
-
await httpClient.
|
|
111
|
+
await httpClient.authenticatedRequest('/api/auth/cli/logout', auth.accessToken, {
|
|
102
112
|
method: 'POST',
|
|
103
113
|
headers: {
|
|
104
114
|
'Content-Type': 'application/json'
|
|
@@ -107,7 +117,7 @@ export async function logout(httpClient, tokenStore) {
|
|
|
107
117
|
});
|
|
108
118
|
} catch (error) {
|
|
109
119
|
// If server request fails, still clear local tokens
|
|
110
|
-
|
|
120
|
+
output.warn(`Failed to revoke tokens on server: ${error.message}`);
|
|
111
121
|
}
|
|
112
122
|
}
|
|
113
123
|
await tokenStore.clearTokens();
|
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/login.js
CHANGED
|
@@ -3,110 +3,13 @@
|
|
|
3
3
|
* Authenticates user via OAuth device flow
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
7
|
+
import { buildTokens, normalizeDeviceFlowResponse, resolveAuthorizedTokenData } from '../auth/core.js';
|
|
6
8
|
import { completeDeviceFlow, createAuthClient, createTokenStore, initiateDeviceFlow, pollDeviceAuthorization } from '../auth/index.js';
|
|
7
9
|
import { openBrowser } from '../utils/browser.js';
|
|
8
10
|
import { getApiUrl } from '../utils/environment-config.js';
|
|
11
|
+
import { waitForLoginPoll } from '../utils/login-poll-wait.js';
|
|
9
12
|
import * as output from '../utils/output.js';
|
|
10
|
-
function createLoginDeps(deps = {}) {
|
|
11
|
-
return {
|
|
12
|
-
completeDeviceFlow: deps.completeDeviceFlow || completeDeviceFlow,
|
|
13
|
-
createAuthClient: deps.createAuthClient || createAuthClient,
|
|
14
|
-
createTokenStore: deps.createTokenStore || createTokenStore,
|
|
15
|
-
getApiUrl: deps.getApiUrl || getApiUrl,
|
|
16
|
-
initiateDeviceFlow: deps.initiateDeviceFlow || initiateDeviceFlow,
|
|
17
|
-
now: deps.now || (() => Date.now()),
|
|
18
|
-
openBrowser: deps.openBrowser || openBrowser,
|
|
19
|
-
output: deps.output || output,
|
|
20
|
-
pollDeviceAuthorization: deps.pollDeviceAuthorization || pollDeviceAuthorization,
|
|
21
|
-
waitForEnter: deps.waitForEnter || waitForEnter,
|
|
22
|
-
exit: deps.exit || (code => process.exit(code))
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
function configureOutput(output, globalOptions) {
|
|
26
|
-
output.configure({
|
|
27
|
-
json: globalOptions.json,
|
|
28
|
-
verbose: globalOptions.verbose,
|
|
29
|
-
color: !globalOptions.noColor
|
|
30
|
-
});
|
|
31
|
-
}
|
|
32
|
-
export function normalizeDeviceFlowResponse(deviceFlow) {
|
|
33
|
-
let verificationUri = deviceFlow.verification_uri || deviceFlow.verificationUri;
|
|
34
|
-
let userCode = deviceFlow.user_code || deviceFlow.userCode;
|
|
35
|
-
let deviceCode = deviceFlow.device_code || deviceFlow.deviceCode;
|
|
36
|
-
if (!verificationUri || !userCode || !deviceCode) {
|
|
37
|
-
throw new Error('Invalid device flow response from server');
|
|
38
|
-
}
|
|
39
|
-
return {
|
|
40
|
-
verificationUri,
|
|
41
|
-
userCode,
|
|
42
|
-
deviceCode,
|
|
43
|
-
urlWithCode: `${verificationUri}?code=${encodeURIComponent(userCode)}`
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
|
-
export function resolveAuthorizedTokenData(pollResponse) {
|
|
47
|
-
let accessToken = pollResponse.tokens?.accessToken || pollResponse.tokens?.access_token;
|
|
48
|
-
if (accessToken) {
|
|
49
|
-
return pollResponse;
|
|
50
|
-
}
|
|
51
|
-
if (pollResponse.status === 'pending') {
|
|
52
|
-
throw new Error('Authorization not complete yet. Please complete the authorization in your browser and try running "vizzly login" again.');
|
|
53
|
-
}
|
|
54
|
-
if (pollResponse.status === 'expired') {
|
|
55
|
-
throw new Error('Device code expired. Please try logging in again.');
|
|
56
|
-
}
|
|
57
|
-
if (pollResponse.status === 'denied') {
|
|
58
|
-
throw new Error('Authorization denied. Please try logging in again.');
|
|
59
|
-
}
|
|
60
|
-
throw new Error('Unexpected response from authorization server. Please try logging in again.');
|
|
61
|
-
}
|
|
62
|
-
export function buildTokens(tokenData, now = Date.now()) {
|
|
63
|
-
let tokensData = tokenData.tokens || tokenData;
|
|
64
|
-
let tokenExpiresIn = tokensData.expiresIn || tokensData.expires_in;
|
|
65
|
-
let tokenExpiresAt = tokenExpiresIn ? new Date(now + tokenExpiresIn * 1000).toISOString() : tokenData.expires_at || tokenData.expiresAt;
|
|
66
|
-
return {
|
|
67
|
-
accessToken: tokensData.accessToken || tokensData.access_token,
|
|
68
|
-
refreshToken: tokensData.refreshToken || tokensData.refresh_token,
|
|
69
|
-
expiresAt: tokenExpiresAt,
|
|
70
|
-
user: tokenData.user,
|
|
71
|
-
organizations: tokenData.organizations
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
export function getTokenExpiryHint(expiresAt, now = Date.now()) {
|
|
75
|
-
if (!expiresAt) {
|
|
76
|
-
return null;
|
|
77
|
-
}
|
|
78
|
-
let expiry = new Date(expiresAt);
|
|
79
|
-
let msUntilExpiry = expiry.getTime() - now;
|
|
80
|
-
let daysUntilExpiry = Math.floor(msUntilExpiry / (1000 * 60 * 60 * 24));
|
|
81
|
-
let hoursUntilExpiry = Math.floor(msUntilExpiry / (1000 * 60 * 60));
|
|
82
|
-
let minutesUntilExpiry = Math.floor(msUntilExpiry / (1000 * 60));
|
|
83
|
-
if (daysUntilExpiry > 0) {
|
|
84
|
-
return `Token expires in ${daysUntilExpiry} day${daysUntilExpiry !== 1 ? 's' : ''}`;
|
|
85
|
-
}
|
|
86
|
-
if (hoursUntilExpiry > 0) {
|
|
87
|
-
return `Token expires in ${hoursUntilExpiry} hour${hoursUntilExpiry !== 1 ? 's' : ''}`;
|
|
88
|
-
}
|
|
89
|
-
if (minutesUntilExpiry > 0) {
|
|
90
|
-
return `Token expires in ${minutesUntilExpiry} minute${minutesUntilExpiry !== 1 ? 's' : ''}`;
|
|
91
|
-
}
|
|
92
|
-
return null;
|
|
93
|
-
}
|
|
94
|
-
export async function waitForEnter(stdin = process.stdin) {
|
|
95
|
-
await new Promise(resolve => {
|
|
96
|
-
let canSetRawMode = typeof stdin.setRawMode === 'function';
|
|
97
|
-
if (canSetRawMode) {
|
|
98
|
-
stdin.setRawMode(true);
|
|
99
|
-
}
|
|
100
|
-
stdin.resume();
|
|
101
|
-
stdin.once('data', () => {
|
|
102
|
-
if (canSetRawMode) {
|
|
103
|
-
stdin.setRawMode(false);
|
|
104
|
-
}
|
|
105
|
-
stdin.pause();
|
|
106
|
-
resolve();
|
|
107
|
-
});
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
13
|
|
|
111
14
|
/**
|
|
112
15
|
* Login command implementation using OAuth device flow
|
|
@@ -115,19 +18,14 @@ export async function waitForEnter(stdin = process.stdin) {
|
|
|
115
18
|
*/
|
|
116
19
|
export async function loginCommand(options = {}, globalOptions = {}, deps = {}) {
|
|
117
20
|
let {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
pollDeviceAuthorization,
|
|
127
|
-
waitForEnter,
|
|
128
|
-
exit
|
|
129
|
-
} = createLoginDeps(deps);
|
|
130
|
-
configureOutput(output, globalOptions);
|
|
21
|
+
now = Date.now,
|
|
22
|
+
delay: sleep = delay
|
|
23
|
+
} = deps;
|
|
24
|
+
output.configure({
|
|
25
|
+
json: globalOptions.json,
|
|
26
|
+
verbose: globalOptions.verbose,
|
|
27
|
+
color: !globalOptions.noColor
|
|
28
|
+
});
|
|
131
29
|
let colors = output.getColors();
|
|
132
30
|
try {
|
|
133
31
|
output.header('login');
|
|
@@ -142,6 +40,12 @@ export async function loginCommand(options = {}, globalOptions = {}, deps = {})
|
|
|
142
40
|
output.startSpinner('Connecting to Vizzly...');
|
|
143
41
|
let deviceFlow = await initiateDeviceFlow(client);
|
|
144
42
|
output.stopSpinner();
|
|
43
|
+
let expiresIn = Number(deviceFlow.expires_in ?? deviceFlow.expiresIn);
|
|
44
|
+
let lifetime = Number.isFinite(expiresIn) && expiresIn > 0 ? Math.min(expiresIn, 120) : 120;
|
|
45
|
+
let deadline = now() + lifetime * 1000;
|
|
46
|
+
let timeoutMessage = expiresIn > 0 && expiresIn <= 120 ? 'Device code expired. Please try logging in again.' : 'Login timed out after 2 minutes. Please try logging in again.';
|
|
47
|
+
let interval = Number(deviceFlow.interval);
|
|
48
|
+
let pollDelay = Number.isFinite(interval) && interval > 0 ? interval * 1000 : 5000;
|
|
145
49
|
|
|
146
50
|
// Handle both snake_case and camelCase field names
|
|
147
51
|
let {
|
|
@@ -166,14 +70,57 @@ export async function loginCommand(options = {}, globalOptions = {}, deps = {})
|
|
|
166
70
|
output.hint('Please open the URL manually');
|
|
167
71
|
}
|
|
168
72
|
output.blank();
|
|
169
|
-
output.hint('
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
73
|
+
output.hint('Waiting for authorization in your browser (up to 2 minutes)...');
|
|
74
|
+
let interactive = process.stdin.isTTY && !globalOptions.json;
|
|
75
|
+
let backoff = false;
|
|
76
|
+
let lastCheckAt = 0;
|
|
77
|
+
let pollResponse;
|
|
78
|
+
while (true) {
|
|
79
|
+
let remaining = deadline - now();
|
|
80
|
+
if (remaining <= 0) {
|
|
81
|
+
throw new Error(timeoutMessage);
|
|
82
|
+
}
|
|
83
|
+
await waitForLoginPoll({
|
|
84
|
+
milliseconds: Math.min(pollDelay, remaining),
|
|
85
|
+
manualAllowed: !backoff,
|
|
86
|
+
earliestCheckAt: lastCheckAt + 1000,
|
|
87
|
+
stdin: interactive ? process.stdin : {
|
|
88
|
+
isTTY: false
|
|
89
|
+
},
|
|
90
|
+
now,
|
|
91
|
+
sleep,
|
|
92
|
+
onTick: (milliseconds, canCheck) => {
|
|
93
|
+
let seconds = Math.ceil(milliseconds / 1000);
|
|
94
|
+
let message = backoff ? `Rate limited. Next check in ${seconds}s` : `Next check in ${seconds}s`;
|
|
95
|
+
if (canCheck) message += ' — press Enter to check now';
|
|
96
|
+
if (interactive) output.updateSpinner(message);else output.hint(message);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
remaining = deadline - now();
|
|
100
|
+
if (remaining <= 0) {
|
|
101
|
+
throw new Error(timeoutMessage);
|
|
102
|
+
}
|
|
103
|
+
lastCheckAt = now();
|
|
104
|
+
output.updateSpinner('Checking authorization...');
|
|
105
|
+
try {
|
|
106
|
+
pollResponse = await pollDeviceAuthorization(client, deviceCode, {
|
|
107
|
+
signal: AbortSignal.timeout(Math.ceil(Math.min(remaining, 30000)))
|
|
108
|
+
});
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (now() >= deadline) {
|
|
111
|
+
throw new Error(timeoutMessage);
|
|
112
|
+
}
|
|
113
|
+
if (error.code !== 'RATE_LIMIT_ERROR') throw error;
|
|
114
|
+
backoff = true;
|
|
115
|
+
let retryAfter = Number(error.context?.retryAfter);
|
|
116
|
+
if (Number.isFinite(retryAfter) && retryAfter > 0) {
|
|
117
|
+
pollDelay = Math.max(pollDelay, retryAfter * 1000);
|
|
118
|
+
}
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
backoff = false;
|
|
122
|
+
if (pollResponse.status !== 'pending') break;
|
|
123
|
+
}
|
|
177
124
|
output.stopSpinner();
|
|
178
125
|
let tokenData = resolveAuthorizedTokenData(pollResponse);
|
|
179
126
|
|
|
@@ -201,17 +148,8 @@ export async function loginCommand(options = {}, globalOptions = {}, deps = {})
|
|
|
201
148
|
let orgItems = tokens.organizations.map(org => `${org.name}${org.slug ? ` (@${org.slug})` : ''}`);
|
|
202
149
|
output.list(orgItems);
|
|
203
150
|
}
|
|
204
|
-
|
|
205
|
-
// Show token expiry info
|
|
206
|
-
if (tokens.expiresAt) {
|
|
207
|
-
output.blank();
|
|
208
|
-
let expiryHint = getTokenExpiryHint(tokens.expiresAt, now());
|
|
209
|
-
if (expiryHint) {
|
|
210
|
-
output.hint(expiryHint);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
151
|
output.blank();
|
|
214
|
-
output.hint('
|
|
152
|
+
output.hint('You can now browse cloud builds and download baselines');
|
|
215
153
|
output.cleanup();
|
|
216
154
|
} catch (error) {
|
|
217
155
|
output.stopSpinner();
|
|
@@ -222,18 +160,14 @@ export async function loginCommand(options = {}, globalOptions = {}, deps = {})
|
|
|
222
160
|
output.blank();
|
|
223
161
|
output.hint('Please try logging in again');
|
|
224
162
|
output.hint("If you don't have an account, sign up at https://vizzly.dev");
|
|
225
|
-
output.cleanup();
|
|
226
|
-
exit(1);
|
|
227
163
|
} else if (error.code === 'RATE_LIMIT_ERROR') {
|
|
228
164
|
output.error('Too many login attempts', error);
|
|
229
165
|
output.blank();
|
|
230
166
|
output.hint('Please wait a few minutes before trying again');
|
|
231
|
-
output.cleanup();
|
|
232
|
-
exit(1);
|
|
233
167
|
} else {
|
|
234
168
|
output.error('Login failed', error);
|
|
235
|
-
output.cleanup();
|
|
236
|
-
exit(1);
|
|
237
169
|
}
|
|
170
|
+
output.cleanup();
|
|
171
|
+
process.exit(1);
|
|
238
172
|
}
|
|
239
173
|
}
|
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,
|