diff --git a/package.json b/package.json index fff62f7..510dc75 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "lighthouse": "^13.5.0", "lint-staged": "^17.6.0", "playwright-layout-guard": "0.3.0", - "playwright-privacy-guard": "0.2.0", + "playwright-privacy-guard": "0.3.0", "prettier": "^3.9.10", "prettier-plugin-astro": "^1.1.2", "prettier-plugin-tailwindcss": "^0.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c387ad4..7e7ca6e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -270,8 +270,8 @@ importers: specifier: 0.3.0 version: 0.3.0(@playwright/test@1.64.1) playwright-privacy-guard: - specifier: 0.2.0 - version: 0.2.0(@playwright/test@1.64.1) + specifier: 0.3.0 + version: 0.3.0(@playwright/test@1.64.1) prettier: specifier: ^3.9.10 version: 3.9.10 @@ -3272,8 +3272,8 @@ packages: peerDependencies: '@playwright/test': '>=1.45.0' - playwright-privacy-guard@0.2.0: - resolution: {integrity: sha512-JJx2UkwBvtXMzNzF5a9nGG5vEQNB809oq5Fo/blHH8Nhnhs9yJaL1W0hpy3y+aXIqxtGPYKAPeAy37+M9QRhwQ==} + playwright-privacy-guard@0.3.0: + resolution: {integrity: sha512-2dG0A4ku7gw7SMULoY36aC3c+tL37F0eSWmZAGMfBleu9F8oG+U1cEMjKl4LzleMdEaK3zMDq3LYKio/gcsVyw==} engines: {node: '>=20.19.0'} peerDependencies: '@playwright/test': '>=1.45.0' @@ -7299,7 +7299,7 @@ snapshots: dependencies: '@playwright/test': 1.64.1 - playwright-privacy-guard@0.2.0(@playwright/test@1.64.1): + playwright-privacy-guard@0.3.0(@playwright/test@1.64.1): dependencies: '@playwright/test': 1.64.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dc007ba..8904430 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,10 +2,10 @@ allowBuilds: esbuild: true workerd: true # pnpm does not install a version that is younger than its minimum release age. playwright-privacy-guard -# is my own package and 0.2.0 was published on 2026-10-28: delete this entry once pnpm installs it +# is my own package and 0.3.0 was published on 2026-10-28: delete this entry once pnpm installs it # without it (a few days after the release). minimumReleaseAgeExclude: - - playwright-privacy-guard@0.2.0 + - playwright-privacy-guard@0.3.0 # miniflare (inside wrangler) pins undici 7.29.1 exactly, and 7.29.1 has known denial of service # findings (patched in 7.30.1; found by pnpm audit on 2026-10-24). Written as a range of the bad # versions, so it does nothing once miniflare asks for a patched version itself: then delete it. diff --git a/src/content/projects/privacy-guard.yaml b/src/content/projects/privacy-guard.yaml index 1ce1e40..727f304 100644 --- a/src/content/projects/privacy-guard.yaml +++ b/src/content/projects/privacy-guard.yaml @@ -4,7 +4,7 @@ subtitle: A Playwright fixture that fails a test when the page leaks kind: package start: 2026 stats: - - value: '14' + - value: '15' label: checks in every test, from other origins and cookies to weak policies - value: '3' label: browsers, Chromium, Firefox and WebKit, on every change @@ -13,7 +13,7 @@ stats: highlights: - Any request to another origin, cookie, entry in the storage of the device, violation of the content security policy or error on the page fails the test. The message names the host, the cookie or the script, says which company is behind it, and ends with the exact setting that would allow it if it is meant. - Extracted from the browser tests of this site, which promise no cookies, nothing stored and no other origin. It replaced the hand-written privacy and error tests there, and now watches every one of the browser tests instead of one loop over the pages. Canary tests make a page misbehave on purpose and expect the guard to fail them. - - Goes beyond a privacy test. It also rates security headers and the content security policy, finds scripts without an integrity attribute, forms that send elsewhere, sensitive browser APIs, canvas and audio fingerprinting, and values that must not leave. Results come as a readable message, an HTML and JSON report per test, a report for the whole run and a baseline for projects that leak today. + - Goes beyond a privacy test. It also rates security headers and the content security policy, finds scripts without an integrity attribute, forms that send elsewhere, sensitive browser APIs, canvas and audio fingerprinting, and values that must not leave. Results come as a readable message, an HTML and JSON report per test, a report for the whole run and a baseline for projects that leak today. Everything a test allowed on purpose is listed with its reason, so an exception cannot hide. - Released like a product, with semantic versioning and a changelog from changesets, CI on three browsers and three Node versions including the oldest supported Playwright, which found a bug that the newest one hides, and publishing from GitHub Actions with npm provenance. A release is staged and only goes live when I approve it with two-factor authentication. technologies: [typescript, playwright, vitest, cicd, npm, git, eslint, prettier] code: diff --git a/tests/e2e/axe.ts b/tests/e2e/axe.ts index ea5b9f3..d3ff63d 100644 --- a/tests/e2e/axe.ts +++ b/tests/e2e/axe.ts @@ -24,7 +24,11 @@ export default class AxeBuilder extends BaseAxeBuilder { } override analyze(): ReturnType { - guards.get(this.watched)?.allow({ fingerprint: ['canvas'] }); + guards.get(this.watched)?.allow({ + fingerprint: [ + { match: 'canvas', reason: 'axe-core reads pixels back for its colour contrast check' }, + ], + }); return super.analyze(); } } diff --git a/tests/e2e/contact.spec.ts b/tests/e2e/contact.spec.ts index 44bde9a..03d68f2 100644 --- a/tests/e2e/contact.spec.ts +++ b/tests/e2e/contact.spec.ts @@ -170,8 +170,9 @@ test.describe('contact form', () => { }); test('shows the field problems the server reports', async ({ page, privacy }) => { - // The 422 is the answer under test - privacy.allow({ errors: [/^422 \S+\/api\/contact$/] }); + privacy.allow({ + errors: [{ match: /^422 \S+\/api\/contact$/, reason: 'the 422 is the answer under test' }], + }); await mockApi(page, (route) => route.fulfill({ status: 422, @@ -203,8 +204,14 @@ test.describe('contact form', () => { page, privacy, }) => { - // The error status is the answer under test - privacy.allow({ errors: [new RegExp(`^${String(status)} \\S+/api/contact$`)] }); + privacy.allow({ + errors: [ + { + match: new RegExp(`^${String(status)} \\S+/api/contact$`), + reason: 'the error status is the answer under test', + }, + ], + }); await mockApi(page, (route) => route.fulfill({ status, json: { ok: false, error: 'x', message: 'ignored' } }), ); @@ -219,8 +226,11 @@ test.describe('contact form', () => { } test('says so and disables sending when the form is switched off', async ({ page, privacy }) => { - // The 503 is the answer under test - privacy.allow({ errors: [/^503 \S+\/api\/contact-token$/] }); + privacy.allow({ + errors: [ + { match: /^503 \S+\/api\/contact-token$/, reason: 'the 503 is the answer under test' }, + ], + }); await mockApi(page, undefined, (route) => route.fulfill({ status: 503, json: { ok: false, error: 'unavailable' } }), ); @@ -232,8 +242,14 @@ test.describe('contact form', () => { }); test('reports a network failure without losing the text', async ({ page, privacy }) => { - // The request that is aborted on purpose - privacy.allow({ errors: [/^net::ERR_FAILED \S+\/api\/contact$/] }); + privacy.allow({ + errors: [ + { + match: /^net::ERR_FAILED \S+\/api\/contact$/, + reason: 'the request is aborted on purpose', + }, + ], + }); await mockApi(page, (route) => route.abort('failed')); await page.goto('/'); await fill(page); diff --git a/tests/e2e/palette.spec.ts b/tests/e2e/palette.spec.ts index 42ddb6f..b2bc6aa 100644 --- a/tests/e2e/palette.spec.ts +++ b/tests/e2e/palette.spec.ts @@ -154,8 +154,14 @@ test.describe('search', () => { test('copies the email address and tells so', async ({ page, context, browserName, privacy }) => { test.skip(browserName !== 'chromium', 'the clipboard permission is a Chromium feature'); await context.grantPermissions(['clipboard-read', 'clipboard-write']); - // The test reads the clipboard to see what was copied; the site only writes to it - privacy.allow({ apis: ['clipboard-read'] }); + privacy.allow({ + apis: [ + { + match: 'clipboard-read', + reason: 'the test reads what was copied; the site only writes to the clipboard', + }, + ], + }); await openWithKeyboard(page); await field(page).fill('copy'); await page.keyboard.press('Enter'); diff --git a/tests/e2e/tech-words.spec.ts b/tests/e2e/tech-words.spec.ts index 585cdfd..42bb221 100644 --- a/tests/e2e/tech-words.spec.ts +++ b/tests/e2e/tech-words.spec.ts @@ -199,8 +199,14 @@ test.describe('Tech Words', () => { privacy, }) => { await context.grantPermissions(['clipboard-read', 'clipboard-write']); - // The test reads the clipboard to see what was copied; the game only writes to it - privacy.allow({ apis: ['clipboard-read'] }); + privacy.allow({ + apis: [ + { + match: 'clipboard-read', + reason: 'the test reads what was copied; the game only writes to the clipboard', + }, + ], + }); const answer = answerFor('e2e-share', terms4, 'networking'); await openRound(page, 'seed=e2e-share&length=4&category=networking'); await play(page, answer.term);