@vizzly-testing/cli 0.30.0 → 0.32.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 +9 -9
- package/dist/api/client.js +6 -3
- package/dist/cli.js +58 -29
- package/dist/client/index.js +21 -29
- package/dist/commands/api.js +145 -90
- package/dist/commands/baselines.js +78 -47
- package/dist/commands/builds.js +9 -8
- package/dist/commands/comparisons.js +7 -8
- package/dist/commands/config-cmd.js +15 -7
- package/dist/commands/context.js +329 -18
- package/dist/commands/doctor.js +116 -81
- package/dist/commands/init.js +202 -201
- package/dist/commands/login.js +134 -65
- package/dist/commands/logout.js +17 -6
- package/dist/commands/preview.js +29 -57
- package/dist/commands/project.js +120 -0
- package/dist/commands/projects.js +9 -2
- package/dist/commands/review.js +236 -202
- package/dist/commands/run.js +75 -72
- package/dist/commands/status.js +228 -150
- package/dist/commands/tdd-daemon-process.js +98 -0
- package/dist/commands/tdd-daemon.js +318 -212
- package/dist/commands/tdd.js +23 -8
- package/dist/commands/upload.js +10 -9
- package/dist/commands/whoami.js +63 -25
- package/dist/config/core.js +36 -35
- package/dist/context/local-workspace-provider.js +132 -12
- package/dist/context/provider-resolver.js +5 -1
- package/dist/index.js +5 -4
- package/dist/plugin-loader.js +55 -49
- package/dist/project/core.js +2 -2
- package/dist/project/operations.js +6 -3
- package/dist/reporter/reporter-bundle.css +1 -1
- package/dist/reporter/reporter-bundle.iife.js +7 -7
- package/dist/sdk/index.js +162 -107
- package/dist/server/handlers/tdd-handler.js +30 -54
- package/dist/server/routers/baseline.js +195 -178
- package/dist/server/routers/events.js +212 -174
- package/dist/server/routers/projects.js +14 -5
- package/dist/server/routers/screenshot.js +1 -31
- package/dist/services/build-manager.js +19 -97
- package/dist/services/config-service.js +26 -34
- package/dist/services/index.js +2 -4
- package/dist/services/static-report-generator.js +11 -12
- package/dist/services/test-runner.js +21 -27
- package/dist/tdd/core/region-coverage.js +109 -21
- package/dist/tdd/index.js +1 -1
- package/dist/tdd/metadata/region-metadata.js +0 -37
- package/dist/tdd/runtime-context.js +78 -0
- package/dist/tdd/server-registry.js +38 -17
- package/dist/tdd/services/baseline-manager.js +23 -0
- package/dist/tdd/services/comparison-service.js +10 -1
- package/dist/tdd/services/hotspot-service.js +0 -13
- package/dist/tdd/tdd-service.js +144 -138
- package/dist/types/client.d.ts +36 -1
- package/dist/types/index.d.ts +114 -22
- package/dist/types/sdk.d.ts +40 -0
- package/dist/uploader/core.js +5 -4
- package/dist/uploader/index.js +79 -2
- package/dist/uploader/operations.js +40 -5
- package/dist/utils/api-url.js +14 -0
- package/dist/utils/async-utils.js +26 -0
- package/dist/utils/ci-env.js +19 -12
- package/dist/utils/config-loader.js +39 -65
- package/dist/utils/config-schema.js +19 -55
- package/dist/utils/fetch-utils.js +12 -5
- package/dist/utils/git.js +35 -72
- package/dist/utils/global-config.js +20 -1
- package/dist/utils/output.js +19 -7
- package/dist/utils/patterns.js +14 -0
- package/dist/utils/project-link-store.js +105 -0
- package/dist/utils/screenshot-options.js +28 -0
- package/dist/utils/secret-store.js +65 -0
- package/package.json +14 -11
- package/dist/services/screenshot-server.js +0 -65
- package/dist/services/server-manager.js +0 -81
- package/dist/services/uploader.js +0 -100
- package/dist/tdd/services/region-service.js +0 -75
- package/dist/utils/build-history.js +0 -103
|
@@ -12,30 +12,10 @@ import { existsSync } from 'node:fs';
|
|
|
12
12
|
import { writeFile } from 'node:fs/promises';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
14
|
import { cosmiconfigSync } from 'cosmiconfig';
|
|
15
|
+
import { CONFIG_DEFAULTS } from '../config/core.js';
|
|
15
16
|
import { loadGlobalConfig, saveGlobalConfig } from '../utils/global-config.js';
|
|
16
17
|
import * as output from '../utils/output.js';
|
|
17
18
|
|
|
18
|
-
/**
|
|
19
|
-
* Default configuration values
|
|
20
|
-
*/
|
|
21
|
-
let DEFAULT_CONFIG = {
|
|
22
|
-
comparison: {
|
|
23
|
-
threshold: 2.0,
|
|
24
|
-
minClusterSize: 2
|
|
25
|
-
},
|
|
26
|
-
server: {
|
|
27
|
-
port: 47392,
|
|
28
|
-
timeout: 30000
|
|
29
|
-
},
|
|
30
|
-
build: {
|
|
31
|
-
name: 'Build {timestamp}',
|
|
32
|
-
environment: 'test'
|
|
33
|
-
},
|
|
34
|
-
tdd: {
|
|
35
|
-
openReport: false
|
|
36
|
-
}
|
|
37
|
-
};
|
|
38
|
-
|
|
39
19
|
/**
|
|
40
20
|
* Create a config service instance
|
|
41
21
|
* @param {Object} options
|
|
@@ -76,9 +56,7 @@ export function createConfigService({
|
|
|
76
56
|
* Get merged configuration with source tracking
|
|
77
57
|
*/
|
|
78
58
|
async function getMergedConfig() {
|
|
79
|
-
let config =
|
|
80
|
-
...DEFAULT_CONFIG
|
|
81
|
-
};
|
|
59
|
+
let config = cloneConfigValue(CONFIG_DEFAULTS);
|
|
82
60
|
let sources = {};
|
|
83
61
|
|
|
84
62
|
// Layer 1: Global config
|
|
@@ -100,15 +78,15 @@ export function createConfigService({
|
|
|
100
78
|
|
|
101
79
|
// Layer 3: Environment variables
|
|
102
80
|
if (process.env.VIZZLY_THRESHOLD) {
|
|
103
|
-
config.comparison.threshold =
|
|
81
|
+
config.comparison.threshold = Number(process.env.VIZZLY_THRESHOLD);
|
|
104
82
|
sources.comparison = 'env';
|
|
105
83
|
}
|
|
106
84
|
if (process.env.VIZZLY_MIN_CLUSTER_SIZE) {
|
|
107
|
-
config.comparison.minClusterSize =
|
|
85
|
+
config.comparison.minClusterSize = Number(process.env.VIZZLY_MIN_CLUSTER_SIZE);
|
|
108
86
|
sources.comparison = 'env';
|
|
109
87
|
}
|
|
110
88
|
if (process.env.VIZZLY_PORT) {
|
|
111
|
-
config.server.port =
|
|
89
|
+
config.server.port = Number(process.env.VIZZLY_PORT);
|
|
112
90
|
sources.server = 'env';
|
|
113
91
|
}
|
|
114
92
|
return {
|
|
@@ -241,7 +219,7 @@ export default defineConfig(${JSON.stringify(newConfig, null, 2)});
|
|
|
241
219
|
// Validate threshold
|
|
242
220
|
if (config.comparison?.threshold !== undefined) {
|
|
243
221
|
let threshold = config.comparison.threshold;
|
|
244
|
-
if (typeof threshold !== 'number' || threshold < 0) {
|
|
222
|
+
if (typeof threshold !== 'number' || !Number.isFinite(threshold) || threshold < 0) {
|
|
245
223
|
errors.push('comparison.threshold must be a non-negative number');
|
|
246
224
|
} else if (threshold > 100) {
|
|
247
225
|
warnings.push('comparison.threshold above 100 may cause all comparisons to pass');
|
|
@@ -292,30 +270,44 @@ export default defineConfig(${JSON.stringify(newConfig, null, 2)});
|
|
|
292
270
|
* Deep merge two objects
|
|
293
271
|
*/
|
|
294
272
|
function mergeDeep(target, source) {
|
|
295
|
-
let result =
|
|
296
|
-
...target
|
|
297
|
-
};
|
|
273
|
+
let result = cloneConfigValue(target);
|
|
298
274
|
for (let key in source) {
|
|
299
275
|
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
|
300
276
|
result[key] = mergeDeep(result[key] || {}, source[key]);
|
|
277
|
+
} else if (Array.isArray(source[key])) {
|
|
278
|
+
result[key] = [...source[key]];
|
|
301
279
|
} else {
|
|
302
280
|
result[key] = source[key];
|
|
303
281
|
}
|
|
304
282
|
}
|
|
305
283
|
return result;
|
|
306
284
|
}
|
|
285
|
+
function cloneConfigValue(value) {
|
|
286
|
+
if (Array.isArray(value)) {
|
|
287
|
+
return value.map(cloneConfigValue);
|
|
288
|
+
}
|
|
289
|
+
if (value && typeof value === 'object') {
|
|
290
|
+
let clone = {};
|
|
291
|
+
for (let key in value) {
|
|
292
|
+
clone[key] = cloneConfigValue(value[key]);
|
|
293
|
+
}
|
|
294
|
+
return clone;
|
|
295
|
+
}
|
|
296
|
+
return value;
|
|
297
|
+
}
|
|
307
298
|
|
|
308
299
|
/**
|
|
309
300
|
* Merge config with source tracking
|
|
310
301
|
*/
|
|
311
|
-
function mergeWithTracking(target, source, sources, sourceName) {
|
|
302
|
+
function mergeWithTracking(target, source, sources, sourceName, sectionName) {
|
|
312
303
|
for (let key in source) {
|
|
304
|
+
let sourceSection = sectionName || key;
|
|
313
305
|
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
|
314
306
|
if (!target[key]) target[key] = {};
|
|
315
|
-
mergeWithTracking(target[key], source[key], sources, sourceName);
|
|
307
|
+
mergeWithTracking(target[key], source[key], sources, sourceName, sourceSection);
|
|
316
308
|
} else {
|
|
317
309
|
target[key] = source[key];
|
|
318
|
-
sources[
|
|
310
|
+
sources[sourceSection] = sourceName;
|
|
319
311
|
}
|
|
320
312
|
}
|
|
321
313
|
}
|
package/dist/services/index.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* This factory is only used by cli.js to provide services to plugins.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import { createServerManager } from '../server-manager/index.js';
|
|
13
13
|
import { TestRunner } from './test-runner.js';
|
|
14
14
|
|
|
15
15
|
/**
|
|
@@ -26,9 +26,7 @@ import { TestRunner } from './test-runner.js';
|
|
|
26
26
|
* @returns {Object} Services object for plugins
|
|
27
27
|
*/
|
|
28
28
|
export function createServices(config) {
|
|
29
|
-
let serverManager =
|
|
30
|
-
services: {}
|
|
31
|
-
});
|
|
29
|
+
let serverManager = createServerManager(config, {});
|
|
32
30
|
let testRunner = new TestRunner(config, serverManager);
|
|
33
31
|
return {
|
|
34
32
|
serverManager,
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import { dirname, join } from 'node:path';
|
|
10
|
-
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
11
11
|
let __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
12
|
|
|
13
13
|
/**
|
|
@@ -119,6 +119,13 @@ ${css}
|
|
|
119
119
|
</body>
|
|
120
120
|
</html>`;
|
|
121
121
|
}
|
|
122
|
+
export async function loadStaticReportRenderer(ssrModulePath = getSSRModulePath()) {
|
|
123
|
+
let moduleUrl = pathToFileURL(ssrModulePath).href;
|
|
124
|
+
let {
|
|
125
|
+
renderStaticReport
|
|
126
|
+
} = await import(moduleUrl);
|
|
127
|
+
return renderStaticReport;
|
|
128
|
+
}
|
|
122
129
|
|
|
123
130
|
/**
|
|
124
131
|
* Generate a static report from the current TDD results
|
|
@@ -155,17 +162,9 @@ export async function generateStaticReport(workingDir, options = {}) {
|
|
|
155
162
|
|
|
156
163
|
// Transform image URLs to relative paths
|
|
157
164
|
let transformedData = transformImageUrls(reportData);
|
|
158
|
-
|
|
159
|
-
// Load and use the SSR module
|
|
160
|
-
let ssrModulePath = getSSRModulePath();
|
|
161
|
-
let {
|
|
162
|
-
renderStaticReport
|
|
163
|
-
} = await import(ssrModulePath);
|
|
165
|
+
let renderStaticReport = options.renderStaticReport || (await loadStaticReportRenderer());
|
|
164
166
|
let renderedContent = renderStaticReport(transformedData);
|
|
165
|
-
|
|
166
|
-
// Get CSS
|
|
167
|
-
let reporterDistPath = getReporterDistPath();
|
|
168
|
-
let css = readFileSync(join(reporterDistPath, 'reporter-bundle.css'), 'utf8');
|
|
167
|
+
let css = options.css ?? readFileSync(join(getReporterDistPath(), 'reporter-bundle.css'), 'utf8');
|
|
169
168
|
|
|
170
169
|
// Create output directory
|
|
171
170
|
mkdirSync(outputDir, {
|
|
@@ -201,5 +200,5 @@ export async function generateStaticReport(workingDir, options = {}) {
|
|
|
201
200
|
* Get the file:// URL for a report path
|
|
202
201
|
*/
|
|
203
202
|
export function getReportFileUrl(reportPath) {
|
|
204
|
-
return
|
|
203
|
+
return pathToFileURL(reportPath).href;
|
|
205
204
|
}
|
|
@@ -2,16 +2,15 @@
|
|
|
2
2
|
* Test Runner Service
|
|
3
3
|
* Orchestrates the test execution flow
|
|
4
4
|
*
|
|
5
|
-
* This
|
|
6
|
-
* src/test-runner/.
|
|
7
|
-
* delegating to pure functions for testability.
|
|
5
|
+
* This EventEmitter adapter keeps the stable plugin-facing runner contract
|
|
6
|
+
* while delegating execution to the functional operations in src/test-runner/.
|
|
8
7
|
*/
|
|
9
8
|
|
|
10
9
|
import { spawn } from 'node:child_process';
|
|
11
10
|
import { EventEmitter } from 'node:events';
|
|
12
11
|
import { createBuild as createApiBuild, createApiClient, finalizeBuild as finalizeApiBuild, getBuild } from '../api/index.js';
|
|
13
12
|
import { VizzlyError } from '../errors/vizzly-error.js';
|
|
14
|
-
import { cancelTests, createBuild, finalizeBuild, initializeDaemon, runTests } from '../test-runner/index.js';
|
|
13
|
+
import { cancelTests, createBuild, fetchBuildUrl, finalizeBuild, initializeDaemon, runTests } from '../test-runner/index.js';
|
|
15
14
|
import * as output from '../utils/output.js';
|
|
16
15
|
import { writeSession as defaultWriteSession } from '../utils/session.js';
|
|
17
16
|
import { createBuildObject } from './build-manager.js';
|
|
@@ -81,6 +80,7 @@ export class TestRunner extends EventEmitter {
|
|
|
81
80
|
return result;
|
|
82
81
|
}
|
|
83
82
|
async createBuild(options, tdd) {
|
|
83
|
+
let createdApiBuild = null;
|
|
84
84
|
let buildId = await createBuild({
|
|
85
85
|
runOptions: options,
|
|
86
86
|
tdd,
|
|
@@ -88,11 +88,23 @@ export class TestRunner extends EventEmitter {
|
|
|
88
88
|
deps: {
|
|
89
89
|
buildManager: this.buildManager,
|
|
90
90
|
createApiClient: this.deps.createApiClient,
|
|
91
|
-
createApiBuild:
|
|
91
|
+
createApiBuild: async (client, payload) => {
|
|
92
|
+
createdApiBuild = await this.deps.createApiBuild(client, payload);
|
|
93
|
+
return createdApiBuild;
|
|
94
|
+
},
|
|
92
95
|
output: this.deps.output
|
|
93
96
|
}
|
|
94
97
|
});
|
|
95
98
|
if (!tdd && buildId) {
|
|
99
|
+
let buildUrl = createdApiBuild?.url || (await fetchBuildUrl({
|
|
100
|
+
buildId,
|
|
101
|
+
config: this.config,
|
|
102
|
+
deps: {
|
|
103
|
+
createApiClient: this.deps.createApiClient,
|
|
104
|
+
getBuild: this.deps.getBuild,
|
|
105
|
+
output: this.deps.output
|
|
106
|
+
}
|
|
107
|
+
}));
|
|
96
108
|
let writeSession = this.deps.writeSession || defaultWriteSession;
|
|
97
109
|
writeSession({
|
|
98
110
|
buildId,
|
|
@@ -100,6 +112,10 @@ export class TestRunner extends EventEmitter {
|
|
|
100
112
|
commit: options?.commit,
|
|
101
113
|
parallelId: options?.parallelId
|
|
102
114
|
});
|
|
115
|
+
this.emit('build-created', {
|
|
116
|
+
buildId,
|
|
117
|
+
url: buildUrl
|
|
118
|
+
});
|
|
103
119
|
}
|
|
104
120
|
return buildId;
|
|
105
121
|
}
|
|
@@ -119,28 +135,6 @@ export class TestRunner extends EventEmitter {
|
|
|
119
135
|
}
|
|
120
136
|
});
|
|
121
137
|
}
|
|
122
|
-
async executeTestCommand(testCommand, env) {
|
|
123
|
-
return new Promise((resolve, reject) => {
|
|
124
|
-
let proc = this.deps.spawn(testCommand, {
|
|
125
|
-
env,
|
|
126
|
-
stdio: 'inherit',
|
|
127
|
-
shell: true
|
|
128
|
-
});
|
|
129
|
-
this.testProcess = proc;
|
|
130
|
-
proc.on('error', error => {
|
|
131
|
-
reject(this.deps.createError(`Failed to run test command: ${error.message}`, 'TEST_COMMAND_FAILED'));
|
|
132
|
-
});
|
|
133
|
-
proc.on('exit', (code, signal) => {
|
|
134
|
-
if (signal === 'SIGINT') {
|
|
135
|
-
reject(this.deps.createError('Test command was interrupted', 'TEST_COMMAND_INTERRUPTED'));
|
|
136
|
-
} else if (code !== 0) {
|
|
137
|
-
reject(this.deps.createError(`Test command exited with code ${code}`, 'TEST_COMMAND_FAILED'));
|
|
138
|
-
} else {
|
|
139
|
-
resolve();
|
|
140
|
-
}
|
|
141
|
-
});
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
138
|
async cancel() {
|
|
145
139
|
await cancelTests({
|
|
146
140
|
testProcess: this.testProcess,
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* 2D boxes that users have manually confirmed via the cloud UI.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
let REGION_CENTER_TOLERANCE = 10;
|
|
13
|
+
|
|
12
14
|
/**
|
|
13
15
|
* Check if a diff cluster intersects with a region (2D box intersection)
|
|
14
16
|
*
|
|
@@ -41,13 +43,77 @@ export function clusterIntersectsRegion(cluster, region) {
|
|
|
41
43
|
let noOverlap = clusterX2 < region.x1 || clusterX1 > region.x2 || clusterY2 < region.y1 || clusterY1 > region.y2;
|
|
42
44
|
return !noOverlap;
|
|
43
45
|
}
|
|
46
|
+
function normalizeBoundingBox(cluster) {
|
|
47
|
+
let box = cluster?.boundingBox || cluster;
|
|
48
|
+
if (!box) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
if (Number.isFinite(box.x) && Number.isFinite(box.y) && Number.isFinite(box.width) && Number.isFinite(box.height)) {
|
|
52
|
+
return {
|
|
53
|
+
x1: box.x,
|
|
54
|
+
y1: box.y,
|
|
55
|
+
x2: box.x + box.width,
|
|
56
|
+
y2: box.y + box.height,
|
|
57
|
+
width: Math.abs(box.width),
|
|
58
|
+
height: Math.abs(box.height)
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (Number.isFinite(box.x1) && Number.isFinite(box.y1) && Number.isFinite(box.x2) && Number.isFinite(box.y2)) {
|
|
62
|
+
let width = Math.abs(box.x2 - box.x1);
|
|
63
|
+
let height = Math.abs(box.y2 - box.y1);
|
|
64
|
+
return {
|
|
65
|
+
x1: box.x1,
|
|
66
|
+
y1: box.y1,
|
|
67
|
+
x2: box.x2,
|
|
68
|
+
y2: box.y2,
|
|
69
|
+
width,
|
|
70
|
+
height
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
function getBoundingBoxCenter(box) {
|
|
76
|
+
return {
|
|
77
|
+
x: (box.x1 + box.x2) / 2,
|
|
78
|
+
y: (box.y1 + box.y2) / 2
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function getRegionLabel(region) {
|
|
82
|
+
return region.id || region.label || null;
|
|
83
|
+
}
|
|
84
|
+
function clusterMatchesRegion(cluster, region) {
|
|
85
|
+
let clusterBox = normalizeBoundingBox(cluster);
|
|
86
|
+
let regionBox = normalizeBoundingBox(region);
|
|
87
|
+
if (!clusterBox || !regionBox) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
let clusterCenter = getBoundingBoxCenter(clusterBox);
|
|
91
|
+
let regionCenter = getBoundingBoxCenter(regionBox);
|
|
92
|
+
let distance = Math.sqrt((clusterCenter.x - regionCenter.x) ** 2 + (clusterCenter.y - regionCenter.y) ** 2);
|
|
93
|
+
return distance <= REGION_CENTER_TOLERANCE;
|
|
94
|
+
}
|
|
95
|
+
function estimateClusterPixels(cluster) {
|
|
96
|
+
if (Number.isFinite(cluster?.pixelCount)) {
|
|
97
|
+
return cluster.pixelCount;
|
|
98
|
+
}
|
|
99
|
+
let box = normalizeBoundingBox(cluster);
|
|
100
|
+
if (!box) {
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
return box.width * box.height * 0.5;
|
|
104
|
+
}
|
|
44
105
|
|
|
45
106
|
/**
|
|
46
|
-
* Calculate what percentage of
|
|
107
|
+
* Calculate what percentage of changed pixels match confirmed regions.
|
|
108
|
+
*
|
|
109
|
+
* This mirrors cloud dynamic-region approval: clusters match confirmed regions
|
|
110
|
+
* by center proximity, then coverage is weighted by changed pixels. A huge
|
|
111
|
+
* unmatched change should not be hidden just because several tiny clusters
|
|
112
|
+
* match confirmed regions.
|
|
47
113
|
*
|
|
48
114
|
* @param {Array} diffClusters - Array of diff clusters from honeydiff
|
|
49
115
|
* @param {Array} regions - Array of confirmed regions { x1, y1, x2, y2 }
|
|
50
|
-
* @returns {{ coverage: number, clustersInRegions: number, totalClusters: number, matchedRegions: string[] }}
|
|
116
|
+
* @returns {{ coverage: number, clustersInRegions: number, totalClusters: number, matchedRegions: string[], pixelsInRegions: number, totalPixels: number }}
|
|
51
117
|
*/
|
|
52
118
|
export function calculateRegionCoverage(diffClusters, regions) {
|
|
53
119
|
if (!diffClusters || diffClusters.length === 0) {
|
|
@@ -55,7 +121,9 @@ export function calculateRegionCoverage(diffClusters, regions) {
|
|
|
55
121
|
coverage: 0,
|
|
56
122
|
clustersInRegions: 0,
|
|
57
123
|
totalClusters: 0,
|
|
58
|
-
matchedRegions: []
|
|
124
|
+
matchedRegions: [],
|
|
125
|
+
pixelsInRegions: 0,
|
|
126
|
+
totalPixels: 0
|
|
59
127
|
};
|
|
60
128
|
}
|
|
61
129
|
if (!regions || regions.length === 0) {
|
|
@@ -63,35 +131,41 @@ export function calculateRegionCoverage(diffClusters, regions) {
|
|
|
63
131
|
coverage: 0,
|
|
64
132
|
clustersInRegions: 0,
|
|
65
133
|
totalClusters: diffClusters.length,
|
|
66
|
-
matchedRegions: []
|
|
134
|
+
matchedRegions: [],
|
|
135
|
+
pixelsInRegions: 0,
|
|
136
|
+
totalPixels: 0
|
|
67
137
|
};
|
|
68
138
|
}
|
|
69
139
|
let clustersInRegions = 0;
|
|
70
140
|
let matchedRegionIds = new Set();
|
|
141
|
+
let pixelsInRegions = 0;
|
|
142
|
+
let totalPixels = 0;
|
|
71
143
|
for (let cluster of diffClusters) {
|
|
72
|
-
|
|
73
|
-
|
|
144
|
+
let pixelCount = estimateClusterPixels(cluster);
|
|
145
|
+
totalPixels += pixelCount;
|
|
146
|
+
let matchedAnyRegion = false;
|
|
74
147
|
for (let region of regions) {
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (
|
|
79
|
-
matchedRegionIds.add(
|
|
80
|
-
} else if (region.label) {
|
|
81
|
-
matchedRegionIds.add(region.label);
|
|
148
|
+
if (clusterMatchesRegion(cluster, region)) {
|
|
149
|
+
matchedAnyRegion = true;
|
|
150
|
+
let regionLabel = getRegionLabel(region);
|
|
151
|
+
if (regionLabel) {
|
|
152
|
+
matchedRegionIds.add(regionLabel);
|
|
82
153
|
}
|
|
83
154
|
}
|
|
84
155
|
}
|
|
85
|
-
if (
|
|
156
|
+
if (matchedAnyRegion) {
|
|
86
157
|
clustersInRegions++;
|
|
158
|
+
pixelsInRegions += pixelCount;
|
|
87
159
|
}
|
|
88
160
|
}
|
|
89
|
-
let coverage =
|
|
161
|
+
let coverage = totalPixels > 0 ? pixelsInRegions / totalPixels : 0;
|
|
90
162
|
return {
|
|
91
163
|
coverage,
|
|
92
164
|
clustersInRegions,
|
|
93
165
|
totalClusters: diffClusters.length,
|
|
94
|
-
matchedRegions: [...matchedRegionIds]
|
|
166
|
+
matchedRegions: [...matchedRegionIds],
|
|
167
|
+
pixelsInRegions,
|
|
168
|
+
totalPixels
|
|
95
169
|
};
|
|
96
170
|
}
|
|
97
171
|
|
|
@@ -99,17 +173,31 @@ export function calculateRegionCoverage(diffClusters, regions) {
|
|
|
99
173
|
* Determine if a comparison should auto-pass based on region coverage
|
|
100
174
|
*
|
|
101
175
|
* Unlike hotspots which require confidence scoring, user-defined regions
|
|
102
|
-
* are already confirmed by humans
|
|
176
|
+
* are already confirmed by humans. Cloud still requires strong structural
|
|
177
|
+
* similarity so a layout shift cannot hide inside a confirmed box.
|
|
103
178
|
*
|
|
104
179
|
* @param {Array} regions - Confirmed regions (already filtered to confirmed status)
|
|
105
180
|
* @param {{ coverage: number }} coverageResult - Result from calculateRegionCoverage
|
|
181
|
+
* @param {Object} options - Approval thresholds
|
|
182
|
+
* @param {number|null} options.ssimScore - Honeydiff SSIM/perceptual score
|
|
183
|
+
* @param {number} options.coverageThreshold - Required region coverage
|
|
184
|
+
* @param {number} options.ssimThreshold - Required structural similarity
|
|
106
185
|
* @returns {boolean} True if diff should auto-pass as region-filtered
|
|
107
186
|
*/
|
|
108
|
-
export function shouldAutoApproveFromRegions(regions, coverageResult) {
|
|
187
|
+
export function shouldAutoApproveFromRegions(regions, coverageResult, options = {}) {
|
|
109
188
|
if (!regions || regions.length === 0 || !coverageResult) {
|
|
110
189
|
return false;
|
|
111
190
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
191
|
+
let {
|
|
192
|
+
ssimScore = null,
|
|
193
|
+
coverageThreshold = 0.9,
|
|
194
|
+
ssimThreshold = 0.95
|
|
195
|
+
} = options;
|
|
196
|
+
if (coverageResult.coverage < coverageThreshold) {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
if (ssimScore === null || ssimScore === undefined) {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
return ssimScore >= ssimThreshold;
|
|
115
203
|
}
|
package/dist/tdd/index.js
CHANGED
|
@@ -15,5 +15,5 @@ export { baselineMatchesSha, buildBaselineMetadataEntry, downloadBaselineImage,
|
|
|
15
15
|
// Services
|
|
16
16
|
export { baselineExists, clearBaselineData, getBaselinePath, getCurrentPath, getDiffPath, initializeDirectories, promoteCurrentToBaseline, readBaseline, readCurrent, saveBaseline, saveCurrent } from './services/baseline-manager.js';
|
|
17
17
|
export { buildErrorComparison, buildFailedComparison, buildNewComparison, buildPassedComparison, compareImages, isDimensionMismatchError } from './services/comparison-service.js';
|
|
18
|
-
export { downloadHotspots
|
|
18
|
+
export { downloadHotspots } from './services/hotspot-service.js';
|
|
19
19
|
export { buildResults, calculateSummary, findComparison, findComparisonById, getErrorComparisons, getFailedComparisons, getNewComparisons, isSuccessful } from './services/result-service.js';
|
|
@@ -53,41 +53,4 @@ export function saveRegionMetadata(workingDir, regionData, summary = {}) {
|
|
|
53
53
|
regions: regionData
|
|
54
54
|
};
|
|
55
55
|
writeFileSync(regionsPath, JSON.stringify(content, null, 2));
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Get regions for a specific screenshot with caching support
|
|
60
|
-
*
|
|
61
|
-
* This is a pure function that takes a cache object as parameter
|
|
62
|
-
* for stateless operation. The cache is mutated if data needs to be loaded.
|
|
63
|
-
*
|
|
64
|
-
* @param {Object} cache - Cache object { data: Object|null, loaded: boolean }
|
|
65
|
-
* @param {string} workingDir - Working directory
|
|
66
|
-
* @param {string} screenshotName - Name of the screenshot
|
|
67
|
-
* @returns {Object|null} Region data or null if not available
|
|
68
|
-
*/
|
|
69
|
-
export function getRegionsForScreenshot(cache, workingDir, screenshotName) {
|
|
70
|
-
// Check cache first
|
|
71
|
-
if (cache.data?.[screenshotName]) {
|
|
72
|
-
return cache.data[screenshotName];
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Load from disk if not yet loaded
|
|
76
|
-
if (!cache.loaded) {
|
|
77
|
-
cache.data = loadRegionMetadata(workingDir);
|
|
78
|
-
cache.loaded = true;
|
|
79
|
-
}
|
|
80
|
-
return cache.data?.[screenshotName] || null;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Create an empty region cache object
|
|
85
|
-
*
|
|
86
|
-
* @returns {{ data: null, loaded: boolean }}
|
|
87
|
-
*/
|
|
88
|
-
export function createRegionCache() {
|
|
89
|
-
return {
|
|
90
|
-
data: null,
|
|
91
|
-
loaded: false
|
|
92
|
-
};
|
|
93
56
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime context assembly for TddService constructor.
|
|
3
|
+
* Keeps dependency merging and path bootstrap logic isolated and testable.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function buildTddDependencyOps(deps = {}, defaults = {}) {
|
|
7
|
+
let {
|
|
8
|
+
output = defaults.output,
|
|
9
|
+
colors = defaults.colors,
|
|
10
|
+
validatePathSecurity = defaults.validatePathSecurity,
|
|
11
|
+
initializeDirectories = defaults.initializeDirectories,
|
|
12
|
+
calculateHotspotCoverage = defaults.calculateHotspotCoverage,
|
|
13
|
+
fs = {},
|
|
14
|
+
api = {},
|
|
15
|
+
metadata = {},
|
|
16
|
+
baseline = {},
|
|
17
|
+
comparison = {},
|
|
18
|
+
signature = {},
|
|
19
|
+
results = {}
|
|
20
|
+
} = deps;
|
|
21
|
+
let fsOps = {
|
|
22
|
+
...defaults.fs,
|
|
23
|
+
...fs
|
|
24
|
+
};
|
|
25
|
+
let apiOps = {
|
|
26
|
+
...defaults.api,
|
|
27
|
+
...api
|
|
28
|
+
};
|
|
29
|
+
let metadataOps = {
|
|
30
|
+
...defaults.metadata,
|
|
31
|
+
...metadata
|
|
32
|
+
};
|
|
33
|
+
let baselineOps = {
|
|
34
|
+
...defaults.baseline,
|
|
35
|
+
...baseline
|
|
36
|
+
};
|
|
37
|
+
let comparisonOps = {
|
|
38
|
+
...defaults.comparison,
|
|
39
|
+
...comparison
|
|
40
|
+
};
|
|
41
|
+
let signatureOps = {
|
|
42
|
+
...defaults.signature,
|
|
43
|
+
...signature
|
|
44
|
+
};
|
|
45
|
+
let resultsOps = {
|
|
46
|
+
...defaults.results,
|
|
47
|
+
...results
|
|
48
|
+
};
|
|
49
|
+
let runtimeDeps = {
|
|
50
|
+
output,
|
|
51
|
+
colors,
|
|
52
|
+
validatePathSecurity,
|
|
53
|
+
initializeDirectories,
|
|
54
|
+
calculateHotspotCoverage,
|
|
55
|
+
...fsOps,
|
|
56
|
+
...apiOps,
|
|
57
|
+
...metadataOps,
|
|
58
|
+
...baselineOps,
|
|
59
|
+
...comparisonOps,
|
|
60
|
+
...signatureOps,
|
|
61
|
+
...resultsOps
|
|
62
|
+
};
|
|
63
|
+
return {
|
|
64
|
+
runtimeDeps,
|
|
65
|
+
apiOps
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export function resolveTddWorkingDirectory(workingDir, validatePathSecurity, output) {
|
|
69
|
+
try {
|
|
70
|
+
return validatePathSecurity(workingDir, workingDir);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
output.error(`Invalid working directory: ${error.message}`);
|
|
73
|
+
throw new Error(`Working directory validation failed: ${error.message}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export function resolveTddPaths(workingDir, initializeDirectories) {
|
|
77
|
+
return initializeDirectories(workingDir);
|
|
78
|
+
}
|