Skip to content

Commit 78caf31

Browse files
committed
test: replace puppeteer scripts with playwright e2e suite
1 parent e6c4010 commit 78caf31

12 files changed

Lines changed: 540 additions & 211 deletions

.github/workflows/deploy.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,22 @@ jobs:
3434
- name: Verify the built HTML is scrambled
3535
run: npm run verify:static
3636

37+
# Only Chromium — the technique needs the FontFace API and canvas glyph
38+
# rasterisation, and these run against the built artifact, not a dev server.
39+
- name: Install Playwright browser
40+
run: npx playwright install --with-deps chromium
41+
42+
- name: End-to-end checks against the built site
43+
run: npm run e2e
44+
45+
- name: Upload Playwright report on failure
46+
if: failure()
47+
uses: actions/upload-artifact@v4
48+
with:
49+
name: playwright-report
50+
path: playwright-report/
51+
retention-days: 7
52+
3753
- name: Add SPA fallback and disable Jekyll
3854
run: |
3955
cp dist/demo/browser/index.html dist/demo/browser/404.html

.gitignore

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,11 @@ testem.log
3939
/typings
4040
__screenshots__/
4141

42-
# Output of scripts/verify-in-browser.mjs
43-
/scripts/*.png
44-
/scripts/.last-verify.json
42+
# Playwright
43+
/test-results/
44+
/playwright-report/
45+
/blob-report/
46+
/playwright/.cache/
4547

4648
# System files
4749
.DS_Store

README.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,37 @@ stylesheet. daisyUI is a demo-only concern.
4545
npm run build:lib # build the library — required before building the demo
4646
npm start # dev server for the demo on :4321
4747
npm test # unit tests for library + demo
48-
npm run verify:browser # end-to-end check in real Chrome (dev server must be running)
48+
npm run build:pages # prerendered static site in dist/demo/browser
49+
npm run verify:static # assert the built HTML is scrambled and subpath-safe
50+
npm run e2e # Playwright checks against the built site (run build:pages first)
51+
npm run e2e:ui # the same suite in Playwright's watch UI
4952
```
5053

5154
`npm start` and `npm test` both depend on the library being built first.
5255

56+
## Testing
57+
58+
Three layers, because no single one can cover this library.
59+
60+
**Unit tests** (`npm test`, Vitest + jsdom) cover the cipher, the font forge and
61+
the directives. They cannot cover the central claim: jsdom has no `FontFace`, so
62+
every unit test necessarily runs the fail-open path and never observes a forged
63+
font at all.
64+
65+
**The build guard** (`npm run verify:static`) inspects bytes that never reach a
66+
browser — that the prerendered HTML holds no plaintext, that the SSR marker and
67+
transfer state are present, that no asset path is absolute, and that the demo
68+
specimen's plaintext stays pinned to a single JS chunk.
69+
70+
**End-to-end** (`npm run e2e`, Playwright + Chromium) runs against the **built
71+
static site** served under the real `/no-ai/` base path by `e2e/serve-static.mjs`,
72+
so it exercises the artifact that actually ships. It is the only layer that
73+
proves the forged font registers, that the painted glyphs differ from the
74+
fallback, that the clipboard carries ciphertext, and that hydration does not
75+
scramble the static form twice.
76+
77+
All three run in CI before the site is published.
78+
5379
## Deployment
5480

5581
The demo publishes on every push to `main` via

e2e/clipboard.spec.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
/**
4+
* The page claims the clipboard carries the DOM's characters rather than the
5+
* glyphs you read. Nothing else verifies that: the unit tests run without a
6+
* FontFace, and reading `textContent` is a weaker statement than a real copy.
7+
*
8+
* This drives a genuine selection, a genuine Ctrl+C and the system clipboard.
9+
*/
10+
test.describe('copy-paste', () => {
11+
test.beforeEach(async ({ context }) => {
12+
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
13+
});
14+
15+
test('copying protected text yields ciphertext, not the words on screen', async ({ page }) => {
16+
await page.goto('./');
17+
const body = page.locator('.protected-body');
18+
await expect(body).toBeVisible();
19+
20+
// Select the paragraph the way a person dragging across it would.
21+
await body.evaluate((el) => {
22+
const range = document.createRange();
23+
range.selectNodeContents(el);
24+
const selection = window.getSelection()!;
25+
selection.removeAllRanges();
26+
selection.addRange(range);
27+
});
28+
29+
await page.keyboard.press('ControlOrMeta+c');
30+
31+
const clipboard = await page.evaluate(() => navigator.clipboard.readText());
32+
33+
expect(clipboard.length).toBeGreaterThan(50);
34+
expect(clipboard.toLowerCase()).not.toContain('quick brown fox');
35+
expect(clipboard.trim()).toBe((await body.textContent())!.trim());
36+
});
37+
38+
test('pasting it back is judged unusable', async ({ page }) => {
39+
await page.goto('./');
40+
const body = page.locator('.protected-body');
41+
await expect(body).toBeVisible();
42+
43+
await body.evaluate((el) => {
44+
const range = document.createRange();
45+
range.selectNodeContents(el);
46+
const selection = window.getSelection()!;
47+
selection.removeAllRanges();
48+
selection.addRange(range);
49+
});
50+
await page.keyboard.press('ControlOrMeta+c');
51+
52+
const textarea = page.locator('textarea.dom-dump');
53+
await textarea.click();
54+
await page.keyboard.press('ControlOrMeta+v');
55+
56+
await expect(textarea).not.toHaveValue(/quick brown fox/i);
57+
await expect(page.getByText('scrambled — unusable')).toBeVisible();
58+
});
59+
60+
test('a partial readable paste is not mislabelled as unusable', async ({ page }) => {
61+
// Regression: comparing the paste against the whole article labelled every
62+
// partial copy "unusable", which is the opposite of the truth.
63+
await page.goto('./');
64+
await expect(page.locator('.protected-body')).toBeVisible();
65+
66+
await page.locator('textarea.dom-dump').fill('Pack my box with five dozen liquor jugs');
67+
68+
await expect(page.getByText('came through readable')).toBeVisible();
69+
await expect(page.getByText('scrambled — unusable')).toBeHidden();
70+
});
71+
});

e2e/config-lab.spec.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
/**
4+
* The lab renders four real child injectors, each with its own service, cipher
5+
* and forged font. It sits behind `@defer (on viewport)` because a child
6+
* service instantiated during prerender would write to the one shared
7+
* TransferState seed key and clobber the shell's cipher.
8+
*/
9+
test.describe('the configuration lab', () => {
10+
test.beforeEach(async ({ page }) => {
11+
await page.goto('./');
12+
await page.locator('app-config-lab').scrollIntoViewIfNeeded();
13+
await expect(page.locator('.config-sample').first()).toBeVisible();
14+
});
15+
16+
test('renders four independent instances', async ({ page }) => {
17+
await expect(page.locator('.config-sample')).toHaveCount(4);
18+
});
19+
20+
test('no two forged fonts share a family name', async ({ page }) => {
21+
// Regression: familyName derives from the seed alone, so instances without
22+
// an explicit seed inherit the page's and register conflicting cmaps under
23+
// one family. The browser then paints with whichever face it picked.
24+
const forged = await page.evaluate(() =>
25+
[...document.fonts].map((f) => f.family).filter((f) => /^(NoAi|Lab)-/.test(f)),
26+
);
27+
28+
expect(forged.length).toBeGreaterThan(1);
29+
expect(new Set(forged).size, `duplicate forged families: ${forged.join(', ')}`).toBe(
30+
forged.length,
31+
);
32+
});
33+
34+
test('the pinned-seed instance uses the seed it was given', async ({ page }) => {
35+
const card = page.locator('app-config-lab .card').filter({ hasText: 'Pinned cipher' });
36+
const stack = await card
37+
.locator('.config-sample')
38+
.evaluate((el) => getComputedStyle(el).fontFamily);
39+
40+
expect(stack).toContain(`NoAi-${(12345).toString(36)}`);
41+
});
42+
43+
test('the kill switch leaves its text readable and forges nothing', async ({ page }) => {
44+
const card = page.locator('app-config-lab .card').filter({ hasText: 'Kill switch' });
45+
const sample = card.locator('.config-sample');
46+
47+
await expect(sample).toContainText('Untouched text');
48+
expect(await sample.evaluate((el) => getComputedStyle(el).fontFamily)).not.toMatch(/NoAi-/);
49+
});
50+
51+
test('the kill switch does not hide readable text from assistive technology', async ({
52+
page,
53+
}) => {
54+
// aria-hidden belongs on ciphered specimens only. Applying it to text that
55+
// is genuinely readable imposes the cost without the protection.
56+
const sample = page
57+
.locator('app-config-lab .card')
58+
.filter({ hasText: 'Kill switch' })
59+
.locator('.config-sample');
60+
61+
await expect(sample).not.toHaveAttribute('aria-hidden', 'true');
62+
});
63+
64+
test('the narrow charset ciphers digits and leaves letters alone', async ({ page }) => {
65+
const sample = page
66+
.locator('app-config-lab .card')
67+
.filter({ hasText: 'Narrow charset' })
68+
.locator('.config-sample');
69+
70+
const text = (await sample.textContent())!;
71+
72+
// Letters are outside the cipher, so they survive verbatim in the DOM.
73+
expect(text).toContain('Order');
74+
expect(text).toContain('shipped');
75+
// The digits do not.
76+
expect(text).not.toContain('8391');
77+
});
78+
});
79+
80+
test.describe('prerender safety', () => {
81+
test('no lab instance renders during prerender', async ({ request }) => {
82+
// A child service running on the server overwrites the shell's seed in
83+
// TransferState, and the client then rebuilds the wrong cipher.
84+
const html = await (await request.get('./')).text();
85+
86+
expect(html).not.toContain('config-sample');
87+
});
88+
});

e2e/hydration.spec.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
const decode = (s: string) =>
4+
s
5+
.replaceAll('&lt;', '<')
6+
.replaceAll('&gt;', '>')
7+
.replaceAll('&quot;', '"')
8+
.replaceAll('&#39;', "'")
9+
.replaceAll('&amp;', '&');
10+
11+
/**
12+
* Guards the bug that shipped once already: `<p noAi>literal</p>` takes the
13+
* element's own text as the original, and on a hydrating page that text is
14+
* already ciphertext. Scrambling it a second time leaves the reader looking at
15+
* the server's output, because the font only ever undoes one layer.
16+
*
17+
* The site is a static file, so every request carries the same baked seed and
18+
* the served text and the hydrated text are directly comparable.
19+
*/
20+
test.describe('hydration', () => {
21+
test('the static form is not scrambled twice', async ({ page, request }) => {
22+
const html = await (await request.get('./')).text();
23+
// Not anchored to `<p class=` — Angular emits other attributes first.
24+
const served = decode(
25+
html.match(/<p[^>]*class="api-demo[^"]*"[^>]*>([\s\S]*?)<\/p>/)?.[1] ?? '',
26+
).trim();
27+
28+
expect(served, 'no api-demo element found in the served HTML').not.toBe('');
29+
30+
await page.goto('./');
31+
const hydrated = (await page.locator('.api-demo').first().textContent())!.trim();
32+
33+
expect(hydrated).toBe(served);
34+
});
35+
36+
test('server-scrambled elements are marked so the client can tell', async ({ page, request }) => {
37+
const html = await (await request.get('./')).text();
38+
expect(html).toContain('data-no-ai-ssr');
39+
40+
await page.goto('./');
41+
await expect(page.locator('.protected-body')).toHaveAttribute('data-no-ai-ssr', '');
42+
});
43+
44+
test('the client rebuilds the exact cipher the server used', async ({ page, request }) => {
45+
const html = await (await request.get('./')).text();
46+
const seed = html.match(/"noAiSeed":(\d+)/)?.[1];
47+
expect(seed, 'no noAiSeed in the transfer state').toBeTruthy();
48+
49+
await page.goto('./');
50+
const family = await page
51+
.locator('.protected-body')
52+
.evaluate((el) => getComputedStyle(el).fontFamily.split(',')[0].replaceAll('"', '').trim());
53+
54+
expect(family).toBe(`NoAi-${Number(seed).toString(36)}`);
55+
});
56+
57+
test('all three template APIs render readable text', async ({ page }) => {
58+
await page.goto('./');
59+
const demos = page.locator('.api-demo');
60+
await expect(demos).toHaveCount(3);
61+
62+
for (let i = 0; i < 3; i++) {
63+
const el = demos.nth(i);
64+
await expect(el).toBeVisible();
65+
const stack = await el.evaluate((e) => getComputedStyle(e).fontFamily);
66+
expect(stack, `api-demo ${i} is not using a forged font`).toMatch(/NoAi-/);
67+
}
68+
});
69+
});

0 commit comments

Comments
 (0)