@vizzly-testing/cli 0.38.1 → 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.
- package/README.md +221 -195
- package/dist/cli.js +45 -53
- package/dist/commands/init.js +156 -483
- package/dist/commands/project.js +128 -44
- package/dist/commands/run.js +2 -2
- package/dist/commands/tdd.js +89 -38
- package/dist/commands/upload.js +2 -2
- package/dist/init/agents.js +99 -0
- package/dist/init/core.js +31 -0
- package/dist/init/prompts.js +44 -0
- package/dist/reporter/reporter-bundle.css +1 -1
- package/dist/reporter/reporter-bundle.iife.js +30 -61
- package/dist/server/handlers/tdd-handler.js +33 -2
- package/dist/services/index.js +8 -2
- package/dist/tdd/services/comparison-service.js +4 -1
- package/dist/tdd/tdd-service.js +14 -2
- package/dist/test-runner/core.js +28 -3
- package/dist/test-runner/operations.js +41 -20
- package/dist/types/index.d.ts +8 -0
- package/dist/uploader/core.js +1 -0
- package/dist/utils/config-discovery.js +73 -0
- package/dist/utils/config-loader.js +29 -8
- package/dist/utils/config-schema.js +5 -0
- package/dist/utils/file-changes.js +105 -0
- package/dist/utils/project-binding.js +96 -0
- package/dist/utils/project-link-store.js +13 -7
- package/package.json +3 -3
- package/skills/vizzly/SKILL.md +4 -3
- package/skills/vizzly/references/ci.md +33 -0
- package/skills/vizzly/references/cli-context.md +81 -71
- package/skills/vizzly/references/dynamic-content.md +16 -17
- package/skills/vizzly/references/sdks.md +22 -22
- package/skills/vizzly/references/setup-ci.md +0 -44
package/dist/commands/project.js
CHANGED
|
@@ -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 {
|
|
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
|
|
59
|
-
let
|
|
60
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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(`
|
|
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
|
}
|
package/dist/commands/run.js
CHANGED
|
@@ -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.
|
|
278
|
-
minClusterSize: config.
|
|
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,
|
package/dist/commands/tdd.js
CHANGED
|
@@ -12,7 +12,10 @@ import { createAuthService as defaultCreateAuthService } from '../services/auth-
|
|
|
12
12
|
import { createBuildObject as defaultCreateBuildObject } from '../services/build-manager.js';
|
|
13
13
|
import { createConfigService as defaultCreateConfigService } from '../services/config-service.js';
|
|
14
14
|
import { createProjectService as defaultCreateProjectService } from '../services/project-service.js';
|
|
15
|
+
import { generateStaticReport as defaultGenerateStaticReport, getReportFileUrl } from '../services/static-report-generator.js';
|
|
16
|
+
import { getServerRegistry } from '../tdd/server-registry.js';
|
|
15
17
|
import { initializeDaemon as defaultInitializeDaemon, runTests as defaultRunTests } from '../test-runner/index.js';
|
|
18
|
+
import { openBrowser as defaultOpenBrowser } from '../utils/browser.js';
|
|
16
19
|
import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
|
|
17
20
|
import { detectBranch as defaultDetectBranch, detectCommit as defaultDetectCommit, detectCommitAuthor as defaultDetectCommitAuthor, detectCommitMessage as defaultDetectCommitMessage, detectPullRequestNumber as defaultDetectPullRequestNumber, generateBuildNameWithGit as defaultGenerateBuildNameWithGit } from '../utils/git.js';
|
|
18
21
|
import * as defaultOutput from '../utils/output.js';
|
|
@@ -63,7 +66,9 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {},
|
|
|
63
66
|
detectPullRequestNumber = defaultDetectPullRequestNumber,
|
|
64
67
|
generateBuildNameWithGit = defaultGenerateBuildNameWithGit,
|
|
65
68
|
spawn = defaultSpawn,
|
|
66
|
-
output = defaultOutput
|
|
69
|
+
output = defaultOutput,
|
|
70
|
+
generateStaticReport = defaultGenerateStaticReport,
|
|
71
|
+
openBrowser = defaultOpenBrowser
|
|
67
72
|
} = deps;
|
|
68
73
|
output.configure({
|
|
69
74
|
json: globalOptions.json,
|
|
@@ -93,6 +98,9 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {},
|
|
|
93
98
|
...options
|
|
94
99
|
};
|
|
95
100
|
let config = await loadConfig(globalOptions.config, allOptions);
|
|
101
|
+
if (options.autoPort) {
|
|
102
|
+
config.server.port = await getServerRegistry().findAvailablePort(config.server.port);
|
|
103
|
+
}
|
|
96
104
|
|
|
97
105
|
// Dev mode works locally by default - only needs token for baseline download
|
|
98
106
|
let needsToken = options.baselineBuild || options.baselineComparison || config.baselineBuildId || config.baselineComparisonId;
|
|
@@ -207,38 +215,57 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {},
|
|
|
207
215
|
|
|
208
216
|
// Normal dev mode - run tests
|
|
209
217
|
output.debug('run', testCommand);
|
|
210
|
-
let
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
218
|
+
let runError = null;
|
|
219
|
+
let runResult;
|
|
220
|
+
try {
|
|
221
|
+
runResult = await runTests({
|
|
222
|
+
runOptions,
|
|
223
|
+
config: configWithVerbose,
|
|
224
|
+
deps: {
|
|
225
|
+
serverManager,
|
|
226
|
+
buildManager,
|
|
227
|
+
spawn: (command, spawnOptions) => {
|
|
228
|
+
let proc = spawn(command, spawnOptions);
|
|
229
|
+
testProcess = proc;
|
|
230
|
+
return proc;
|
|
231
|
+
},
|
|
232
|
+
createApiClient,
|
|
233
|
+
createApiBuild,
|
|
234
|
+
getBuild,
|
|
235
|
+
finalizeApiBuild,
|
|
236
|
+
createError: (msg, code) => new VizzlyError(msg, code),
|
|
237
|
+
output,
|
|
238
|
+
onBuildCreated: data => {
|
|
239
|
+
output.debug('build', `created ${data.buildId?.substring(0, 8)}`);
|
|
240
|
+
},
|
|
241
|
+
onServerReady: data => {
|
|
242
|
+
output.debug('server', `ready on :${data.port}`);
|
|
243
|
+
},
|
|
244
|
+
onFinalizeFailed: data => {
|
|
245
|
+
output.warn(`Failed to finalize build: ${data.error}`);
|
|
246
|
+
}
|
|
235
247
|
}
|
|
248
|
+
});
|
|
249
|
+
} catch (error) {
|
|
250
|
+
if (!error.runResult) throw error;
|
|
251
|
+
runResult = error.runResult;
|
|
252
|
+
runError = error;
|
|
253
|
+
}
|
|
254
|
+
let reportPath = null;
|
|
255
|
+
let reportError = null;
|
|
256
|
+
if (runResult.screenshotsCaptured > 0 || runResult.comparisons?.length > 0) {
|
|
257
|
+
let report = await generateStaticReport(process.cwd());
|
|
258
|
+
if (report.success) {
|
|
259
|
+
reportPath = report.reportPath;
|
|
260
|
+
} else {
|
|
261
|
+
reportError = report.error;
|
|
262
|
+
output.warn(`Failed to generate static report: ${report.error}`);
|
|
236
263
|
}
|
|
237
|
-
}
|
|
264
|
+
}
|
|
238
265
|
|
|
239
266
|
// Determine success based on comparison results
|
|
240
267
|
// (Summary is printed by printResults() in tdd-service.js, called from getTddResults)
|
|
241
|
-
let hasFailures = runResult.failed || runResult.comparisons?.some(c => c.status
|
|
268
|
+
let hasFailures = runResult.failed || runResult.testsFailed > 0 || runResult.capture?.setup === 'disabled' || runResult.capture?.rejected > 0 || runResult.capture?.errors > 0 || Boolean(reportError) || runResult.comparisons?.some(c => ['failed', 'error'].includes(c.status));
|
|
242
269
|
let exitCode = hasFailures ? 1 : 0;
|
|
243
270
|
|
|
244
271
|
// JSON output mode
|
|
@@ -251,9 +278,9 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {},
|
|
|
251
278
|
diffPercentage: c.diffPercentage ?? c.diff_percentage ?? null,
|
|
252
279
|
threshold: c.threshold ?? config.comparison.threshold,
|
|
253
280
|
paths: {
|
|
254
|
-
baseline: c.baselinePath || c.baseline_path || null,
|
|
255
|
-
current: c.currentPath || c.current_path || null,
|
|
256
|
-
diff: c.diffPath || c.diff_path || null
|
|
281
|
+
baseline: c.baseline || c.baselinePath || c.baseline_path || null,
|
|
282
|
+
current: c.current || c.currentPath || c.current_path || null,
|
|
283
|
+
diff: c.diff || c.diffPath || c.diff_path || null
|
|
257
284
|
},
|
|
258
285
|
viewport: c.viewport || {
|
|
259
286
|
width: c.viewportWidth || c.viewport_width,
|
|
@@ -267,29 +294,50 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {},
|
|
|
267
294
|
total: comparisons.length,
|
|
268
295
|
passed: comparisons.filter(c => c.status === 'passed').length,
|
|
269
296
|
failed: comparisons.filter(c => c.status === 'failed').length,
|
|
270
|
-
new: comparisons.filter(c => c.status === 'new').length
|
|
297
|
+
new: comparisons.filter(c => c.status === 'new').length,
|
|
298
|
+
errors: comparisons.filter(c => c.status === 'error').length
|
|
271
299
|
};
|
|
272
300
|
output.data({
|
|
273
301
|
status: hasFailures ? 'failed' : 'completed',
|
|
274
302
|
exitCode,
|
|
275
303
|
comparisons,
|
|
276
304
|
summary,
|
|
277
|
-
|
|
305
|
+
capture: runResult.capture,
|
|
306
|
+
reportPath,
|
|
307
|
+
reportError,
|
|
308
|
+
...(runError ? {
|
|
309
|
+
error: {
|
|
310
|
+
message: runError.message
|
|
311
|
+
}
|
|
312
|
+
} : {}),
|
|
278
313
|
contextCommand: buildLocalContextCommand({
|
|
279
314
|
structured: true
|
|
280
315
|
})
|
|
281
316
|
});
|
|
282
317
|
output.cleanup();
|
|
283
318
|
}
|
|
284
|
-
if (!globalOptions.json
|
|
319
|
+
if (!globalOptions.json) {
|
|
320
|
+
if (reportPath) output.hint(`Report: ${getReportFileUrl(reportPath)}`);
|
|
285
321
|
output.blank?.();
|
|
286
322
|
output.print(` Context ${buildLocalContextCommand()}`);
|
|
287
323
|
}
|
|
324
|
+
if (reportPath && options.open !== false) {
|
|
325
|
+
try {
|
|
326
|
+
await openBrowser(getReportFileUrl(reportPath));
|
|
327
|
+
} catch (error) {
|
|
328
|
+
output.warn(`Could not open the report: ${error.message}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
288
331
|
return {
|
|
289
332
|
result: {
|
|
333
|
+
...runResult,
|
|
290
334
|
success: !hasFailures,
|
|
291
335
|
exitCode,
|
|
292
|
-
|
|
336
|
+
reportPath,
|
|
337
|
+
reportError,
|
|
338
|
+
...(runError ? {
|
|
339
|
+
error: runError.message
|
|
340
|
+
} : {})
|
|
293
341
|
},
|
|
294
342
|
cleanup
|
|
295
343
|
};
|
|
@@ -308,8 +356,10 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {},
|
|
|
308
356
|
total: 0,
|
|
309
357
|
passed: 0,
|
|
310
358
|
failed: 0,
|
|
311
|
-
new: 0
|
|
312
|
-
|
|
359
|
+
new: 0,
|
|
360
|
+
errors: 0
|
|
361
|
+
},
|
|
362
|
+
reportPath: null
|
|
313
363
|
});
|
|
314
364
|
output.cleanup();
|
|
315
365
|
} else {
|
|
@@ -319,7 +369,8 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {},
|
|
|
319
369
|
result: {
|
|
320
370
|
success: false,
|
|
321
371
|
exitCode: 1,
|
|
322
|
-
error: error.message
|
|
372
|
+
error: error.message,
|
|
373
|
+
reportPath: null
|
|
323
374
|
},
|
|
324
375
|
cleanup
|
|
325
376
|
};
|
package/dist/commands/upload.js
CHANGED
|
@@ -132,8 +132,8 @@ export async function uploadCommand(screenshotsPath, options = {}, globalOptions
|
|
|
132
132
|
commitAuthorName: commitAuthor.name,
|
|
133
133
|
commitAuthorEmail: commitAuthor.email,
|
|
134
134
|
environment: config.build.environment,
|
|
135
|
-
threshold: config.
|
|
136
|
-
minClusterSize: config.
|
|
135
|
+
threshold: config._comparisonOverrides?.threshold,
|
|
136
|
+
minClusterSize: config._comparisonOverrides?.minClusterSize,
|
|
137
137
|
uploadAll: options.uploadAll || false,
|
|
138
138
|
metadata: options.metadata ? JSON.parse(options.metadata) : {},
|
|
139
139
|
pullRequestNumber,
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { readOptionalFile } from '../utils/config-discovery.js';
|
|
5
|
+
import { fileChange } from '../utils/file-changes.js';
|
|
6
|
+
let sourcePath = fileURLToPath(new URL('../../skills/vizzly/', import.meta.url));
|
|
7
|
+
let start = '<!-- vizzly-agent-guidance -->';
|
|
8
|
+
let end = '<!-- /vizzly-agent-guidance -->';
|
|
9
|
+
function guidanceContent() {
|
|
10
|
+
return `${start}
|
|
11
|
+
## Visual testing with Vizzly
|
|
12
|
+
|
|
13
|
+
For user-facing changes, use the repo-local Vizzly skill at
|
|
14
|
+
\`.agents/skills/vizzly/SKILL.md\`.
|
|
15
|
+
|
|
16
|
+
- Check existing screenshots and diffs before and after the change.
|
|
17
|
+
- Use the repository's existing visual test command.
|
|
18
|
+
- For cloud reviews, discover supported requests with
|
|
19
|
+
\`vizzly api schema --json\` and inspect baseline, current, and diff images
|
|
20
|
+
together.
|
|
21
|
+
- For local results, use \`vizzly context ... --json\`.
|
|
22
|
+
|
|
23
|
+
Don't approve or reject changes, or replace baselines, unless the task asks
|
|
24
|
+
for it.
|
|
25
|
+
${end}
|
|
26
|
+
`;
|
|
27
|
+
}
|
|
28
|
+
function addGuidance(source) {
|
|
29
|
+
let content = source || '';
|
|
30
|
+
let starts = content.split(start).length - 1;
|
|
31
|
+
let ends = content.split(end).length - 1;
|
|
32
|
+
let begin = content.indexOf(start);
|
|
33
|
+
let finish = content.indexOf(end);
|
|
34
|
+
if (starts !== ends || starts > 1 || starts && finish < begin) {
|
|
35
|
+
throw new Error('Repair the incomplete or duplicate Vizzly guidance markers in AGENTS.md before running init.');
|
|
36
|
+
}
|
|
37
|
+
let block = guidanceContent();
|
|
38
|
+
if (starts) return content;
|
|
39
|
+
return content.trim() ? `${content.trimEnd()}\n\n${block}` : block;
|
|
40
|
+
}
|
|
41
|
+
async function skillFiles(directory, prefix = '') {
|
|
42
|
+
let files = [];
|
|
43
|
+
for (let entry of await readdir(directory, {
|
|
44
|
+
withFileTypes: true
|
|
45
|
+
})) {
|
|
46
|
+
let relative = path.join(prefix, entry.name);
|
|
47
|
+
if (entry.isDirectory()) {
|
|
48
|
+
files.push(...(await skillFiles(path.join(directory, entry.name), relative)));
|
|
49
|
+
} else if (entry.isFile()) {
|
|
50
|
+
files.push(relative);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return files;
|
|
54
|
+
}
|
|
55
|
+
export async function planAgentSetup(cwd, {
|
|
56
|
+
skill,
|
|
57
|
+
guidance
|
|
58
|
+
}) {
|
|
59
|
+
if (!skill && !guidance) return {
|
|
60
|
+
changes: []
|
|
61
|
+
};
|
|
62
|
+
let targetPath = path.join(cwd, '.agents', 'skills', 'vizzly');
|
|
63
|
+
let existing = await readOptionalFile(path.join(targetPath, 'SKILL.md'));
|
|
64
|
+
let changes = [];
|
|
65
|
+
if (!existing) {
|
|
66
|
+
for (let filename of await skillFiles(sourcePath)) {
|
|
67
|
+
changes.push(await fileChange(path.join(targetPath, filename), await readOptionalFile(path.join(sourcePath, filename))));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
let agentSkill = {
|
|
71
|
+
status: existing ? 'exists' : 'installed',
|
|
72
|
+
sourcePath,
|
|
73
|
+
targetPath
|
|
74
|
+
};
|
|
75
|
+
let agentGuidance;
|
|
76
|
+
if (guidance) {
|
|
77
|
+
let agentsPath = path.join(cwd, 'AGENTS.md');
|
|
78
|
+
let before = await readOptionalFile(agentsPath);
|
|
79
|
+
let after = addGuidance(before);
|
|
80
|
+
changes.push(before === after ? null : {
|
|
81
|
+
path: agentsPath,
|
|
82
|
+
before,
|
|
83
|
+
after
|
|
84
|
+
});
|
|
85
|
+
let status = 'exists';
|
|
86
|
+
if (before !== after) {
|
|
87
|
+
status = before === null ? 'created' : 'updated';
|
|
88
|
+
}
|
|
89
|
+
agentGuidance = {
|
|
90
|
+
status,
|
|
91
|
+
agentsPath
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
changes: changes.filter(Boolean),
|
|
96
|
+
agentSkill,
|
|
97
|
+
agentGuidance
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
function createConfigContent(format) {
|
|
2
|
+
if (format === 'json' || format === 'yaml') return '{}\n';
|
|
3
|
+
if (!['javascript', 'typescript'].includes(format)) {
|
|
4
|
+
throw new Error(`Cannot generate ${format} config. Use a .js, .ts or .json file.`);
|
|
5
|
+
}
|
|
6
|
+
return `// Vizzly supplies defaults. Uncomment only settings you want to change.
|
|
7
|
+
// Configuration: https://docs.vizzly.dev/integration/cli/
|
|
8
|
+
export default {
|
|
9
|
+
// comparison: { threshold: 2, minClusterSize: 2 },
|
|
10
|
+
// upload: { screenshotsDir: './screenshots' },
|
|
11
|
+
};
|
|
12
|
+
`;
|
|
13
|
+
}
|
|
14
|
+
export function planConfig(config) {
|
|
15
|
+
let after;
|
|
16
|
+
if (config.format === 'package') {
|
|
17
|
+
let pkg = {
|
|
18
|
+
...config.packageConfig,
|
|
19
|
+
vizzly: {}
|
|
20
|
+
};
|
|
21
|
+
let indent = config.source?.match(/\n([ \t]+)\S/)?.[1] || ' ';
|
|
22
|
+
after = `${JSON.stringify(pkg, null, indent)}\n`;
|
|
23
|
+
} else {
|
|
24
|
+
after = createConfigContent(config.format);
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
path: config.configPath,
|
|
28
|
+
before: config.source,
|
|
29
|
+
after
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline';
|
|
2
|
+
export function createPrompts({
|
|
3
|
+
input = process.stdin,
|
|
4
|
+
output = process.stderr
|
|
5
|
+
} = {}) {
|
|
6
|
+
let reader = createInterface({
|
|
7
|
+
input,
|
|
8
|
+
crlfDelay: Infinity,
|
|
9
|
+
terminal: false
|
|
10
|
+
});
|
|
11
|
+
let lines = reader[Symbol.asyncIterator]();
|
|
12
|
+
let cancelled = () => Object.assign(new Error('Setup cancelled.'), {
|
|
13
|
+
code: 'INIT_CANCELLED'
|
|
14
|
+
});
|
|
15
|
+
let interrupted = false;
|
|
16
|
+
let onInterrupt = () => {
|
|
17
|
+
interrupted = true;
|
|
18
|
+
reader.close();
|
|
19
|
+
};
|
|
20
|
+
process.on('SIGINT', onInterrupt);
|
|
21
|
+
async function ask(question, fallback = '') {
|
|
22
|
+
output.write(`${question}${fallback ? ` [${fallback}]` : ''}: `);
|
|
23
|
+
let line = await lines.next();
|
|
24
|
+
if (line.done || interrupted) throw cancelled();
|
|
25
|
+
return line.value.trim() || fallback;
|
|
26
|
+
}
|
|
27
|
+
async function confirm(question, defaultYes = false) {
|
|
28
|
+
while (true) {
|
|
29
|
+
let answer = (await ask(`${question} ${defaultYes ? '[Y/n]' : '[y/N]'}`)).toLowerCase();
|
|
30
|
+
if (!answer) return defaultYes;
|
|
31
|
+
if (['y', 'yes'].includes(answer)) return true;
|
|
32
|
+
if (['n', 'no'].includes(answer)) return false;
|
|
33
|
+
output.write('Enter yes or no.\n');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
ask,
|
|
38
|
+
confirm,
|
|
39
|
+
close() {
|
|
40
|
+
process.off('SIGINT', onInterrupt);
|
|
41
|
+
reader.close();
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|