@percy/cli-exec 1.32.5 → 1.32.6-beta.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.
@@ -0,0 +1,336 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import url from 'url';
5
+ import { createResource, createRootResource } from '@percy/cli-command/utils';
6
+
7
+ // Drop-in baseline seeding — the `percy exec` side.
8
+ //
9
+ // Provider contract (framework knowledge stays in the SDK package, as with command discovery):
10
+ // any installed Percy SDK can declare
11
+ //
12
+ // "@percy/cli": { "baselineProvider": "./path/to/module.js" }
13
+ //
14
+ // in its package.json. The module's default export must be:
15
+ //
16
+ // {
17
+ // buildSource, // drop-in source tag for the head build (e.g. 'playwright-dropin')
18
+ // async discoverBaselines({ cwd, log })
19
+ // // -> { baselines: [{ filepath, name, browserFamily, width, height }], degraded?, reason? }
20
+ // }
21
+ //
22
+ // On an empty Percy project, `percy exec` uploads committed baseline screenshots as build #1
23
+ // (auto-approved server-side, the user's suite never runs for it) before starting the head
24
+ // build. On an established project nothing is seeded; the user is pointed at the explicit
25
+ // `percy playwright:setup-baseline` command instead.
26
+
27
+ // Parallel seed-upload cap: fast on large baseline sets without stampeding the API.
28
+ const SEED_CONCURRENCY = 8;
29
+ const BASELINE_SOURCE = 'playwright-dropin-baseline';
30
+
31
+ // Path hygiene at the fs boundary: directory-entry names must be single path components (a name
32
+ // containing a separator or dot-segment never comes from an honest readdir) and path strings
33
+ // are NUL-stripped.
34
+ export function sanitizePath(p) {
35
+ return String(p).replace(/\0/g, '');
36
+ }
37
+ export function sanitizeDirentName(name) {
38
+ let clean = String(name).replace(/\0/g, '');
39
+ if (!clean || clean === '.' || clean === '..') return null;
40
+ if (clean.includes('/') || clean.includes('\\')) return null;
41
+ return clean;
42
+ }
43
+
44
+ // Collect @percy/* (and percy-cli-*) package roots from the nearest node_modules — the same
45
+ // semantics as @percy/cli's command-discovery walk (findModulePackages): stop at the FIRST
46
+ // node_modules at or above `dir`, never cross the home directory, and degrade to [] on any
47
+ // filesystem error so discovery can never break `percy exec`.
48
+ function findPercyPackages(dir, log) {
49
+ try {
50
+ dir = sanitizePath(dir);
51
+
52
+ // not given node_modules or a directory that contains node_modules, look up
53
+ if (path.basename(dir) !== 'node_modules') {
54
+ let modulesDir = path.join(dir, 'node_modules');
55
+ let next = fs.existsSync(modulesDir) ? modulesDir : path.dirname(dir);
56
+ if (next === dir || next === os.homedir()) return [];
57
+ return findPercyPackages(next, log);
58
+ }
59
+ let found = [];
60
+ for (let entry of fs.readdirSync(dir)) {
61
+ let name = sanitizeDirentName(entry);
62
+ // istanbul ignore next: readdir yields single path components — defense-in-depth only
63
+ if (name === null) continue;
64
+ if (name === '@percy') {
65
+ for (let scopedEntry of fs.readdirSync(path.join(dir, name))) {
66
+ let scoped = sanitizeDirentName(scopedEntry);
67
+ // istanbul ignore next: readdir yields single path components — defense-in-depth only
68
+ if (scoped === null) continue;
69
+ found.push(path.join(dir, name, scoped));
70
+ }
71
+ } else if (name.startsWith('percy-cli-')) {
72
+ found.push(path.join(dir, name));
73
+ }
74
+ }
75
+ return found;
76
+ } catch (err) {
77
+ log === null || log === void 0 || log.debug(`Baseline provider discovery walk failed: ${err.message}`);
78
+ return [];
79
+ }
80
+ }
81
+
82
+ // Find the first installed package declaring a baseline provider and import it. Returns null
83
+ // when none is installed or when the drop-in is disabled (PERCY_DROPIN_DISABLE is the same
84
+ // switch the SDK override honors, so one env var turns off both the matcher and the seeding).
85
+ export async function findBaselineProvider({
86
+ cwd = process.cwd(),
87
+ log
88
+ } = {}) {
89
+ if (process.env.PERCY_DROPIN_DISABLE === 'true') {
90
+ log === null || log === void 0 || log.debug('Drop-in disabled via PERCY_DROPIN_DISABLE — skipping baseline provider discovery');
91
+ return null;
92
+ }
93
+ for (let pkgPath of findPercyPackages(cwd, log)) {
94
+ let pkgFile = path.join(sanitizePath(pkgPath), 'package.json');
95
+ try {
96
+ var _pkg$PercyCli;
97
+ if (!fs.existsSync(pkgFile)) continue;
98
+ let pkg = JSON.parse(fs.readFileSync(pkgFile, 'utf-8'));
99
+ let providerPath = (_pkg$PercyCli = pkg['@percy/cli']) === null || _pkg$PercyCli === void 0 ? void 0 : _pkg$PercyCli.baselineProvider;
100
+ if (!providerPath) continue;
101
+
102
+ // Confine the provider module to the declaring package — a package.json pointing outside
103
+ // its own root (../../x.js) is malformed at best and gets skipped.
104
+ let resolved = path.resolve(sanitizePath(pkgPath), sanitizePath(providerPath));
105
+ if (!resolved.startsWith(path.resolve(sanitizePath(pkgPath)) + path.sep)) {
106
+ log === null || log === void 0 || log.debug(`Skipping baseline provider from ${pkgPath}: provider path escapes the package`);
107
+ continue;
108
+ }
109
+ let module = await import(url.pathToFileURL(resolved).href);
110
+ let provider = module.default || module;
111
+ if (typeof provider.discoverBaselines === 'function') {
112
+ return {
113
+ ...provider,
114
+ packageName: pkg.name
115
+ };
116
+ }
117
+ } catch (err) {
118
+ log === null || log === void 0 || log.debug(`Skipping baseline provider from ${pkgPath}: ${err.message}`);
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+
124
+ // The seed build keeps processing (renders + auto-approval) after finalize. The head build must
125
+ // not start until it reaches a terminal state — head snapshots select their baseline as they are
126
+ // processed, and an unapproved seed means the whole first run shows as new instead of diffing.
127
+ // The timeout matches the pipeline latency budget (~99% of builds finish under 5 minutes) —
128
+ // a seed of committed screenshots still renders server-side, so first runs can hold for minutes.
129
+ export async function waitForSeedBuild(client, buildId, {
130
+ log,
131
+ timeout = 600000,
132
+ interval = 5000
133
+ }) {
134
+ let deadline = Date.now() + timeout;
135
+ let state = 'pending';
136
+ let polls = 0;
137
+ for (;;) {
138
+ ({
139
+ state
140
+ } = (await client.getBuild(buildId)).data.attributes);
141
+ if (state !== 'pending' && state !== 'processing') return state;
142
+ if (Date.now() >= deadline) return state;
143
+ // A visible heartbeat every ~30s so a multi-minute first-run hold doesn't look like a hang.
144
+ log[polls++ % 6 === 0 ? 'info' : 'debug'](`Baseline build still ${state} — waiting for it to finish before tests start`);
145
+ await new Promise(resolve => setTimeout(resolve, interval));
146
+ }
147
+ }
148
+
149
+ // Establish the project's baseline from committed screenshots BEFORE the head build starts.
150
+ // Never throws — a seeding problem must not break `percy exec`. Returns true when a baseline
151
+ // build was created and finalized.
152
+ export async function maybeSeedBaseline(percy, provider, {
153
+ log,
154
+ waitTimeout,
155
+ waitInterval
156
+ }) {
157
+ try {
158
+ var _res$data, _res$data$attributes;
159
+ // Percy on Automate captures happen on the remote BrowserStack browser — committed local
160
+ // screenshots can never pair with those comparisons, so there is nothing to seed; the
161
+ // project's baseline is established by its first run instead.
162
+ if (percy.projectType === 'automate') {
163
+ log.debug('Automate project — baseline seeding does not apply (the first run establishes the baseline)');
164
+ return false;
165
+ }
166
+
167
+ // Parallel shards would race each other seeding; the head build dedup doesn't apply to the
168
+ // separate seed build, so leave parallel runs to the explicit setup command.
169
+ if (process.env.PERCY_PARALLEL_TOTAL) {
170
+ log.debug('Skipping baseline seeding for a parallel build');
171
+ return false;
172
+ }
173
+ let {
174
+ baselines = [],
175
+ degraded,
176
+ reason
177
+ } = (await provider.discoverBaselines({
178
+ cwd: process.cwd(),
179
+ log
180
+ })) || {};
181
+ if (degraded) {
182
+ log.debug(`Baseline discovery degraded (${reason}) — nothing seeded`);
183
+ return false;
184
+ }
185
+ // A provider yielding sparse/null entries must not wedge the upload workers.
186
+ baselines = baselines.filter(Boolean);
187
+ if (!baselines.length) return false;
188
+
189
+ // First-ness is decided by the API, never locally: on an established project it answers
190
+ // with the baseline-skipped sentinel (no build persisted), on an empty project it creates
191
+ // build #1 as the baseline.
192
+ let res = await percy.client.createBuild({
193
+ projectType: percy.projectType,
194
+ source: BASELINE_SOURCE,
195
+ dropinBaselineCandidate: true
196
+ });
197
+
198
+ // Compat guard for APIs that predate the candidate attribute (which decide first-ness
199
+ // server-side): only ever seed build #1 — an old API ignores the attribute and hands back
200
+ // a NORMAL build, so anything past #1 abandons the build unused and points at the explicit
201
+ // setup command (which works at any build number) instead of polluting history.
202
+ if (!(res !== null && res !== void 0 && (_res$data = res.data) !== null && _res$data !== void 0 && _res$data.id) || ((_res$data$attributes = res.data.attributes) === null || _res$data$attributes === void 0 ? void 0 : _res$data$attributes['build-number']) !== 1) {
203
+ log.info(`Found ${baselines.length} committed baseline snapshot(s), but this project ` + 'already has builds — to (re)establish the baseline from them, run: ' + 'npx percy playwright:setup-baseline');
204
+ return false;
205
+ }
206
+ let buildId = res.data.id;
207
+ log.info(`New Percy project with ${baselines.length} committed baseline snapshot(s) ` + 'detected — establishing your baseline (build #1) before running tests');
208
+ let seeded = await uploadBaselines(percy.client, buildId, baselines, {
209
+ log,
210
+ projectType: percy.projectType
211
+ });
212
+
213
+ // Nothing uploaded (e.g. a transient network failure across the board): do NOT finalize —
214
+ // an empty auto-approved build #1 would establish the project with a blank baseline and
215
+ // permanently disable the auto-seed path. The unfinalized build expires server-side.
216
+ if (seeded === 0) {
217
+ log.warn(`Could not upload any of the ${baselines.length} committed baseline snapshot(s) — ` + 'baseline not established. Once connectivity recovers, run: ' + 'npx percy playwright:setup-baseline');
218
+ return false;
219
+ }
220
+ await percy.client.finalizeBuild(buildId);
221
+ let state;
222
+ try {
223
+ state = await waitForSeedBuild(percy.client, buildId, {
224
+ log,
225
+ timeout: waitTimeout,
226
+ interval: waitInterval
227
+ });
228
+ } catch (err) {
229
+ log.debug(`Baseline build wait failed: ${err.message}`);
230
+ }
231
+ if (state === 'finished') {
232
+ log.info(`Baseline established from ${seeded}/${baselines.length} committed snapshot(s) ` + 'and auto-approved — this run diffs against it.');
233
+ } else {
234
+ log.warn(`Baseline build did not finish processing in time (state: ${state || 'unknown'}) — ` + 'snapshots in this run may show as new instead of diffing against the baseline');
235
+ }
236
+ return true;
237
+ } catch (err) {
238
+ log.warn('Skipping baseline setup');
239
+ log.debug(err.message);
240
+ return false;
241
+ }
242
+ }
243
+
244
+ // Clamp a pixel dimension to the API's accepted snapshot range (same as `percy upload`).
245
+ function clampDimension(value, fallback) {
246
+ return Math.max(10, Math.min(value || fallback, 2000));
247
+ }
248
+
249
+ // A committed baseline PNG becomes a WEB snapshot: a generated root DOM displaying the image at
250
+ // its native size plus the image resource — the exact shape `percy upload` uses for web projects,
251
+ // so Percy renders the baseline in the project's own browsers and it pairs with the head run's
252
+ // DOM snapshots by (name, browser, width).
253
+ async function imageSnapshotResources({
254
+ name,
255
+ filepath,
256
+ width,
257
+ height
258
+ }) {
259
+ let rootUrl = `http://local/${encodeURIComponent(name)}`;
260
+ let imageUrl = `http://local/${encodeURIComponent(name)}.png`;
261
+ let content = await fs.promises.readFile(filepath);
262
+ return [createRootResource(rootUrl, `
263
+ <!doctype html>
264
+ <html lang="en">
265
+ <head>
266
+ <meta charset="utf-8">
267
+ <title>${name}</title>
268
+ <style>
269
+ *, *::before, *::after { margin: 0; padding: 0; font-size: 0; }
270
+ html, body { width: 100%; }
271
+ img { max-width: 100%; }
272
+ </style>
273
+ </head>
274
+ <body>
275
+ <img src="${imageUrl}" width="${width}px" height="${height}px"/>
276
+ </body>
277
+ </html>
278
+ `), createResource(imageUrl, content, 'image/png')];
279
+ }
280
+
281
+ // Bounded-concurrency upload of committed baseline files into `buildId`, shaped by project type:
282
+ // • web — each PNG becomes a rendered WEB SNAPSHOT (root DOM + image resource); web projects
283
+ // reject bare comparison tiles ("root resource" validation), and rendering server-side makes
284
+ // the baseline pair with the head run's DOM snapshots.
285
+ // • app — each PNG uploads straight through the COMPARISON ingest (tag + tile); no render flow
286
+ // is triggered, exactly how App Percy ingests screenshots. The tag width is the identity
287
+ // width (project viewport) so it pairs with the head run's uploads; height comes from the
288
+ // PNG bytes.
289
+ // Per-file failures are skipped (a partial baseline beats none) and reported in the count.
290
+ export async function uploadBaselines(client, buildId, baselines, {
291
+ log,
292
+ projectType = 'web'
293
+ }) {
294
+ let queue = [...baselines];
295
+ let seeded = 0;
296
+ let uploadOne = async b => {
297
+ if (projectType === 'app') {
298
+ await client.sendComparison(buildId, {
299
+ name: b.name,
300
+ // Mirror the drop-in SDK's tag shape exactly (incl. browserName) — comparison pairing
301
+ // matches on the canonical tag row, so any attribute difference orphans the baseline.
302
+ tag: {
303
+ name: b.browserFamily,
304
+ browserName: b.browserFamily,
305
+ width: b.width,
306
+ height: b.height
307
+ },
308
+ tiles: [{
309
+ filepath: b.filepath
310
+ }]
311
+ });
312
+ } else {
313
+ await client.sendSnapshot(buildId, {
314
+ name: b.name,
315
+ widths: [clampDimension(b.width, 1280)],
316
+ minHeight: clampDimension(b.height, 1024),
317
+ resources: await imageSnapshotResources(b)
318
+ });
319
+ }
320
+ };
321
+ let worker = async () => {
322
+ for (let b = queue.shift(); b; b = queue.shift()) {
323
+ try {
324
+ await uploadOne(b);
325
+ seeded += 1;
326
+ log.progress(`Uploading baseline snapshots: ${seeded}/${baselines.length}`, true);
327
+ } catch (err) {
328
+ log.warn(`Skipped baseline snapshot "${b.name}": ${err.message}`);
329
+ }
330
+ }
331
+ };
332
+ await Promise.all(Array.from({
333
+ length: Math.min(SEED_CONCURRENCY, baselines.length)
334
+ }, worker));
335
+ return seeded;
336
+ }
package/dist/exec.js CHANGED
@@ -5,6 +5,8 @@ import stop from './stop.js';
5
5
  import ping from './ping.js';
6
6
  import replay from './replay.js';
7
7
  import { waitForTimeout } from '@percy/client/utils';
8
+ import { yieldTo } from '@percy/cli-command/utils';
9
+ import { findBaselineProvider, maybeSeedBaseline } from './baseline.js';
8
10
  export const exec = command('exec', {
9
11
  description: 'Start and stop Percy around a supplied command',
10
12
  usage: '[options] [--] <command>',
@@ -83,6 +85,28 @@ export const exec = command('exec', {
83
85
  } else {
84
86
  log.debug('Skipping percy project attribute calculation');
85
87
  }
88
+
89
+ // Drop-in baseline seeding: when an installed SDK declares a baseline provider and the
90
+ // Percy project is empty, committed baseline screenshots are uploaded as an auto-approved
91
+ // build #1 before the head build starts. Never throws. Automate projects get the head-build
92
+ // source tag only — their captures happen on the remote BrowserStack browser, so committed
93
+ // local screenshots can never pair with them and the baseline comes from the first run.
94
+ if (['web', 'app', 'automate'].includes(percy.projectType)) {
95
+ let provider = await findBaselineProvider({
96
+ log
97
+ });
98
+ if (provider) {
99
+ // Tag the head build so the API can key drop-in behavior on its source.
100
+ if (provider.buildSource && !process.env.PERCY_BUILD_SOURCE) {
101
+ process.env.PERCY_BUILD_SOURCE = provider.buildSource;
102
+ }
103
+ // yieldTo keeps the runner tick-responsive — a bare yield would make the up-to-10min
104
+ // seed wait the one place Ctrl-C can't unwind until the promise settles.
105
+ yield* yieldTo(maybeSeedBaseline(percy, provider, {
106
+ log
107
+ }));
108
+ }
109
+ }
86
110
  yield* percy.yield.start();
87
111
  } catch (error) {
88
112
  if (error.name === 'AbortError') throw error;
package/dist/index.js CHANGED
@@ -2,4 +2,7 @@ export { default, exec } from './exec.js';
2
2
  export { start } from './start.js';
3
3
  export { stop } from './stop.js';
4
4
  export { ping } from './ping.js';
5
- export { replay } from './replay.js';
5
+ export { replay } from './replay.js';
6
+ // Baseline seeding building blocks, reused by SDK-contributed setup commands
7
+ // (e.g. @percy/playwright's `percy playwright:setup-baseline`).
8
+ export { findBaselineProvider, maybeSeedBaseline, uploadBaselines } from './baseline.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@percy/cli-exec",
3
- "version": "1.32.5",
3
+ "version": "1.32.6-beta.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "publishConfig": {
11
11
  "access": "public",
12
- "tag": "latest"
12
+ "tag": "beta"
13
13
  },
14
14
  "engines": {
15
15
  "node": ">=14"
@@ -34,10 +34,10 @@
34
34
  ]
35
35
  },
36
36
  "dependencies": {
37
- "@percy/cli-command": "1.32.5",
38
- "@percy/logger": "1.32.5",
37
+ "@percy/cli-command": "1.32.6-beta.0",
38
+ "@percy/logger": "1.32.6-beta.0",
39
39
  "cross-spawn": "^7.0.3",
40
40
  "which": "^2.0.2"
41
41
  },
42
- "gitHead": "1c83908c5913e3fab1cdd7207db49a2e0c27a3e9"
42
+ "gitHead": "735e1589b696e9b74f52d9cb0944c6bc0af889b2"
43
43
  }