@vizzly-testing/cli 0.38.0 → 0.39.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.
Files changed (40) hide show
  1. package/README.md +222 -183
  2. package/dist/auth/client.js +4 -1
  3. package/dist/auth/core.js +64 -3
  4. package/dist/auth/operations.js +21 -11
  5. package/dist/cli.js +45 -53
  6. package/dist/commands/init.js +156 -483
  7. package/dist/commands/login.js +71 -137
  8. package/dist/commands/project.js +128 -44
  9. package/dist/commands/run.js +2 -2
  10. package/dist/commands/tdd.js +89 -38
  11. package/dist/commands/upload.js +2 -2
  12. package/dist/init/agents.js +99 -0
  13. package/dist/init/core.js +31 -0
  14. package/dist/init/prompts.js +44 -0
  15. package/dist/reporter/reporter-bundle.css +1 -1
  16. package/dist/reporter/reporter-bundle.iife.js +31 -127
  17. package/dist/server/handlers/tdd-handler.js +33 -2
  18. package/dist/server/routers/auth.js +32 -42
  19. package/dist/services/index.js +8 -2
  20. package/dist/tdd/services/comparison-service.js +4 -1
  21. package/dist/tdd/tdd-service.js +14 -2
  22. package/dist/test-runner/core.js +28 -3
  23. package/dist/test-runner/operations.js +41 -20
  24. package/dist/types/index.d.ts +8 -0
  25. package/dist/uploader/core.js +1 -0
  26. package/dist/utils/browser.js +7 -1
  27. package/dist/utils/config-discovery.js +73 -0
  28. package/dist/utils/config-loader.js +29 -8
  29. package/dist/utils/config-schema.js +5 -0
  30. package/dist/utils/file-changes.js +105 -0
  31. package/dist/utils/login-poll-wait.js +50 -0
  32. package/dist/utils/project-binding.js +96 -0
  33. package/dist/utils/project-link-store.js +13 -7
  34. package/package.json +27 -27
  35. package/skills/vizzly/SKILL.md +4 -3
  36. package/skills/vizzly/references/ci.md +33 -0
  37. package/skills/vizzly/references/cli-context.md +81 -71
  38. package/skills/vizzly/references/dynamic-content.md +16 -17
  39. package/skills/vizzly/references/sdks.md +22 -22
  40. package/skills/vizzly/references/setup-ci.md +0 -44
@@ -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
- completeDeviceFlow,
119
- createAuthClient,
120
- createTokenStore,
121
- getApiUrl,
122
- initiateDeviceFlow,
123
- now,
124
- openBrowser,
125
- output,
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('After authorizing, press Enter to continue...');
170
-
171
- // Wait for user to press Enter
172
- await waitForEnter();
173
-
174
- // Check authorization status
175
- output.startSpinner('Checking authorization...');
176
- let pollResponse = await pollDeviceAuthorization(client, deviceCode);
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('Run "vizzly project link <org>/<project>" to enable cloud uploads');
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
  }
@@ -1,9 +1,11 @@
1
1
  import { createApiClient as defaultCreateApiClient } from '../api/client.js';
2
2
  import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
3
3
  import { getApiUrl as defaultGetApiUrl } from '../utils/environment-config.js';
4
+ import { preflightChanges } from '../utils/file-changes.js';
4
5
  import { getAccessToken as defaultGetAccessToken } from '../utils/global-config.js';
5
6
  import * as defaultOutput from '../utils/output.js';
6
- import { saveProjectLink as defaultSaveProjectLink } from '../utils/project-link-store.js';
7
+ import { planProjectBinding as defaultPlanProjectBinding, writeProjectBinding as defaultWriteProjectBinding } from '../utils/project-binding.js';
8
+ import { getProjectLink as defaultGetProjectLink, saveProjectLink as defaultSaveProjectLink } from '../utils/project-link-store.js';
7
9
  export function parseProjectSelector(selector, options = {}) {
8
10
  let organizationSlug = options.org || null;
9
11
  let projectSlug = options.project || null;
@@ -42,7 +44,10 @@ export async function projectLinkCommand(selector, options = {}, globalOptions =
42
44
  getApiUrl = defaultGetApiUrl,
43
45
  loadConfig = defaultLoadConfig,
44
46
  output = defaultOutput,
47
+ getProjectLink = defaultGetProjectLink,
48
+ planProjectBinding = defaultPlanProjectBinding,
45
49
  saveProjectLink = defaultSaveProjectLink,
50
+ writeProjectBinding = defaultWriteProjectBinding,
46
51
  exit = code => process.exit(code)
47
52
  } = deps;
48
53
  output.configure({
@@ -55,66 +60,145 @@ export async function projectLinkCommand(selector, options = {}, globalOptions =
55
60
  projectSlug
56
61
  } = parseProjectSelector(selector, options);
57
62
  try {
58
- let config = await loadConfig(globalOptions.config, globalOptions);
59
- let userToken = config.userToken || globalOptions.token || (await getAccessToken());
60
- if (!userToken) {
63
+ let config;
64
+ let configError;
65
+ try {
66
+ config = await loadConfig(globalOptions.config, globalOptions);
67
+ } catch (error) {
68
+ configError = error;
69
+ }
70
+ let apiUrl = config?.apiUrl || getApiUrl();
71
+ let project = {
72
+ organizationSlug,
73
+ projectSlug
74
+ };
75
+ let binding = await planProjectBinding({
76
+ configPath: globalOptions.config || config?._configPath,
77
+ project,
78
+ apiUrl,
79
+ currentProject: config?.project,
80
+ currentApiUrl: config?._configApiUrl
81
+ });
82
+ if (binding.status === 'manual') {
83
+ let result = {
84
+ linked: false,
85
+ organizationSlug,
86
+ projectSlug,
87
+ binding: {
88
+ status: 'manual',
89
+ configPath: binding.configPath,
90
+ instructions: binding.instructions
91
+ }
92
+ };
93
+ if (globalOptions.json) output.data(result);else {
94
+ output.error('Project configuration needs an explicit edit');
95
+ output.hint(binding.instructions);
96
+ }
97
+ output.cleanup();
98
+ exit(2);
99
+ return result;
100
+ }
101
+ if (configError) throw configError;
102
+ if (binding.after !== null) {
103
+ await preflightChanges([{
104
+ path: binding.configPath,
105
+ before: binding.before,
106
+ after: binding.after
107
+ }]);
108
+ }
109
+ let linkedProject = !options.name && !options.expiresAt ? await getProjectLink({
110
+ apiUrl,
111
+ ...project
112
+ }) : null;
113
+ let reused = Boolean(linkedProject);
114
+ let userToken = linkedProject ? null : config.userToken || globalOptions.token || (await getAccessToken());
115
+ if (!linkedProject && !userToken) {
61
116
  output.error('Login required before linking a project');
62
117
  output.hint('Run "vizzly login" first, then try project link again');
63
118
  output.cleanup();
64
119
  exit(1);
65
- return;
120
+ return {
121
+ linked: false,
122
+ reason: 'auth_required'
123
+ };
66
124
  }
67
- output.startSpinner(`Linking ${organizationSlug}/${projectSlug}...`);
68
- let apiUrl = config.apiUrl || getApiUrl();
69
- let client = createApiClient({
70
- baseUrl: apiUrl,
71
- token: userToken,
72
- command: 'project-link'
73
- });
74
- let response = await client.request(`/api/cli/${projectSlug}/link-token`, {
75
- method: 'POST',
76
- headers: {
77
- 'Content-Type': 'application/json',
78
- 'X-Organization': organizationSlug
79
- },
80
- body: JSON.stringify({
81
- name: options.name,
82
- expiresAt: options.expiresAt
83
- })
84
- });
85
- let linkedProject = await saveProjectLink({
86
- apiUrl,
87
- organizationSlug: response.organization?.slug || organizationSlug,
88
- organizationName: response.organization?.name,
89
- projectSlug: response.project?.slug || projectSlug,
90
- projectName: response.project?.name,
91
- token: response.token.token,
92
- tokenId: response.token.id,
93
- tokenPrefix: response.token.token_prefix,
94
- expiresAt: response.token.expires_at,
95
- createdAt: response.token.created_at
96
- });
125
+ if (!linkedProject) {
126
+ output.startSpinner(`Linking ${organizationSlug}/${projectSlug}...`);
127
+ let client = createApiClient({
128
+ baseUrl: apiUrl,
129
+ token: userToken,
130
+ command: 'project-link'
131
+ });
132
+ let response = await client.request(`/api/cli/${projectSlug}/link-token`, {
133
+ method: 'POST',
134
+ headers: {
135
+ 'Content-Type': 'application/json',
136
+ 'X-Organization': organizationSlug
137
+ },
138
+ body: JSON.stringify({
139
+ name: options.name,
140
+ expiresAt: options.expiresAt
141
+ })
142
+ });
143
+ linkedProject = await saveProjectLink({
144
+ apiUrl,
145
+ organizationSlug: response.organization?.slug || organizationSlug,
146
+ organizationName: response.organization?.name,
147
+ projectSlug: response.project?.slug || projectSlug,
148
+ projectName: response.project?.name,
149
+ token: response.token.token,
150
+ tokenId: response.token.id,
151
+ tokenPrefix: response.token.token_prefix,
152
+ expiresAt: response.token.expires_at,
153
+ createdAt: response.token.created_at
154
+ });
155
+ }
156
+ if (linkedProject.organizationSlug !== organizationSlug || linkedProject.projectSlug !== projectSlug) {
157
+ binding = await planProjectBinding({
158
+ configPath: binding.configPath,
159
+ project: {
160
+ organizationSlug: linkedProject.organizationSlug,
161
+ projectSlug: linkedProject.projectSlug
162
+ },
163
+ apiUrl,
164
+ currentProject: config?.project,
165
+ currentApiUrl: config?._configApiUrl
166
+ });
167
+ if (binding.status === 'manual') throw new Error(binding.instructions);
168
+ }
169
+ await writeProjectBinding(binding);
97
170
  output.stopSpinner();
171
+ let result = {
172
+ linked: true,
173
+ organizationSlug: linkedProject.organizationSlug,
174
+ projectSlug: linkedProject.projectSlug,
175
+ tokenPrefix: linkedProject.tokenPrefix,
176
+ storage: linkedProject.storage,
177
+ reused,
178
+ binding: {
179
+ status: 'bound',
180
+ configPath: binding.configPath
181
+ }
182
+ };
98
183
  if (globalOptions.json) {
99
- output.data({
100
- linked: true,
101
- organizationSlug: linkedProject.organizationSlug,
102
- projectSlug: linkedProject.projectSlug,
103
- tokenPrefix: linkedProject.tokenPrefix,
104
- storage: linkedProject.storage
105
- });
184
+ output.data(result);
106
185
  output.cleanup();
107
- return;
186
+ return result;
108
187
  }
109
188
  output.complete(`Linked ${linkedProject.organizationSlug}/${linkedProject.projectSlug}`);
110
- output.hint(`Active project: ${linkedProject.organizationSlug}/${linkedProject.projectSlug}`);
189
+ output.hint(`Repository project: ${linkedProject.organizationSlug}/${linkedProject.projectSlug}`);
111
190
  output.hint(`Cloud uploads and project-scoped context will use ${linkedProject.tokenPrefix}...`);
112
191
  output.hint(`Credential storage: ${linkedProject.storage}`);
113
192
  output.cleanup();
193
+ return result;
114
194
  } catch (error) {
115
195
  output.stopSpinner();
116
196
  output.error('Failed to link project', error);
117
197
  output.cleanup();
118
198
  exit(1);
199
+ return {
200
+ linked: false,
201
+ reason: 'link_failed'
202
+ };
119
203
  }
120
204
  }
@@ -274,8 +274,8 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
274
274
  commitAuthorName: commitAuthor.name,
275
275
  commitAuthorEmail: commitAuthor.email,
276
276
  environment: config.build.environment,
277
- threshold: config.comparison.threshold,
278
- minClusterSize: config.comparison.minClusterSize,
277
+ threshold: config._comparisonOverrides?.threshold,
278
+ minClusterSize: config._comparisonOverrides?.minClusterSize,
279
279
  eager: config.eager || false,
280
280
  allowNoToken: config.allowNoToken || false,
281
281
  wait: config.wait || options.wait || false,