-
Notifications
You must be signed in to change notification settings - Fork 61
PER-8985 feat: Playwright drop-in baseline seeding — build attributes + exec orchestration #2335
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 3 commits
Commits
Show all changes
22 commits
Select commit
Hold shift + click to select a range
8195aff
feat: Playwright drop-in baseline seeding — client attributes + exec …
Shivanshu-07 0493d70
fix: seed baselines as web snapshots, not comparisons
Shivanshu-07 c001f92
feat: app-project baseline seeding — comparison ingest, no render flow
Shivanshu-07 8ba3083
feat: PERCY_DROPIN_DISABLE opts out of baseline seeding
Shivanshu-07 14982f9
fix: CI reds — build.source shape, exec coverage, path sanitizers
Shivanshu-07 2c0e631
test: close remaining cli-exec coverage-gate branches
Shivanshu-07 4b4e494
fix: wait for the seed build to finish before the head build starts
Shivanshu-07 ee87aa4
Merge remote-tracking branch 'origin/master' into feat/playwright-dro…
Shivanshu-07 174dd3a
test: cover the pending-state branch of waitForSeedBuild
Shivanshu-07 b5609a5
test: cover findBaselineProvider default-parameter branches
Shivanshu-07 ed1d400
test: cover the healthcheck build source branch
Shivanshu-07 ce0bc57
test: cover canonicalHost's unparseable-IPv6 fallback
Shivanshu-07 25e746a
chore: istanbul-ignore canonicalHost's unreachable null guard
Shivanshu-07 75decf8
fix: app seed tags must mirror the SDK's tag shape (browserName)
Shivanshu-07 5b720b3
review: harden baseline provider discovery + seeding guards
Shivanshu-07 0a38a90
chore: wrap provider containment paths in the path sanitizer (semgrep…
Shivanshu-07 b73530f
Merge remote-tracking branch 'origin/master' into feat/playwright-dro…
Shivanshu-07 6e8fd08
chore: trim comment narration in the drop-in seeding code
Shivanshu-07 e2b4399
review: never finalize a zero-upload seed; single-line established no…
Shivanshu-07 da65d31
review: interruptible seed-wait, honest zero-seed recovery text, narr…
Shivanshu-07 2687542
feat: tag automate drop-in head builds, skip baseline seeding for aut…
Shivanshu-07 bf3b5b2
Merge remote-tracking branch 'origin/master' into feat/playwright-dro…
Shivanshu-07 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,225 @@ | ||
| import fs from 'fs'; | ||
| import path from 'path'; | ||
| import url from 'url'; | ||
| import { createResource, createRootResource } from '@percy/cli-command/utils'; | ||
|
|
||
| // Playwright drop-in baseline seeding — the `percy exec` side. | ||
| // | ||
| // Generic provider contract (framework knowledge stays in the SDK package, never in the CLI — | ||
| // the same reason command discovery works the way it does): any installed Percy SDK can declare | ||
| // | ||
| // "@percy/cli": { "baselineProvider": "./path/to/module.js" } | ||
| // | ||
| // in its package.json. The module's default export must be: | ||
| // | ||
| // { | ||
| // buildSource, // drop-in source tag for the head build (e.g. 'playwright-dropin') | ||
| // async discoverBaselines({ cwd, log }) | ||
| // // -> { baselines: [{ filepath, name, browserFamily, width, height }], degraded?, reason? } | ||
| // } | ||
| // | ||
| // When the user runs a plain `percy exec -- <cmd>` in a project with committed baseline | ||
| // screenshots and an EMPTY Percy project, the CLI establishes the baseline first (build #1, | ||
| // uploaded directly from the committed files — the user's test suite never runs for it) and only | ||
| // then starts the head build (#2) that runs the real command. The API auto-approves build #1 | ||
| // server-side. On an established project nothing is seeded; the user is pointed at the explicit | ||
| // `percy playwright:setup-baseline` command instead. | ||
|
|
||
| // Parallel seed-upload cap: fast on large baseline sets without stampeding the API. | ||
| const SEED_CONCURRENCY = 8; | ||
|
|
||
| const BASELINE_SOURCE = 'playwright-dropin-baseline'; | ||
|
|
||
| // Walk up from `dir` collecting @percy/* (and percy-*) package roots from each node_modules on | ||
| // the way — trimmed-down version of @percy/cli's command-discovery walk. | ||
| function findPercyPackages(dir) { | ||
| let found = []; | ||
|
|
||
| while (dir !== path.dirname(dir)) { | ||
| let modulesDir = path.join(dir, 'node_modules'); | ||
|
|
||
| if (fs.existsSync(modulesDir)) { | ||
| for (let name of fs.readdirSync(modulesDir)) { | ||
| if (name === '@percy') { | ||
| for (let scoped of fs.readdirSync(path.join(modulesDir, name))) { | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
| found.push(path.join(modulesDir, name, scoped)); | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
github-advanced-security[bot] marked this conversation as resolved.
Fixed
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
| } | ||
| } else if (name.startsWith('percy-')) { | ||
| found.push(path.join(modulesDir, name)); | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
| } | ||
| } | ||
| } | ||
|
|
||
| dir = path.dirname(dir); | ||
| } | ||
|
|
||
| return found; | ||
| } | ||
|
|
||
| // Find the first installed package declaring a baseline provider and import it. Returns null when | ||
| // none is installed (the overwhelmingly common case — one existsSync walk, negligible cost). | ||
| export async function findBaselineProvider({ cwd = process.cwd(), log } = {}) { | ||
| for (let pkgPath of findPercyPackages(cwd)) { | ||
| let pkgFile = path.join(pkgPath, 'package.json'); | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
|
|
||
| try { | ||
| if (!fs.existsSync(pkgFile)) continue; | ||
| let pkg = JSON.parse(fs.readFileSync(pkgFile, 'utf-8')); | ||
| let providerPath = pkg['@percy/cli']?.baselineProvider; | ||
| if (!providerPath) continue; | ||
|
|
||
| let module = await import(url.pathToFileURL(path.join(pkgPath, providerPath)).href); | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
| let provider = module.default || module; | ||
|
|
||
| if (typeof provider.discoverBaselines === 'function') { | ||
| return { ...provider, packageName: pkg.name }; | ||
| } | ||
| } catch (err) { | ||
| log?.debug(`Skipping baseline provider from ${pkgPath}: ${err.message}`); | ||
| } | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| // Establish the project's baseline from committed screenshots BEFORE the head build starts. | ||
| // Never throws — a seeding problem must not break `percy exec`. Returns true when a baseline | ||
| // build was created and finalized. | ||
| export async function maybeSeedBaseline(percy, provider, { log }) { | ||
| try { | ||
| // Parallel shards would race each other seeding; the head build dedup doesn't apply to the | ||
| // separate seed build, so leave parallel runs to the explicit setup command. | ||
| if (process.env.PERCY_PARALLEL_TOTAL) { | ||
| log.debug('Skipping baseline seeding for a parallel build'); | ||
| return false; | ||
| } | ||
|
|
||
| let { baselines = [], degraded, reason } = | ||
| (await provider.discoverBaselines({ cwd: process.cwd(), log })) || {}; | ||
|
|
||
| if (degraded) { | ||
| log.debug(`Baseline discovery degraded (${reason}) — nothing seeded`); | ||
| return false; | ||
| } | ||
| if (!baselines.length) return false; | ||
|
|
||
| // Ask the server. An explicit baseline source on an ESTABLISHED project returns the | ||
| // baseline-skipped sentinel (no build persisted); on an empty project it creates build #1 as | ||
| // the baseline. First-ness is decided by the API from the project token — never locally. | ||
| let res = await percy.client.createBuild({ | ||
|
Shivanshu-07 marked this conversation as resolved.
|
||
| projectType: percy.projectType, | ||
| source: BASELINE_SOURCE, | ||
| dropinBaselineCandidate: true | ||
| }); | ||
|
|
||
| if (!res?.data?.id) { | ||
| log.info(`Found ${baselines.length} committed baseline snapshot(s), but this project ` + | ||
| 'already has builds — skipping baseline setup.'); | ||
| log.info('To (re)establish the baseline from your committed snapshots, run: ' + | ||
| 'npx percy playwright:setup-baseline'); | ||
| return false; | ||
| } | ||
|
|
||
| let buildId = res.data.id; | ||
| log.info(`New Percy project with ${baselines.length} committed baseline snapshot(s) ` + | ||
| 'detected — establishing your baseline (build #1) before running tests'); | ||
|
|
||
| let seeded = await uploadBaselines(percy.client, buildId, baselines, { | ||
| log, projectType: percy.projectType | ||
| }); | ||
|
|
||
| await percy.client.finalizeBuild(buildId); | ||
|
Shivanshu-07 marked this conversation as resolved.
|
||
| log.info(`Baseline established from ${seeded}/${baselines.length} committed snapshot(s) ` + | ||
| 'and auto-approved — this run diffs against it.'); | ||
| return true; | ||
| } catch (err) { | ||
| log.warn('Skipping baseline setup'); | ||
| log.debug(err.message); | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| // Clamp a pixel dimension to the API's accepted snapshot range (same as `percy upload`). | ||
| function clampDimension(value, fallback) { | ||
| return Math.max(10, Math.min(value || fallback, 2000)); | ||
| } | ||
|
|
||
| // A committed baseline PNG becomes a WEB snapshot: a generated root DOM displaying the image at | ||
| // its native size plus the image resource — the exact shape `percy upload` uses for web projects, | ||
| // so Percy renders the baseline in the project's own browsers and it pairs with the head run's | ||
| // DOM snapshots by (name, browser, width). | ||
| async function imageSnapshotResources({ name, filepath, width, height }) { | ||
| let rootUrl = `http://local/${encodeURIComponent(name)}`; | ||
| let imageUrl = `http://local/${encodeURIComponent(name)}.png`; | ||
| let content = await fs.promises.readFile(filepath); | ||
|
|
||
| return [ | ||
| createRootResource(rootUrl, ` | ||
| <!doctype html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="utf-8"> | ||
| <title>${name}</title> | ||
| <style> | ||
| *, *::before, *::after { margin: 0; padding: 0; font-size: 0; } | ||
| html, body { width: 100%; } | ||
| img { max-width: 100%; } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <img src="${imageUrl}" width="${width}px" height="${height}px"/> | ||
| </body> | ||
| </html> | ||
| `), | ||
| createResource(imageUrl, content, 'image/png') | ||
| ]; | ||
| } | ||
|
|
||
| // Bounded-concurrency upload of committed baseline files into `buildId`, shaped by project type: | ||
| // • web — each PNG becomes a rendered WEB SNAPSHOT (root DOM + image resource); web projects | ||
| // reject bare comparison tiles ("root resource" validation), and rendering server-side makes | ||
| // the baseline pair with the head run's DOM snapshots. | ||
| // • app — each PNG uploads straight through the COMPARISON ingest (tag + tile); no render flow | ||
| // is triggered, exactly how App Percy ingests screenshots. The tag width is the identity | ||
| // width (project viewport) so it pairs with the head run's uploads; height comes from the | ||
| // PNG bytes. | ||
| // Per-file failures are skipped (a partial baseline beats none) and reported in the count. | ||
| export async function uploadBaselines(client, buildId, baselines, { log, projectType = 'web' }) { | ||
| let queue = [...baselines]; | ||
| let seeded = 0; | ||
|
|
||
| let uploadOne = async b => { | ||
| if (projectType === 'app') { | ||
| await client.sendComparison(buildId, { | ||
| name: b.name, | ||
| tag: { name: b.browserFamily, width: b.width, height: b.height }, | ||
| tiles: [{ filepath: b.filepath }] | ||
| }); | ||
| } else { | ||
| await client.sendSnapshot(buildId, { | ||
| name: b.name, | ||
| widths: [clampDimension(b.width, 1280)], | ||
| minHeight: clampDimension(b.height, 1024), | ||
| resources: await imageSnapshotResources(b) | ||
| }); | ||
| } | ||
| }; | ||
|
|
||
| let worker = async () => { | ||
| for (let b = queue.shift(); b; b = queue.shift()) { | ||
| try { | ||
| await uploadOne(b); | ||
| seeded += 1; | ||
| log.progress(`Uploading baseline snapshots: ${seeded}/${baselines.length}`, true); | ||
| } catch (err) { | ||
| log.warn(`Skipped baseline snapshot "${b.name}": ${err.message}`); | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| await Promise.all( | ||
| Array.from({ length: Math.min(SEED_CONCURRENCY, baselines.length) }, worker) | ||
| ); | ||
|
|
||
| return seeded; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.