diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0d5e4c6cb..c9755ffe7 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 @@ -36,9 +40,26 @@ 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 }}" + 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') + 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" @@ -69,6 +90,78 @@ jobs: wranglerVersion: 4.130.0 env: FORCE_COLOR: 0 + - name: Restore target branch Lighthouse baseline + if: ${{ github.actor != 'dependabot[bot]' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + mkdir -p .lighthouse-target + + ARTIFACT_ID=$(gh api --method GET \ + "/repos/${GITHUB_REPOSITORY}/actions/artifacts" \ + -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 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-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 + with: + urls: ${{ steps.deploy.outputs.deployment-url }} + configPath: ./apps/docs/lighthouserc.json + - 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/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 248ccb3dc..6e6976dcd 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -119,6 +119,42 @@ 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 + env: + 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: 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 + 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..b0a2dd55a 100644 --- a/.gitignore +++ b/.gitignore @@ -111,3 +111,10 @@ apps/docs/.env.local .vitest-attachments apps/docs/.vitest-attachments apps/guide/.vitest-attachments + +# Playwright artifacts +apps/docs/playwright-report/ +apps/docs/test-results/ + +# Lighthouse artifacts +.lighthouseci/ 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/lighthouserc.json b/apps/docs/lighthouserc.json new file mode 100644 index 000000000..ec2c98a83 --- /dev/null +++ b/apps/docs/lighthouserc.json @@ -0,0 +1,11 @@ +{ + "ci": { + "collect": { + "numberOfRuns": 3, + "settings": { + "chromeFlags": "--no-sandbox --disable-dev-shm-usage", + "maxWaitForLoad": 45000 + } + } + } +} diff --git a/apps/docs/package.json b/apps/docs/package.json index 3baad8db8..24a9c5e4a 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 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", "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/__tests__/lighthouse-report.test.mjs b/apps/docs/scripts/__tests__/lighthouse-report.test.mjs new file mode 100644 index 000000000..be955112c --- /dev/null +++ b/apps/docs/scripts/__tests__/lighthouse-report.test.mjs @@ -0,0 +1,135 @@ +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 target = { + ...current, + branch: "test/homepage-performance-guardrails", + commit: "abcdef1234567890", + scores: { + performance: 78, + accessibility: 74, + bestPractices: 98, + seo: 50, + }, + }; + const comment = formatComment({ + current, + target, + targetBranch: "test/homepage-performance-guardrails", + artifactUrl: "https://github.com/example/actions/runs/1/artifacts/2", + }); + + 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( + comment, + /\[Download the full Lighthouse reports and baseline\]\(https:\/\/github\.com\/example\/actions\/runs\/1\/artifacts\/2\)/, + ); +}); + +test("formatComment identifies a missing target branch baseline", () => { + const current = createBaseline({ + reports, + branch: "perf/homepage", + commit: "1234567890abcdef", + url: "https://1234.rescript-lang.pages.dev", + }); + const comment = formatComment({ + current, + target: undefined, + targetBranch: "master", + artifactUrl: "https://github.com/example/actions/runs/1/artifacts/2", + }); + + 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/homepage-performance-budget.json b/apps/docs/scripts/homepage-performance-budget.json new file mode 100644 index 000000000..118e4d474 --- /dev/null +++ b/apps/docs/scripts/homepage-performance-budget.json @@ -0,0 +1,21 @@ +{ + "javascript": { + "requests": 21, + "rawBytes": 1410064, + "gzipBytes": 284515 + }, + "css": { + "requests": 3, + "rawBytes": 80247, + "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/apps/docs/scripts/lighthouse-report.mjs b/apps/docs/scripts/lighthouse-report.mjs new file mode 100644 index 000000000..6a04ca2ce --- /dev/null +++ b/apps/docs/scripts/lighthouse-report.mjs @@ -0,0 +1,234 @@ +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(target, current) { + const delta = current - target; + return delta > 0 ? `+${delta}` : String(delta); +} + +function shortCommit(commit) { + return commit.slice(0, 7); +} + +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 targetScore = target?.scores[key]; + return `| ${label} | ${targetScore ?? "N/A"} | **${current.scores[key]}** | ${ + targetScore === undefined + ? "N/A" + : formatDelta(targetScore, current.scores[key]) + } |`; + }); + + return [ + "## Lighthouse baseline", + "", + `${comparison} Scores are the median of ${current.runs} runs against the deployed Cloudflare preview.`, + "", + "| Category | Target | 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 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(targetBaselinePath), + ]); + 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 (target) { + await writeFile(targetSnapshotPath, `${JSON.stringify(target, 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 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, target] = await Promise.all([ + readOptionalJson(baselinePath), + readOptionalJson(targetBaselinePath), + ]); + + if (!current) { + throw new Error(`Current Lighthouse baseline not found at ${baselinePath}`); + } + + await writeFile( + commentPath, + formatComment({ + current, + target, + targetBranch: requiredEnvironment("LIGHTHOUSE_TARGET_BRANCH"), + 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]); +} 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",