M1: initial scaffold — pipeline, specs, source, Dockerfile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul O'Reilly
2026-04-17 23:52:40 +12:00
commit 8584def154
22 changed files with 1413 additions and 0 deletions

127
spec/dom-analysis.md Normal file
View File

@@ -0,0 +1,127 @@
# DOM Analysis Spec (WE-D)
## Overview
Extracts structured metadata from a loaded page using Playwright's `page.evaluate()`.
Provides the page structure data used in the report's "Page Structure" section.
Runs in the same browser session as the screenshots to avoid a second page load.
## Responsibilities
- Extract title, meta tags, canonical URL, Open Graph tags
- Count and validate heading hierarchy (H1H6)
- Audit images for missing alt text
- Audit links for external targets missing `rel="noopener"`
- Detect JSON-LD structured data
- Return a plain object (serialisable to JSON)
Delegates to: Playwright `page.evaluate()`
## Dependencies
Read [pipeline.md](pipeline.md) for output file layout.
Read [screenshot.md](screenshot.md) — DOM extraction reuses the same Playwright page.
## Data Model
```js
{
title: 'Example Domain',
titleLength: 14,
meta: {
description: null, // string or null
descriptionLength: null, // number or null
robots: 'index, follow', // string or null
viewport: 'width=device-width, initial-scale=1', // string or null
},
canonical: null, // string URL or null
openGraph: {
title: null,
description: null,
image: null,
type: null,
},
headings: {
h1: ['Example Domain'], // array of text content
h2: [],
h3: [],
h4: [],
h5: [],
h6: [],
skips: [], // e.g. ['H1→H3'] if H2 is absent between H1 and H3
},
images: {
total: 12,
missingAlt: 3, // count with no alt attribute or alt=""
missingAltSrcs: ['...'], // up to 5 example src values
},
links: {
total: 24,
external: 8,
externalNewTab: 4, // target="_blank"
externalNewTabNoOpener: 3, // target="_blank" without rel containing noopener
},
structuredData: [], // array of parsed JSON-LD objects (empty if none)
}
```
## Requirements
**WE-D-1:** Extract metadata in a single `page.evaluate()` call to minimise
round-trips. All DOM access happens inside the browser context.
**WE-D-2:** `meta.description` is `null` if the `<meta name="description">` tag
is absent or has an empty `content` attribute.
**WE-D-3:** Heading hierarchy skips are detected by comparing sequential heading
levels. If the level jumps by more than 1 (e.g., H1 → H3), record the skip as
`"H1→H3"` in `headings.skips`.
Why: Heading skips confuse screen readers and weaken SEO.
**WE-D-4:** An image is counted as `missingAlt` if it lacks the `alt` attribute
entirely OR if `alt` is an empty string `""`.
Why: Both mean the image is inaccessible to screen readers.
**WE-D-5:** A link is counted as `externalNewTabNoOpener` if it has `target="_blank"`
AND its `rel` attribute does not contain `noopener`.
Why: Without `noopener`, the opened page can access `window.opener` — a security risk.
**WE-D-6:** Structured data is extracted from all `<script type="application/ld+json">`
tags. Parse each; if parsing fails, include the raw text string instead of the object.
**WE-D-7:** The `missingAltSrcs` array contains at most 5 example `src` values
(truncated to 80 chars each) to keep the report concise.
**WE-D-8:** The result must be JSON-serialisable (no DOM nodes, functions, or circular refs).
**WE-D-9:** Run on the desktop viewport page (the same page used for the desktop screenshot).
Do not navigate again.
## Scenarios
### Scenario: Well-structured page
**Given:** Page with one H1, sequential headings, all images have alt, no blank-target links
**When:** `analyzeDom(page)` is called
**Then:** `headings.skips` is empty, `images.missingAlt === 0`, `links.externalNewTabNoOpener === 0`
### Scenario: Missing meta description
**Given:** Page with no `<meta name="description">` tag
**When:** DOM is analysed
**Then:** `meta.description === null`, `meta.descriptionLength === null`
### Scenario: Heading skip detected
**Given:** Page with H1 then H3 (no H2)
**When:** DOM is analysed
**Then:** `headings.skips` includes `"H1→H3"`
### Scenario: JSON-LD present
**Given:** Page with `<script type="application/ld+json">{"@type":"WebSite"}</script>`
**When:** DOM is analysed
**Then:** `structuredData` contains `[{ "@type": "WebSite" }]`

123
spec/lighthouse.md Normal file
View File

@@ -0,0 +1,123 @@
# Lighthouse Spec (WE-L)
## Overview
Runs Lighthouse audits against a URL in two presets (desktop and mobile) and
extracts structured findings. Returns a normalised result object used by the
report assembler. Also saves the full Lighthouse JSON to the raw directory for
reference.
## Responsibilities
- Launch Chrome via chrome-launcher using Playwright's bundled Chromium
- Run a desktop Lighthouse audit
- Run a mobile Lighthouse audit
- Extract scores, Core Web Vitals, and categorised audit findings
- Save raw results to the raw directory
- Return structured results for report assembly
Delegates to: Lighthouse, chrome-launcher, Playwright (for executable path)
## Dependencies
Read [pipeline.md](pipeline.md) for output directory layout.
## Data Model
### LighthouseResult (returned per preset)
```js
{
preset: 'desktop' | 'mobile',
scores: {
performance: 87, // 0-100
accessibility: 92,
bestPractices: 100,
seo: 90,
},
vitals: {
lcp: { value: '1.2 s', status: 'good' }, // good | needs-improvement | poor
tbt: { value: '120 ms', status: 'good' },
cls: { value: '0.02', status: 'good' },
ttfb: { value: '380 ms', status: 'needs-improvement' },
fcp: { value: '0.8 s', status: 'good' },
si: { value: '1.1 s', status: 'good' },
},
findings: {
critical: [ { id, title, description, impact } ], // score === 0
warnings: [ { id, title, description, impact } ], // 0 < score < 0.9
},
}
```
### Vital status thresholds
| Vital | Good | Needs improvement | Poor |
|---|---|---|---|
| LCP | ≤ 2.5s | ≤ 4.0s | > 4.0s |
| TBT | ≤ 200ms | ≤ 600ms | > 600ms |
| CLS | ≤ 0.1 | ≤ 0.25 | > 0.25 |
| TTFB | ≤ 800ms | ≤ 1800ms | > 1800s |
| FCP | ≤ 1.8s | ≤ 3.0s | > 3.0s |
| SI | ≤ 3.4s | ≤ 5.8s | > 5.8s |
## Requirements
**WE-L-1:** Use `chromium.executablePath()` from Playwright to locate the Chrome
binary. Pass this path to `chrome-launcher` as `chromePath`.
Why: The Docker base image includes Playwright's Chromium — we must use that
executable rather than letting chrome-launcher search default system paths.
**WE-L-2:** Run two separate audits: one with `formFactor: 'desktop'` and one
with `formFactor: 'mobile'`.
**WE-L-3:** Desktop screen emulation: `{ mobile: false, width: 1440, height: 900,
deviceScaleFactor: 1, disabled: false }`.
**WE-L-4:** Mobile screen emulation: `{ mobile: true, width: 375, height: 812,
deviceScaleFactor: 2, disabled: false }`.
**WE-L-5:** Run only the four standard categories: `performance`, `accessibility`,
`best-practices`, `seo`. Omit `pwa`.
**WE-L-6:** Scores are extracted from `lhr.categories[id].score * 100`, rounded
to the nearest integer.
**WE-L-7:** Core Web Vitals are extracted from these audit IDs:
- LCP: `largest-contentful-paint`
- TBT: `total-blocking-time` (TBT is the Lighthouse proxy for FID/INP)
- CLS: `cumulative-layout-shift`
- TTFB: `server-response-time`
- FCP: `first-contentful-paint`
- SI: `speed-index`
**WE-L-8:** A finding is **critical** if `audit.score === 0` and the audit is
not `informative` mode. A finding is a **warning** if `0 < audit.score < 0.9`.
Audits with score ≥ 0.9 or score `null` are omitted.
**WE-L-9:** Finding `description` is taken from `audit.description` (Markdown,
may contain links). Finding `impact` is taken from `audit.details.type === 'opportunity'`
savings estimate if present, otherwise omitted.
**WE-L-10:** Save the full `lhr` object as JSON to `raw/lighthouse-{preset}.json`.
**WE-L-11:** Kill the Chrome instance in a `finally` block even if the audit throws.
Why: Orphaned Chrome processes inside Docker cause container bloat.
## Scenarios
### Scenario: Happy path
**Given:** Reachable URL, valid Chrome path
**When:** `runLighthouse(url, rawDir)` is called
**Then:** Returns `[desktopResult, mobileResult]`; both `lighthouse-desktop.json`
and `lighthouse-mobile.json` exist in `rawDir`
### Scenario: Unreachable URL
**Given:** URL returns connection refused
**When:** Lighthouse audit runs
**Then:** Error is thrown; Chrome is killed before the error propagates
### Scenario: Score extraction
**Given:** `lhr.categories.performance.score = 0.87`
**When:** Score is extracted
**Then:** `scores.performance === 87`

93
spec/pipeline.md Normal file
View File

@@ -0,0 +1,93 @@
# Pipeline Spec (WE-P)
## Overview
Orchestrates the full evaluation pipeline for a single URL. Accepts a URL, runs
screenshot capture, Lighthouse audits, and DOM analysis in sequence, writes all
outputs to a configured directory, and exits with a clear success or failure code.
## Responsibilities
- Parse and validate input (URL + output directory)
- Execute pipeline stages in order: screenshot → lighthouse → DOM → report
- Write all output files to the configured output directory
- Exit 0 on success, exit 1 on any stage failure (with error to stderr)
Delegates to: `screenshot.js`, `lighthouse.js`, `dom-analysis.js`, `report.js`
## Dependencies
None — this is the root spec. All other specs depend on this one for the I/O contract.
## Data Model
### Input
```
CLI: node src/index.js <url>
ENV: TARGET_URL=<url> (fallback if no CLI arg)
ENV: OUTPUT_DIR=/output (default: /output)
```
### Output directory layout
```
$OUTPUT_DIR/
report.md ← primary artifact
screenshots/
desktop.png
mobile.png
raw/
lighthouse-desktop.json
lighthouse-mobile.json
dom.json
```
## Requirements
**WE-P-1:** The URL must be provided as the first CLI argument or via `TARGET_URL`.
If neither is present, print usage to stderr and exit 1.
**WE-P-2:** The URL must start with `http://` or `https://`. If it does not, print
an error and exit 1.
Why: Lighthouse and Playwright both require a valid HTTP URL. Silent failures with
invalid URLs are confusing.
**WE-P-3:** The output directory (default `/output`) and its subdirectories
(`screenshots/`, `raw/`) must be created if they do not exist.
Why: The Docker volume mount only creates the parent; subdirs must be explicit.
**WE-P-4:** Pipeline stages run sequentially in this order:
1. Screenshots + DOM extraction (single Playwright browser session)
2. Lighthouse desktop audit
3. Lighthouse mobile audit
4. Report assembly
Why: Lighthouse requires its own Chrome instance. Running screenshots and DOM
extraction first in a single Playwright session is more efficient than two sessions.
**WE-P-5:** Each pipeline stage logs a single progress line to stdout before starting
(e.g., `"Capturing screenshots..."`).
**WE-P-6:** If any stage throws, the error message is printed to stderr and the
process exits 1. Partial output files are acceptable — the caller should check
exit code, not file presence.
**WE-P-7:** On success, print the path to `report.md` and exit 0.
## Scenarios
### Scenario: Happy path
**Given:** Valid URL `https://example.com` and writable `/output`
**When:** Pipeline completes all stages
**Then:** Exit 0; `report.md`, `screenshots/desktop.png`, `screenshots/mobile.png`,
`raw/lighthouse-desktop.json`, `raw/lighthouse-mobile.json`, `raw/dom.json` all exist
### Scenario: Missing URL
**Given:** No CLI arg and `TARGET_URL` not set
**When:** `index.js` starts
**Then:** Prints usage to stderr, exits 1
### Scenario: Invalid URL scheme
**Given:** URL `file:///etc/passwd`
**When:** Validation runs
**Then:** Prints error "URL must start with http:// or https://", exits 1

130
spec/report.md Normal file
View File

@@ -0,0 +1,130 @@
# Report Spec (WE-R)
## Overview
Assembles all collected data into a single Markdown file optimised for Claude
to read. Findings are phrased in a pre-interpreted tone: each issue explains
what it means and what to do about it, not just that a value is absent or wrong.
## Responsibilities
- Accept LighthouseResult[] and DomData as inputs
- Produce a single Markdown string
- Apply severity grading to Lighthouse scores
- Categorise findings as Critical (must fix) / Warning (should fix) / Info
- Write the report in the defined section order
- Deduplicate findings that appear in both desktop and mobile audits
Delegates to: nothing (pure function, no I/O)
## Dependencies
Read [lighthouse.md](lighthouse.md) for `LighthouseResult` shape.
Read [dom-analysis.md](dom-analysis.md) for `DomData` shape.
## Data Model
### Score grading
| Score | Grade | Colour label |
|---|---|---|
| 90100 | A | Good |
| 7589 | B | Needs improvement |
| 5074 | C | Poor |
| 049 | F | Critical |
### Severity rules
- **Critical (must fix):** Lighthouse finding with `score === 0` in any preset
- **Warning (should fix):** Lighthouse finding with `0 < score < 0.9` in any preset; or DOM issues (missing description, heading skips, missing alt)
- **Info:** Minor or informational items
## Requirements
**WE-R-1:** The report begins with a level-1 heading: `# Website Evaluation: {hostname}`.
Hostname is extracted from the URL (no scheme, no path).
**WE-R-2:** A generation line follows: `_Generated: {ISO date} · Audited: {full URL}_`
**WE-R-3:** Section order (all sections always present, even if empty):
1. Lighthouse Scores (desktop + mobile tables)
2. Core Web Vitals (desktop + mobile tables)
3. Critical Issues
4. Warnings
5. Info
6. Page Structure
7. Screenshots reference
8. Raw Data reference
**WE-R-4:** Lighthouse score tables have columns: Category | Desktop | Mobile | Desktop Grade | Mobile Grade.
Why: Side-by-side makes it easy to spot mobile regressions.
**WE-R-5:** Core Web Vitals tables have columns: Metric | Desktop | Mobile | Status.
Status is the worst of the two presets for that metric (e.g., if desktop is "good"
but mobile is "needs-improvement", show "needs-improvement").
**WE-R-6:** Each finding is rendered as a Markdown checkbox list item:
```
- [ ] **{title}** — {pre-interpreted explanation with fix guidance}
```
The title comes from `audit.title`. The explanation is built from `audit.description`
(stripped of Markdown links) plus any savings estimate.
**WE-R-7:** If the same audit ID appears as a finding in both desktop and mobile
results, it appears once in the report (not duplicated). The severity is the worst
across both presets.
**WE-R-8:** DOM issues (missing meta description, heading skips, missing alt text,
missing noopener) are included in the Warnings section with pre-interpreted phrasing.
Exact phrasing:
- Missing meta description: "Meta description missing — search engines will auto-generate one, often poorly. Add a 155-char summary."
- Heading skip: "Heading hierarchy skips {skip} — this confuses screen readers and weakens document structure."
- Missing alt: "{n} image(s) lack alt text — WCAG 1.1.1 violation (Level A); screen readers will skip them entirely."
- Missing noopener: "{n} link(s) open in a new tab without `rel=\"noopener\"` — the opened page can access `window.opener`."
**WE-R-9:** Page Structure section lists:
- Title (value + char count + assessment: "good" if 3065 chars, "too short" / "too long" otherwise)
- Meta description (value or "missing")
- Canonical (value or "not set")
- Heading counts per level (e.g., "H1×1, H2×4, H3×7")
- Images (total, missing alt count)
- Links (total, external, new-tab-no-opener count)
- Structured data (types detected or "none")
- Open Graph (present/absent)
**WE-R-10:** Screenshots section contains Markdown image references:
```markdown
![Desktop view](screenshots/desktop.png)
![Mobile view](screenshots/mobile.png)
```
**WE-R-11:** If all Critical, Warning, and Info sections are empty, replace each
with `_None detected._`.
**WE-R-12:** The function signature is:
```js
assembleReport(url, lighthouseResults, domData) string
```
It is a pure function — no file I/O, no side effects.
## Scenarios
### Scenario: Score table format
**Given:** Desktop performance 87, mobile performance 62
**When:** Report is assembled
**Then:** Score table shows desktop 87 (B), mobile 62 (C) in the same row
### Scenario: Finding deduplication
**Given:** `meta-description` audit fails (score 0) in both desktop and mobile results
**When:** Report is assembled
**Then:** The meta-description finding appears exactly once in Critical Issues
### Scenario: Empty sections
**Given:** All Lighthouse audits pass (score ≥ 0.9), no DOM issues
**When:** Report is assembled
**Then:** Critical Issues, Warnings, and Info sections each contain `_None detected._`
### Scenario: Title assessment
**Given:** Page title is 14 chars ("Example Domain")
**When:** Page Structure section is built
**Then:** Title line shows `"Example Domain" (14 chars — too short)`

77
spec/screenshot.md Normal file
View File

@@ -0,0 +1,77 @@
# Screenshot Spec (WE-S)
## Overview
Captures full-page screenshots of a URL at desktop and mobile viewport sizes
using Playwright (Chromium). Screenshots are saved as PNG files to the output
screenshots directory.
## Responsibilities
- Launch a Playwright Chromium browser
- Capture a full-page desktop screenshot (1440×900 viewport)
- Capture a full-page mobile screenshot (375×812 viewport, mobile UA)
- Save both as PNG to the screenshots directory
- Return the browser instance for DOM analysis reuse
Delegates to: Playwright
## Dependencies
Read [pipeline.md](pipeline.md) for output directory layout.
## Data Model
### Viewport configs
```js
const DESKTOP = { width: 1440, height: 900, deviceScaleFactor: 1 };
const MOBILE = { width: 375, height: 812, deviceScaleFactor: 2, isMobile: true,
hasTouch: true, userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0...' };
```
### Output
```
screenshots/desktop.png — full-page, lossless PNG
screenshots/mobile.png — full-page, lossless PNG, 2× pixel density
```
## Requirements
**WE-S-1:** Navigate to the URL and wait for `networkidle` before capturing.
Why: Ensures lazy-loaded content and web fonts are rendered.
**WE-S-2:** Capture a full-page screenshot (not just the visible viewport).
Why: Full-page screenshots let Claude see below-the-fold content.
**WE-S-3:** Desktop viewport: 1440×900, deviceScaleFactor 1, no mobile emulation.
**WE-S-4:** Mobile viewport: 375×812, deviceScaleFactor 2, mobile UA, touch enabled.
The mobile UA string should identify as an iPhone running Safari to maximise
site responsiveness compatibility.
**WE-S-5:** Save format: PNG (lossless). Do not use JPEG.
Why: Lossless preserves fine text and UI details important for design critique.
**WE-S-6:** The function must accept an already-open Playwright browser instance
(not launch its own) so the caller can reuse the session for DOM extraction.
**WE-S-7:** Both screenshots must be saved before returning.
## Scenarios
### Scenario: Happy path
**Given:** Valid browser instance, reachable URL, writable screenshots dir
**When:** `captureScreenshots(browser, url, screenshotsDir)` is called
**Then:** `desktop.png` and `mobile.png` exist in `screenshotsDir`, both non-zero bytes
### Scenario: Navigation timeout
**Given:** URL that times out (server unresponsive)
**When:** Page navigation exceeds 30s
**Then:** Error is thrown with the URL and "navigation timeout" in the message
### Scenario: Page with no content
**Given:** URL that returns an empty 200 response
**When:** Screenshots are captured
**Then:** Screenshots are saved (may be blank); no error is thrown