snapdrift 0.2.0 → 0.3.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.
@@ -1,280 +1,21 @@
1
1
  // @ts-check
2
-
3
- import fs from 'node:fs/promises';
4
- import path from 'node:path';
5
-
6
- export const DEFAULT_CONFIG_PATH = path.resolve('.github', 'snapdrift.json');
7
- export const VALID_DIFF_MODES = ['report-only', 'fail-on-changes', 'fail-on-incomplete', 'strict'];
8
-
9
- export const SNAPDRIFT_VIEWPORT_PRESETS = {
10
- desktop: {
11
- width: 1440,
12
- height: 900,
13
- deviceScaleFactor: 1,
14
- isMobile: false,
15
- hasTouch: false
16
- },
17
- mobile: {
18
- width: 390,
19
- height: 844,
20
- deviceScaleFactor: 3,
21
- isMobile: true,
22
- hasTouch: true
23
- }
24
- };
25
-
26
- export const SNAPDRIFT_NAVIGATION_TIMEOUT_MS = 30000;
27
- export const SNAPDRIFT_SETTLE_DELAY_MS = 300;
28
-
29
- /**
30
- * @param {string[]} names
31
- * @returns {string | undefined}
32
- */
33
- export function readFirstDefinedEnv(names) {
34
- for (const name of names) {
35
- const value = process.env[name];
36
- if (typeof value === 'string' && value.length > 0) {
37
- return value;
38
- }
39
- }
40
- return undefined;
41
- }
42
-
43
- /**
44
- * @typedef {import('../types/visual-diff-types').VisualRegressionConfig} SnapdriftConfig
45
- * @typedef {import('../types/visual-diff-types').VisualRegressionRouteConfig} SnapdriftRouteConfig
46
- */
47
-
48
- const VALID_VIEWPORT_PRESETS = new Set(Object.keys(SNAPDRIFT_VIEWPORT_PRESETS));
49
-
50
- /**
51
- * @param {unknown} value
52
- * @returns {boolean}
53
- */
54
- function isValidCustomViewport(value) {
55
- if (!isRecord(value)) return false;
56
- const rec = /** @type {Record<string,unknown>} */ (value);
57
- return Number.isInteger(rec.width) && /** @type {number} */ (rec.width) > 0 &&
58
- Number.isInteger(rec.height) && /** @type {number} */ (rec.height) > 0;
59
- }
60
- const VALID_DIFF_MODE_SET = new Set(VALID_DIFF_MODES);
61
-
62
- /**
63
- * @param {unknown} value
64
- * @returns {value is Record<string, unknown>}
65
- */
66
- function isRecord(value) {
67
- return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
68
- }
69
-
70
- /**
71
- * @param {unknown} value
72
- * @returns {value is string}
73
- */
74
- function isNonEmptyString(value) {
75
- return typeof value === 'string' && value.trim().length > 0;
76
- }
77
-
78
- /**
79
- * @param {unknown} value
80
- * @returns {value is string[]}
81
- */
82
- function isNonEmptyStringArray(value) {
83
- return Array.isArray(value) && value.every((item) => isNonEmptyString(item));
84
- }
85
-
86
- /**
87
- * @param {string | undefined} value
88
- * @returns {string[]}
89
- */
90
- export function splitCommaList(value) {
91
- return String(value || '')
92
- .split(',')
93
- .map((item) => item.trim())
94
- .filter(Boolean);
95
- }
96
-
97
- /**
98
- * @param {unknown} value
99
- * @param {string} sourceLabel
100
- * @returns {SnapdriftConfig}
101
- */
102
- export function validateSnapdriftConfig(value, sourceLabel = 'inline config') {
103
- /** @type {string[]} */
104
- const errors = [];
105
-
106
- if (!isRecord(value)) {
107
- throw new Error(`Invalid SnapDrift config at ${sourceLabel}: expected a JSON object.`);
108
- }
109
-
110
- const candidate = /** @type {Record<string, unknown>} */ (value);
111
- const requiredStringFields = [
112
- 'baselineArtifactName',
113
- 'workingDirectory',
114
- 'baseUrl',
115
- 'resultsFile',
116
- 'manifestFile',
117
- 'screenshotsRoot'
118
- ];
119
-
120
- for (const fieldName of requiredStringFields) {
121
- if (!isNonEmptyString(candidate[fieldName])) {
122
- errors.push(`${fieldName} must be a non-empty string.`);
123
- }
124
- }
125
-
126
- if (!Array.isArray(candidate.routes) || candidate.routes.length === 0) {
127
- errors.push('routes must contain at least one route definition.');
128
- } else {
129
- const routeIds = new Set();
130
-
131
- for (const [index, route] of candidate.routes.entries()) {
132
- const routeLabel = `routes[${index}]`;
133
- if (!isRecord(route)) {
134
- errors.push(`${routeLabel} must be an object.`);
135
- continue;
136
- }
137
-
138
- if (!isNonEmptyString(route.id)) {
139
- errors.push(`${routeLabel}.id must be a non-empty string.`);
140
- } else if (routeIds.has(route.id)) {
141
- errors.push(`${routeLabel}.id duplicates the route id "${route.id}".`);
142
- } else {
143
- routeIds.add(route.id);
144
- }
145
-
146
- if (!isNonEmptyString(route.path)) {
147
- errors.push(`${routeLabel}.path must be a non-empty string.`);
148
- }
149
-
150
- const viewportValid = (isNonEmptyString(route.viewport) && VALID_VIEWPORT_PRESETS.has(route.viewport)) ||
151
- isValidCustomViewport(route.viewport);
152
- if (!viewportValid) {
153
- errors.push(`${routeLabel}.viewport must be one of: ${[...VALID_VIEWPORT_PRESETS].join(', ')} or an object with positive integer width and height.`);
154
- }
155
-
156
- if (route.changePaths !== undefined && !isNonEmptyStringArray(route.changePaths)) {
157
- errors.push(`${routeLabel}.changePaths must be an array of non-empty strings when provided.`);
158
- }
159
- }
160
- }
161
-
162
- if (!isRecord(candidate.diff)) {
163
- errors.push('diff must be an object.');
164
- } else {
165
- if (typeof candidate.diff.threshold !== 'number' || !Number.isFinite(candidate.diff.threshold)) {
166
- errors.push('diff.threshold must be a finite number.');
167
- } else if (candidate.diff.threshold < 0 || candidate.diff.threshold > 1) {
168
- errors.push('diff.threshold must be between 0 and 1.');
169
- }
170
-
171
- if (!isNonEmptyString(candidate.diff.mode) || !VALID_DIFF_MODE_SET.has(candidate.diff.mode)) {
172
- errors.push(`diff.mode must be one of: ${VALID_DIFF_MODES.join(', ')}.`);
173
- }
174
- }
175
-
176
- if (candidate.selection !== undefined) {
177
- if (!isRecord(candidate.selection)) {
178
- errors.push('selection must be an object when provided.');
179
- } else {
180
- if (candidate.selection.sharedPrefixes !== undefined && !isNonEmptyStringArray(candidate.selection.sharedPrefixes)) {
181
- errors.push('selection.sharedPrefixes must be an array of non-empty strings when provided.');
182
- }
183
- if (candidate.selection.sharedExact !== undefined && !isNonEmptyStringArray(candidate.selection.sharedExact)) {
184
- errors.push('selection.sharedExact must be an array of non-empty strings when provided.');
185
- }
186
- }
187
- }
188
-
189
- if (errors.length > 0) {
190
- throw new Error(`Invalid SnapDrift config at ${sourceLabel}:\n- ${errors.join('\n- ')}`);
191
- }
192
-
193
- return /** @type {SnapdriftConfig} */ (/** @type {unknown} */ (value));
194
- }
195
-
196
- /**
197
- * @param {string | undefined} [configPath]
198
- * @returns {Promise<{ config: SnapdriftConfig, configPath: string }>}
199
- */
200
- export async function loadSnapdriftConfig(configPath = readFirstDefinedEnv(['SNAPDRIFT_CONFIG_PATH'])) {
201
- const resolvedConfigPath = configPath ? path.resolve(configPath) : DEFAULT_CONFIG_PATH;
202
- const raw = await fs.readFile(resolvedConfigPath, 'utf8');
203
- const parsed = JSON.parse(raw);
204
-
205
- return {
206
- config: validateSnapdriftConfig(parsed, resolvedConfigPath),
207
- configPath: resolvedConfigPath
208
- };
209
- }
210
-
211
- /**
212
- * @param {SnapdriftConfig} config
213
- * @param {string} relativePath
214
- * @returns {string}
215
- */
216
- export function resolveFromWorkingDirectory(config, relativePath) {
217
- return path.resolve(config.workingDirectory || '.', relativePath);
218
- }
219
-
220
- /**
221
- * @param {SnapdriftConfig} config
222
- * @param {Iterable<string>} requestedRouteIds
223
- * @returns {{ routes: SnapdriftRouteConfig[], selectedRouteIds: string[] }}
224
- */
225
- export function selectConfiguredRoutes(config, requestedRouteIds) {
226
- const requestedIds = new Set([...requestedRouteIds].filter(Boolean));
227
- if (requestedIds.size === 0) {
228
- return {
229
- routes: [...config.routes],
230
- selectedRouteIds: config.routes.map((route) => route.id)
231
- };
232
- }
233
-
234
- const routes = config.routes.filter((route) => requestedIds.has(route.id));
235
- const missingRouteIds = [...requestedIds].filter((routeId) => !routes.some((route) => route.id === routeId));
236
- if (missingRouteIds.length > 0) {
237
- throw new Error(`Unknown SnapDrift route ids: ${missingRouteIds.join(', ')}`);
238
- }
239
-
240
- return {
241
- routes,
242
- selectedRouteIds: routes.map((route) => route.id)
243
- };
244
- }
245
-
246
- /**
247
- * @param {SnapdriftConfig} config
248
- * @param {string[]} changedFiles
249
- * @returns {{ shouldRun: boolean, reason: string, selectedRouteIds: string[] }}
250
- */
251
- export function selectRoutesForChangedFiles(config, changedFiles) {
252
- const sharedPrefixes = config.selection?.sharedPrefixes || [];
253
- const sharedExact = new Set(config.selection?.sharedExact || []);
254
-
255
- if (changedFiles.some((file) => sharedExact.has(file) || sharedPrefixes.some((prefix) => file.startsWith(prefix)))) {
256
- return {
257
- shouldRun: true,
258
- reason: 'shared_snapdrift_change',
259
- selectedRouteIds: config.routes.map((route) => route.id)
260
- };
261
- }
262
-
263
- const selectedRouteIds = config.routes
264
- .filter((route) => (route.changePaths || []).some((prefix) => changedFiles.some((file) => file.startsWith(prefix))))
265
- .map((route) => route.id);
266
-
267
- if (selectedRouteIds.length > 0) {
268
- return {
269
- shouldRun: true,
270
- reason: 'scoped_snapdrift_change',
271
- selectedRouteIds
272
- };
273
- }
274
-
275
- return {
276
- shouldRun: false,
277
- reason: changedFiles.length > 0 ? 'no_snapdrift_relevant_changes' : 'no_changed_files',
278
- selectedRouteIds: []
279
- };
280
- }
2
+ // Re-exports from packages — this module is a thin shim.
3
+
4
+ export {
5
+ VIEWPORT_PRESETS as SNAPDRIFT_VIEWPORT_PRESETS,
6
+ SNAPDRIFT_NAVIGATION_TIMEOUT_MS,
7
+ SNAPDRIFT_SETTLE_DELAY_MS,
8
+ validateSnapdriftConfig,
9
+ selectConfiguredRoutes,
10
+ selectRoutesForChangedFiles,
11
+ resolveFromWorkingDirectory,
12
+ splitCommaList,
13
+ VALID_DIFF_MODES
14
+ } from '@snapdrift/manifest';
15
+
16
+ export {
17
+ loadSnapdriftConfig,
18
+ readFirstDefinedEnv,
19
+ DEFAULT_CONFIG_PATH,
20
+ SNAPDRIFT_CAPTURE_CONCURRENCY
21
+ } from '@snapdrift/adapter-fs';
@@ -1,125 +1,4 @@
1
1
  // @ts-check
2
+ // Re-exports from packages — this module is a thin shim.
2
3
 
3
- import fs from 'node:fs/promises';
4
- import path from 'node:path';
5
-
6
- /**
7
- * @param {'baseline' | 'diff'} artifactType
8
- * @returns {string}
9
- */
10
- export function getDefaultArtifactBundleDir(artifactType) {
11
- return artifactType === 'baseline'
12
- ? path.join('qa-artifacts', 'snapdrift', 'bundles', 'baseline')
13
- : path.join('qa-artifacts', 'snapdrift', 'bundles', 'drift');
14
- }
15
-
16
- /**
17
- * @param {string} filePath
18
- * @returns {Promise<boolean>}
19
- */
20
- async function exists(filePath) {
21
- try {
22
- await fs.access(filePath);
23
- return true;
24
- } catch {
25
- return false;
26
- }
27
- }
28
-
29
- /**
30
- * @param {string | undefined} sourcePath
31
- * @param {string} targetPath
32
- * @returns {Promise<void>}
33
- */
34
- async function copyFileIfPresent(sourcePath, targetPath) {
35
- if (!sourcePath) {
36
- return;
37
- }
38
-
39
- if (!(await exists(sourcePath))) {
40
- return;
41
- }
42
-
43
- await fs.mkdir(path.dirname(targetPath), { recursive: true });
44
- await fs.copyFile(sourcePath, targetPath);
45
- }
46
-
47
- /**
48
- * @param {string} sourceDir
49
- * @param {string} targetDir
50
- * @returns {Promise<void>}
51
- */
52
- async function copyPngFiles(sourceDir, targetDir) {
53
- if (!(await exists(sourceDir))) {
54
- return;
55
- }
56
-
57
- await fs.mkdir(targetDir, { recursive: true });
58
- const entries = await fs.readdir(sourceDir, { withFileTypes: true });
59
- for (const entry of entries) {
60
- const sourcePath = path.join(sourceDir, entry.name);
61
- if (entry.isDirectory()) {
62
- await copyPngFiles(sourcePath, targetDir);
63
- continue;
64
- }
65
- if (!entry.isFile() || !entry.name.endsWith('.png')) {
66
- continue;
67
- }
68
- await fs.copyFile(sourcePath, path.join(targetDir, entry.name));
69
- }
70
- }
71
-
72
- /**
73
- * @param {{
74
- * artifactType: 'baseline' | 'diff',
75
- * bundleDir?: string,
76
- * resultsPath?: string,
77
- * manifestPath?: string,
78
- * screenshotsDir?: string,
79
- * summaryJsonPath?: string,
80
- * summaryMarkdownPath?: string,
81
- * reportHtmlPath?: string,
82
- * baselineResultsPath?: string,
83
- * currentResultsPath?: string,
84
- * baselineManifestPath?: string,
85
- * currentManifestPath?: string,
86
- * baselineScreenshotsDir?: string,
87
- * currentScreenshotsDir?: string
88
- * }} options
89
- * @returns {Promise<{ bundleDir: string }>}
90
- */
91
- export async function stageArtifacts(options) {
92
- const resolvedBundleDir = path.resolve(options.bundleDir || getDefaultArtifactBundleDir(options.artifactType));
93
-
94
- await fs.rm(resolvedBundleDir, { recursive: true, force: true });
95
-
96
- if (options.artifactType === 'baseline') {
97
- await fs.mkdir(path.join(resolvedBundleDir, 'screenshots'), { recursive: true });
98
- await copyFileIfPresent(options.resultsPath, path.join(resolvedBundleDir, 'results.json'));
99
- await copyFileIfPresent(options.manifestPath, path.join(resolvedBundleDir, 'manifest.json'));
100
- if (options.screenshotsDir) {
101
- await copyPngFiles(options.screenshotsDir, path.join(resolvedBundleDir, 'screenshots'));
102
- }
103
- } else {
104
- await fs.mkdir(path.join(resolvedBundleDir, 'baseline', 'screenshots'), { recursive: true });
105
- await fs.mkdir(path.join(resolvedBundleDir, 'current', 'screenshots'), { recursive: true });
106
- await copyFileIfPresent(options.summaryJsonPath, path.join(resolvedBundleDir, 'summary.json'));
107
- await copyFileIfPresent(options.summaryMarkdownPath, path.join(resolvedBundleDir, 'summary.md'));
108
- await copyFileIfPresent(options.reportHtmlPath, path.join(resolvedBundleDir, 'report.html'));
109
- await copyFileIfPresent(options.baselineResultsPath, path.join(resolvedBundleDir, 'baseline', 'results.json'));
110
- await copyFileIfPresent(options.currentResultsPath, path.join(resolvedBundleDir, 'current', 'results.json'));
111
- await copyFileIfPresent(options.baselineManifestPath, path.join(resolvedBundleDir, 'baseline', 'manifest.json'));
112
- await copyFileIfPresent(options.currentManifestPath, path.join(resolvedBundleDir, 'current', 'manifest.json'));
113
-
114
- if (options.baselineScreenshotsDir) {
115
- await copyPngFiles(options.baselineScreenshotsDir, path.join(resolvedBundleDir, 'baseline', 'screenshots'));
116
- }
117
- if (options.currentScreenshotsDir) {
118
- await copyPngFiles(options.currentScreenshotsDir, path.join(resolvedBundleDir, 'current', 'screenshots'));
119
- }
120
- }
121
-
122
- return {
123
- bundleDir: resolvedBundleDir
124
- };
125
- }
4
+ export { stageArtifacts, getDefaultArtifactBundleDir } from '@snapdrift/adapter-fs';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "snapdrift",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "SnapDrift captures, compares, and reports UI drift for Playwright-based app checks.",
@@ -34,6 +34,9 @@
34
34
  "engines": {
35
35
  "node": ">=22"
36
36
  },
37
+ "workspaces": [
38
+ "packages/*"
39
+ ],
37
40
  "devDependencies": {
38
41
  "@eslint/js": "^10.0.1",
39
42
  "@types/jest": "^30.0.0",
@@ -49,7 +52,8 @@
49
52
  "jest": {
50
53
  "testEnvironment": "node",
51
54
  "testMatch": [
52
- "**/tests/**/*.test.js"
55
+ "<rootDir>/tests/**/*.test.js",
56
+ "<rootDir>/packages/*/tests/**/*.test.js"
53
57
  ],
54
58
  "transform": {},
55
59
  "coverageThreshold": {
@@ -88,6 +92,10 @@
88
92
  "registry": "https://registry.npmjs.org"
89
93
  },
90
94
  "dependencies": {
95
+ "@snapdrift/adapter-fs": "^1.0.0",
96
+ "@snapdrift/adapter-report-md": "^1.0.0",
97
+ "@snapdrift/compare-core": "^1.0.0",
98
+ "@snapdrift/manifest": "^1.0.0",
91
99
  "playwright": "^1.59.1",
92
100
  "pngjs": "^7.0.0"
93
101
  }
@@ -12,6 +12,7 @@ export interface VisualRegressionRouteConfig {
12
12
  path: string;
13
13
  viewport: VisualViewport;
14
14
  changePaths?: string[];
15
+ navigationTimeout?: number;
15
16
  }
16
17
 
17
18
  export interface VisualRegressionConfig {