@vizzly-testing/cli 0.38.1 → 0.39.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 +222 -195
- package/dist/cli.js +55 -54
- package/dist/commands/init.js +154 -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 +140 -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/reporter-ssr/ssr-entry.js +3 -6
- 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/cli-update.js +105 -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/dist/utils/update-check.js +2 -0
- package/dist/utils/update-notifications.js +175 -0
- package/package.json +16 -16
- 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
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { renderToString } from "react-dom/server";
|
|
2
2
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
-
|
|
3
|
+
// src/utils/status-utils.js
|
|
4
4
|
function isNewComparisonStatus(status) {
|
|
5
5
|
return status === "new" || status === "baseline-created";
|
|
6
6
|
}
|
|
7
|
-
|
|
8
|
-
//#region src/components/static-report-view.jsx
|
|
7
|
+
// src/components/static-report-view.jsx
|
|
9
8
|
/**
|
|
10
9
|
* Static Report View
|
|
11
10
|
*
|
|
@@ -637,8 +636,7 @@ function StaticReportView({ reportData }) {
|
|
|
637
636
|
})]
|
|
638
637
|
});
|
|
639
638
|
}
|
|
640
|
-
|
|
641
|
-
//#region src/ssr-entry.jsx
|
|
639
|
+
// src/ssr-entry.jsx
|
|
642
640
|
/**
|
|
643
641
|
* SSR Entry Point
|
|
644
642
|
*
|
|
@@ -653,5 +651,4 @@ function StaticReportView({ reportData }) {
|
|
|
653
651
|
function renderStaticReport(reportData) {
|
|
654
652
|
return renderToString(/* @__PURE__ */ jsx(StaticReportView, { reportData }));
|
|
655
653
|
}
|
|
656
|
-
//#endregion
|
|
657
654
|
export { renderStaticReport };
|
|
@@ -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 {
|
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
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import { access, readFile, realpath } from 'node:fs/promises';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { getPackageName } from './package-info.js';
|
|
8
|
+
let exec = promisify(execFile);
|
|
9
|
+
let bundledRoot = fileURLToPath(new URL('../../', import.meta.url));
|
|
10
|
+
let installCommands = {
|
|
11
|
+
npm: 'install',
|
|
12
|
+
pnpm: 'add'
|
|
13
|
+
};
|
|
14
|
+
let saveFlags = {
|
|
15
|
+
dependencies: '--save-prod',
|
|
16
|
+
devDependencies: '--save-dev',
|
|
17
|
+
optionalDependencies: '--save-optional'
|
|
18
|
+
};
|
|
19
|
+
async function exists(filename) {
|
|
20
|
+
try {
|
|
21
|
+
await access(filename);
|
|
22
|
+
return true;
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Only offer an installer when the running package can be matched to that
|
|
29
|
+
// installation. A checkout, npx cache, or unknown manager gets manual guidance.
|
|
30
|
+
export async function findCliUpdatePlan(cwd, version, packageRoot = bundledRoot) {
|
|
31
|
+
if (!/^\d+\.\d+\.\d+$/.test(version)) return null;
|
|
32
|
+
let installed = await realpath(packageRoot);
|
|
33
|
+
if (!installed.split(path.sep).includes('node_modules')) return null;
|
|
34
|
+
let name = getPackageName();
|
|
35
|
+
let spec = `${name}@${version}`;
|
|
36
|
+
for (let directory = cwd;; directory = path.dirname(directory)) {
|
|
37
|
+
let manifest = path.join(directory, 'package.json');
|
|
38
|
+
try {
|
|
39
|
+
let pkg = JSON.parse(await readFile(manifest, 'utf8'));
|
|
40
|
+
let group = Object.keys(saveFlags).find(key => pkg[key]?.[name]);
|
|
41
|
+
if (group) {
|
|
42
|
+
let resolved = createRequire(manifest).resolve(name);
|
|
43
|
+
if ((await realpath(resolved)) !== path.join(installed, 'dist', 'index.js')) return null;
|
|
44
|
+
if (/^(workspace:|file:|link:|git|https?:|npm:)/.test(pkg[group][name])) return null;
|
|
45
|
+
let manager = pkg.packageManager?.split('@')[0];
|
|
46
|
+
if (!manager) {
|
|
47
|
+
if (await exists(path.join(directory, 'pnpm-lock.yaml'))) manager = 'pnpm';else if (await exists(path.join(directory, 'package-lock.json'))) manager = 'npm';
|
|
48
|
+
}
|
|
49
|
+
if (!['npm', 'pnpm'].includes(manager)) return null;
|
|
50
|
+
let args = [installCommands[manager], saveFlags[group]];
|
|
51
|
+
let preserveExactVersion = /^\d+\.\d+\.\d+(?:[-+].*)?$/.test(pkg[group][name]);
|
|
52
|
+
if (preserveExactVersion) args.push('--save-exact');
|
|
53
|
+
args.push(spec);
|
|
54
|
+
return {
|
|
55
|
+
command: manager,
|
|
56
|
+
args,
|
|
57
|
+
cwd: directory,
|
|
58
|
+
cliPath: path.join(directory, 'node_modules', name, 'dist', 'cli.js')
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
// Keep looking for the workspace manifest that owns the dependency.
|
|
63
|
+
}
|
|
64
|
+
if (directory === path.dirname(directory)) break;
|
|
65
|
+
}
|
|
66
|
+
for (let manager of ['npm', 'pnpm']) {
|
|
67
|
+
try {
|
|
68
|
+
let {
|
|
69
|
+
stdout
|
|
70
|
+
} = await exec(manager, ['root', '--global'], {
|
|
71
|
+
timeout: 3000
|
|
72
|
+
});
|
|
73
|
+
let globalPackage = path.join(stdout.trim(), name);
|
|
74
|
+
if ((await realpath(globalPackage)) === installed) {
|
|
75
|
+
return {
|
|
76
|
+
command: manager,
|
|
77
|
+
args: [installCommands[manager], '--global', spec],
|
|
78
|
+
cwd,
|
|
79
|
+
cliPath: path.join(globalPackage, 'dist', 'cli.js')
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
// An unavailable manager cannot own an update we offer to install.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
function runProcess(command, args, cwd, stdio) {
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
let child = spawn(command, args, {
|
|
91
|
+
cwd,
|
|
92
|
+
stdio,
|
|
93
|
+
shell: process.platform === 'win32' && ['npm', 'pnpm'].includes(command)
|
|
94
|
+
});
|
|
95
|
+
child.once('error', reject);
|
|
96
|
+
child.once('close', (code, signal) => resolve(code ?? (signal === 'SIGINT' ? 130 : 1)));
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
export async function runUpdateProcess(command, args, cwd) {
|
|
100
|
+
let code = await runProcess(command, args, cwd, ['inherit', process.stderr, process.stderr]);
|
|
101
|
+
if (code !== 0) throw new Error(`${command} exited with status ${code}.`);
|
|
102
|
+
}
|
|
103
|
+
export function runUpdatedCli(cliPath, args, cwd) {
|
|
104
|
+
return runProcess(process.execPath, [cliPath, '--no-update-notifier', ...args], cwd, 'inherit');
|
|
105
|
+
}
|
|
@@ -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),
|