@greendrake/e2e 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eugene Greendrake
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,133 @@
1
+ # @greendrake/e2e
2
+
3
+ A Playwright kit for single-page apps: the config factories an app would otherwise hand-copy, a same-origin SPA crawler, a clipped-content detector, a polling helper for asynchronous writes, and a standalone layout checker.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ bun add -d @greendrake/e2e
9
+ ```
10
+
11
+ `@playwright/test` (`>=1.49.0`) is a peer dependency: the consumer pins its own Playwright, and this package resolves that copy. The package ships TypeScript source, which Node cannot load from `node_modules`, so a config importing it runs Playwright under bun: `bun --bun playwright test`. The `./poll` entry and the `check-layout` bin have no such requirement.
12
+
13
+ ## `spaPlaywright()`
14
+
15
+ ```js
16
+ // playwright.config.js
17
+ import { spaPlaywright } from '@greendrake/e2e'
18
+
19
+ export default spaPlaywright({ defaultPort: '5173' })
20
+ ```
21
+
22
+ The frontend port is `VITE_PORT`, falling back to `defaultPort` — the same pin `@greendrake/vite-preset` requires for `vite dev`, so the config targets the server the app actually started. Omit `defaultPort` in an app whose harness always supplies `VITE_PORT`: a missing port then throws rather than silently testing whatever happens to be listening on a default.
23
+
24
+ What it returns (a `PlaywrightTestConfig` via `defineConfig`):
25
+
26
+ - `testDir: './tests'`, `fullyParallel: true`, `workers: 5`, `retries: 0`, `forbidOnly` when `CI` is set.
27
+ - `timeout: 120_000` — crawl-style specs visit every page and click through every link; Playwright's 30s default is sized for single-interaction tests.
28
+ - Reporter: `html`, `open: 'never'`.
29
+ - `use`: `baseURL` `http://localhost:<port>`, `trace: 'on-first-retry'`, `video: 'retain-on-failure'`.
30
+ - One project, `chromium`, on `devices['Desktop Chrome']`.
31
+ - No `webServer`: the app, and whatever backs it, are started by the caller.
32
+
33
+ `overrides` is a `PlaywrightTestConfig` spread over the result last, key by key at the top level — an `overrides.use` replaces the whole `use` object rather than merging into it.
34
+
35
+ ## `serviceWorkerPlaywright()`
36
+
37
+ ```js
38
+ // playwright.sw.config.js
39
+ import { serviceWorkerPlaywright } from '@greendrake/e2e'
40
+ import { fileURLToPath } from 'node:url'
41
+
42
+ export default serviceWorkerPlaywright({
43
+ dist: fileURLToPath(new URL('./dist', import.meta.url)),
44
+ defaultPort: '4173'
45
+ })
46
+ ```
47
+
48
+ The config for a service-worker suite: specs in `./tests/sw`, run against a built app that `vite preview` serves statically, with no backend at all. Serving an existing `dist` rather than building one here is deliberate — a build can carry deploy-time artefacts fetched from production, which a test run must not depend on. Nothing in the suite needs an API either: the app boots, its calls park unanswered, and what is asserted is that it got far enough to say so. The worker under test is whichever one the app's own build emits.
49
+
50
+ Options:
51
+
52
+ - `dist` (required) — absolute path to the app's built output. The factory throws when it does not exist; build first.
53
+ - `defaultPort` — as for `spaPlaywright()`.
54
+ - `previewEnv` — extra environment for the preview server, for a Vite config that resolves something before it will start.
55
+ - `notifications` — run on the full Chromium build (`channel: 'chromium'`) rather than the headless shell Playwright otherwise picks for a headless run. Required by any suite that asserts on notifications: the shell ships without the notification platform and hard-denies the permission — `grantPermissions()` is accepted and `Notification.permission` still reads `"denied"`, making a worker's push handling untestable. Off by default: the shell starts faster, and the two builds are not behaviourally identical.
56
+ - `overrides` — as for `spaPlaywright()`.
57
+
58
+ What it returns:
59
+
60
+ - `testDir: './tests/sw'`, `fullyParallel: false`, `workers: 1` — every test drives the same origin's worker registration and cache storage, which are shared browser-wide state.
61
+ - `forbidOnly` when `FORBID_ONLY` is set; `timeout: 60_000`; reporter `html`, `open: 'never'`.
62
+ - `use`: `devices['Desktop Chrome']`, `baseURL` `http://localhost:<port>`, plus `channel: 'chromium'` under `notifications`.
63
+ - `webServer`: `bun --bun vite preview` at `http://localhost:<port>`, `reuseExistingServer: false`, with `VITE_PORT` and `previewEnv` in its environment.
64
+
65
+ ## `crawlLocalLinks()`
66
+
67
+ ```js
68
+ import { test } from '@playwright/test'
69
+ import { crawlLocalLinks } from '@greendrake/e2e'
70
+
71
+ test('every reachable page renders', async ({ page }) => {
72
+ const visited = await crawlLocalLinks(page, 'http://localhost:5173')
73
+ console.log(visited)
74
+ })
75
+ ```
76
+
77
+ `crawlLocalLinks(page, origin)` crawls every same-origin page link reachable from `/`, verifies each page renders content, exercises SPA navigation by clicking through links, fails on any uncaught page error, and returns the visited paths. It asserts with `expect` from `@playwright/test`, so it runs inside a test.
78
+
79
+ Per page: navigate (`domcontentloaded`) and wait up to 10s for the body to hold more than 100 characters of text — `networkidle` is unreliable with keepalive and analytics requests; on timeout the pending requests are logged before the error propagates. A route may deliberately leave the site (an external-redirect stub); once off-origin, nothing on the page belongs to the app, so the crawler asserts that no page error occurred and moves on without harvesting — otherwise the foreign site's paths would be queued onto the app's origin. On-origin, the body must have text and no page error may have fired.
80
+
81
+ Link harvesting takes `a[href]` elements, keeping root-relative hrefs and absolute same-origin URLs (reduced to their pathname) and dropping `#`, `mailto:`, `tel:` and any href whose last path segment carries a file extension. Each new path joins the queue. Then every visible, enabled page link on the page is clicked — a given href is click-tested once across the whole crawl, since nav menus repeat on every page — the body after the click must have text, and the page goes back. A click-through that fails is logged as a warning, not a failure. Failed network requests (other than aborts) are warned about as they happen; console errors are collected and reported as a warning at the end. Only `pageerror` events fail the crawl.
82
+
83
+ ## `findClippedContent()`
84
+
85
+ ```js
86
+ import { expect, test } from '@playwright/test'
87
+ import { findClippedContent } from '@greendrake/e2e'
88
+
89
+ test('no collapsed boxes', async ({ page }) => {
90
+ await page.goto('/')
91
+ expect(await findClippedContent(page)).toEqual([])
92
+ })
93
+ ```
94
+
95
+ `findClippedContent(page)` returns the elements whose own content is clipped away and cannot be scrolled to — the signature of a box that collapsed under its contents.
96
+
97
+ The usual cause is a grid or flex item that is also a scroll container. Such an item has no automatic minimum size (CSS Sizing 3 §3.4: the size of a scroll container's content does not constrain it), so its track or line collapses to nothing while its children keep their full height and are clipped. It is easy to write by accident — `overflow: hidden` for rounded corners on a grid card is enough — and the result is invisible content rather than an error. Engines disagree on which overflow values trigger it (`overflow: clip` is safe in Blink and not in older WebKit), so a page that looks right in one browser can collapse in another; run the check in every engine the app is used in.
98
+
99
+ A box counts as clipped when its `overflow-y` is `hidden` or `clip` (`auto` and `scroll` are deliberate scroll containers, `visible` does not clip), its `scrollHeight` exceeds its `clientHeight` by more than a 4px rounding tolerance, and it has child elements — a clamped line of text has none and is intended truncation.
100
+
101
+ Each `ClippedElement` carries `selector` (a readable path such as `div.News > a.Card`), `height` (rendered box height) and `contentHeight` (the height its content needs), `overflowY`, `parentDisplay` (the hazard is specific to grid and flex items) and `text` (the first 60 characters of the element's text).
102
+
103
+ ## `@greendrake/e2e/poll`
104
+
105
+ ```js
106
+ import { pollUntil } from '@greendrake/e2e/poll'
107
+
108
+ const row = await pollUntil(
109
+ () => listRows(),
110
+ rows => rows.find(row => row.id === expectedId)
111
+ )
112
+ ```
113
+
114
+ `pollUntil(produce, predicate, budgetMs = 5000, intervalMs = 200)` calls the async `produce` and applies `predicate` to its value until the predicate returns a truthy match, and returns that match; once the budget is spent it throws `pollUntil: predicate never satisfied within <budgetMs>ms`. It exists for verifying fire-and-forget writes (tracking calls, queued tasks): the row lands asynchronously, so a single fetch races the write and a wait-and-retry is required. The default budget covers a 1.5s client-side error debounce plus network slack.
115
+
116
+ It is a separate subpath because the main entry loads `@playwright/test` through the config factories; importing that from a spec in an app that pins a different Playwright version loads a second `@playwright/test` copy — a hard collection error. `./poll` imports nothing, so a spec can use it under any pinned version.
117
+
118
+ ## `check-layout`
119
+
120
+ The `check-layout` bin loads a URL in every engine, at phone and desktop widths, and reports any box whose content is clipped away unreachably — the `findClippedContent()` detection, inlined so the script runs as plain JavaScript against any URL with no TypeScript pipeline in the way.
121
+
122
+ ```sh
123
+ bunx check-layout http://localhost:5173/
124
+ bunx check-layout http://localhost:5173/ --css '.Card { overflow: hidden }'
125
+ ```
126
+
127
+ `--css <stylesheet>` injects the stylesheet after the page loads and before measuring — how a fix is tried against a deployed page without deploying it, and how the check is confirmed to still catch the thing it exists to catch.
128
+
129
+ Engines and viewports: `chromium` and `webkit` (an engine the resolved Playwright lacks is skipped), each at `phone` 390×844 and `desktop` 1280×800; every page load waits for `networkidle` with a 30s timeout. Per engine/viewport the script prints `ok`, or `CLIP` followed by the findings grouped by selector — the number of instances, the smallest box height, the largest content height, `overflow-y`, the parent's display and a sample of the hidden text — or `ERROR` with the first line of the failure when the page does not load.
130
+
131
+ Exit code: `0` when nothing is clipped anywhere; `1` when anything is clipped or a page fails to load, so it can gate a deploy; `2` on a usage error (no URL) or when no Playwright can be resolved.
132
+
133
+ Playwright is resolved from the caller's side, trying in order `playwright`, `playwright-core`, then a global bun install (`~/.bun/install/global/node_modules/playwright`). Install it in the workspace, or globally with `bun add -g playwright`.
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env node
2
+ // Load a URL in every engine, at phone and desktop widths, and report any box
3
+ // whose content is clipped away unreachably — see src/findClippedContent.ts for
4
+ // what that means and why engines disagree about it.
5
+ //
6
+ // ./check-layout.mjs http://localhost:5173/news
7
+ // ./check-layout.mjs https://app.example.com/news --css '.News .Card { overflow: hidden }'
8
+ //
9
+ // --css injects a stylesheet before measuring, which is how a fix is tried
10
+ // against a deployed page without deploying it, and how this check is confirmed
11
+ // to still catch the thing it exists to catch.
12
+ //
13
+ // Exits non-zero when anything is clipped, so it can gate a deploy.
14
+
15
+ import { createRequire } from 'node:module'
16
+
17
+ const VIEWPORTS = [
18
+ {
19
+ name: 'phone',
20
+ width: 390,
21
+ height: 844
22
+ },
23
+ {
24
+ name: 'desktop',
25
+ width: 1280,
26
+ height: 800
27
+ }
28
+ ]
29
+
30
+ const [url, ...rest] = process.argv.slice(2)
31
+ if (!url) {
32
+ console.error('usage: check-layout.mjs <url> [--css <stylesheet>]')
33
+ process.exit(2)
34
+ }
35
+ const cssIndex = rest.indexOf('--css')
36
+ const extraCSS = cssIndex === -1 ? null : rest[cssIndex + 1]
37
+
38
+ // Playwright is a peer dependency: resolve it from wherever the caller has it
39
+ // rather than pinning a path that only exists on one machine.
40
+ const require = createRequire(import.meta.url)
41
+ let playwright
42
+ for (const candidate of ['playwright', 'playwright-core', `${process.env.HOME}/.bun/install/global/node_modules/playwright`]) {
43
+ try {
44
+ // Playwright is CommonJS, so an ESM import puts its exports behind
45
+ // `default` — except where the interop hoists them. Accept either.
46
+ const loaded = await import(require.resolve(candidate))
47
+ const exports = loaded.chromium ? loaded : loaded.default
48
+ if (exports?.chromium) {
49
+ playwright = exports
50
+ break
51
+ }
52
+ } catch {
53
+ continue
54
+ }
55
+ }
56
+ if (!playwright) {
57
+ console.error('No playwright available. Install it in the workspace, or globally: bun add -g playwright')
58
+ process.exit(2)
59
+ }
60
+
61
+ // Inlined rather than imported from src/: this runs as a plain script against
62
+ // any URL, with no TypeScript pipeline in the way.
63
+ const findClipped = tolerance => {
64
+ const describe = el => {
65
+ const name = e => e.tagName.toLowerCase() + (e.classList.length ? `.${[...e.classList].join('.')}` : '')
66
+ return el.parentElement ? `${name(el.parentElement)} > ${name(el)}` : name(el)
67
+ }
68
+ const found = []
69
+ for (const el of document.querySelectorAll('*')) {
70
+ const style = getComputedStyle(el)
71
+ if (style.overflowY !== 'hidden' && style.overflowY !== 'clip') continue
72
+ if (el.scrollHeight <= el.clientHeight + tolerance) continue
73
+ if (el.childElementCount === 0) continue
74
+ found.push({
75
+ selector: describe(el),
76
+ height: Math.round(el.getBoundingClientRect().height),
77
+ contentHeight: el.scrollHeight,
78
+ overflowY: style.overflowY,
79
+ parentDisplay: el.parentElement ? getComputedStyle(el.parentElement).display : '',
80
+ text: (el.textContent ?? '').trim().slice(0, 60)
81
+ })
82
+ }
83
+ return found
84
+ }
85
+
86
+ let failures = 0
87
+ for (const engineName of ['chromium', 'webkit']) {
88
+ const engine = playwright[engineName]
89
+ if (!engine) continue
90
+ const browser = await engine.launch()
91
+ for (const viewport of VIEWPORTS) {
92
+ const context = await browser.newContext({ viewport: { width: viewport.width, height: viewport.height } })
93
+ const page = await context.newPage()
94
+ try {
95
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 })
96
+ if (extraCSS) await page.addStyleTag({ content: extraCSS })
97
+ const clipped = await page.evaluate(findClipped, 4)
98
+ const label = `${engineName}/${viewport.name}`
99
+ if (clipped.length === 0) {
100
+ console.log(`ok ${label}`)
101
+ } else {
102
+ failures += clipped.length
103
+ console.log(`CLIP ${label}`)
104
+ // One component collapses once per instance; the selector is
105
+ // the finding and the count is the blast radius.
106
+ const bySelector = new Map()
107
+ for (const c of clipped) {
108
+ const seen = bySelector.get(c.selector)
109
+ if (seen) {
110
+ seen.count++
111
+ seen.height = Math.min(seen.height, c.height)
112
+ seen.contentHeight = Math.max(seen.contentHeight, c.contentHeight)
113
+ } else {
114
+ bySelector.set(c.selector, { ...c, count: 1 })
115
+ }
116
+ }
117
+ for (const c of bySelector.values()) {
118
+ console.log(` ${c.selector} ×${c.count}: ${c.height}px tall, up to ${c.contentHeight}px of content ` + `(overflow-y: ${c.overflowY}, parent display: ${c.parentDisplay})`)
119
+ if (c.text) console.log(` hidden: ${JSON.stringify(c.text)}`)
120
+ }
121
+ }
122
+ } catch (error) {
123
+ failures++
124
+ console.log(`ERROR ${engineName}/${viewport.name}: ${error.message.split('\n')[0]}`)
125
+ }
126
+ await context.close()
127
+ }
128
+ await browser.close()
129
+ }
130
+
131
+ process.exit(failures > 0 ? 1 : 0)
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@greendrake/e2e",
3
+ "version": "0.1.0",
4
+ "description": "Playwright kit for single-page apps: config factories, a same-origin crawler, a clipped-content detector, a polling helper and a layout checker",
5
+ "license": "MIT",
6
+ "author": "Eugene Greendrake <eugene@greendrake.nz>",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/greendrake/lib.git",
10
+ "directory": "e2e"
11
+ },
12
+ "homepage": "https://github.com/greendrake/lib/tree/main/e2e#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/greendrake/lib/issues"
15
+ },
16
+ "type": "module",
17
+ "main": "src/main.ts",
18
+ "types": "src/main.ts",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./src/main.ts",
22
+ "default": "./src/main.ts"
23
+ },
24
+ "./poll": {
25
+ "types": "./src/pollUntil.ts",
26
+ "default": "./src/pollUntil.ts"
27
+ }
28
+ },
29
+ "files": [
30
+ "src",
31
+ "bin"
32
+ ],
33
+ "bin": {
34
+ "check-layout": "./bin/check-layout.mjs"
35
+ },
36
+ "sideEffects": false,
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "scripts": {
41
+ "check": "tsc --noEmit"
42
+ },
43
+ "prettier": "@greendrake/dev-config/prettier",
44
+ "peerDependencies": {
45
+ "@playwright/test": ">=1.49.0"
46
+ },
47
+ "devDependencies": {
48
+ "@greendrake/dev-config": "0.1.0",
49
+ "@playwright/test": "1.60.0",
50
+ "typescript": "6.0.3"
51
+ }
52
+ }
@@ -0,0 +1,179 @@
1
+ import { expect, type Page } from '@playwright/test'
2
+
3
+ // Crawl every same-origin page link reachable from '/': verify each page
4
+ // renders content, exercise SPA navigation by clicking through links, and
5
+ // fail on any uncaught page error. Returns the visited paths for reporting.
6
+
7
+ const hasFileExtension = (url: string): boolean => {
8
+ const pathname = url.split('?')[0].split('#')[0]
9
+ const lastSegment = pathname.split('/').pop()
10
+ return !!lastSegment && lastSegment.includes('.') && !lastSegment.startsWith('.')
11
+ }
12
+
13
+ const isPageLink = (href: string | null): href is string => {
14
+ if (!href) return false
15
+ if (href.startsWith('#')) return false
16
+ if (href.startsWith('mailto:')) return false
17
+ if (href.startsWith('tel:')) return false
18
+ if (hasFileExtension(href)) return false
19
+ return true
20
+ }
21
+
22
+ export const crawlLocalLinks = async (page: Page, origin: string): Promise<string[]> => {
23
+ const visitedUrls = new Set<string>()
24
+ const urlsToVisit = ['/']
25
+ // Each distinct href is click-tested once across the whole crawl — nav
26
+ // menus repeat on every page and re-clicking them is pure wait time.
27
+ const clickedHrefs = new Set<string>()
28
+
29
+ // Track pending network requests for debugging
30
+ const pendingRequests = new Set<string>()
31
+ page.on('request', request => {
32
+ pendingRequests.add(request.url())
33
+ })
34
+ page.on('requestfinished', request => {
35
+ pendingRequests.delete(request.url())
36
+ })
37
+ page.on('requestfailed', request => {
38
+ pendingRequests.delete(request.url())
39
+ const url = request.url()
40
+ const reason = request.failure()?.errorText ?? 'unknown'
41
+ // Only warn about real failures of HTTP(S) requests. Aborted requests
42
+ // are normal when the test navigates away mid-load (e.g. via goBack()).
43
+ if ((url.startsWith('http://') || url.startsWith('https://')) && reason !== 'net::ERR_ABORTED') {
44
+ console.warn(`Request failed: ${url} (${reason})`)
45
+ }
46
+ })
47
+
48
+ const consoleErrors: string[] = []
49
+ page.on('console', msg => {
50
+ if (msg.type() === 'error') {
51
+ consoleErrors.push(msg.text())
52
+ }
53
+ })
54
+
55
+ const pageErrors: string[] = []
56
+ page.on('pageerror', error => {
57
+ pageErrors.push(error.message)
58
+ })
59
+
60
+ while (urlsToVisit.length > 0) {
61
+ const currentPath = urlsToVisit.shift()!
62
+ if (visitedUrls.has(currentPath)) {
63
+ continue
64
+ }
65
+
66
+ console.log(`Visiting: ${currentPath}`)
67
+ visitedUrls.add(currentPath)
68
+
69
+ await page.goto(`${origin}${currentPath}`, { waitUntil: 'domcontentloaded' })
70
+
71
+ // Wait for real content to render (networkidle is unreliable with
72
+ // keepalive/analytics requests).
73
+ try {
74
+ await page.waitForFunction(() => {
75
+ const body = document.body
76
+ return !!body && !!body.textContent && body.textContent.trim().length > 100
77
+ }, { timeout: 10000 })
78
+ } catch (error) {
79
+ console.error(`Pending requests: ${Array.from(pendingRequests).join(', ')}`)
80
+ throw error
81
+ }
82
+
83
+ // Routes may deliberately leave the site (external-redirect stubs like
84
+ // /store → a marketplace). Once off-origin, nothing on the page belongs
85
+ // to the app: harvesting there would queue the FOREIGN site's paths
86
+ // onto the origin under test. Reaching the redirect without a page
87
+ // error is the
88
+ // assertion. Checked after the content wait — the guard's replace can
89
+ // land any time during boot.
90
+ if (new URL(page.url()).origin !== new URL(origin).origin) {
91
+ console.log(` → left origin (${page.url()}), skipping harvest`)
92
+ expect(pageErrors).toEqual([])
93
+ continue
94
+ }
95
+
96
+ const bodyText = await page.textContent('body')
97
+ expect(bodyText).toBeTruthy()
98
+ expect(pageErrors).toEqual([])
99
+
100
+ // Collect same-origin page links for the crawl queue.
101
+ const links = await page.evaluate(() => {
102
+ const fileExt = (url: string): boolean => {
103
+ const pathname = url.split('?')[0].split('#')[0]
104
+ const lastSegment = pathname.split('/').pop()
105
+ return !!lastSegment && lastSegment.includes('.') && !lastSegment.startsWith('.')
106
+ }
107
+ return Array.from(document.querySelectorAll('a[href]'))
108
+ .map(a => {
109
+ const href = a.getAttribute('href')
110
+ if (!href) return null
111
+ if (href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:')) return null
112
+ if (fileExt(href)) return null
113
+ if (href.startsWith('http://') || href.startsWith('https://')) {
114
+ try {
115
+ const url = new URL(href)
116
+ if (url.origin !== window.location.origin) return null
117
+ if (fileExt(url.pathname)) return null
118
+ return url.pathname
119
+ } catch {
120
+ return null
121
+ }
122
+ }
123
+ return href.startsWith('/') ? href : null
124
+ })
125
+ .filter((href): href is string => href !== null)
126
+ })
127
+ for (const link of links) {
128
+ if (!visitedUrls.has(link) && !urlsToVisit.includes(link)) {
129
+ urlsToVisit.push(link)
130
+ }
131
+ }
132
+
133
+ // Click through the links on this page to exercise SPA navigation.
134
+ // Locators are re-resolved by index each iteration: goBack() rebuilds
135
+ // the DOM, so a locator captured before the click can go stale.
136
+ const linkCount = await page.locator('a[href]').count()
137
+ for (let i = 0; i < linkCount; i++) {
138
+ // A previous click may have landed on an external-redirect route;
139
+ // goBack() cannot always recover (location.replace rewrites the
140
+ // entry). Re-anchor on the page under test before continuing.
141
+ if (new URL(page.url()).origin !== new URL(origin).origin) {
142
+ await page.goto(`${origin}${currentPath}`, { waitUntil: 'domcontentloaded' })
143
+ await page.waitForTimeout(500)
144
+ }
145
+ const link = page.locator('a[href]').nth(i)
146
+ try {
147
+ const href = await link.getAttribute('href', { timeout: 2000 })
148
+ if (!isPageLink(href) || clickedHrefs.has(href)) {
149
+ continue
150
+ }
151
+ if (href.startsWith('http://') || href.startsWith('https://')) {
152
+ if (new URL(href).origin !== new URL(origin).origin) continue
153
+ }
154
+ if ((await link.isVisible()) && (await link.isEnabled())) {
155
+ clickedHrefs.add(href)
156
+ await link.click()
157
+ // Brief settle for the router transition; content asserted below.
158
+ await page.waitForTimeout(500)
159
+ const postClickBody = await page.textContent('body')
160
+ expect(postClickBody).toBeTruthy()
161
+ await page.goBack()
162
+ await page.waitForTimeout(500)
163
+ }
164
+ } catch (error) {
165
+ console.warn(`Warning: link #${i} click-through failed: ${(error as Error).message}`)
166
+ }
167
+ }
168
+ }
169
+
170
+ console.log(`\nTest Summary:`)
171
+ console.log(`Total unique pages visited: ${visitedUrls.size}`)
172
+ console.log(`Pages: ${Array.from(visitedUrls).join(', ')}`)
173
+
174
+ if (consoleErrors.length > 0) {
175
+ console.warn('Console errors detected:', consoleErrors)
176
+ }
177
+ expect(pageErrors).toEqual([])
178
+ return Array.from(visitedUrls)
179
+ }
@@ -0,0 +1,62 @@
1
+ import type { Page } from '@playwright/test'
2
+
3
+ // Find elements whose own content is clipped away and cannot be scrolled to —
4
+ // the signature of a box that collapsed under its contents.
5
+ //
6
+ // The usual cause is a grid or flex item that is also a scroll container. Such
7
+ // an item has no automatic minimum size (CSS Sizing 3 §3.4: the size of a
8
+ // scroll container's content does not constrain it), so its track or line
9
+ // collapses to nothing while its children keep their full height and are
10
+ // clipped. It is easy to write by accident — `overflow: hidden` for rounded
11
+ // corners on a grid card is enough — and the result is invisible content
12
+ // rather than an error.
13
+ //
14
+ // Engines disagree on which overflow values trigger it, so a page that looks
15
+ // right in one browser can collapse in another. Run this in every engine the
16
+ // app is used in; `overflow: clip` in particular is safe in Blink and not in
17
+ // older WebKit.
18
+
19
+ export interface ClippedElement {
20
+ // A readable path to the element, e.g. "div.News > a.Card".
21
+ selector: string
22
+ // Rendered box height, and the height its content actually needs.
23
+ height: number
24
+ contentHeight: number
25
+ overflowY: string
26
+ // The parent's display, since the hazard is specific to grid/flex items.
27
+ parentDisplay: string
28
+ text: string
29
+ }
30
+
31
+ // A box may sit a pixel or two under its content through rounding alone.
32
+ const TOLERANCE_PX = 4
33
+
34
+ export const findClippedContent = async (page: Page): Promise<ClippedElement[]> =>
35
+ page.evaluate(tolerance => {
36
+ const describe = (el: Element): string => {
37
+ const name = (e: Element) => e.tagName.toLowerCase() + (e.classList.length ? `.${[...e.classList].join('.')}` : '')
38
+ return el.parentElement ? `${name(el.parentElement)} > ${name(el)}` : name(el)
39
+ }
40
+
41
+ const found: ClippedElement[] = []
42
+ for (const el of document.querySelectorAll<HTMLElement>('*')) {
43
+ const style = getComputedStyle(el)
44
+ // Only boxes that clip without offering a scrollbar hide content
45
+ // for good; `auto`/`scroll` are deliberate scroll containers, and
46
+ // `visible` does not clip at all.
47
+ if (style.overflowY !== 'hidden' && style.overflowY !== 'clip') continue
48
+ if (el.scrollHeight <= el.clientHeight + tolerance) continue
49
+ // A collapsed box has children taller than itself. An ordinary
50
+ // truncation (a clamped line of text) has none, and is intended.
51
+ if (el.childElementCount === 0) continue
52
+ found.push({
53
+ selector: describe(el),
54
+ height: Math.round(el.getBoundingClientRect().height),
55
+ contentHeight: el.scrollHeight,
56
+ overflowY: style.overflowY,
57
+ parentDisplay: el.parentElement ? getComputedStyle(el.parentElement).display : '',
58
+ text: (el.textContent ?? '').trim().slice(0, 60)
59
+ })
60
+ }
61
+ return found
62
+ }, TOLERANCE_PX)
package/src/main.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { spaPlaywright, type SpaPlaywrightOptions } from './playwrightConfig'
2
+ export { serviceWorkerPlaywright, type ServiceWorkerPlaywrightOptions } from './serviceWorkerConfig'
3
+ export { crawlLocalLinks } from './crawlLocalLinks'
4
+ export { findClippedContent, type ClippedElement } from './findClippedContent'
@@ -0,0 +1,42 @@
1
+ import { defineConfig, devices, type PlaywrightTestConfig } from '@playwright/test'
2
+
3
+ export interface SpaPlaywrightOptions {
4
+ // Frontend port when VITE_PORT is unset (each app's .env.test convention).
5
+ // Omit it for apps whose harness always supplies VITE_PORT: a missing port
6
+ // then fails the run rather than silently testing whatever happens to be
7
+ // listening on a default.
8
+ defaultPort?: string
9
+ overrides?: PlaywrightTestConfig
10
+ }
11
+
12
+ // The config an SPA would otherwise hand-copy: chromium-only, ./tests dir, an
13
+ // html reporter that never auto-opens, and a baseURL built from VITE_PORT.
14
+ export const spaPlaywright = (options: SpaPlaywrightOptions = {}): PlaywrightTestConfig => {
15
+ const port = process.env.VITE_PORT || options.defaultPort
16
+ if (!port) {
17
+ throw new Error('spaPlaywright: VITE_PORT must be set (this app configures no defaultPort)')
18
+ }
19
+ return defineConfig({
20
+ testDir: './tests',
21
+ fullyParallel: true,
22
+ forbidOnly: !!process.env.CI,
23
+ retries: 0,
24
+ workers: 5,
25
+ // Crawl-style specs visit every page and click through every link;
26
+ // Playwright's 30s default is sized for single-interaction tests.
27
+ timeout: 120_000,
28
+ reporter: [['html', { open: 'never' }]],
29
+ use: {
30
+ baseURL: `http://localhost:${port}`,
31
+ trace: 'on-first-retry',
32
+ video: 'retain-on-failure'
33
+ },
34
+ projects: [
35
+ {
36
+ name: 'chromium',
37
+ use: { ...devices['Desktop Chrome'] }
38
+ }
39
+ ],
40
+ ...options.overrides
41
+ })
42
+ }
@@ -0,0 +1,21 @@
1
+ // Poll an async producer until the predicate returns a truthy match, and
2
+ // return that match. For verifying fire-and-forget writes (visit tracking,
3
+ // queued tasks): the row lands asynchronously, so a single fetch races the
4
+ // write and a wait-and-retry is required. The default budget covers the
5
+ // visit lib's error debouncer (1500ms) plus network slack.
6
+ //
7
+ // Exposed as the `@greendrake/e2e/poll` subpath (not the barrel): the
8
+ // barrel pulls in @playwright/test via the config factory, and importing
9
+ // that from a spec loads a SECOND @playwright/test copy in apps that pin
10
+ // a different playwright version — a hard collection error.
11
+ export const pollUntil = async <T, M>(produce: () => Promise<T>, predicate: (value: T) => M | undefined, budgetMs = 5000, intervalMs = 200): Promise<M> => {
12
+ const deadline = Date.now() + budgetMs
13
+ for (;;) {
14
+ const match = predicate(await produce())
15
+ if (match) return match
16
+ if (Date.now() >= deadline) {
17
+ throw new Error(`pollUntil: predicate never satisfied within ${budgetMs}ms`)
18
+ }
19
+ await new Promise(resolve => setTimeout(resolve, intervalMs))
20
+ }
21
+ }
@@ -0,0 +1,62 @@
1
+ import { defineConfig, devices, type PlaywrightTestConfig } from '@playwright/test'
2
+ import { existsSync } from 'node:fs'
3
+
4
+ export interface ServiceWorkerPlaywrightOptions {
5
+ // The app's own dist/, which `vite preview` will serve. Absolute.
6
+ dist: string
7
+ // Frontend port when VITE_PORT is unset, per the app's harness convention.
8
+ defaultPort?: string
9
+ // Extra environment for the preview server, for configs that resolve
10
+ // something before they will start.
11
+ previewEnv?: Record<string, string>
12
+ // Run against the full Chromium build rather than the headless shell
13
+ // Playwright otherwise picks for a headless run. Required by any suite that
14
+ // asserts on notifications: the shell ships without the notification
15
+ // platform, so it hard-denies the permission — grantPermissions() is
16
+ // accepted and Notification.permission still reads "denied", making a
17
+ // worker's push handling untestable. Off by default because the shell
18
+ // starts faster and the two builds are not behaviourally identical — a
19
+ // suite driving several windows at once may pass on one and not the other.
20
+ notifications?: boolean
21
+ overrides?: PlaywrightTestConfig
22
+ }
23
+
24
+ // The service-worker suite's config: tests/sw/, run against a built app served
25
+ // statically by `vite preview`, with no backend at all.
26
+ //
27
+ // Serving an existing dist rather than building one here is deliberate — a
28
+ // build can carry deploy-time artefacts fetched from production, which a test
29
+ // run must not depend on. Nothing in the suite needs an API either: the app
30
+ // boots, its calls park unanswered, and what is asserted is that it got far
31
+ // enough to say so.
32
+ export const serviceWorkerPlaywright = ({ dist, defaultPort, previewEnv, notifications, overrides }: ServiceWorkerPlaywrightOptions): PlaywrightTestConfig => {
33
+ const port = process.env.VITE_PORT || defaultPort
34
+ if (!port) {
35
+ throw new Error('serviceWorkerPlaywright: VITE_PORT must be set (this app configures no defaultPort)')
36
+ }
37
+ if (!existsSync(dist)) {
38
+ throw new Error(`serviceWorkerPlaywright: no dist/ at ${dist} — run \`bun run build\` first`)
39
+ }
40
+ return defineConfig({
41
+ testDir: './tests/sw',
42
+ // One worker: every test drives the same origin's worker registration
43
+ // and cache storage, which are shared browser-wide state.
44
+ fullyParallel: false,
45
+ workers: 1,
46
+ forbidOnly: !!process.env.FORBID_ONLY,
47
+ reporter: [['html', { open: 'never' }]],
48
+ timeout: 60_000,
49
+ use: {
50
+ ...devices['Desktop Chrome'],
51
+ ...(notifications ? { channel: 'chromium' } : {}),
52
+ baseURL: `http://localhost:${port}`
53
+ },
54
+ webServer: {
55
+ command: 'bun --bun vite preview',
56
+ url: `http://localhost:${port}`,
57
+ reuseExistingServer: false,
58
+ env: { VITE_PORT: port, ...previewEnv }
59
+ },
60
+ ...overrides
61
+ })
62
+ }