Skip to content

Commit 098e25e

Browse files
lwwmanningclaude
andcommitted
Add Playwright e2e smoke and /api/subscribe verify check
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Will Manning <will@willmanning.io>
1 parent f9e109b commit 098e25e

7 files changed

Lines changed: 270 additions & 9 deletions

File tree

.github/workflows/ci.yml

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,51 @@ jobs:
9595
- name: Verify endpoints
9696
run: bun run verify
9797

98+
# Hard-gates on vulnerable direct/transitive deps. Two advisories are
99+
# ignored because they're upstream-blocked (both via @lhci/cli@0.15.1
100+
# and resend's transitive svix; both dev-/server-side with no
101+
# exploitable code path) — see CLAUDE.md "Audit advisories" for
102+
# context and removal triggers. Any new advisory fails the job.
103+
- name: Dependency audit
104+
run: bun audit --ignore=GHSA-w5hq-g745-h8pq --ignore=GHSA-52f5-9888-hmc6
105+
106+
# Playwright browsers are ~250 MB. Cache them keyed on the
107+
# @playwright/test version pinned in package.json — invalidates on
108+
# bumps, hits otherwise. System deps (apt packages) aren't covered by
109+
# the cache, so install those separately even on cache hit (~10s vs
110+
# ~3min for the full install).
111+
- name: Get Playwright version
112+
id: playwright-version
113+
run: |
114+
VERSION=$(grep -oE '"@playwright/test": "[^"]+"' package.json | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')
115+
echo "version=$VERSION" >> $GITHUB_OUTPUT
116+
117+
- name: Cache Playwright browsers
118+
id: playwright-cache
119+
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
120+
with:
121+
path: ~/.cache/ms-playwright
122+
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
123+
124+
- name: Install Playwright browsers (cache miss)
125+
if: steps.playwright-cache.outputs.cache-hit != 'true'
126+
run: bunx playwright install --with-deps chromium webkit
127+
128+
- name: Install Playwright system deps (cache hit)
129+
if: steps.playwright-cache.outputs.cache-hit == 'true'
130+
run: bunx playwright install-deps chromium webkit
131+
132+
- name: Browser smoke tests
133+
run: bun run test:e2e
134+
135+
- name: Upload Playwright report
136+
if: failure()
137+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
138+
with:
139+
name: playwright-report
140+
path: playwright-report/
141+
retention-days: 7
142+
98143
- name: Stop server
99144
if: always()
100145
run: |
@@ -106,14 +151,6 @@ jobs:
106151
if: failure()
107152
run: cat /tmp/server.log || true
108153

109-
# Hard-gates on vulnerable direct/transitive deps. Two advisories are
110-
# ignored because they're upstream-blocked (both via @lhci/cli@0.15.1
111-
# and resend's transitive svix; both dev-/server-side with no
112-
# exploitable code path) — see CLAUDE.md "Audit advisories" for
113-
# context and removal triggers. Any new advisory fails the job.
114-
- name: Dependency audit
115-
run: bun audit --ignore=GHSA-w5hq-g745-h8pq --ignore=GHSA-52f5-9888-hmc6
116-
117154
# Runs only on PRs (no baseline diff to compute on a push to main).
118155
# Compares the PR's dependency manifest against main and flags
119156
# high-severity advisories or license incompatibilities. Posts a summary

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,8 @@ public/static
4949

5050
# lighthouse-ci output
5151
.lighthouseci
52+
53+
# playwright
54+
/playwright-report/
55+
/playwright/.cache/
56+
/test-results/

bun.lock

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
"check:ci": "biome check",
1919
"typecheck": "tsc --noEmit",
2020
"verify": "bun scripts/verify.ts",
21-
"lighthouse": "lhci autorun"
21+
"lighthouse": "lhci autorun",
22+
"test:e2e": "playwright test",
23+
"test:e2e:install": "playwright install --with-deps chromium webkit"
2224
},
2325
"dependencies": {
2426
"@mdx-js/react": "^3.1.1",
@@ -40,6 +42,7 @@
4042
"devDependencies": {
4143
"@biomejs/biome": "^2.4.13",
4244
"@lhci/cli": "^0.15.1",
45+
"@playwright/test": "^1.59.1",
4346
"@tailwindcss/postcss": "4.2.3",
4447
"@types/node": "^25.6.0",
4548
"@types/react": "^19.2.14",

playwright.config.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { defineConfig, devices } from "@playwright/test";
2+
3+
export default defineConfig({
4+
testDir: "./tests",
5+
fullyParallel: true,
6+
forbidOnly: !!process.env.CI,
7+
retries: process.env.CI ? 1 : 0,
8+
workers: 1,
9+
reporter: process.env.CI ? "github" : "list",
10+
use: {
11+
baseURL: process.env.BASE_URL ?? "http://localhost:3000",
12+
trace: "retain-on-failure"
13+
},
14+
projects: [
15+
{
16+
name: "desktop",
17+
use: {
18+
...devices["Desktop Chrome"],
19+
viewport: { width: 1280, height: 720 }
20+
}
21+
},
22+
{
23+
name: "mobile",
24+
use: { ...devices["iPhone 13"] }
25+
}
26+
]
27+
});

scripts/verify.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,62 @@ async function checkInternalLinks(
369369
pass("internal links", `${targets.size} checked`);
370370
}
371371

372+
// The /api/subscribe handler instantiates `new Resend(...)` *inside* the
373+
// request handler so the build doesn't fail when RESEND_API_KEY is unset.
374+
// When the env var is missing it returns 503 with a JSON error body. We
375+
// assert that contract here so a regression (e.g. moving the Resend
376+
// constructor to module scope, or removing the env-gate) surfaces in CI.
377+
//
378+
// In environments where RESEND_API_KEY *is* set (e.g. a dev with .env.local
379+
// + the var exported into the verify shell), the test is skipped rather
380+
// than calling the real Resend API.
381+
async function checkSubscribe(): Promise<void> {
382+
if (process.env.RESEND_API_KEY) {
383+
pass("/api/subscribe", "skipped (RESEND_API_KEY is set in this shell)");
384+
return;
385+
}
386+
let res: Response;
387+
try {
388+
res = await fetch(`${BASE}/api/subscribe`, {
389+
method: "POST",
390+
headers: { "Content-Type": "application/json" },
391+
body: JSON.stringify({ email: "verify-suite@example.com" })
392+
});
393+
} catch (err) {
394+
fail(
395+
"/api/subscribe",
396+
`request failed: ${err instanceof Error ? err.message : String(err)}`
397+
);
398+
return;
399+
}
400+
if (res.status !== 503) {
401+
fail(
402+
"/api/subscribe",
403+
`expected 503 with RESEND_API_KEY unset, got ${res.status}`
404+
);
405+
return;
406+
}
407+
let body: unknown;
408+
try {
409+
body = await res.json();
410+
} catch {
411+
fail("/api/subscribe", "response was not JSON");
412+
return;
413+
}
414+
if (
415+
typeof body !== "object" ||
416+
body === null ||
417+
typeof (body as { error?: unknown }).error !== "string"
418+
) {
419+
fail("/api/subscribe", `response shape mismatch: ${JSON.stringify(body)}`);
420+
return;
421+
}
422+
pass(
423+
"/api/subscribe",
424+
`503 with error="${(body as { error: string }).error}"`
425+
);
426+
}
427+
372428
// === Run ===============================================================
373429

374430
async function main(): Promise<void> {
@@ -379,6 +435,7 @@ async function main(): Promise<void> {
379435
await checkRssXml();
380436
await checkRssJson();
381437
await checkRssAtom();
438+
await checkSubscribe();
382439

383440
const htmlByPath = new Map<string, string>();
384441
for (const path of sitemapPaths) {

tests/smoke.spec.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { expect, test } from "@playwright/test";
2+
3+
/**
4+
* Browser smoke tests. Structural and behavioral checks against a real
5+
* browser engine — catches regressions the HTTP-only verify script can't
6+
* see (hydration, client-mounted WebGL canvas, mobile layout overflow,
7+
* client-side syntax-highlighting).
8+
*
9+
* Not pixel-diff. The 3D hero won't produce a stable WebGL output across
10+
* Linux/macOS GPU stacks, so screenshots are out of scope. Promote to
11+
* snapshot diffs only if a regression slips through.
12+
*
13+
* The post-dependent specs discover a published slug at runtime by parsing
14+
* `/blog`'s anchor list. Skips when no published posts are found, so the
15+
* suite stays useful on a hypothetical empty-content branch.
16+
*/
17+
18+
let seedPostSlug: string | null = null;
19+
20+
test.beforeAll(async () => {
21+
const baseURL = process.env.BASE_URL ?? "http://localhost:3000";
22+
try {
23+
const res = await fetch(`${baseURL}/blog`);
24+
if (!res.ok) return;
25+
const html = await res.text();
26+
const match = html.match(/href="\/blog\/([a-z0-9-]+)"/);
27+
seedPostSlug = match?.[1] ?? null;
28+
} catch {
29+
// leave seedPostSlug as null
30+
}
31+
});
32+
33+
test.describe("home", () => {
34+
test("hero copy is visible", async ({ page }) => {
35+
await page.goto("/");
36+
await expect(
37+
page.getByText(/highly performant.*columnar data format/i)
38+
).toBeVisible();
39+
await expect(page.getByText(/100x faster random access/i)).toBeVisible();
40+
});
41+
42+
test("title is correct", async ({ page }) => {
43+
await page.goto("/");
44+
await expect(page).toHaveTitle(/Vortex/);
45+
});
46+
47+
test("WebGL hero canvas mounts after hydration", async ({ page }) => {
48+
await page.goto("/");
49+
// OGL's Renderer constructor throws when WebGL context creation fails,
50+
// so the canvas only gets appended if WebGL is actually available.
51+
// Headless WebKit doesn't always have WebGL — skip there and rely on
52+
// the hero-copy / title tests above to catch a generic page-load
53+
// regression. Chromium has SwiftShader fallback so this runs there.
54+
const hasWebGL = await page.evaluate(() => {
55+
try {
56+
return !!document.createElement("canvas").getContext("webgl");
57+
} catch {
58+
return false;
59+
}
60+
});
61+
test.skip(!hasWebGL, "browser doesn't support WebGL");
62+
await expect(page.locator("canvas").first()).toBeAttached({
63+
timeout: 5000
64+
});
65+
});
66+
});
67+
68+
test.describe("blog index", () => {
69+
test("links to a published post", async ({ page }) => {
70+
test.skip(!seedPostSlug, "no published posts");
71+
await page.goto("/blog");
72+
await expect(
73+
page.locator(`a[href="/blog/${seedPostSlug}"]`).first()
74+
).toBeVisible();
75+
});
76+
});
77+
78+
test.describe("blog post", () => {
79+
test("post heading renders", async ({ page }) => {
80+
test.skip(!seedPostSlug, "no published posts");
81+
await page.goto(`/blog/${seedPostSlug}`);
82+
await expect(page.locator("h1").first()).toBeVisible();
83+
});
84+
85+
test("rehype-pretty-code syntax highlighting is rendered", async ({
86+
page
87+
}) => {
88+
test.skip(!seedPostSlug, "no published posts");
89+
await page.goto(`/blog/${seedPostSlug}`);
90+
// `figure[data-rehype-pretty-code-figure]` + `[data-line]` children are
91+
// emitted only when the rehype plugin successfully tokenized the code
92+
// block. A fallback `<pre>` path (e.g. plugin disabled or theme load
93+
// failure) wouldn't have either attribute.
94+
const figure = page
95+
.locator("figure[data-rehype-pretty-code-figure]")
96+
.first();
97+
await expect(figure).toBeVisible();
98+
expect(await figure.locator("[data-line]").count()).toBeGreaterThan(0);
99+
});
100+
});
101+
102+
test.describe("mobile layout", () => {
103+
test.use({ viewport: { width: 375, height: 812 } });
104+
105+
test("home has no horizontal scroll", async ({ page }) => {
106+
await page.goto("/");
107+
const { scrollWidth, clientWidth } = await page.evaluate(() => ({
108+
scrollWidth: document.documentElement.scrollWidth,
109+
clientWidth: document.documentElement.clientWidth
110+
}));
111+
expect(scrollWidth).toBeLessThanOrEqual(clientWidth);
112+
});
113+
114+
test("blog post page has no horizontal scroll", async ({ page }) => {
115+
test.skip(!seedPostSlug, "no published posts");
116+
await page.goto(`/blog/${seedPostSlug}`);
117+
const { scrollWidth, clientWidth } = await page.evaluate(() => ({
118+
scrollWidth: document.documentElement.scrollWidth,
119+
clientWidth: document.documentElement.clientWidth
120+
}));
121+
expect(scrollWidth).toBeLessThanOrEqual(clientWidth);
122+
});
123+
});

0 commit comments

Comments
 (0)