@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.
- package/README.md +222 -183
- 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 +45 -53
- package/dist/commands/init.js +156 -483
- package/dist/commands/login.js +71 -137
- 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 +31 -127
- package/dist/server/handlers/tdd-handler.js +33 -2
- package/dist/server/routers/auth.js +32 -42
- 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/browser.js +7 -1
- 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/login-poll-wait.js +50 -0
- package/dist/utils/project-binding.js +96 -0
- package/dist/utils/project-link-store.js +13 -7
- package/package.json +27 -27
- 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
|
@@ -136,12 +136,20 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
|
|
|
136
136
|
const tddService = new TddService(config, workingDir, setBaseline);
|
|
137
137
|
const reportPath = join(workingDir, '.vizzly', 'report-data.json');
|
|
138
138
|
const detailsPath = join(workingDir, '.vizzly', 'comparison-details.json');
|
|
139
|
+
let capture = {
|
|
140
|
+
accepted: 0,
|
|
141
|
+
rejected: 0
|
|
142
|
+
};
|
|
139
143
|
let clearJsonFile = path => {
|
|
140
144
|
if (existsSync(path)) {
|
|
141
145
|
unlinkSync(path);
|
|
142
146
|
}
|
|
143
147
|
};
|
|
144
148
|
let prepareRunArtifacts = () => {
|
|
149
|
+
capture = {
|
|
150
|
+
accepted: 0,
|
|
151
|
+
rejected: 0
|
|
152
|
+
};
|
|
145
153
|
clearJsonFile(reportPath);
|
|
146
154
|
clearJsonFile(detailsPath);
|
|
147
155
|
tddService.clearRunData?.();
|
|
@@ -284,7 +292,7 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
|
|
|
284
292
|
output.debug('tdd', `baseline: ${baseline.buildName}`);
|
|
285
293
|
}
|
|
286
294
|
};
|
|
287
|
-
|
|
295
|
+
let processScreenshot = async (_buildId, name, image, properties = {}, type, warnings = [], screenshotOptions = {}) => {
|
|
288
296
|
let handlerStart = Date.now();
|
|
289
297
|
output.debug('tdd', `${name} received`);
|
|
290
298
|
|
|
@@ -411,6 +419,7 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
|
|
|
411
419
|
aaPixelsIgnored: comparison.aaPixelsIgnored,
|
|
412
420
|
aaPercentage: comparison.aaPercentage,
|
|
413
421
|
heightDiff: comparison.heightDiff,
|
|
422
|
+
heightChange: comparison.heightChange,
|
|
414
423
|
error: comparison.error,
|
|
415
424
|
warnings,
|
|
416
425
|
originalName: name,
|
|
@@ -498,8 +507,30 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
|
|
|
498
507
|
}
|
|
499
508
|
};
|
|
500
509
|
};
|
|
510
|
+
let handleScreenshot = async (...args) => {
|
|
511
|
+
let result;
|
|
512
|
+
try {
|
|
513
|
+
result = await processScreenshot(...args);
|
|
514
|
+
} catch (error) {
|
|
515
|
+
result = {
|
|
516
|
+
statusCode: error.code === 'INVALID_IMAGE' ? 400 : 500,
|
|
517
|
+
body: {
|
|
518
|
+
error: error.message,
|
|
519
|
+
tddMode: true
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
if (result.statusCode >= 400) capture.rejected++;else capture.accepted++;
|
|
524
|
+
return result;
|
|
525
|
+
};
|
|
501
526
|
const getResults = async () => {
|
|
502
|
-
|
|
527
|
+
let results = await tddService.printResults();
|
|
528
|
+
return {
|
|
529
|
+
...results,
|
|
530
|
+
capture: {
|
|
531
|
+
...capture
|
|
532
|
+
}
|
|
533
|
+
};
|
|
503
534
|
};
|
|
504
535
|
const acceptBaseline = async comparisonId => {
|
|
505
536
|
try {
|
|
@@ -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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/services/index.js
CHANGED
|
@@ -26,8 +26,14 @@ import { TestRunner } from './test-runner.js';
|
|
|
26
26
|
* @returns {Object} Services object for plugins
|
|
27
27
|
*/
|
|
28
28
|
export function createServices(config) {
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
// Direct programmatic config is explicit; loadConfig already distinguishes
|
|
30
|
+
// explicit comparison settings from its local fallback defaults.
|
|
31
|
+
let serviceConfig = {
|
|
32
|
+
...config,
|
|
33
|
+
_comparisonOverrides: config._comparisonOverrides ?? config.comparison ?? {}
|
|
34
|
+
};
|
|
35
|
+
let serverManager = createServerManager(serviceConfig, {});
|
|
36
|
+
let testRunner = new TestRunner(serviceConfig, serverManager);
|
|
31
37
|
return {
|
|
32
38
|
serverManager,
|
|
33
39
|
testRunner
|
|
@@ -27,7 +27,8 @@ export async function compareImages(baselinePath, currentPath, diffPath, options
|
|
|
27
27
|
let analysis = await analyze(baselinePath, currentPath, {
|
|
28
28
|
threshold,
|
|
29
29
|
antialiasing: true,
|
|
30
|
-
minimumRegionPixels: minClusterSize
|
|
30
|
+
minimumRegionPixels: minClusterSize,
|
|
31
|
+
alignHeightChanges: true
|
|
31
32
|
}, {
|
|
32
33
|
diffPath,
|
|
33
34
|
overwrite: true
|
|
@@ -45,6 +46,7 @@ export async function compareImages(baselinePath, currentPath, diffPath, options
|
|
|
45
46
|
aaPixelsIgnored: comparison.pixels.suppressed.antialiasing.count,
|
|
46
47
|
aaPercentage: totalPixels > 0 ? comparison.pixels.suppressed.antialiasing.count / totalPixels * 100 : 0,
|
|
47
48
|
boundingBox: comparison.difference.spatial?.bounds || null,
|
|
49
|
+
heightChange: heightChange || null,
|
|
48
50
|
heightDiff: heightChange ? heightChange.direction === 'added' ? heightChange.rowCount : -heightChange.rowCount : 0,
|
|
49
51
|
intensityStats: comparison.difference.appearance,
|
|
50
52
|
diffClusters: comparison.difference.regions.map(region => ({
|
|
@@ -181,6 +183,7 @@ export function buildFailedComparison(params) {
|
|
|
181
183
|
aaPercentage: honeydiffResult.aaPercentage,
|
|
182
184
|
boundingBox: honeydiffResult.boundingBox,
|
|
183
185
|
heightDiff: honeydiffResult.heightDiff,
|
|
186
|
+
heightChange: honeydiffResult.heightChange,
|
|
184
187
|
intensityStats: honeydiffResult.intensityStats,
|
|
185
188
|
diffClusters
|
|
186
189
|
};
|
package/dist/tdd/tdd-service.js
CHANGED
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { existsSync as defaultExistsSync, mkdirSync as defaultMkdirSync, readFileSync as defaultReadFileSync, writeFileSync as defaultWriteFileSync } from 'node:fs';
|
|
12
|
+
import { compareSync } from '@vizzly-testing/honeydiff';
|
|
12
13
|
import { createApiClient as defaultCreateApiClient, getBuilds as defaultGetBuilds, getComparison as defaultGetComparison, getTddBaselines as defaultGetTddBaselines } from '../api/index.js';
|
|
13
|
-
import { NetworkError } from '../errors/vizzly-error.js';
|
|
14
|
+
import { NetworkError, VizzlyError } from '../errors/vizzly-error.js';
|
|
14
15
|
import { colors as defaultColors } from '../utils/colors.js';
|
|
15
16
|
import { fetchWithTimeout as defaultFetchWithTimeout } from '../utils/fetch-utils.js';
|
|
16
17
|
import { getDefaultBranch as defaultGetDefaultBranch } from '../utils/git.js';
|
|
@@ -809,6 +810,17 @@ export class TddService {
|
|
|
809
810
|
let currentImagePath = getCurrentPath(this.currentPath, filename);
|
|
810
811
|
let baselineImagePath = getBaselinePath(this.baselinePath, filename);
|
|
811
812
|
let diffImagePath = getDiffPath(this.diffPath, filename);
|
|
813
|
+
let hasBaseline = baselineExists(this.baselinePath, filename);
|
|
814
|
+
|
|
815
|
+
// Existing baselines are decoded by normal comparison. New or explicitly
|
|
816
|
+
// replaced baselines need the same validation before persisting any bytes.
|
|
817
|
+
if (this.setBaseline || !hasBaseline) {
|
|
818
|
+
try {
|
|
819
|
+
compareSync(imageBuffer, imageBuffer);
|
|
820
|
+
} catch (error) {
|
|
821
|
+
throw new VizzlyError(`Invalid screenshot image: ${error.message}`, 'INVALID_IMAGE');
|
|
822
|
+
}
|
|
823
|
+
}
|
|
812
824
|
|
|
813
825
|
// Save current screenshot
|
|
814
826
|
saveCurrent(this.currentPath, filename, imageBuffer);
|
|
@@ -819,7 +831,7 @@ export class TddService {
|
|
|
819
831
|
}
|
|
820
832
|
|
|
821
833
|
// Check if baseline exists
|
|
822
|
-
if (!
|
|
834
|
+
if (!hasBaseline) {
|
|
823
835
|
// Create new baseline
|
|
824
836
|
saveBaseline(this.baselinePath, filename, imageBuffer);
|
|
825
837
|
|
package/dist/test-runner/core.js
CHANGED
|
@@ -119,11 +119,20 @@ export function determineBuildMode(tdd) {
|
|
|
119
119
|
* Build result object for disabled run (no Vizzly integration)
|
|
120
120
|
* @returns {Object} Result object
|
|
121
121
|
*/
|
|
122
|
-
export function buildDisabledRunResult() {
|
|
122
|
+
export function buildDisabledRunResult(reason = null) {
|
|
123
123
|
return {
|
|
124
124
|
testsPassed: 1,
|
|
125
125
|
testsFailed: 0,
|
|
126
|
-
screenshotsCaptured: 0
|
|
126
|
+
screenshotsCaptured: 0,
|
|
127
|
+
...(reason ? {
|
|
128
|
+
capture: {
|
|
129
|
+
setup: 'disabled',
|
|
130
|
+
reason,
|
|
131
|
+
accepted: 0,
|
|
132
|
+
rejected: 0,
|
|
133
|
+
errors: 0
|
|
134
|
+
}
|
|
135
|
+
} : {})
|
|
127
136
|
};
|
|
128
137
|
}
|
|
129
138
|
|
|
@@ -157,7 +166,23 @@ export function buildRunResult({
|
|
|
157
166
|
uploads: uploadStats
|
|
158
167
|
} : {}),
|
|
159
168
|
comparisons: tddResults?.comparisons || null,
|
|
160
|
-
failed: (tddResults?.failed || 0) > 0
|
|
169
|
+
failed: (tddResults?.failed || 0) > 0,
|
|
170
|
+
...(tddResults ? {
|
|
171
|
+
summary: {
|
|
172
|
+
total: tddResults.total ?? screenshotCount,
|
|
173
|
+
passed: tddResults.passed || 0,
|
|
174
|
+
failed: tddResults.failed || 0,
|
|
175
|
+
new: tddResults.new || 0,
|
|
176
|
+
errors: tddResults.errors || 0
|
|
177
|
+
},
|
|
178
|
+
capture: {
|
|
179
|
+
setup: 'ready',
|
|
180
|
+
reason: tddResults.error || null,
|
|
181
|
+
accepted: tddResults.capture?.accepted ?? screenshotCount,
|
|
182
|
+
rejected: tddResults.capture?.rejected || 0,
|
|
183
|
+
errors: tddResults.errors || 0
|
|
184
|
+
}
|
|
185
|
+
} : {})
|
|
161
186
|
};
|
|
162
187
|
}
|
|
163
188
|
|
|
@@ -55,7 +55,7 @@ export async function createBuild({
|
|
|
55
55
|
throw new Error('No API key available for build creation');
|
|
56
56
|
}
|
|
57
57
|
let client = createApiClient(clientOptions);
|
|
58
|
-
let payload = buildApiBuildPayload(runOptions, config.
|
|
58
|
+
let payload = buildApiBuildPayload(runOptions, config._comparisonOverrides);
|
|
59
59
|
let buildResult = await createApiBuild(client, payload);
|
|
60
60
|
if (!buildResult?.id) {
|
|
61
61
|
throw new Error('Vizzly API did not return a build ID');
|
|
@@ -242,22 +242,31 @@ export function executeTestCommand({
|
|
|
242
242
|
async function executeDisabledTestRun({
|
|
243
243
|
testCommand,
|
|
244
244
|
json,
|
|
245
|
+
reason,
|
|
245
246
|
deps
|
|
246
247
|
}) {
|
|
247
248
|
let {
|
|
248
249
|
spawn,
|
|
249
250
|
createError
|
|
250
251
|
} = deps;
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
252
|
+
let result = buildDisabledRunResult(reason);
|
|
253
|
+
try {
|
|
254
|
+
await executeTestCommand({
|
|
255
|
+
command: testCommand,
|
|
256
|
+
env: buildDisabledEnv(),
|
|
257
|
+
json,
|
|
258
|
+
deps: {
|
|
259
|
+
spawn,
|
|
260
|
+
createError
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
} catch (error) {
|
|
264
|
+
result.testsPassed = 0;
|
|
265
|
+
result.testsFailed = 1;
|
|
266
|
+
error.runResult = result;
|
|
267
|
+
throw error;
|
|
268
|
+
}
|
|
269
|
+
return result;
|
|
261
270
|
}
|
|
262
271
|
|
|
263
272
|
// ============================================================================
|
|
@@ -389,6 +398,7 @@ export async function runTests({
|
|
|
389
398
|
return executeDisabledTestRun({
|
|
390
399
|
testCommand,
|
|
391
400
|
json: runOptions.json,
|
|
401
|
+
reason: error.message,
|
|
392
402
|
deps: {
|
|
393
403
|
spawn,
|
|
394
404
|
createError
|
|
@@ -430,6 +440,15 @@ export async function runTests({
|
|
|
430
440
|
output.debug('tdd', 'failed to get results', {
|
|
431
441
|
error: tddError.message
|
|
432
442
|
});
|
|
443
|
+
tddResults = {
|
|
444
|
+
total: 0,
|
|
445
|
+
errors: 1,
|
|
446
|
+
error: tddError.message,
|
|
447
|
+
capture: {
|
|
448
|
+
accepted: 0,
|
|
449
|
+
rejected: 0
|
|
450
|
+
}
|
|
451
|
+
};
|
|
433
452
|
}
|
|
434
453
|
}
|
|
435
454
|
|
|
@@ -474,23 +493,25 @@ export async function runTests({
|
|
|
474
493
|
output.error('Failed to stop server:', stopError);
|
|
475
494
|
}
|
|
476
495
|
}
|
|
496
|
+
let result = buildRunResult({
|
|
497
|
+
buildId,
|
|
498
|
+
buildUrl,
|
|
499
|
+
testSuccess,
|
|
500
|
+
screenshotCount,
|
|
501
|
+
tddResults,
|
|
502
|
+
uploadStats
|
|
503
|
+
});
|
|
477
504
|
|
|
478
|
-
// Throw test error after cleanup
|
|
505
|
+
// Throw test error after cleanup, retaining any completed visual evidence.
|
|
479
506
|
if (testError) {
|
|
480
507
|
if (!runOptions.json) {
|
|
481
508
|
output.error('Test run failed:', testError);
|
|
482
509
|
}
|
|
483
510
|
if (uploadStats) testError.uploads = uploadStats;
|
|
511
|
+
if (tdd) testError.runResult = result;
|
|
484
512
|
throw testError;
|
|
485
513
|
}
|
|
486
|
-
return
|
|
487
|
-
buildId,
|
|
488
|
-
buildUrl,
|
|
489
|
-
testSuccess,
|
|
490
|
-
screenshotCount,
|
|
491
|
-
tddResults,
|
|
492
|
-
uploadStats
|
|
493
|
-
});
|
|
514
|
+
return result;
|
|
494
515
|
}
|
|
495
516
|
|
|
496
517
|
/**
|
package/dist/types/index.d.ts
CHANGED
|
@@ -48,9 +48,16 @@ export interface TddConfig {
|
|
|
48
48
|
openReport?: boolean;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
export interface ProjectConfig {
|
|
52
|
+
organizationSlug: string;
|
|
53
|
+
projectSlug: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
51
56
|
export interface VizzlyConfig {
|
|
52
57
|
apiKey?: string;
|
|
53
58
|
apiUrl?: string;
|
|
59
|
+
/** Nonsecret repository destination. Upload credentials stay in user storage. */
|
|
60
|
+
project?: ProjectConfig;
|
|
54
61
|
server?: ServerConfig;
|
|
55
62
|
build?: BuildConfig;
|
|
56
63
|
upload?: UploadConfig;
|
|
@@ -131,6 +138,7 @@ export interface TddResults {
|
|
|
131
138
|
failed: number;
|
|
132
139
|
new: number;
|
|
133
140
|
errors: number;
|
|
141
|
+
capture?: { accepted: number; rejected: number };
|
|
134
142
|
comparisons: ComparisonResult[];
|
|
135
143
|
baseline: BaselineData | null;
|
|
136
144
|
}
|
package/dist/uploader/core.js
CHANGED
package/dist/utils/browser.js
CHANGED
|
@@ -52,7 +52,13 @@ export async function openBrowser(url) {
|
|
|
52
52
|
args = [url];
|
|
53
53
|
break;
|
|
54
54
|
}
|
|
55
|
-
|
|
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);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { cosmiconfigSync, defaultLoadersSync, getDefaultSearchPlacesSync } from 'cosmiconfig';
|
|
4
|
+
let searchPlaces = getDefaultSearchPlacesSync('vizzly');
|
|
5
|
+
export function createConfigExplorer() {
|
|
6
|
+
return cosmiconfigSync('vizzly', {
|
|
7
|
+
searchPlaces,
|
|
8
|
+
searchStrategy: 'none'
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
export async function readOptionalFile(filename) {
|
|
12
|
+
try {
|
|
13
|
+
return await readFile(filename, 'utf8');
|
|
14
|
+
} catch (error) {
|
|
15
|
+
if (error.code === 'ENOENT') return null;
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function configFormat(filename) {
|
|
20
|
+
if (path.basename(filename) === 'package.json') return 'package';
|
|
21
|
+
let extension = path.extname(filename);
|
|
22
|
+
if (extension === '.json') return 'json';
|
|
23
|
+
if (extension === '.ts') return 'typescript';
|
|
24
|
+
if (extension === '.js') return 'javascript';
|
|
25
|
+
if (extension === '.cjs') return 'commonjs';
|
|
26
|
+
if (['.yaml', '.yml', ''].includes(extension)) return 'yaml';
|
|
27
|
+
return 'unknown';
|
|
28
|
+
}
|
|
29
|
+
async function inspectConfig(filename, allowInvalid) {
|
|
30
|
+
let source = await readOptionalFile(filename);
|
|
31
|
+
let result = {
|
|
32
|
+
configPath: filename,
|
|
33
|
+
exists: source !== null,
|
|
34
|
+
format: configFormat(filename),
|
|
35
|
+
source
|
|
36
|
+
};
|
|
37
|
+
if (source === null) return result;
|
|
38
|
+
try {
|
|
39
|
+
if (result.format === 'package') {
|
|
40
|
+
result.packageConfig = JSON.parse(source);
|
|
41
|
+
result.parsedConfig = result.packageConfig.vizzly;
|
|
42
|
+
} else if (result.format === 'json') {
|
|
43
|
+
result.parsedConfig = JSON.parse(source);
|
|
44
|
+
} else if (result.format === 'yaml') {
|
|
45
|
+
result.parsedConfig = defaultLoadersSync.noExt(filename, source);
|
|
46
|
+
}
|
|
47
|
+
} catch (error) {
|
|
48
|
+
// Replacing a standalone file does not need its old values. Package config
|
|
49
|
+
// still needs valid JSON so unrelated scripts and dependencies survive.
|
|
50
|
+
if (!allowInvalid || result.format === 'package') throw error;
|
|
51
|
+
result.parseError = error.message;
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Setup discovers source files without evaluating executable configuration or
|
|
57
|
+
// reading credentials. Runtime loading remains the explorer's responsibility.
|
|
58
|
+
export async function discoverConfig({
|
|
59
|
+
cwd = process.cwd(),
|
|
60
|
+
configPath = null,
|
|
61
|
+
allowInvalid = false
|
|
62
|
+
} = {}) {
|
|
63
|
+
if (configPath) return inspectConfig(path.resolve(cwd, configPath), allowInvalid);
|
|
64
|
+
for (let place of searchPlaces) {
|
|
65
|
+
let candidate = await inspectConfig(path.resolve(cwd, place), allowInvalid);
|
|
66
|
+
if (!candidate.exists || !candidate.source.trim()) continue;
|
|
67
|
+
if (['package', 'json', 'yaml'].includes(candidate.format) && candidate.parsedConfig == null && !candidate.parseError) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
return candidate;
|
|
71
|
+
}
|
|
72
|
+
return inspectConfig(path.resolve(cwd, 'vizzly.config.js'), allowInvalid);
|
|
73
|
+
}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { resolve } from 'node:path';
|
|
2
|
-
import { cosmiconfigSync } from 'cosmiconfig';
|
|
3
2
|
import { CONFIG_DEFAULTS, deepMerge } from '../config/core.js';
|
|
3
|
+
import { createConfigExplorer } from './config-discovery.js';
|
|
4
4
|
import { validateVizzlyConfigWithDefaults } from './config-schema.js';
|
|
5
5
|
import { getApiToken, getApiUrl, getBuildName, getMinClusterSize, getParallelId, getThreshold } from './environment-config.js';
|
|
6
6
|
import { getAccessToken } from './global-config.js';
|
|
7
7
|
import * as output from './output.js';
|
|
8
|
-
import {
|
|
8
|
+
import { getProjectLink } from './project-link-store.js';
|
|
9
9
|
export async function loadConfig(configPath = null, cliOverrides = {}) {
|
|
10
10
|
// 1. Load from config file using cosmiconfig
|
|
11
|
-
let explorer =
|
|
11
|
+
let explorer = createConfigExplorer();
|
|
12
12
|
let result = configPath ? explorer.load(configPath) : explorer.search();
|
|
13
13
|
let fileConfig = {};
|
|
14
14
|
if (result?.config) {
|
|
@@ -18,6 +18,12 @@ export async function loadConfig(configPath = null, cliOverrides = {}) {
|
|
|
18
18
|
|
|
19
19
|
// 2. Validate config file using Zod schema
|
|
20
20
|
let validatedFileConfig = validateVizzlyConfigWithDefaults(fileConfig);
|
|
21
|
+
let comparisonOverrides = {};
|
|
22
|
+
for (let key of ['threshold', 'minClusterSize']) {
|
|
23
|
+
if (fileConfig.comparison?.[key] !== undefined) {
|
|
24
|
+
comparisonOverrides[key] = validatedFileConfig.comparison[key];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
21
27
|
|
|
22
28
|
// Create a proper clone of the default config to avoid shared object references
|
|
23
29
|
let config = deepMerge(CONFIG_DEFAULTS, {});
|
|
@@ -42,9 +48,13 @@ export async function loadConfig(configPath = null, cliOverrides = {}) {
|
|
|
42
48
|
output.debug('config', 'using build name from environment');
|
|
43
49
|
}
|
|
44
50
|
if (envParallelId) config.parallelId = envParallelId;
|
|
45
|
-
if (envThreshold !== undefined)
|
|
51
|
+
if (envThreshold !== undefined) {
|
|
52
|
+
config.comparison.threshold = envThreshold;
|
|
53
|
+
comparisonOverrides.threshold = envThreshold;
|
|
54
|
+
}
|
|
46
55
|
if (envMinClusterSize !== undefined) {
|
|
47
56
|
config.comparison.minClusterSize = envMinClusterSize;
|
|
57
|
+
comparisonOverrides.minClusterSize = envMinClusterSize;
|
|
48
58
|
}
|
|
49
59
|
|
|
50
60
|
// 4. Apply CLI overrides (highest priority)
|
|
@@ -52,11 +62,21 @@ export async function loadConfig(configPath = null, cliOverrides = {}) {
|
|
|
52
62
|
output.debug('config', 'using token from --token flag');
|
|
53
63
|
}
|
|
54
64
|
applyCLIOverrides(config, cliOverrides);
|
|
65
|
+
for (let key of ['threshold', 'minClusterSize']) {
|
|
66
|
+
if (cliOverrides[key] !== undefined) {
|
|
67
|
+
comparisonOverrides[key] = config.comparison[key];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
let validatedComparison = validateVizzlyConfigWithDefaults({
|
|
71
|
+
comparison: comparisonOverrides
|
|
72
|
+
}).comparison;
|
|
73
|
+
config._comparisonOverrides = Object.fromEntries(Object.keys(comparisonOverrides).map(key => [key, validatedComparison[key]]));
|
|
55
74
|
|
|
56
|
-
// 5.
|
|
57
|
-
if (!config.apiKey) {
|
|
58
|
-
let linkedProject = await
|
|
59
|
-
apiUrl: config.apiUrl
|
|
75
|
+
// 5. Use only the credential belonging to this repository's destination.
|
|
76
|
+
if (!config.apiKey && config.project) {
|
|
77
|
+
let linkedProject = await getProjectLink({
|
|
78
|
+
apiUrl: config.apiUrl,
|
|
79
|
+
...config.project
|
|
60
80
|
});
|
|
61
81
|
if (linkedProject?.token) {
|
|
62
82
|
config.apiKey = linkedProject.token;
|
|
@@ -83,6 +103,7 @@ export async function loadConfig(configPath = null, cliOverrides = {}) {
|
|
|
83
103
|
output.debug('config', 'using user login for user-authenticated commands');
|
|
84
104
|
}
|
|
85
105
|
config._configPath = result?.filepath || null;
|
|
106
|
+
config._configApiUrl = validatedFileConfig.apiUrl;
|
|
86
107
|
return config;
|
|
87
108
|
}
|
|
88
109
|
|
|
@@ -50,6 +50,10 @@ const comparisonSchema = z.object({
|
|
|
50
50
|
const tddSchema = z.object({
|
|
51
51
|
openReport: z.boolean().default(CONFIG_DEFAULTS.tdd.openReport)
|
|
52
52
|
});
|
|
53
|
+
let projectSchema = z.object({
|
|
54
|
+
organizationSlug: z.string().trim().min(1),
|
|
55
|
+
projectSlug: z.string().trim().min(1)
|
|
56
|
+
});
|
|
53
57
|
|
|
54
58
|
/**
|
|
55
59
|
* Core Vizzly configuration schema
|
|
@@ -59,6 +63,7 @@ export const vizzlyConfigSchema = z.object({
|
|
|
59
63
|
// Core Vizzly config
|
|
60
64
|
apiKey: z.string().optional(),
|
|
61
65
|
apiUrl: z.string().url().default(CONFIG_DEFAULTS.apiUrl),
|
|
66
|
+
project: projectSchema.optional(),
|
|
62
67
|
server: serverSchema.default(CONFIG_DEFAULTS.server),
|
|
63
68
|
build: buildSchema.default(CONFIG_DEFAULTS.build),
|
|
64
69
|
upload: uploadSchema.default(CONFIG_DEFAULTS.upload),
|