diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3a1f1e..d3d0a8f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,6 +95,51 @@ jobs: - name: Verify endpoints run: bun run verify + # Hard-gates on vulnerable direct/transitive deps. Two advisories are + # ignored because they're upstream-blocked (both via @lhci/cli@0.15.1 + # and resend's transitive svix; both dev-/server-side with no + # exploitable code path) — see CLAUDE.md "Audit advisories" for + # context and removal triggers. Any new advisory fails the job. + - name: Dependency audit + run: bun audit --ignore=GHSA-w5hq-g745-h8pq --ignore=GHSA-52f5-9888-hmc6 + + # Playwright browsers are ~250 MB. Cache them keyed on the + # @playwright/test version pinned in package.json — invalidates on + # bumps, hits otherwise. System deps (apt packages) aren't covered by + # the cache, so install those separately even on cache hit (~10s vs + # ~3min for the full install). + - name: Get Playwright version + id: playwright-version + run: | + VERSION=$(grep -oE '"@playwright/test": "[^"]+"' package.json | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }} + + - name: Install Playwright browsers (cache miss) + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: bunx playwright install --with-deps chromium webkit + + - name: Install Playwright system deps (cache hit) + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: bunx playwright install-deps chromium webkit + + - name: Browser smoke tests + run: bun run test:e2e + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 + - name: Stop server if: always() run: | @@ -106,14 +151,6 @@ jobs: if: failure() run: cat /tmp/server.log || true - # Hard-gates on vulnerable direct/transitive deps. Two advisories are - # ignored because they're upstream-blocked (both via @lhci/cli@0.15.1 - # and resend's transitive svix; both dev-/server-side with no - # exploitable code path) — see CLAUDE.md "Audit advisories" for - # context and removal triggers. Any new advisory fails the job. - - name: Dependency audit - run: bun audit --ignore=GHSA-w5hq-g745-h8pq --ignore=GHSA-52f5-9888-hmc6 - # Runs only on PRs (no baseline diff to compute on a push to main). # Compares the PR's dependency manifest against main and flags # high-severity advisories or license incompatibilities. Posts a summary diff --git a/.gitignore b/.gitignore index 0c15e3a..46816da 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,8 @@ public/static # lighthouse-ci output .lighthouseci + +# playwright +/playwright-report/ +/playwright/.cache/ +/test-results/ diff --git a/bun.lock b/bun.lock index 2f845cb..f29d87d 100644 --- a/bun.lock +++ b/bun.lock @@ -24,6 +24,7 @@ "devDependencies": { "@biomejs/biome": "^2.4.13", "@lhci/cli": "^0.15.1", + "@playwright/test": "^1.59.1", "@tailwindcss/postcss": "4.2.3", "@types/node": "^25.6.0", "@types/react": "^19.2.14", @@ -214,6 +215,8 @@ "@paulirish/trace_engine": ["@paulirish/trace_engine@0.0.53", "", { "dependencies": { "legacy-javascript": "latest", "third-party-web": "latest" } }, "sha512-PUl/vlfo08Oj804VI5nDPeSk9vyslnBlVzDDwFt8SUVxY8+KdGMkra/vrXjEEHe8gb7+RqVTfOIlGw0nyrEelA=="], + "@playwright/test": ["@playwright/test@1.59.1", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="], + "@puppeteer/browsers": ["@puppeteer/browsers@2.13.0", "", { "dependencies": { "debug": "^4.4.3", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.7.4", "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA=="], "@sentry-internal/tracing": ["@sentry-internal/tracing@7.120.4", "", { "dependencies": { "@sentry/core": "7.120.4", "@sentry/types": "7.120.4", "@sentry/utils": "7.120.4" } }, "sha512-Fz5+4XCg3akeoFK+K7g+d7HqGMjmnLoY2eJlpONJmaeT9pXY7yfUyXKZMmMajdE2LxxKJgQ2YKvSCaGVamTjHw=="], @@ -550,6 +553,8 @@ "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], @@ -878,6 +883,10 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="], + + "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="], + "postal-mime": ["postal-mime@2.7.4", "", {}, "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g=="], "postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="], diff --git a/package.json b/package.json index 66763bd..33369ed 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,9 @@ "check:ci": "biome check", "typecheck": "tsc --noEmit", "verify": "bun scripts/verify.ts", - "lighthouse": "lhci autorun" + "lighthouse": "lhci autorun", + "test:e2e": "playwright test", + "test:e2e:install": "playwright install --with-deps chromium webkit" }, "dependencies": { "@mdx-js/react": "^3.1.1", @@ -40,6 +42,7 @@ "devDependencies": { "@biomejs/biome": "^2.4.13", "@lhci/cli": "^0.15.1", + "@playwright/test": "^1.59.1", "@tailwindcss/postcss": "4.2.3", "@types/node": "^25.6.0", "@types/react": "^19.2.14", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..a512f57 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,27 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: process.env.CI ? "github" : "list", + use: { + baseURL: process.env.BASE_URL ?? "http://localhost:3000", + trace: "retain-on-failure" + }, + projects: [ + { + name: "desktop", + use: { + ...devices["Desktop Chrome"], + viewport: { width: 1280, height: 720 } + } + }, + { + name: "mobile", + use: { ...devices["iPhone 13"] } + } + ] +}); diff --git a/scripts/verify.ts b/scripts/verify.ts index ef17275..236adb4 100644 --- a/scripts/verify.ts +++ b/scripts/verify.ts @@ -369,6 +369,62 @@ async function checkInternalLinks( pass("internal links", `${targets.size} checked`); } +// The /api/subscribe handler instantiates `new Resend(...)` *inside* the +// request handler so the build doesn't fail when RESEND_API_KEY is unset. +// When the env var is missing it returns 503 with a JSON error body. We +// assert that contract here so a regression (e.g. moving the Resend +// constructor to module scope, or removing the env-gate) surfaces in CI. +// +// In environments where RESEND_API_KEY *is* set (e.g. a dev with .env.local +// + the var exported into the verify shell), the test is skipped rather +// than calling the real Resend API. +async function checkSubscribe(): Promise { + if (process.env.RESEND_API_KEY) { + pass("/api/subscribe", "skipped (RESEND_API_KEY is set in this shell)"); + return; + } + let res: Response; + try { + res = await fetch(`${BASE}/api/subscribe`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "verify-suite@example.com" }) + }); + } catch (err) { + fail( + "/api/subscribe", + `request failed: ${err instanceof Error ? err.message : String(err)}` + ); + return; + } + if (res.status !== 503) { + fail( + "/api/subscribe", + `expected 503 with RESEND_API_KEY unset, got ${res.status}` + ); + return; + } + let body: unknown; + try { + body = await res.json(); + } catch { + fail("/api/subscribe", "response was not JSON"); + return; + } + if ( + typeof body !== "object" || + body === null || + typeof (body as { error?: unknown }).error !== "string" + ) { + fail("/api/subscribe", `response shape mismatch: ${JSON.stringify(body)}`); + return; + } + pass( + "/api/subscribe", + `503 with error="${(body as { error: string }).error}"` + ); +} + // === Run =============================================================== async function main(): Promise { @@ -379,6 +435,7 @@ async function main(): Promise { await checkRssXml(); await checkRssJson(); await checkRssAtom(); + await checkSubscribe(); const htmlByPath = new Map(); for (const path of sitemapPaths) { diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts new file mode 100644 index 0000000..9efb3e9 --- /dev/null +++ b/tests/smoke.spec.ts @@ -0,0 +1,123 @@ +import { expect, test } from "@playwright/test"; + +/** + * Browser smoke tests. Structural and behavioral checks against a real + * browser engine — catches regressions the HTTP-only verify script can't + * see (hydration, client-mounted WebGL canvas, mobile layout overflow, + * client-side syntax-highlighting). + * + * Not pixel-diff. The 3D hero won't produce a stable WebGL output across + * Linux/macOS GPU stacks, so screenshots are out of scope. Promote to + * snapshot diffs only if a regression slips through. + * + * The post-dependent specs discover a published slug at runtime by parsing + * `/blog`'s anchor list. Skips when no published posts are found, so the + * suite stays useful on a hypothetical empty-content branch. + */ + +let seedPostSlug: string | null = null; + +test.beforeAll(async () => { + const baseURL = process.env.BASE_URL ?? "http://localhost:3000"; + try { + const res = await fetch(`${baseURL}/blog`); + if (!res.ok) return; + const html = await res.text(); + const match = html.match(/href="\/blog\/([a-z0-9-]+)"/); + seedPostSlug = match?.[1] ?? null; + } catch { + // leave seedPostSlug as null + } +}); + +test.describe("home", () => { + test("hero copy is visible", async ({ page }) => { + await page.goto("/"); + await expect( + page.getByText(/highly performant.*columnar data format/i) + ).toBeVisible(); + await expect(page.getByText(/100x faster random access/i)).toBeVisible(); + }); + + test("title is correct", async ({ page }) => { + await page.goto("/"); + await expect(page).toHaveTitle(/Vortex/); + }); + + test("WebGL hero canvas mounts after hydration", async ({ page }) => { + await page.goto("/"); + // OGL's Renderer constructor throws when WebGL context creation fails, + // so the canvas only gets appended if WebGL is actually available. + // Headless WebKit doesn't always have WebGL — skip there and rely on + // the hero-copy / title tests above to catch a generic page-load + // regression. Chromium has SwiftShader fallback so this runs there. + const hasWebGL = await page.evaluate(() => { + try { + return !!document.createElement("canvas").getContext("webgl"); + } catch { + return false; + } + }); + test.skip(!hasWebGL, "browser doesn't support WebGL"); + await expect(page.locator("canvas").first()).toBeAttached({ + timeout: 5000 + }); + }); +}); + +test.describe("blog index", () => { + test("links to a published post", async ({ page }) => { + test.skip(!seedPostSlug, "no published posts"); + await page.goto("/blog"); + await expect( + page.locator(`a[href="/blog/${seedPostSlug}"]`).first() + ).toBeVisible(); + }); +}); + +test.describe("blog post", () => { + test("post heading renders", async ({ page }) => { + test.skip(!seedPostSlug, "no published posts"); + await page.goto(`/blog/${seedPostSlug}`); + await expect(page.locator("h1").first()).toBeVisible(); + }); + + test("rehype-pretty-code syntax highlighting is rendered", async ({ + page + }) => { + test.skip(!seedPostSlug, "no published posts"); + await page.goto(`/blog/${seedPostSlug}`); + // `figure[data-rehype-pretty-code-figure]` + `[data-line]` children are + // emitted only when the rehype plugin successfully tokenized the code + // block. A fallback `
` path (e.g. plugin disabled or theme load
+    // failure) wouldn't have either attribute.
+    const figure = page
+      .locator("figure[data-rehype-pretty-code-figure]")
+      .first();
+    await expect(figure).toBeVisible();
+    expect(await figure.locator("[data-line]").count()).toBeGreaterThan(0);
+  });
+});
+
+test.describe("mobile layout", () => {
+  test.use({ viewport: { width: 375, height: 812 } });
+
+  test("home has no horizontal scroll", async ({ page }) => {
+    await page.goto("/");
+    const { scrollWidth, clientWidth } = await page.evaluate(() => ({
+      scrollWidth: document.documentElement.scrollWidth,
+      clientWidth: document.documentElement.clientWidth
+    }));
+    expect(scrollWidth).toBeLessThanOrEqual(clientWidth);
+  });
+
+  test("blog post page has no horizontal scroll", async ({ page }) => {
+    test.skip(!seedPostSlug, "no published posts");
+    await page.goto(`/blog/${seedPostSlug}`);
+    const { scrollWidth, clientWidth } = await page.evaluate(() => ({
+      scrollWidth: document.documentElement.scrollWidth,
+      clientWidth: document.documentElement.clientWidth
+    }));
+    expect(scrollWidth).toBeLessThanOrEqual(clientWidth);
+  });
+});