From e8acbdc0701c43c3d41c4a4420b9d0f125d3be29 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Fri, 18 Sep 2026 07:25:23 -0400 Subject: [PATCH 1/8] test: add homepage performance guardrails Add production Playwright coverage for prerendering, hydration, navigation, clipboard behavior, and image loading. Track initial asset, DOM, and media baselines in CI before the homepage optimization stack changes them. --- .github/workflows/pull-request.yml | 38 +++ .gitignore | 4 + .../__tests__/LandingPageBehavior_.test.res | 40 +++ .../homepage-prerender.spec.mjs | 18 ++ apps/docs/e2e-playwright/homepage.spec.mjs | 152 +++++++++++ apps/docs/package.json | 4 + apps/docs/playwright.config.mjs | 30 +++ .../__tests__/homepage-performance.test.mjs | 125 ++++++++++ .../scripts/homepage-performance-budget.json | 21 ++ apps/docs/scripts/homepage-performance.mjs | 236 ++++++++++++++++++ package.json | 3 + 11 files changed, 671 insertions(+) create mode 100644 apps/docs/e2e-playwright/homepage-prerender.spec.mjs create mode 100644 apps/docs/e2e-playwright/homepage.spec.mjs create mode 100644 apps/docs/playwright.config.mjs create mode 100644 apps/docs/scripts/__tests__/homepage-performance.test.mjs create mode 100644 apps/docs/scripts/homepage-performance-budget.json create mode 100644 apps/docs/scripts/homepage-performance.mjs diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 248ccb3dc..ef424c2ce 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -119,6 +119,44 @@ jobs: run: yarn workspace @rescript-lang/docs ci:test - name: Guide Vitest run: yarn workspace @rescript-lang/guide ci:test + Homepage_E2E: + runs-on: ubuntu-latest + container: + # Keep this image tag aligned with package.json's playwright version. + image: mcr.microsoft.com/playwright:v1.59.1-noble + env: + PLAYWRIGHT_BROWSERS_PATH: /ms-playwright + VITE_ALGOLIA_APP_ID: test-app-id + VITE_ALGOLIA_INDEX_NAME: test-index + VITE_ALGOLIA_SEARCH_API_KEY: test-search-key + steps: + - name: Checkout + uses: actions/checkout@v6.0.2 + - name: Install Corepack + run: npm install --global corepack + - name: Enable Corepack + run: corepack enable + - name: Setup Node.js environment + uses: actions/setup-node@v6.3.0 + with: + node-version-file: ".node-version" + cache: yarn + - name: Install dependencies + run: yarn + - name: Build docs + run: yarn workspace @rescript-lang/docs build + - name: Check homepage performance budgets + run: yarn workspace @rescript-lang/docs ci:homepage-performance + - name: Run homepage Playwright tests + run: yarn workspace @rescript-lang/docs ci:test:e2e + - name: Upload Playwright artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: homepage-playwright-artifacts + path: | + apps/docs/playwright-report + apps/docs/test-results/playwright Visual_Regression: runs-on: ubuntu-latest container: diff --git a/.gitignore b/.gitignore index 0fce7f12c..518b46f76 100644 --- a/.gitignore +++ b/.gitignore @@ -111,3 +111,7 @@ apps/docs/.env.local .vitest-attachments apps/docs/.vitest-attachments apps/guide/.vitest-attachments + +# Playwright artifacts +apps/docs/playwright-report/ +apps/docs/test-results/ diff --git a/apps/docs/__tests__/LandingPageBehavior_.test.res b/apps/docs/__tests__/LandingPageBehavior_.test.res index cbe53431c..b159b7c93 100644 --- a/apps/docs/__tests__/LandingPageBehavior_.test.res +++ b/apps/docs/__tests__/LandingPageBehavior_.test.res @@ -113,6 +113,46 @@ test("landing page playground hero renders highlighted code tokens", async () => expect(javascriptCodeBlock.innerHTML->String.includes("toBe(true) }) +test("landing page renders its critical sections", async () => { + let screen = await render( + + + , + ) + + let intro = await screen->getByText("JavaScript Made Simple for Humans and AI") + let playground = await screen->getByText("Write in ReScript") + let install = await screen->getByText("Quick Install") + let mainSellingPoint = await screen->getByText("The fastest build system on the web") + let community = await screen->getByText( + "A community of programmers who value getting things done", + ) + let users = await screen->getByText("Trusted by our users") + let resources = await screen->getByText("Curated resources") + + await element(intro)->toBeVisible + await element(playground)->toBeVisible + await element(install)->toBeVisible + await element(mainSellingPoint)->toBeVisible + await element(community)->toBeVisible + await element(users)->toBeVisible + await element(resources)->toBeVisible +}) + +test("landing page copy button shows success feedback", async () => { + let screen = await render( + + + , + ) + + let copyButton = await screen->getByLabelText("Copy npm install rescript command") + await copyButton->click + + let feedback = await screen->getByText("Copied!") + await element(feedback)->toBeVisible +}) + test( "landing page playground hero keeps highlight styling in the sandboxed snapshot copy", async () => { diff --git a/apps/docs/e2e-playwright/homepage-prerender.spec.mjs b/apps/docs/e2e-playwright/homepage-prerender.spec.mjs new file mode 100644 index 000000000..991895dc5 --- /dev/null +++ b/apps/docs/e2e-playwright/homepage-prerender.spec.mjs @@ -0,0 +1,18 @@ +import { expect, test } from "playwright/test"; +import { JSDOM } from "jsdom"; + +test("homepage response contains prerendered content and highlighted examples", async ({ + request, +}) => { + const response = await request.get("/"); + const html = await response.text(); + const { document } = new JSDOM(html).window; + + expect(response.ok()).toBe(true); + expect(html).toContain("JavaScript Made Simple for Humans and AI"); + expect(html).toContain("Write in ReScript"); + expect(document.querySelector("code.lang-res span")).not.toBeNull(); + expect(document.querySelector("code.lang-js span")).not.toBeNull(); + expect(html).toContain('href="/docs/manual/installation"'); + expect(html).toMatch(/href="\/try\?code=[^"]+"/); +}); diff --git a/apps/docs/e2e-playwright/homepage.spec.mjs b/apps/docs/e2e-playwright/homepage.spec.mjs new file mode 100644 index 000000000..26a25a647 --- /dev/null +++ b/apps/docs/e2e-playwright/homepage.spec.mjs @@ -0,0 +1,152 @@ +import { expect, test } from "playwright/test"; + +function observeRuntimeErrors(page) { + const errors = []; + + page.on("pageerror", (error) => errors.push(error.message)); + page.on("console", (message) => { + if (message.type() === "error") { + errors.push(message.text()); + } + }); + + return errors; +} + +function observeFailedLocalImages(page) { + const failures = []; + + page.on("response", (response) => { + const request = response.request(); + if ( + request.resourceType() === "image" && + new URL(response.url()).origin === "http://127.0.0.1:4173" && + !response.ok() + ) { + failures.push(`${response.status()} ${response.url()}`); + } + }); + + return failures; +} + +async function expectPageStyles(page) { + await expect + .poll(() => + page.evaluate(() => getComputedStyle(document.documentElement).opacity), + ) + .toBe("1"); +} + +async function loadHomepageImages(page) { + const sections = page.locator("main section"); + const sectionCount = await sections.count(); + + for (let index = 0; index < sectionCount; index += 1) { + await sections.nth(index).scrollIntoViewIfNeeded(); + } + + return page.locator("img").evaluateAll(async (images) => { + await Promise.all( + images.map((image) => image.decode().catch(() => undefined)), + ); + return images + .filter((image) => image.complete && image.naturalWidth === 0) + .map((image) => image.currentSrc || image.src); + }); +} + +test("homepage hydrates with working links and copy feedback", async ({ + context, + page, +}) => { + const runtimeErrors = observeRuntimeErrors(page); + const failedImages = observeFailedLocalImages(page); + + await context.grantPermissions(["clipboard-read", "clipboard-write"], { + origin: "http://127.0.0.1:4173", + }); + await page.goto("/"); + await expectPageStyles(page); + + await expect( + page.getByRole("heading", { + level: 1, + name: "JavaScript Made Simple for Humans and AI", + }), + ).toBeVisible(); + await expect( + page.getByRole("link", { name: "Get started", exact: true }), + ).toHaveAttribute("href", "/docs/manual/installation"); + await expect( + page.getByRole("link", { name: "Edit this example in Playground" }), + ).toHaveAttribute("href", /\/try\?code=.+/); + + await page + .getByRole("button", { name: "Copy npm install rescript command" }) + .click(); + await expect(page.getByText("Copied!", { exact: true })).toBeVisible(); + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe("npm install rescript"); + + const brokenLoadedImages = await loadHomepageImages(page); + + expect(brokenLoadedImages).toEqual([]); + expect(failedImages).toEqual([]); + expect(runtimeErrors).toEqual([]); +}); + +test("client navigation preserves homepage and documentation styles", async ({ + page, +}) => { + const runtimeErrors = observeRuntimeErrors(page); + + await page.goto("/"); + await expectPageStyles(page); + await page.getByRole("link", { name: "Docs", exact: true }).click(); + + await expect(page).toHaveURL(/\/docs\/manual\/introduction$/); + await expect( + page.getByRole("heading", { level: 1, name: "ReScript", exact: true }), + ).toBeVisible(); + await expectPageStyles(page); + + await page.getByRole("link", { name: "homepage" }).click(); + await expect(page).toHaveURL("/"); + await expect( + page.getByRole("heading", { + level: 1, + name: "JavaScript Made Simple for Humans and AI", + }), + ).toBeVisible(); + await expectPageStyles(page); + + expect(runtimeErrors).toEqual([]); +}); + +test("mobile navigation opens the packages route", async ({ page }) => { + const runtimeErrors = observeRuntimeErrors(page); + + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto("/"); + + await page.getByRole("button", { name: "Toggle additional menu" }).click(); + const packagesLink = page.getByRole("link", { + name: "Packages", + exact: true, + }); + await expect(packagesLink).toBeVisible(); + await packagesLink.click(); + + await expect(page).toHaveURL(/\/packages(?:\?search=)?$/); + await expect( + page.getByRole("heading", { + level: 1, + name: "Libraries & Bindings", + exact: true, + }), + ).toBeVisible(); + await expectPageStyles(page); + expect(runtimeErrors).toEqual([]); +}); diff --git a/apps/docs/package.json b/apps/docs/package.json index 3baad8db8..50ba8f52c 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -16,7 +16,9 @@ "check:algolia-public-env": "node _scripts/LogAlgoliaEnvStatus.mjs", "build": "yarn build:res && yarn build:scripts && yarn check:algolia-public-env && yarn build:update-index && yarn build:vite && yarn build:generate-sitemap", "ci:format": "cd ../.. && oxfmt --check", + "ci:homepage-performance": "node --test scripts/__tests__/homepage-performance.test.mjs && node scripts/homepage-performance.mjs", "ci:test": "vitest --run --browser.headless", + "ci:test:e2e": "playwright test", "ci:test:visual": "VISUAL_TESTS=1 vitest --run --browser.headless", "ci:test:visual:update": "VISUAL_TESTS=1 VISUAL_BASELINE_UPDATE=1 vitest --run --browser.headless --update", "clean:res": "rescript clean", @@ -28,8 +30,10 @@ "format": "cd ../.. && oxfmt && cd apps/docs && rescript format", "prepare": "yarn build:res && yarn build:scripts && yarn check:algolia-public-env && yarn build:update-index", "preview": "yarn build && static-server build/client", + "serve:build": "static-server --host 127.0.0.1 --port 4173 build/client", "reanalyze": "rescript-tools reanalyze -all-cmt .", "test": "node scripts/test.mjs", + "test:e2e": "yarn build && playwright test", "cy:open": "yarn build:res && cypress open --e2e --browser electron", "cy:run": "yarn build:res && cypress run", "cy:e2e": "yarn build:res && cypress run --browser chrome", diff --git a/apps/docs/playwright.config.mjs b/apps/docs/playwright.config.mjs new file mode 100644 index 000000000..82e1bf40e --- /dev/null +++ b/apps/docs/playwright.config.mjs @@ -0,0 +1,30 @@ +import { defineConfig } from "playwright/test"; + +const isCI = process.env.CI === "true"; +const port = 4173; +const baseURL = `http://127.0.0.1:${port}`; + +export default defineConfig({ + testDir: "./e2e-playwright", + outputDir: "./test-results/playwright", + fullyParallel: true, + forbidOnly: isCI, + retries: isCI ? 2 : 0, + workers: isCI ? 1 : undefined, + reporter: isCI + ? [ + ["github"], + ["html", { open: "never", outputFolder: "playwright-report" }], + ] + : [["list"]], + use: { + baseURL, + screenshot: "only-on-failure", + trace: "on-first-retry", + }, + webServer: { + command: "yarn serve:build", + url: baseURL, + reuseExistingServer: !isCI, + }, +}); diff --git a/apps/docs/scripts/__tests__/homepage-performance.test.mjs b/apps/docs/scripts/__tests__/homepage-performance.test.mjs new file mode 100644 index 000000000..a335ebf8b --- /dev/null +++ b/apps/docs/scripts/__tests__/homepage-performance.test.mjs @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { gzipSync } from "node:zlib"; +import { createReport, getBudgetFailures } from "../homepage-performance.mjs"; + +const javascript = Buffer.from("console.log('home')"); +const css = Buffer.from("body { color: black; }"); +const image = Buffer.from("image"); +const poster = Buffer.from("poster"); + +const assets = new Map([ + ["/assets/home.js", javascript], + ["/assets/home.css", css], + ["/images/home.png", image], + ["/images/poster.png", poster], +]); + +const html = ` + + + + + + + + +
+ + +`; + +function readAsset(url) { + const contents = assets.get(url.pathname); + if (contents === undefined) { + throw new Error(`missing fixture ${url.pathname}`); + } + return contents; +} + +test("createReport measures unique local assets and media contracts", async () => { + const report = await createReport({ html, readAsset }); + + assert.deepEqual(report.javascript, { + requests: 1, + rawBytes: javascript.byteLength, + gzipBytes: gzipSync(javascript, { level: 9 }).byteLength, + assets: [ + { + path: "/assets/home.js", + rawBytes: javascript.byteLength, + gzipBytes: gzipSync(javascript, { level: 9 }).byteLength, + }, + ], + }); + assert.equal(report.css.requests, 1); + assert.equal(report.css.rawBytes, css.byteLength); + assert.equal(report.bodyElements, 3); + assert.deepEqual(report.media, { + images: 1, + imagesMissingWidth: 1, + imagesMissingHeight: 1, + videos: 1, + videosMissingWidth: 1, + videosMissingHeight: 1, + localAssets: 2, + }); +}); + +test("createReport rejects a missing local media asset", async () => { + await assert.rejects( + createReport({ + html: html.replace("/images/home.png", "/images/missing.png"), + readAsset, + }), + /Unable to read local asset \/images\/missing.png/, + ); +}); + +test("createReport rejects an empty initial asset group", async () => { + await assert.rejects( + createReport({ + html: "
Home
", + readAsset, + }), + /found no local JavaScript assets/, + ); +}); + +test("getBudgetFailures reports each exceeded ceiling", () => { + const report = { + javascript: { requests: 2, rawBytes: 20, gzipBytes: 10 }, + css: { requests: 1, rawBytes: 10, gzipBytes: 5 }, + bodyElements: 4, + media: { + images: 1, + imagesMissingWidth: 1, + imagesMissingHeight: 1, + videos: 0, + videosMissingWidth: 0, + videosMissingHeight: 0, + }, + }; + const budget = { + javascript: { requests: 1, rawBytes: 19, gzipBytes: 9 }, + css: { requests: 1, rawBytes: 10, gzipBytes: 5 }, + bodyElements: 3, + media: { + images: 1, + imagesMissingWidth: 0, + imagesMissingHeight: 0, + videos: 0, + videosMissingWidth: 0, + videosMissingHeight: 0, + }, + }; + + assert.deepEqual(getBudgetFailures(report, budget), [ + "initial JavaScript requests: 2 exceeds 1", + "initial JavaScript raw bytes: 20 exceeds 19", + "initial JavaScript gzip bytes: 10 exceeds 9", + "body elements: 4 exceeds 3", + "images missing width: 1 exceeds 0", + "images missing height: 1 exceeds 0", + ]); +}); diff --git a/apps/docs/scripts/homepage-performance-budget.json b/apps/docs/scripts/homepage-performance-budget.json new file mode 100644 index 000000000..dcc114624 --- /dev/null +++ b/apps/docs/scripts/homepage-performance-budget.json @@ -0,0 +1,21 @@ +{ + "javascript": { + "requests": 22, + "rawBytes": 1402061, + "gzipBytes": 274525 + }, + "css": { + "requests": 3, + "rawBytes": 80214, + "gzipBytes": 14725 + }, + "bodyElements": 418, + "media": { + "images": 63, + "imagesMissingWidth": 61, + "imagesMissingHeight": 63, + "videos": 3, + "videosMissingWidth": 3, + "videosMissingHeight": 3 + } +} diff --git a/apps/docs/scripts/homepage-performance.mjs b/apps/docs/scripts/homepage-performance.mjs new file mode 100644 index 000000000..bc0580f2f --- /dev/null +++ b/apps/docs/scripts/homepage-performance.mjs @@ -0,0 +1,236 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { gzipSync } from "node:zlib"; +import { JSDOM } from "jsdom"; + +const buildDirectory = fileURLToPath( + new URL("../build/client/", import.meta.url), +); +const homepagePath = path.join(buildDirectory, "index.html"); +const budgetPath = fileURLToPath( + new URL("./homepage-performance-budget.json", import.meta.url), +); +const localOrigin = "https://build.local"; + +function unique(values) { + return [...new Set(values)].sort(); +} + +function getLocalAssetUrls(document, selector, attribute) { + const hrefs = [...document.querySelectorAll(selector)] + .map((element) => element.getAttribute(attribute)) + .filter((value) => value !== null) + .map((value) => new URL(value, localOrigin)) + .filter((url) => url.origin === localOrigin) + .map((url) => url.href); + + return unique(hrefs).map((href) => new URL(href)); +} + +function getInitialJavaScriptUrls(document) { + return getLocalAssetUrls( + document, + 'link[rel~="modulepreload"][href], link[rel~="preload"][as="script"][href]', + "href", + ).concat(getLocalAssetUrls(document, "script[src]", "src")); +} + +function getInitialCssUrls(document) { + return getLocalAssetUrls( + document, + 'link[rel~="stylesheet"][href], link[rel~="preload"][as="style"][href]', + "href", + ); +} + +function getLocalMediaUrls(document) { + return getLocalAssetUrls(document, "img[src], source[src]", "src").concat( + getLocalAssetUrls(document, "video[poster]", "poster"), + ); +} + +async function readRequiredAsset(readAsset, url) { + try { + return await readAsset(url); + } catch (cause) { + throw new Error(`Unable to read local asset ${url.pathname}`, { cause }); + } +} + +async function measureAssets(urls, readAsset) { + const hrefs = unique(urls.map((url) => url.href)); + const assets = await Promise.all( + hrefs.map(async (href) => { + const url = new URL(href); + const contents = await readRequiredAsset(readAsset, url); + return { + path: url.pathname, + rawBytes: contents.byteLength, + gzipBytes: gzipSync(contents, { level: 9 }).byteLength, + }; + }), + ); + + return { + requests: assets.length, + rawBytes: assets.reduce((total, asset) => total + asset.rawBytes, 0), + gzipBytes: assets.reduce((total, asset) => total + asset.gzipBytes, 0), + assets, + }; +} + +function hasPositiveNumericAttribute(element, attribute) { + const value = element.getAttribute(attribute); + return value !== null && Number.isFinite(Number(value)) && Number(value) > 0; +} + +function measureMedia(document, localAssets) { + const images = [...document.querySelectorAll("img")]; + const videos = [...document.querySelectorAll("video")]; + const countMissing = (elements, attribute) => + elements.filter( + (element) => !hasPositiveNumericAttribute(element, attribute), + ).length; + + return { + images: images.length, + imagesMissingWidth: countMissing(images, "width"), + imagesMissingHeight: countMissing(images, "height"), + videos: videos.length, + videosMissingWidth: countMissing(videos, "width"), + videosMissingHeight: countMissing(videos, "height"), + localAssets, + }; +} + +function assertMeasuredAssets(name, urls) { + if (urls.length === 0) { + throw new Error(`Homepage report found no local ${name} assets`); + } +} + +export async function createReport({ html, readAsset }) { + const { document } = new JSDOM(html).window; + const javascriptUrls = getInitialJavaScriptUrls(document); + const cssUrls = getInitialCssUrls(document); + const mediaUrls = getLocalMediaUrls(document); + + assertMeasuredAssets("JavaScript", javascriptUrls); + assertMeasuredAssets("CSS", cssUrls); + await Promise.all( + unique(mediaUrls.map((url) => url.href)).map((href) => + readRequiredAsset(readAsset, new URL(href)), + ), + ); + + return { + javascript: await measureAssets(javascriptUrls, readAsset), + css: await measureAssets(cssUrls, readAsset), + bodyElements: document.body.querySelectorAll("*").length, + media: measureMedia( + document, + unique(mediaUrls.map((url) => url.href)).length, + ), + }; +} + +export function getBudgetFailures(report, budget) { + const checks = [ + [ + "initial JavaScript requests", + report.javascript.requests, + budget.javascript.requests, + ], + [ + "initial JavaScript raw bytes", + report.javascript.rawBytes, + budget.javascript.rawBytes, + ], + [ + "initial JavaScript gzip bytes", + report.javascript.gzipBytes, + budget.javascript.gzipBytes, + ], + ["initial CSS requests", report.css.requests, budget.css.requests], + ["initial CSS raw bytes", report.css.rawBytes, budget.css.rawBytes], + ["initial CSS gzip bytes", report.css.gzipBytes, budget.css.gzipBytes], + ["body elements", report.bodyElements, budget.bodyElements], + ["images", report.media.images, budget.media.images], + [ + "images missing width", + report.media.imagesMissingWidth, + budget.media.imagesMissingWidth, + ], + [ + "images missing height", + report.media.imagesMissingHeight, + budget.media.imagesMissingHeight, + ], + ["videos", report.media.videos, budget.media.videos], + [ + "videos missing width", + report.media.videosMissingWidth, + budget.media.videosMissingWidth, + ], + [ + "videos missing height", + report.media.videosMissingHeight, + budget.media.videosMissingHeight, + ], + ]; + + return checks + .filter(([, actual, maximum]) => actual > maximum) + .map(([name, actual, maximum]) => `${name}: ${actual} exceeds ${maximum}`); +} + +function toAssetPath(url) { + const relativePath = decodeURIComponent(url.pathname).replace(/^\/+/, ""); + return path.join(buildDirectory, relativePath); +} + +function formatReport(report) { + return [ + "Homepage performance report", + `JavaScript: ${report.javascript.requests} requests, ${report.javascript.rawBytes} raw bytes, ${report.javascript.gzipBytes} gzip bytes`, + `CSS: ${report.css.requests} requests, ${report.css.rawBytes} raw bytes, ${report.css.gzipBytes} gzip bytes`, + `DOM: ${report.bodyElements} body elements`, + `Images: ${report.media.images} total, ${report.media.imagesMissingWidth} missing width, ${report.media.imagesMissingHeight} missing height`, + `Videos: ${report.media.videos} total, ${report.media.videosMissingWidth} missing width, ${report.media.videosMissingHeight} missing height`, + `Local media assets checked: ${report.media.localAssets}`, + ].join("\n"); +} + +async function main() { + const [html, budgetContents] = await Promise.all([ + readFile(homepagePath, "utf8"), + readFile(budgetPath, "utf8"), + ]); + const report = await createReport({ + html, + readAsset: (url) => readFile(toAssetPath(url)), + }); + const budget = JSON.parse(budgetContents); + + console.log( + process.argv.includes("--json") + ? JSON.stringify(report, null, 2) + : formatReport(report), + ); + + const failures = getBudgetFailures(report, budget); + if (failures.length > 0) { + throw new Error( + `Homepage performance budgets failed:\n${failures.join("\n")}`, + ); + } +} + +const entryPath = process.argv[1] + ? pathToFileURL(path.resolve(process.argv[1])).href + : ""; + +if (import.meta.url === entryPath) { + await main(); +} diff --git a/package.json b/package.json index b9972ac38..b54140105 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,9 @@ "build:vite": "yarn workspace @rescript-lang/docs build:vite", "build": "yarn workspace @rescript-lang/docs build", "ci:format": "oxfmt --check", + "ci:homepage-performance": "yarn workspace @rescript-lang/docs ci:homepage-performance", "ci:test": "yarn workspace @rescript-lang/docs ci:test && yarn workspace @rescript-lang/guide ci:test", + "ci:test:e2e": "yarn workspace @rescript-lang/docs ci:test:e2e", "ci:test:visual": "yarn workspace @rescript-lang/docs ci:test:visual", "clean:res": "rescript clean", "convert-images": "yarn workspace @rescript-lang/docs convert-images", @@ -33,6 +35,7 @@ "preview": "yarn workspace @rescript-lang/docs preview", "reanalyze": "yarn workspace @rescript-lang/docs reanalyze", "test": "yarn workspace @rescript-lang/docs test", + "test:e2e": "yarn workspace @rescript-lang/docs test:e2e", "cy:open": "yarn workspace @rescript-lang/docs cy:open", "cy:run": "yarn workspace @rescript-lang/docs cy:run", "cy:e2e": "yarn workspace @rescript-lang/docs cy:e2e", From 65f4ea6306ad8b99e9585edb07f800854bd5fcba Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Fri, 18 Sep 2026 07:44:23 -0400 Subject: [PATCH 2/8] test: refresh homepage guardrail toolchain Align the Playwright container and deterministic homepage budgets with the React Router, Vite, and Playwright versions now on master. --- .github/workflows/pull-request.yml | 2 +- apps/docs/scripts/homepage-performance-budget.json | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index ef424c2ce..0d53927a8 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -123,7 +123,7 @@ jobs: runs-on: ubuntu-latest container: # Keep this image tag aligned with package.json's playwright version. - image: mcr.microsoft.com/playwright:v1.59.1-noble + image: mcr.microsoft.com/playwright:v1.63.0-noble env: PLAYWRIGHT_BROWSERS_PATH: /ms-playwright VITE_ALGOLIA_APP_ID: test-app-id diff --git a/apps/docs/scripts/homepage-performance-budget.json b/apps/docs/scripts/homepage-performance-budget.json index dcc114624..118e4d474 100644 --- a/apps/docs/scripts/homepage-performance-budget.json +++ b/apps/docs/scripts/homepage-performance-budget.json @@ -1,12 +1,12 @@ { "javascript": { - "requests": 22, - "rawBytes": 1402061, - "gzipBytes": 274525 + "requests": 21, + "rawBytes": 1410064, + "gzipBytes": 284515 }, "css": { "requests": 3, - "rawBytes": 80214, + "rawBytes": 80247, "gzipBytes": 14725 }, "bodyElements": 418, From 7622a05ca031db336140a723116d7e45baad0cab Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Fri, 18 Sep 2026 07:55:03 -0400 Subject: [PATCH 3/8] ci: build homepage tests on runner Run the production prerender build on the standard Ubuntu runner and install Chromium explicitly, avoiding the React Router temporary-server failure inside the Playwright job container. --- .github/workflows/pull-request.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 0d53927a8..6e6976dcd 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -121,11 +121,7 @@ jobs: run: yarn workspace @rescript-lang/guide ci:test Homepage_E2E: runs-on: ubuntu-latest - container: - # Keep this image tag aligned with package.json's playwright version. - image: mcr.microsoft.com/playwright:v1.63.0-noble env: - PLAYWRIGHT_BROWSERS_PATH: /ms-playwright VITE_ALGOLIA_APP_ID: test-app-id VITE_ALGOLIA_INDEX_NAME: test-index VITE_ALGOLIA_SEARCH_API_KEY: test-search-key @@ -143,6 +139,8 @@ jobs: cache: yarn - name: Install dependencies run: yarn + - name: Install Playwright Chromium + run: yarn workspace @rescript-lang/docs playwright install --with-deps chromium - name: Build docs run: yarn workspace @rescript-lang/docs build - name: Check homepage performance budgets From 55cfb26bbcb5d9b521797001779add04eec2869b Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Fri, 18 Sep 2026 09:04:30 -0400 Subject: [PATCH 4/8] ci: audit deployed homepage with Lighthouse Run Lighthouse three times against the exact Cloudflare Pages deployment URL and enforce conservative category and Core Web Vitals baselines. Upload the generated reports as workflow artifacts for inspection. --- .github/workflows/deploy.yml | 8 +++++++ .gitignore | 3 +++ apps/docs/lighthouserc.json | 43 ++++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 apps/docs/lighthouserc.json diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0d5e4c6cb..c79269c5a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -69,6 +69,14 @@ jobs: wranglerVersion: 4.130.0 env: FORCE_COLOR: 0 + - name: Audit homepage with Lighthouse + if: ${{ github.actor != 'dependabot[bot]' }} + uses: treosh/lighthouse-ci-action@v12 + with: + urls: ${{ steps.deploy.outputs.deployment-url }} + configPath: ./apps/docs/lighthouserc.json + uploadArtifacts: true + artifactName: homepage-lighthouse-reports - name: Comment with docs preview link if: ${{ github.event_name == 'pull_request' && steps.deploy.outcome == 'success' }} uses: marocchino/sticky-pull-request-comment@v2 diff --git a/.gitignore b/.gitignore index 518b46f76..b0a2dd55a 100644 --- a/.gitignore +++ b/.gitignore @@ -115,3 +115,6 @@ apps/guide/.vitest-attachments # Playwright artifacts apps/docs/playwright-report/ apps/docs/test-results/ + +# Lighthouse artifacts +.lighthouseci/ diff --git a/apps/docs/lighthouserc.json b/apps/docs/lighthouserc.json new file mode 100644 index 000000000..d299bb880 --- /dev/null +++ b/apps/docs/lighthouserc.json @@ -0,0 +1,43 @@ +{ + "ci": { + "collect": { + "numberOfRuns": 3, + "settings": { + "chromeFlags": "--no-sandbox --disable-dev-shm-usage", + "maxWaitForLoad": 45000 + } + }, + "assert": { + "assertions": { + "categories:performance": [ + "error", + { "minScore": 0.75, "aggregationMethod": "optimistic" } + ], + "categories:accessibility": [ + "error", + { "minScore": 0.7, "aggregationMethod": "optimistic" } + ], + "categories:best-practices": [ + "error", + { "minScore": 0.95, "aggregationMethod": "optimistic" } + ], + "categories:seo": [ + "error", + { "minScore": 0.5, "aggregationMethod": "optimistic" } + ], + "largest-contentful-paint": [ + "error", + { "maxNumericValue": 4500, "aggregationMethod": "optimistic" } + ], + "total-blocking-time": [ + "error", + { "maxNumericValue": 300, "aggregationMethod": "optimistic" } + ], + "cumulative-layout-shift": [ + "error", + { "maxNumericValue": 0.1, "aggregationMethod": "optimistic" } + ] + } + } + } +} From 3997ab613895020de752b714bd4e55ae1040581a Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Fri, 18 Sep 2026 09:33:44 -0400 Subject: [PATCH 5/8] ci: compare Lighthouse scores across commits Persist a branch-scoped Lighthouse baseline with the full reports, restore it on the next deployment, and publish a sticky PR comparison comment with a direct artifact link. Cancel stale branch deploys and remove superseded artifacts so each branch retains one current baseline. --- .github/workflows/deploy.yml | 77 +++++- apps/docs/package.json | 2 +- .../__tests__/lighthouse-report.test.mjs | 125 +++++++++ apps/docs/scripts/lighthouse-report.mjs | 239 ++++++++++++++++++ 4 files changed, 440 insertions(+), 3 deletions(-) create mode 100644 apps/docs/scripts/__tests__/lighthouse-report.test.mjs create mode 100644 apps/docs/scripts/lighthouse-report.mjs diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c79269c5a..13baa7d73 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -16,7 +16,11 @@ jobs: runs-on: ubuntu-latest name: Deploy if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false }} + concurrency: + group: docs-deploy-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true permissions: + actions: write contents: read deployments: write pull-requests: write @@ -39,6 +43,11 @@ jobs: shell: bash run: | RAW_BRANCH="${{ github.head_ref || github.ref_name }}" + ARTIFACT_BRANCH=$(echo "$RAW_BRANCH" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/^-+//; s/-+$//; s/-+/-/g') + ARTIFACT_BRANCH="${ARTIFACT_BRANCH:0:80}" + + echo "LIGHTHOUSE_ARTIFACT_NAME=homepage-lighthouse-${ARTIFACT_BRANCH}" >> "$GITHUB_ENV" + echo "LIGHTHOUSE_BRANCH=$RAW_BRANCH" >> "$GITHUB_ENV" if [[ "$RAW_BRANCH" == "master" ]]; then echo "VITE_DEPLOYMENT_URL=" >> "$GITHUB_ENV" @@ -69,14 +78,78 @@ jobs: wranglerVersion: 4.130.0 env: FORCE_COLOR: 0 + - name: Restore previous Lighthouse baseline + if: ${{ github.actor != 'dependabot[bot]' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + mkdir -p .lighthouse-baseline + + ARTIFACT_ID=$(gh api --method GET \ + "/repos/${GITHUB_REPOSITORY}/actions/artifacts" \ + -f name="$LIGHTHOUSE_ARTIFACT_NAME" \ + -f per_page=100 \ + --jq '.artifacts | map(select(.expired == false)) | sort_by(.created_at) | last | .id // empty') + + if [[ -z "$ARTIFACT_ID" ]]; then + echo "No previous Lighthouse baseline found for $LIGHTHOUSE_BRANCH" + exit 0 + fi + + gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" > "$RUNNER_TEMP/lighthouse-baseline.zip" + unzip -q "$RUNNER_TEMP/lighthouse-baseline.zip" -d .lighthouse-baseline - name: Audit homepage with Lighthouse if: ${{ github.actor != 'dependabot[bot]' }} uses: treosh/lighthouse-ci-action@v12 with: urls: ${{ steps.deploy.outputs.deployment-url }} configPath: ./apps/docs/lighthouserc.json - uploadArtifacts: true - artifactName: homepage-lighthouse-reports + - name: Create Lighthouse baseline + if: ${{ github.actor != 'dependabot[bot]' }} + run: node apps/docs/scripts/lighthouse-report.mjs baseline + env: + LIGHTHOUSE_URL: ${{ steps.deploy.outputs.deployment-url }} + - name: Upload Lighthouse reports + if: ${{ github.actor != 'dependabot[bot]' }} + id: lighthouse-artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ env.LIGHTHOUSE_ARTIFACT_NAME }} + path: .lighthouseci/ + if-no-files-found: error + include-hidden-files: true + overwrite: true + retention-days: 90 + - name: Summarize Lighthouse baseline + if: ${{ github.actor != 'dependabot[bot]' }} + run: | + node apps/docs/scripts/lighthouse-report.mjs comment + cat .lighthouseci/comment.md >> "$GITHUB_STEP_SUMMARY" + env: + LIGHTHOUSE_ARTIFACT_URL: ${{ steps.lighthouse-artifact.outputs.artifact-url }} + - name: Comment PR with Lighthouse baseline + if: ${{ github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' }} + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: lighthouse-baseline + path: .lighthouseci/comment.md + - name: Remove superseded Lighthouse artifacts + if: ${{ github.actor != 'dependabot[bot]' }} + env: + CURRENT_ARTIFACT_ID: ${{ steps.lighthouse-artifact.outputs.artifact-id }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + gh api --method GET \ + "/repos/${GITHUB_REPOSITORY}/actions/artifacts" \ + -f name="$LIGHTHOUSE_ARTIFACT_NAME" \ + -f per_page=100 \ + --jq '.artifacts[].id' | while read -r ARTIFACT_ID; do + if [[ "$ARTIFACT_ID" != "$CURRENT_ARTIFACT_ID" ]]; then + gh api --method DELETE "/repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}" + fi + done - name: Comment with docs preview link if: ${{ github.event_name == 'pull_request' && steps.deploy.outcome == 'success' }} uses: marocchino/sticky-pull-request-comment@v2 diff --git a/apps/docs/package.json b/apps/docs/package.json index 50ba8f52c..24a9c5e4a 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -16,7 +16,7 @@ "check:algolia-public-env": "node _scripts/LogAlgoliaEnvStatus.mjs", "build": "yarn build:res && yarn build:scripts && yarn check:algolia-public-env && yarn build:update-index && yarn build:vite && yarn build:generate-sitemap", "ci:format": "cd ../.. && oxfmt --check", - "ci:homepage-performance": "node --test scripts/__tests__/homepage-performance.test.mjs && node scripts/homepage-performance.mjs", + "ci:homepage-performance": "node --test scripts/__tests__/homepage-performance.test.mjs scripts/__tests__/lighthouse-report.test.mjs && node scripts/homepage-performance.mjs", "ci:test": "vitest --run --browser.headless", "ci:test:e2e": "playwright test", "ci:test:visual": "VISUAL_TESTS=1 vitest --run --browser.headless", diff --git a/apps/docs/scripts/__tests__/lighthouse-report.test.mjs b/apps/docs/scripts/__tests__/lighthouse-report.test.mjs new file mode 100644 index 000000000..e000eecc6 --- /dev/null +++ b/apps/docs/scripts/__tests__/lighthouse-report.test.mjs @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + createBaseline, + formatComment, + median, +} from "../lighthouse-report.mjs"; + +function report({ performance, accessibility, bestPractices, seo, fetchTime }) { + return { + fetchTime, + categories: { + performance: { score: performance }, + accessibility: { score: accessibility }, + "best-practices": { score: bestPractices }, + seo: { score: seo }, + }, + }; +} + +const reports = [ + report({ + performance: 0.79, + accessibility: 0.73, + bestPractices: 1, + seo: 0.5, + fetchTime: "2026-09-18T13:01:00.000Z", + }), + report({ + performance: 0.88, + accessibility: 0.74, + bestPractices: 0.96, + seo: 0.51, + fetchTime: "2026-09-18T13:02:00.000Z", + }), + report({ + performance: 0.8, + accessibility: 0.72, + bestPractices: 0.98, + seo: 0.49, + fetchTime: "2026-09-18T13:03:00.000Z", + }), +]; + +test("median handles odd and even collections without changing the input", () => { + const values = [3, 1, 2]; + + assert.equal(median(values), 2); + assert.equal(median([4, 1, 3, 2]), 2.5); + assert.deepEqual(values, [3, 1, 2]); +}); + +test("createBaseline records median Lighthouse scores", () => { + assert.deepEqual( + createBaseline({ + reports, + branch: "perf/homepage", + commit: "1234567890abcdef", + url: "https://1234.rescript-lang.pages.dev", + }), + { + schemaVersion: 1, + branch: "perf/homepage", + commit: "1234567890abcdef", + url: "https://1234.rescript-lang.pages.dev", + collectedAt: "2026-09-18T13:03:00.000Z", + runs: 3, + scores: { + performance: 80, + accessibility: 73, + bestPractices: 98, + seo: 50, + }, + }, + ); +}); + +test("formatComment compares scores and links the full artifact", () => { + const current = createBaseline({ + reports, + branch: "perf/homepage", + commit: "1234567890abcdef", + url: "https://1234.rescript-lang.pages.dev", + }); + const previous = { + ...current, + commit: "abcdef1234567890", + scores: { + performance: 78, + accessibility: 74, + bestPractices: 98, + seo: 50, + }, + }; + const comment = formatComment({ + current, + previous, + artifactUrl: "https://github.com/example/actions/runs/1/artifacts/2", + }); + + assert.match(comment, /Compared with commit `abcdef1`/); + assert.match(comment, /\| Performance \| 78 \| \*\*80\*\* \| \+2 \|/); + assert.match(comment, /\| Accessibility \| 74 \| \*\*73\*\* \| -1 \|/); + assert.match( + comment, + /\[Download the full Lighthouse reports and baseline\]\(https:\/\/github\.com\/example\/actions\/runs\/1\/artifacts\/2\)/, + ); +}); + +test("formatComment identifies the first branch baseline", () => { + const current = createBaseline({ + reports, + branch: "perf/homepage", + commit: "1234567890abcdef", + url: "https://1234.rescript-lang.pages.dev", + }); + const comment = formatComment({ + current, + previous: undefined, + artifactUrl: "https://github.com/example/actions/runs/1/artifacts/2", + }); + + assert.match(comment, /No previous baseline was available/); + assert.match(comment, /\| Performance \| - \| \*\*80\*\* \| - \|/); +}); diff --git a/apps/docs/scripts/lighthouse-report.mjs b/apps/docs/scripts/lighthouse-report.mjs new file mode 100644 index 000000000..be60fdc27 --- /dev/null +++ b/apps/docs/scripts/lighthouse-report.mjs @@ -0,0 +1,239 @@ +import { readFile, readdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const scoreDefinitions = [ + ["performance", "Performance"], + ["accessibility", "Accessibility"], + ["bestPractices", "Best practices"], + ["seo", "SEO"], +]; + +const reportCategoryKeys = { + performance: "performance", + accessibility: "accessibility", + bestPractices: "best-practices", + seo: "seo", +}; + +function getFiniteNumber(value, description) { + if (!Number.isFinite(value)) { + throw new Error(`Lighthouse report is missing ${description}`); + } + + return value; +} + +export function median(values) { + if (values.length === 0) { + throw new Error("Cannot calculate a median without values"); + } + + const sorted = [...values].sort((left, right) => left - right); + const middle = Math.floor(sorted.length / 2); + + return sorted.length % 2 === 0 + ? (sorted[middle - 1] + sorted[middle]) / 2 + : sorted[middle]; +} + +function medianScore(reports, key) { + return Math.round( + median( + reports.map((report) => + getFiniteNumber( + report.categories?.[reportCategoryKeys[key]]?.score, + `${key} category score`, + ), + ), + ) * 100, + ); +} + +function latestFetchTime(reports) { + const fetchTimes = reports + .map((report) => report.fetchTime) + .filter((fetchTime) => typeof fetchTime === "string") + .sort(); + + if (fetchTimes.length !== reports.length) { + throw new Error("Lighthouse report is missing its fetch time"); + } + + return fetchTimes.at(-1); +} + +export function createBaseline({ reports, branch, commit, url }) { + if (reports.length === 0) { + throw new Error("No Lighthouse reports were found"); + } + + return { + schemaVersion: 1, + branch, + commit, + url, + collectedAt: latestFetchTime(reports), + runs: reports.length, + scores: Object.fromEntries( + scoreDefinitions.map(([key]) => [key, medianScore(reports, key)]), + ), + }; +} + +function formatDelta(previous, current) { + const delta = current - previous; + return delta > 0 ? `+${delta}` : String(delta); +} + +function shortCommit(commit) { + return commit.slice(0, 7); +} + +export function formatComment({ current, previous, artifactUrl }) { + const comparison = previous + ? `Compared with commit \`${shortCommit(previous.commit)}\` on this branch.` + : "No previous baseline was available for this branch."; + const scoreRows = scoreDefinitions.map(([key, label]) => { + const previousScore = previous?.scores[key]; + return `| ${label} | ${previousScore ?? "-"} | **${current.scores[key]}** | ${ + previousScore === undefined + ? "-" + : formatDelta(previousScore, current.scores[key]) + } |`; + }); + + return [ + "## Lighthouse baseline", + "", + `${comparison} Scores are the median of ${current.runs} runs against the deployed Cloudflare preview.`, + "", + "| Category | Previous | Current | Change |", + "| --- | ---: | ---: | ---: |", + ...scoreRows, + "", + `[Download the full Lighthouse reports and baseline](${artifactUrl})`, + "", + `Commit \`${shortCommit(current.commit)}\` ยท [Cloudflare preview](${current.url})`, + "", + ].join("\n"); +} + +async function readReports(reportDirectory) { + const names = (await readdir(reportDirectory)) + .filter((name) => /^lhr-.*\.json$/.test(name)) + .sort(); + + return Promise.all( + names.map(async (name) => + JSON.parse(await readFile(path.join(reportDirectory, name), "utf8")), + ), + ); +} + +async function readOptionalJson(filePath) { + try { + return JSON.parse(await readFile(filePath, "utf8")); + } catch (error) { + if (error?.code === "ENOENT") { + return undefined; + } + + throw error; + } +} + +function requiredEnvironment(name) { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required environment variable ${name}`); + } + + return value; +} + +async function writeBaseline() { + const reportDirectory = + process.env.LIGHTHOUSE_REPORT_DIRECTORY ?? ".lighthouseci"; + const baselinePath = + process.env.LIGHTHOUSE_BASELINE_PATH ?? + path.join(reportDirectory, "baseline.json"); + const previousBaselinePath = + process.env.LIGHTHOUSE_PREVIOUS_BASELINE_PATH ?? + path.join(".lighthouse-baseline", "baseline.json"); + const previousSnapshotPath = path.join( + reportDirectory, + "previous-baseline.json", + ); + const [reports, previous] = await Promise.all([ + readReports(reportDirectory), + readOptionalJson(previousBaselinePath), + ]); + const baseline = createBaseline({ + reports, + branch: requiredEnvironment("LIGHTHOUSE_BRANCH"), + commit: requiredEnvironment("GITHUB_SHA"), + url: requiredEnvironment("LIGHTHOUSE_URL"), + }); + + await writeFile(baselinePath, `${JSON.stringify(baseline, null, 2)}\n`); + if (previous) { + await writeFile( + previousSnapshotPath, + `${JSON.stringify(previous, null, 2)}\n`, + ); + } +} + +async function writeComment() { + const reportDirectory = + process.env.LIGHTHOUSE_REPORT_DIRECTORY ?? ".lighthouseci"; + const baselinePath = + process.env.LIGHTHOUSE_BASELINE_PATH ?? + path.join(reportDirectory, "baseline.json"); + const previousBaselinePath = + process.env.LIGHTHOUSE_PREVIOUS_BASELINE_PATH ?? + path.join(".lighthouse-baseline", "baseline.json"); + const commentPath = + process.env.LIGHTHOUSE_COMMENT_PATH ?? + path.join(reportDirectory, "comment.md"); + const [current, previous] = await Promise.all([ + readOptionalJson(baselinePath), + readOptionalJson(previousBaselinePath), + ]); + + if (!current) { + throw new Error(`Current Lighthouse baseline not found at ${baselinePath}`); + } + + await writeFile( + commentPath, + formatComment({ + current, + previous, + artifactUrl: requiredEnvironment("LIGHTHOUSE_ARTIFACT_URL"), + }), + ); +} + +async function main(command) { + if (command === "baseline") { + await writeBaseline(); + return; + } + + if (command === "comment") { + await writeComment(); + return; + } + + throw new Error(`Unknown Lighthouse report command: ${command ?? ""}`); +} + +const entryPath = process.argv[1] + ? pathToFileURL(path.resolve(process.argv[1])).href + : ""; + +if (import.meta.url === entryPath) { + await main(process.argv[2]); +} From be68ac8c1a54619c494a6244942b5242063c3f00 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Fri, 18 Sep 2026 09:51:13 -0400 Subject: [PATCH 6/8] ci: defer Lighthouse score thresholds Continue collecting and comparing Lighthouse results without failing the performance stack while the homepage optimizations are still in progress. --- apps/docs/lighthouserc.json | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/apps/docs/lighthouserc.json b/apps/docs/lighthouserc.json index d299bb880..ec2c98a83 100644 --- a/apps/docs/lighthouserc.json +++ b/apps/docs/lighthouserc.json @@ -6,38 +6,6 @@ "chromeFlags": "--no-sandbox --disable-dev-shm-usage", "maxWaitForLoad": 45000 } - }, - "assert": { - "assertions": { - "categories:performance": [ - "error", - { "minScore": 0.75, "aggregationMethod": "optimistic" } - ], - "categories:accessibility": [ - "error", - { "minScore": 0.7, "aggregationMethod": "optimistic" } - ], - "categories:best-practices": [ - "error", - { "minScore": 0.95, "aggregationMethod": "optimistic" } - ], - "categories:seo": [ - "error", - { "minScore": 0.5, "aggregationMethod": "optimistic" } - ], - "largest-contentful-paint": [ - "error", - { "maxNumericValue": 4500, "aggregationMethod": "optimistic" } - ], - "total-blocking-time": [ - "error", - { "maxNumericValue": 300, "aggregationMethod": "optimistic" } - ], - "cumulative-layout-shift": [ - "error", - { "maxNumericValue": 0.1, "aggregationMethod": "optimistic" } - ] - } } } } From 44b062fef40251df43f9c23e63224bdd308286a7 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Fri, 18 Sep 2026 10:10:04 -0400 Subject: [PATCH 7/8] fix(ci): compare Lighthouse against target branch Restore the Lighthouse artifact for each pull request's base branch instead of the previous artifact from its head branch. Report missing target baselines as N/A so the first stack layer can establish the initial baseline without implying a zero score. --- .github/workflows/deploy.yml | 17 ++++-- .../__tests__/lighthouse-report.test.mjs | 24 +++++--- apps/docs/scripts/lighthouse-report.mjs | 59 +++++++++---------- 3 files changed, 55 insertions(+), 45 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 13baa7d73..09f81464f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -43,11 +43,16 @@ jobs: shell: bash run: | RAW_BRANCH="${{ github.head_ref || github.ref_name }}" + RAW_TARGET_BRANCH="${{ github.base_ref || github.head_ref || github.ref_name }}" ARTIFACT_BRANCH=$(echo "$RAW_BRANCH" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/^-+//; s/-+$//; s/-+/-/g') ARTIFACT_BRANCH="${ARTIFACT_BRANCH:0:80}" + TARGET_ARTIFACT_BRANCH=$(echo "$RAW_TARGET_BRANCH" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/^-+//; s/-+$//; s/-+/-/g') + TARGET_ARTIFACT_BRANCH="${TARGET_ARTIFACT_BRANCH:0:80}" echo "LIGHTHOUSE_ARTIFACT_NAME=homepage-lighthouse-${ARTIFACT_BRANCH}" >> "$GITHUB_ENV" echo "LIGHTHOUSE_BRANCH=$RAW_BRANCH" >> "$GITHUB_ENV" + echo "LIGHTHOUSE_TARGET_ARTIFACT_NAME=homepage-lighthouse-${TARGET_ARTIFACT_BRANCH}" >> "$GITHUB_ENV" + echo "LIGHTHOUSE_TARGET_BRANCH=$RAW_TARGET_BRANCH" >> "$GITHUB_ENV" if [[ "$RAW_BRANCH" == "master" ]]; then echo "VITE_DEPLOYMENT_URL=" >> "$GITHUB_ENV" @@ -78,27 +83,27 @@ jobs: wranglerVersion: 4.130.0 env: FORCE_COLOR: 0 - - name: Restore previous Lighthouse baseline + - name: Restore target branch Lighthouse baseline if: ${{ github.actor != 'dependabot[bot]' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash run: | - mkdir -p .lighthouse-baseline + mkdir -p .lighthouse-target ARTIFACT_ID=$(gh api --method GET \ "/repos/${GITHUB_REPOSITORY}/actions/artifacts" \ - -f name="$LIGHTHOUSE_ARTIFACT_NAME" \ + -f name="$LIGHTHOUSE_TARGET_ARTIFACT_NAME" \ -f per_page=100 \ --jq '.artifacts | map(select(.expired == false)) | sort_by(.created_at) | last | .id // empty') if [[ -z "$ARTIFACT_ID" ]]; then - echo "No previous Lighthouse baseline found for $LIGHTHOUSE_BRANCH" + echo "No Lighthouse baseline found for target branch $LIGHTHOUSE_TARGET_BRANCH" exit 0 fi - gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" > "$RUNNER_TEMP/lighthouse-baseline.zip" - unzip -q "$RUNNER_TEMP/lighthouse-baseline.zip" -d .lighthouse-baseline + gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" > "$RUNNER_TEMP/lighthouse-target.zip" + unzip -q "$RUNNER_TEMP/lighthouse-target.zip" -d .lighthouse-target - name: Audit homepage with Lighthouse if: ${{ github.actor != 'dependabot[bot]' }} uses: treosh/lighthouse-ci-action@v12 diff --git a/apps/docs/scripts/__tests__/lighthouse-report.test.mjs b/apps/docs/scripts/__tests__/lighthouse-report.test.mjs index e000eecc6..be955112c 100644 --- a/apps/docs/scripts/__tests__/lighthouse-report.test.mjs +++ b/apps/docs/scripts/__tests__/lighthouse-report.test.mjs @@ -82,8 +82,9 @@ test("formatComment compares scores and links the full artifact", () => { commit: "1234567890abcdef", url: "https://1234.rescript-lang.pages.dev", }); - const previous = { + const target = { ...current, + branch: "test/homepage-performance-guardrails", commit: "abcdef1234567890", scores: { performance: 78, @@ -94,11 +95,16 @@ test("formatComment compares scores and links the full artifact", () => { }; const comment = formatComment({ current, - previous, + target, + targetBranch: "test/homepage-performance-guardrails", artifactUrl: "https://github.com/example/actions/runs/1/artifacts/2", }); - assert.match(comment, /Compared with commit `abcdef1`/); + assert.match( + comment, + /Compared with target branch `test\/homepage-performance-guardrails` at commit `abcdef1`/, + ); + assert.match(comment, /\| Category \| Target \| Current \| Change \|/); assert.match(comment, /\| Performance \| 78 \| \*\*80\*\* \| \+2 \|/); assert.match(comment, /\| Accessibility \| 74 \| \*\*73\*\* \| -1 \|/); assert.match( @@ -107,7 +113,7 @@ test("formatComment compares scores and links the full artifact", () => { ); }); -test("formatComment identifies the first branch baseline", () => { +test("formatComment identifies a missing target branch baseline", () => { const current = createBaseline({ reports, branch: "perf/homepage", @@ -116,10 +122,14 @@ test("formatComment identifies the first branch baseline", () => { }); const comment = formatComment({ current, - previous: undefined, + target: undefined, + targetBranch: "master", artifactUrl: "https://github.com/example/actions/runs/1/artifacts/2", }); - assert.match(comment, /No previous baseline was available/); - assert.match(comment, /\| Performance \| - \| \*\*80\*\* \| - \|/); + assert.match( + comment, + /No Lighthouse baseline is available for target branch `master`/, + ); + assert.match(comment, /\| Performance \| N\/A \| \*\*80\*\* \| N\/A \|/); }); diff --git a/apps/docs/scripts/lighthouse-report.mjs b/apps/docs/scripts/lighthouse-report.mjs index be60fdc27..6a04ca2ce 100644 --- a/apps/docs/scripts/lighthouse-report.mjs +++ b/apps/docs/scripts/lighthouse-report.mjs @@ -81,8 +81,8 @@ export function createBaseline({ reports, branch, commit, url }) { }; } -function formatDelta(previous, current) { - const delta = current - previous; +function formatDelta(target, current) { + const delta = current - target; return delta > 0 ? `+${delta}` : String(delta); } @@ -90,16 +90,16 @@ function shortCommit(commit) { return commit.slice(0, 7); } -export function formatComment({ current, previous, artifactUrl }) { - const comparison = previous - ? `Compared with commit \`${shortCommit(previous.commit)}\` on this branch.` - : "No previous baseline was available for this branch."; +export function formatComment({ current, target, targetBranch, artifactUrl }) { + const comparison = target + ? `Compared with target branch \`${target.branch}\` at commit \`${shortCommit(target.commit)}\`.` + : `No Lighthouse baseline is available for target branch \`${targetBranch}\`.`; const scoreRows = scoreDefinitions.map(([key, label]) => { - const previousScore = previous?.scores[key]; - return `| ${label} | ${previousScore ?? "-"} | **${current.scores[key]}** | ${ - previousScore === undefined - ? "-" - : formatDelta(previousScore, current.scores[key]) + const targetScore = target?.scores[key]; + return `| ${label} | ${targetScore ?? "N/A"} | **${current.scores[key]}** | ${ + targetScore === undefined + ? "N/A" + : formatDelta(targetScore, current.scores[key]) } |`; }); @@ -108,7 +108,7 @@ export function formatComment({ current, previous, artifactUrl }) { "", `${comparison} Scores are the median of ${current.runs} runs against the deployed Cloudflare preview.`, "", - "| Category | Previous | Current | Change |", + "| Category | Target | Current | Change |", "| --- | ---: | ---: | ---: |", ...scoreRows, "", @@ -158,16 +158,13 @@ async function writeBaseline() { const baselinePath = process.env.LIGHTHOUSE_BASELINE_PATH ?? path.join(reportDirectory, "baseline.json"); - const previousBaselinePath = - process.env.LIGHTHOUSE_PREVIOUS_BASELINE_PATH ?? - path.join(".lighthouse-baseline", "baseline.json"); - const previousSnapshotPath = path.join( - reportDirectory, - "previous-baseline.json", - ); - const [reports, previous] = await Promise.all([ + const targetBaselinePath = + process.env.LIGHTHOUSE_TARGET_BASELINE_PATH ?? + path.join(".lighthouse-target", "baseline.json"); + const targetSnapshotPath = path.join(reportDirectory, "target-baseline.json"); + const [reports, target] = await Promise.all([ readReports(reportDirectory), - readOptionalJson(previousBaselinePath), + readOptionalJson(targetBaselinePath), ]); const baseline = createBaseline({ reports, @@ -177,11 +174,8 @@ async function writeBaseline() { }); await writeFile(baselinePath, `${JSON.stringify(baseline, null, 2)}\n`); - if (previous) { - await writeFile( - previousSnapshotPath, - `${JSON.stringify(previous, null, 2)}\n`, - ); + if (target) { + await writeFile(targetSnapshotPath, `${JSON.stringify(target, null, 2)}\n`); } } @@ -191,15 +185,15 @@ async function writeComment() { const baselinePath = process.env.LIGHTHOUSE_BASELINE_PATH ?? path.join(reportDirectory, "baseline.json"); - const previousBaselinePath = - process.env.LIGHTHOUSE_PREVIOUS_BASELINE_PATH ?? - path.join(".lighthouse-baseline", "baseline.json"); + const targetBaselinePath = + process.env.LIGHTHOUSE_TARGET_BASELINE_PATH ?? + path.join(".lighthouse-target", "baseline.json"); const commentPath = process.env.LIGHTHOUSE_COMMENT_PATH ?? path.join(reportDirectory, "comment.md"); - const [current, previous] = await Promise.all([ + const [current, target] = await Promise.all([ readOptionalJson(baselinePath), - readOptionalJson(previousBaselinePath), + readOptionalJson(targetBaselinePath), ]); if (!current) { @@ -210,7 +204,8 @@ async function writeComment() { commentPath, formatComment({ current, - previous, + target, + targetBranch: requiredEnvironment("LIGHTHOUSE_TARGET_BRANCH"), artifactUrl: requiredEnvironment("LIGHTHOUSE_ARTIFACT_URL"), }), ); From c2468abf751c103ad2f7bdde4944cbcb2c1a8328 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Fri, 18 Sep 2026 10:18:40 -0400 Subject: [PATCH 8/8] fix(ci): resolve live pull request target Query the pull request API when selecting the Lighthouse target artifact so stacked pull requests compare against their current base branch even when the workflow event payload is stale. --- .github/workflows/deploy.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 09f81464f..c9755ffe7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -40,10 +40,17 @@ jobs: - name: Sync playground bundles run: yarn build:sync-bundles - name: Set VITE_DEPLOYMENT_URL + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash run: | RAW_BRANCH="${{ github.head_ref || github.ref_name }}" - RAW_TARGET_BRANCH="${{ github.base_ref || github.head_ref || github.ref_name }}" + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + RAW_TARGET_BRANCH=$(gh api "/repos/${GITHUB_REPOSITORY}/pulls/${{ github.event.pull_request.number }}" --jq '.base.ref') + else + RAW_TARGET_BRANCH="$RAW_BRANCH" + fi + ARTIFACT_BRANCH=$(echo "$RAW_BRANCH" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/^-+//; s/-+$//; s/-+/-/g') ARTIFACT_BRANCH="${ARTIFACT_BRANCH:0:80}" TARGET_ARTIFACT_BRANCH=$(echo "$RAW_TARGET_BRANCH" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/^-+//; s/-+$//; s/-+/-/g')