Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
6 changes: 3 additions & 3 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ jobs:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
node-version: 14
node-version: 20
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: v1/${{ runner.os }}/node-14/${{ hashFiles('**/yarn.lock') }}
restore-keys: v1/${{ runner.os }}/node-14/
key: v1/${{ runner.os }}/node-20/${{ hashFiles('**/yarn.lock') }}
restore-keys: v1/${{ runner.os }}/node-20/
- run: yarn
- run: yarn lint
5 changes: 4 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest]
node: [14]
node: [20, 22]
runs-on: ${{ matrix.os }}
steps:
- uses: actions-ecosystem/action-regex-match@9e6c4fb3d5e898f505be7a1fb6e7b0a278f6665b # v2.0.2
Expand All @@ -41,6 +41,9 @@ jobs:
key: v1/${{ runner.os }}/node-${{ matrix.node }}/${{ hashFiles('**/yarn.lock') }}
restore-keys: v1/${{ runner.os }}/node-${{ matrix.node }}/
- run: yarn
# Playwright >=1.39 no longer downloads browsers on package install — fetch the one the
# suite launches explicitly (system deps included for the runner image).
- run: npx playwright install --with-deps chromium
- name: Set up @percy/cli from git
if: ${{ github.event_name == 'workflow_dispatch' }}
env:
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/typecheck.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ jobs:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
node-version: 14
node-version: 20
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: v1/${{ runner.os }}/node-14/${{ hashFiles('**/yarn.lock') }}
restore-keys: v1/${{ runner.os }}/node-14/
key: v1/${{ runner.os }}/node-20/${{ hashFiles('**/yarn.lock') }}
restore-keys: v1/${{ runner.os }}/node-20/
- run: yarn
- run: yarn test:types
2 changes: 1 addition & 1 deletion .nycrc
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"exclude": ["test"],
"exclude": ["test", "dropin", "bin"],
"check-coverage": true,
"branches": 100,
"lines": 100,
Expand Down
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 };
Loading
Loading