End-to-end regression suite for the-internet.herokuapp.com, written in TypeScript + Playwright with a Page Object Model layout and a built-in Test Observability Dashboard powered by SQLite + Express + Chart.js.
- 73 tests across 45 spec files covering every challenge on the the-internet site (auth, frames, alerts, dynamic loading, file upload/download, drag-and-drop, shadow DOM, status codes, etc.).
- Custom Playwright reporter captures every run/test into
dashboard/data/results.db. - Local dashboard at
http://localhost:4000with six views: Dashboard · Runs · Tests · Features · Coverage · Triage.
- Quick start
- Available scripts
- Project layout
- Page Object Model conventions
- Adding a new test
- The Test Observability Dashboard
- REST API reference
- Configuration
- Code quality
- Known live-site quirks
- Troubleshooting
- Architecture decisions
# 1. install deps (Node 20+ recommended; tested on Node 22 / 25)
npm install
# 2. install the Chromium browser binary Playwright needs
npx playwright install chromium
# 3. run the suite
npm test # 73 tests against the live site
npm run test:smoke # ~22 @smoke-tagged tests (~30s)
npm run test:headed # show the browser
npm run test:ui # Playwright's interactive UI mode
# 4. open the observability dashboard
npm run dashboard # http://localhost:4000Every npm test invocation appends a row to dashboard/data/results.db.
The dashboard reads from that database — no second build step.
| Script | What it does |
|---|---|
npm test |
Run the full suite (Chromium project) |
npm run test:smoke |
Only the @smoke-tagged subset |
npm run test:headed |
Run with the browser visible |
npm run test:debug |
Run with PWDEBUG=1 (Playwright Inspector) |
npm run test:ui |
Playwright's interactive UI mode |
npm run report |
Open the static HTML report from the last run |
npm run dashboard |
Start the observability dashboard on port 4000 |
npm run dashboard:dev |
Same, but auto-reloads on server-code changes |
npm run lint / npm run lint:fix |
ESLint (with eslint-plugin-playwright) |
npm run format / npm run format:check |
Prettier |
npm run typecheck |
tsc --noEmit over the whole repo |
A husky pre-commit hook runs lint-staged (eslint + prettier on changed files).
.
├── playwright.config.ts # Playwright config (reporters, baseURL, project)
├── tsconfig.json # strict TS, ESM, path aliases
├── eslint.config.js # ESLint flat config + Playwright plugin
├── .prettierrc / .prettierignore # formatting
├── .husky/pre-commit # runs lint-staged
│
├── src/
│ ├── pages/ # Page Objects (one per challenge, all extend BasePage)
│ │ ├── BasePage.ts
│ │ ├── LoginPage.ts
│ │ └── ... (44 more)
│ ├── fixtures/
│ │ └── pageFixtures.ts # extends Playwright `test` to inject every page object
│ ├── utils/
│ │ ├── config.ts # env-driven config (baseUrl, credentials)
│ │ └── paths.ts # filesystem helpers (test-fixtures, downloads)
│ └── types/index.ts # shared types
│
├── tests/ # specs, organised by feature area
│ ├── auth/ # form-auth, basic-auth, digest-auth, forgot-password
│ ├── elements/ # add/remove, checkboxes, dropdown, inputs, slider
│ ├── dynamic/ # dynamic content / controls / loading
│ ├── frames/ # frames, nested frames, iframe
│ ├── windows/ # multiple windows, redirect link
│ ├── files/ # file upload, file download, secure download
│ ├── interactions/ # drag-drop, hovers, context menu, keys, jquery menu, tinymce
│ ├── alerts/ # JS alerts, entry/exit ad, notification msgs
│ ├── tables/ # sortable data tables, challenging DOM
│ └── misc/ # A/B test, broken images, geolocation, etc.
│
├── reporters/
│ └── DashboardReporter.ts # custom Playwright reporter → writes to SQLite
│
├── dashboard/
│ ├── server/
│ │ ├── index.ts # Express bootstrap (port 4000 by default)
│ │ ├── db.ts # SQLite connection + schema bootstrap
│ │ ├── routes.ts # REST API (see "REST API reference")
│ │ └── coverage.ts # static analysis: counts test cases / describes / bug tags
│ ├── public/
│ │ ├── index.html # SPA shell (sidebar + topbar + 6 view sections)
│ │ ├── styles.css # light theme, popover/spinner/toast/badges
│ │ └── app.js # vanilla-JS SPA: hash routing, fetch, Chart.js
│ └── data/ # SQLite db lives here (gitignored)
│
└── test-fixtures/
└── sample-upload.txt # used by file-upload spec
Every page object lives in src/pages/ and extends BasePage:
import type { Locator } from '@playwright/test';
import { BasePage } from './BasePage.js';
export class LoginPage extends BasePage {
readonly path = '/login';
get usernameInput(): Locator { return this.page.locator('#username'); }
get passwordInput(): Locator { return this.page.locator('#password'); }
get submitButton(): Locator { return this.page.locator('button[type="submit"]'); }
get flashMessage(): Locator { return this.page.locator('#flash'); }
async login(username: string, password: string): Promise<void> {
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
}Conventions:
pathis the relative URL (used byBasePage.goto()).- Locators are exposed as
get-properties (lazy, read-only, no constructor wiring). - Action methods are async and never assert — they mutate page state and return data.
- Query methods (
messageText(),getStates()) return primitives so specs can assert. - No assertions in page objects — assertions belong in specs so failure messages point to the test, not to the page object.
src/fixtures/pageFixtures.ts extends Playwright's
test so every spec can request page objects directly:
import { test, expect } from '../../src/fixtures/pageFixtures.js';
test('logs in with valid credentials', async ({ loginPage, page }) => {
await loginPage.goto();
await loginPage.login('tomsmith', 'SuperSecretPassword!');
await expect(page).toHaveURL(/\/secure$/);
});Each fixture instantiates its page object with the per-test page, and Playwright
handles teardown automatically.
- Page object — create
src/pages/MyFeaturePage.tsextendingBasePage. - Fixture — register it in
src/fixtures/pageFixtures.ts(add toPageFixturesinterface and to thetest.extendblock). - Spec — add
tests/<area>/my-feature.spec.tsand write tests using the fixture. - (Optional) tag with
@smoketo include it innpm run test:smoke.
The dashboard picks up new specs automatically — Coverage analyses tests/
on every page load (with a 30-second cache).
playwright test
│
▼
DashboardReporter (reporters/DashboardReporter.ts)
• onBegin → INSERT INTO runs(...)
• onTestEnd → INSERT INTO tests(...) (per test result)
• onEnd → UPDATE runs SET passed/failed/skipped/duration_ms ...
│
▼
SQLite (dashboard/data/results.db, WAL mode)
│
▼
Express API (dashboard/server) ──► public/ (vanilla JS + Chart.js)
runs(
id, started_at, finished_at, project, branch, commit_sha, status,
total, passed, failed, skipped, flaky, duration_ms
)
tests(
id, run_id (FK→runs), suite, title, full_title, file, project, status,
duration_ms, retries, error_message, error_stack, attachments_json, tags_json
)Both reporter and server bootstrap the schema with CREATE TABLE IF NOT EXISTS —
no migration step needed.
| View | Path | Source |
|---|---|---|
| Dashboard | #dashboard |
KPI cards + 4 trend charts (/api/summary, /api/trends, /api/by-suite) |
| Runs | #runs |
Per-run history table; click a row to see all tests in that run |
| Tests | #tests |
All unique tests aggregated across runs (latest status); searchable + filterable + sortable |
| Features | #features |
One card per spec file (pass rate %, dot grid, bug-tag count). Tabs: All / Healthy / Needs Attention |
| Coverage | #coverage |
Spec file inventory + per-file counts. Test Cases uses the runtime count from the latest runs in the selected window so parameterized tests (e.g. for (const x of [...]) test(...)) are counted correctly and Coverage matches Tests. Files with no execution data fall back to the static count from the source. Describe Blocks and Bug Tags are always static. Bar chart + folder donut + inventory table. |
| Triage | #triage |
Failing + flaky tests; left-list + right-detail with error message and links to traces/screenshots |
The "Last 7 days" pill in the topbar is a working <details>-based popover with
Last 24 hours · Last 7 days · Last 30 days · All time. Selecting a range
invalidates the cache and re-renders the active view.
The "Reset All Stats" button (sidebar footer) calls DELETE /api/runs, which
clears both runs and tests and resets the autoincrement counters.
The Coverage analyser counts these as "bug tags" (proxy for test debt):
test.skip(,test.fixme(,test.fail(- Inline comments matching
// TODO,// FIXME,// XXX,// HACK,// BUG
All endpoints are JSON over HTTP. The dashboard serves them under /api/* from
http://localhost:4000.
| Method | Path | Description |
|---|---|---|
| GET | /api/health |
{ ok: true } |
| GET | /api/summary |
Totals across all runs + latest run summary |
| GET | /api/runs?limit=&days= |
Most recent runs (newest first) |
| GET | /api/runs/latest |
Most recent single run |
| GET | /api/runs/:id |
Single run + every test record |
| GET | /api/runs-summary?days= |
Total / healthy / needs-attention buckets |
| GET | /api/trends?limit=&days= |
Time-ordered points for trend charts |
| GET | /api/by-suite?runId= |
Per-suite roll-up for a single run |
| GET | /api/coverage?refresh=1 |
Static analysis report (cached 30s) |
| GET | /api/coverage/files |
Just the byFile array |
| GET | /api/features?days= |
Per-spec-file rollup (test counts + bug tags) |
| GET | /api/tests-aggregated?days= |
All unique tests (latest status per fullTitle) |
| GET | /api/triage?days= |
Failed + flaky tests, ready for the Triage view |
| GET | /api/tests/:id |
Single test record (with attachments/error) |
| DELETE | /api/runs |
Wipe all run + test history |
| GET | /artifacts/<relative-path> |
Serve an attachment file (trace.zip, screenshot, video) |
days is optional; when present it filters by started_at >= now − N days.
Use ?days= (empty) or omit for "all time".
Environment variables:
| Variable | Default | Used by |
|---|---|---|
BASE_URL |
https://the-internet.herokuapp.com |
Playwright use.baseURL, page objects |
BASIC_AUTH_USER / BASIC_AUTH_PASS |
admin / admin |
basic-auth + secure-download tests |
FORM_AUTH_USER / FORM_AUTH_PASS |
tomsmith / SuperSecretPassword! |
form-auth tests |
DASHBOARD_PORT |
4000 |
Express server |
DASHBOARD_DB_PATH |
dashboard/data/results.db |
reporter + server |
GIT_BRANCH, GIT_COMMIT |
unset | recorded into runs.branch / runs.commit_sha |
CI |
unset | enables forbidOnly, retries=2, workers=4 |
src/utils/config.ts is the single source of truth for env-derived defaults.
- TypeScript: strict mode,
noUnusedLocals,noUnusedParameters,noImplicitReturns. Path aliases (@pages/*,@fixtures/*,@utils/*) are configured intsconfig.json. - ESLint: flat config (
eslint.config.js) with@typescript-eslint,eslint-plugin-playwright, andeslint-config-prettier. - Prettier: 100-col, single quotes, trailing commas.
.prettierignoreexcludes generated dirs. - Husky + lint-staged:
pre-commitruns eslint --fix + prettier on changed files only. - No
anyin production code (warnings only);noExplicitAnyis enforced loosely so Playwright DOM helpers don't need wrappers.
The-internet.herokuapp.com is occasionally unreliable. The suite encodes work-arounds for several real bugs in the demo site:
- TinyMCE iframe (
/iframe,/tinymce) — runs inmce-content-readonlymode without a license key, with an "upgrade" overlay covering the editor. The specs verify that the iframe + Bold button render rather than typing. - Sortable Data Tables (
/tables) — the table is intentionally non-sortable ("Example 1: No Class or ID attributes"). The spec asserts that clicking the header does not reorder rows. - Digest auth (
/digest_auth) — Chromium does not always negotiate digest successfully; the spec accepts either 200 or 401. - Forgot password (
/forgot_password) — the live Sinatra app sometimes 500s; the spec only asserts the form submission attempt. - JS alert handling —
page.once('dialog', ...)must be registered before the click. UsingwaitForEvent('dialog')followed by an awaited click deadlocks because the click waits for the dialog to be handled.
The dashboard auto-detects whether it has a live backend by probing
GET /api/health at boot:
- Live mode (
npm run dashboard): full UI, including Reset / Refresh and the time-range filter, reads from/api/*. - Static mode (no backend): reads from
./data/*.json, hides Reset and Refresh, and replaces the time-range popover with a "Static snapshot · <build time>" badge. Same components, same charts, same numbers — just frozen at build time.
npm test # populate dashboard/data/results.db
npm run build:static # → dashboard-dist/
npm run preview:static # serves dashboard-dist on http://localhost:3000dashboard-dist/ is ~200 KB. Upload it to any static host:
| Host | Setup | Notes |
|---|---|---|
| GitHub Pages | Repo → Settings → Pages → Source = "GitHub Actions"; the included dashboard.yml workflow handles the rest |
Free for public repos; needs Pro/Team for private |
| Cloudflare Pages | Connect the repo, build command npm run build:static, output dir dashboard-dist |
Free private repos, generous bandwidth |
| Netlify | Same as Cloudflare; netlify deploy --dir=dashboard-dist --prod |
Free tier covers small projects |
| S3 / R2 / any bucket | aws s3 sync dashboard-dist s3://your-bucket |
Cheapest for high traffic |
.github/workflows/dashboard.yml does, on every push to main
and on demand:
- Checkout +
npm ci npx playwright install --with-deps chromiumnpm test(withcontinue-on-errorso failures still publish the dashboard)npm run build:static- Upload
dashboard-distto GitHub Pages
After enabling Pages → "GitHub Actions" the published URL appears in the workflow summary. Successive runs append to the trend charts because each commit gets a fresh DB and run row.
| Feature | Live | Static | Reason |
|---|---|---|---|
| Reset All Stats | ✅ | hidden | needs DELETE /api/runs |
| Refresh button | ✅ | hidden | data can't change between requests |
| Time-range filter | ✅ | shows "Static snapshot" | the export is one snapshot of all data |
| Run drill-in (per-run test list) | ✅ | ✅ | one JSON file per run is exported |
| Sort / search / filter | ✅ | ✅ | all client-side |
| Trace / screenshot links (Triage) | ✅ | shown as a note | artifacts aren't bundled into the export |
If you need a fully interactive dashboard with persistent history (add/clear runs from the UI), deploy the Express server instead — Fly.io's free tier with a 3 GB persistent volume handles this comfortably.
npm install fails on better-sqlite3 — you need a C++ toolchain.
On macOS: xcode-select --install. On Linux: apt install build-essential python3.
npx playwright install chromium is slow / blocked — the binary is ~150MB.
Behind a corporate proxy, set HTTPS_PROXY and npm_config_https_proxy.
Dashboard says 0 runs after running tests — the reporter only writes when
no --reporter= flag is passed (CLI flags override playwright.config.ts).
Run npm test (which doesn't pass --reporter) to populate the DB.
Port 4000 already in use — set DASHBOARD_PORT=4321 npm run dashboard (or
lsof -ti:4000 | xargs kill -9).
SQLite says "database is locked" — should never happen (WAL mode, single
writer). If it does, stop the dashboard server before running tests, or delete
dashboard/data/results.db-wal and -shm files.
Browser screen tears / unresponsive — the dashboard tears down Chart.js
instances on view switches, but if the tab keeps a chart open for a long time
without re-rendering, refresh the page. Tables are scoped to scroll containers
(max-height: 540px) so they should never grow unbounded.
Reset All Stats doesn't delete files on disk — by design. It clears the
runs and tests tables but leaves the .db file in place so the next
npm test can write to it without re-creating the schema.
- Vanilla JS + Chart.js (no React/Vue) — the dashboard is small (≈1k LOC),
has no build step, ships as a single
app.jsfile, and runs in any browser without a bundler. Chart.js handles all visualisations. - SQLite via
better-sqlite3— synchronous, embedded, prebuilt binaries on macOS / Linux / Windows. No daemon, no migrations, no race conditions for a single-writer / many-reader workload. - Static-analysis "coverage" — Playwright doesn't natively expose
describe/test counts via reporter events. We re-parse the
tests/directory from the dashboard server (cached 30s) so the Coverage view always reflects what's currently checked in, not what was last executed. - Aggregated tests — the Tests view shows one row per unique full title across all runs in the time range, with the most recent status. This means a smoke run after a full run still shows the suite's full inventory rather than collapsing to 22 tests.
- AbortController on every fetch — switching views aborts the previous view's in-flight requests so stale data can never overwrite the new view.
- No back-end framework heavier than Express — keeps the dependency surface
small. All business logic is in three TS files (
db.ts,routes.ts,coverage.ts); each is unit-testable in isolation.
Built for end-to-end regression testing of public web apps.
Pull requests welcome — please run npm run lint && npm run typecheck && npm test before opening one.