Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 45 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,8 @@ public/static

# lighthouse-ci output
.lighthouseci

# playwright
/playwright-report/
/playwright/.cache/
/test-results/
9 changes: 9 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
27 changes: 27 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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"] }
}
]
});
57 changes: 57 additions & 0 deletions scripts/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<void> {
Expand All @@ -379,6 +435,7 @@ async function main(): Promise<void> {
await checkRssXml();
await checkRssJson();
await checkRssAtom();
await checkSubscribe();

const htmlByPath = new Map<string, string>();
for (const path of sitemapPaths) {
Expand Down
123 changes: 123 additions & 0 deletions tests/smoke.spec.ts
Original file line number Diff line number Diff line change
@@ -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 `<pre>` 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);
});
});
Loading