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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
40 changes: 40 additions & 0 deletions apps/docs/__tests__/LandingPageBehavior_.test.res
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,46 @@ test("landing page playground hero renders highlighted code tokens", async () =>
expect(javascriptCodeBlock.innerHTML->String.includes("<span"))->toBe(true)
})

test("landing page renders its critical sections", async () => {
let screen = await render(
<MemoryRouter initialEntries=["/"]>
<LandingPage />
</MemoryRouter>,
)

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(
<MemoryRouter initialEntries=["/"]>
<LandingPage />
</MemoryRouter>,
)

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 () => {
Expand Down
18 changes: 18 additions & 0 deletions apps/docs/e2e-playwright/homepage-prerender.spec.mjs
Original file line number Diff line number Diff line change
@@ -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=[^"]+"/);
});
152 changes: 152 additions & 0 deletions apps/docs/e2e-playwright/homepage.spec.mjs
Original file line number Diff line number Diff line change
@@ -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([]);
});
Loading
Loading