Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,59 @@ $ percy exec -- node script.js
- `options` - [See per-snapshot configuration options](https://www.browserstack.com/docs/percy/take-percy-snapshots/overview#per-snapshot-configuration)


## toHaveScreenshot drop-in

Route your existing Playwright `expect(...).toHaveScreenshot()` assertions through Percy with
**one config line and no test changes**:

```js
// playwright.config.js
require('@percy/playwright/dropin'); // registers the toHaveScreenshot override

module.exports = defineConfig({ /* your config */ });
```

```bash
PERCY_TOKEN=<project-token> npx percy-playwright exec -- npx playwright test
```

The bundled `percy-playwright` wrapper tags the build (`PERCY_BUILD_SOURCE=playwright-dropin`) and
marks it as a first-build baseline candidate (`PERCY_DROPIN_BASELINE_CANDIDATE=true`); the Percy
API decides first-ness server-side. Every `toHaveScreenshot()` is captured and uploaded to Percy;
the assertion **always passes locally** — the visual verdict moves to Percy's review UI, and a
missing/invalid token or any Percy error **never fails your suite** (the whole run falls back to
native `toHaveScreenshot`).

### First build from your committed baselines

Add the drop-in's `globalSetup` and the project's **first** build is seeded from the Playwright
baseline PNGs already committed in your repo — the baselines you've already blessed — and
auto-approved server-side (flag-gated), so diffs start on your very next run:

```js
module.exports = defineConfig({
globalSetup: require.resolve('@percy/playwright/dropin/global-setup'),
/* your config */
});
```

### Capture modes

Zero-config uses screenshot mode (raw-PNG upload — generic/app Percy projects). For a **web**
Percy project, switch to DOM capture in `.percy-playwright-dropin.json`:

```json
{ "captureMode": "snapshot" }
```

Snapshot mode serializes the live page with the same capture `percySnapshot()` uses (readiness
gate, responsive capture, cross-origin iframes) and Percy renders it server-side. Locator
subjects become element-scoped snapshots. An optional CI gate is available via
`reporter: [['@percy/playwright/dropin/reporter']]` with `{ "gate": "fail-on-changes" }`.

Requires `@playwright/test` >= 1.49 (the override hooks Playwright's expect internals; on
unsupported versions it degrades to a no-op **with a loud warning** — never silently).

## Percy on Automate

## Usage
Expand Down
91 changes: 91 additions & 0 deletions bin/percy-playwright.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
#!/usr/bin/env node
'use strict';

// percy-playwright — zero-config head-build tagging wrapper.
//
// The head build is tagged via the PERCY_BUILD_SOURCE env var, which @percy/cli reads when it
// CREATES the build at `percy exec` startup — in the PARENT process, before any test runs. The SDK
// (which runs inside the test process) can't set it after the fact. This thin wrapper closes that
// gap: it sets PERCY_BUILD_SOURCE for you (when unset) and then execs `percy` with your args, so
// `npx percy-playwright exec -- npx playwright test` needs no manual env var.
//
// It is intentionally minimal — it only injects the env var and delegates everything else to the
// real `percy` binary (stdio inherited, exit code forwarded).
// NOTE: reference child_process as a module object (not a destructured `spawn`) so the spawn call
// site stays stubbable from tests (destructuring would bind the reference at import time).
const childProcess = require('child_process');
const path = require('path');
const fs = require('fs');

const BUILD_SOURCE = 'playwright-dropin';

// Resolve the `percy` executable from the locally-installed @percy/cli when possible (the version
// this drop-in was tested against), else fall back to PATH so a globally-installed `percy` works.
function resolvePercyBin() {
try {
// @percy/cli ships an `exports` map that does NOT expose ./package.json, so we resolve its main
// entry and walk up to the package root (the dir containing package.json).
const mainEntry = require.resolve('@percy/cli');
let dir = path.dirname(mainEntry);
while (dir !== path.dirname(dir)) {
const pkgFile = path.join(dir, 'package.json');
if (fs.existsSync(pkgFile)) {
const pkg = JSON.parse(fs.readFileSync(pkgFile, 'utf8'));
if (pkg.name === '@percy/cli') {
const binRel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin && pkg.bin.percy;
if (binRel) return path.resolve(dir, binRel);
}
}
dir = path.dirname(dir);
}
} catch {
// @percy/cli not resolvable from here — fall back to PATH.
}
return 'percy';
}

function main(argv = process.argv.slice(2)) {
// Zero-config tagging: only set PERCY_BUILD_SOURCE when the user hasn't already chosen a value.
const env = { ...process.env };
if (!env.PERCY_BUILD_SOURCE) env.PERCY_BUILD_SOURCE = BUILD_SOURCE;
// First-build-as-baseline candidate flag: rides createBuild via @percy/client; the SERVER
// decides first-ness (a no-op on established projects), so it is always safe to send.
if (!env.PERCY_DROPIN_BASELINE_CANDIDATE) env.PERCY_DROPIN_BASELINE_CANDIDATE = 'true';

const percyBin = resolvePercyBin();
// When percyBin is a resolved .cjs/.js path, run it through the current node. When it's the bare
// "percy" PATH fallback, spawn it directly (shell PATH resolution).
const isScript = percyBin !== 'percy';
const command = isScript ? process.execPath : percyBin;
const args = isScript ? [percyBin, ...argv] : argv;

const child = childProcess.spawn(command, args, { stdio: 'inherit', env });

child.on('error', (err) => {
if (err && err.code === 'ENOENT') {
process.stderr.write(
'percy-playwright: could not find the `percy` executable. Install @percy/cli ' +
'(npm i -D @percy/cli) or ensure `percy` is on your PATH.\n'
);
} else {
process.stderr.write(`percy-playwright: failed to launch percy — ${err && err.message}\n`);
}
process.exit(1);
});

child.on('exit', (code, signal) => {
if (signal) {
// Re-raise the signal so the parent's exit status reflects it (CI signal handling).
process.kill(process.pid, signal);
return;
}
process.exit(code == null ? 1 : code);
});

return child;
}

module.exports = { main, resolvePercyBin, BUILD_SOURCE };

// Run when invoked as a CLI (not when required by the unit test).
if (require.main === module) main();
23 changes: 23 additions & 0 deletions dropin/baseline/base-branch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
'use strict';

// KD2 — the seeded baseline must live on the branch the HEAD actually resolves its base against,
// or the `latest_commit` base-selection (which matches by branch) will never pick it.
//
// percy-api's `LatestCommit` strategy walks: PR-base → target-branch → default_base_branch → head
// branch. We do NOT replicate that whole chain here (the server owns it). Instead we exploit a
// structural fact: the baseline build is created with the SAME git env as the head build, so
// percy-api derives an identical `branch` for both — they automatically share a branch and the
// `id <` ordering does the rest. This module exists to make that attribution EXPLICIT and to give
// the discover/seed path the branch value for logging + the KD2 same-branch (never default-branch)
// guard: we refuse to retarget the baseline onto the default branch (irreversible mainline
// contamination — KD2 / R-lineage-permanence).
//
// `env` is a @percy/env PercyEnv instance.
function resolveBaseBranch(env) {
// The head build's branch is what percy-api keys the baseline match on. Reusing it guarantees
// the baseline and head share a branch without us guessing the PR/target/default fallbacks.
const branch = env?.git?.branch || null;
return branch;
}

module.exports = { resolveBaseBranch };
Binary file added dropin/baseline/discover.js
Binary file not shown.
107 changes: 107 additions & 0 deletions dropin/baseline/first-build.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
'use strict';

// First-build-as-baseline (flag-passing model — supersedes the two-build parallel-nonce seed).
//
// Chain: the `percy-playwright` wrapper (or the customer) sets PERCY_DROPIN_BASELINE_CANDIDATE=true
// → @percy/client sends `dropin-baseline-candidate` on createBuild → percy-api rewrites the build's
// source to 'playwright-dropin-baseline' IFF this is the project's FIRST visible build (server
// decides first-ness; the flag can never rebaseline an established project) → the CLI exposes the
// decided source through /percy/healthcheck (`percy.build.source`).
//
// When the server says "this build IS the baseline":
// • globalSetup fills the run's ONE build with the repo's COMMITTED snapshot PNGs — the baselines
// the user has already blessed (TB) — via the normal CLI comparison path (they belong to this
// very build, so no client-direct ingest, no nonce, no poll, no defer-uploads dance).
// • The toHaveScreenshot override SKIPS posting live captures (PERCY_DROPIN_SEEDED_BASELINE=1) so
// the baseline contains exactly the blessed PNGs; assertions still pass (always-pass).
// • percy-api auto-approves the build at finish (KD13: source-keyed + first-baseline bound +
// the `playwright-dropin-baseline-ingest` rollout kill-switch). Diffs start on the next run.
//
// With no committed snapshots the live captures become the first build's content instead (the
// override posts normally) — still auto-approved server-side, matching native Playwright's own
// "first run writes the baselines" behavior.
const fs = require('fs');
const utils = require('@percy/sdk-utils');
const { discoverBaselines } = require('./discover');
const { pngDimensions } = require('../png');

const log = utils.logger('playwright-dropin');

const BASELINE_SOURCE = 'playwright-dropin-baseline';

// Parallel seed-upload cap: high enough to keep globalSetup fast on large baseline sets, low
// enough not to stampede the local CLI server's request queue.
const SEED_CONCURRENCY = 8;

const OUTCOME = Object.freeze({
NOT_FIRST_BUILD: 'not_first_build',
SEEDED: 'seeded',
NO_BASELINES: 'no_baselines',
UNMAPPABLE: 'unmappable'
});

// Runs inside globalSetup, after isPercyEnabled() has populated `utils.percy`. Returns
// { firstBuild, seeded, outcome } and never throws (a Percy problem must not abort the suite).
async function firstBuildBaseline(
{ rootDir, snapshotDir, playwrightConfig, clientInfo, environmentInfo } = {},
deps = {}
) {
const discover = deps.discoverBaselines || discoverBaselines;
const post = deps.postComparison || (options => utils.postComparison(options));
const readFile = deps.readFile || fs.promises.readFile;
const build = deps.build !== undefined ? deps.build : (utils.percy && utils.percy.build);

// The server decided this is NOT the project's first build (or the CLI predates the candidate
// flag and never sent it) — the run's build is a normal head; nothing to seed.
if (!build || build.source !== BASELINE_SOURCE) {
return { firstBuild: false, seeded: 0, outcome: OUTCOME.NOT_FIRST_BUILD };
}

const { baselines, degraded, reason } = discover({
rootDir, snapshotDir, config: playwrightConfig
});

if (degraded) {
return { firstBuild: true, seeded: 0, outcome: OUTCOME.UNMAPPABLE, degradeReason: reason };
}
if (!baselines || !baselines.length) {
return { firstBuild: true, seeded: 0, outcome: OUTCOME.NO_BASELINES };
}

// Bounded-concurrency ingest (TB §11 scale): a repo can carry hundreds of committed baselines
// and globalSetup blocks the suite start — post in parallel, but capped so we never stampede
// the local CLI server. Per-file failures are skipped (partial baseline beats none).
const concurrency = deps.concurrency || SEED_CONCURRENCY;
let seeded = 0;
const queue = [...baselines];
const worker = async () => {
for (let b = queue.shift(); b; b = queue.shift()) {
try {
// The buffer is both the tile content and the source of truth for tag height (percy-api
// validates height presence on screenshot records — see src/png.js).
const buf = await readFile(b.filepath);
const dims = pngDimensions(buf);
await post({
name: b.name,
clientInfo,
environmentInfo,
tag: {
name: b.browserFamily,
browserName: b.browserFamily,
width: b.width || (dims && dims.width) || undefined,
height: (dims && dims.height) || undefined
},
tiles: [{ content: buf.toString('base64') }]
});
seeded += 1;
} catch (err) {
log.debug(`Percy: skipped committed baseline "${b.name}" — ${err.message}`);
}
}
};
await Promise.all(Array.from({ length: Math.min(concurrency, baselines.length) }, worker));

return { firstBuild: true, seeded, outcome: seeded > 0 ? OUTCOME.SEEDED : OUTCOME.NO_BASELINES };
}

module.exports = { firstBuildBaseline, BASELINE_SOURCE, OUTCOME };
20 changes: 20 additions & 0 deletions dropin/capture.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
'use strict';

// KD4 (spike-decided): full-override capture — own the screenshot, return a pure PNG buffer with
// NO assertion side-effects. Capture options mirror Playwright's toHaveScreenshot defaults so the
// bytes match how repo baselines were generated (D2 — keeps first-build noise low).
const PASS_THROUGH_OPTS = ['clip', 'fullPage', 'mask', 'maskColor', 'omitBackground', 'scale', 'animations', 'caret', 'style', 'stylePath'];

async function captureFullOverride(pageOrLocator, options = {}) {
const target = pageOrLocator && typeof pageOrLocator.screenshot === 'function'
? pageOrLocator // Page or Locator both expose screenshot()
: pageOrLocator.page();

const shotOpts = { animations: 'disabled', caret: 'hide', scale: 'css' };
for (const k of PASS_THROUGH_OPTS) {
if (options && options[k] !== undefined) shotOpts[k] = options[k];
}
return target.screenshot(shotOpts);
}

module.exports = { captureFullOverride };
Loading
Loading