diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index ce4f66a11..bbb0615cc 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -20,6 +20,9 @@ jobs:
contents: read
deployments: write
pull-requests: write
+ outputs:
+ deployment-url: ${{ steps.set-url.outputs.deployment-url }}
+ is-pr: ${{ github.event_name == 'pull_request' }}
steps:
- uses: actions/checkout@v4
- name: Setup yarn
@@ -38,11 +41,12 @@ jobs:
if [[ "$RAW_BRANCH" == "master" ]]; then
echo "VITE_DEPLOYMENT_URL=" >> "$GITHUB_ENV"
+ echo "SAFE_BRANCH=" >> "$GITHUB_ENV"
else
SAFE_BRANCH="${RAW_BRANCH//\//-}"
-
+
SAFE_BRANCH=$(echo "$SAFE_BRANCH" | tr '[:upper:]' '[:lower:]')
-
+
echo "SAFE_BRANCH=$SAFE_BRANCH" >> "$GITHUB_ENV"
echo "VITE_DEPLOYMENT_URL=https://${SAFE_BRANCH}.rescript-lang.pages.dev" >> "$GITHUB_ENV"
fi
@@ -61,6 +65,15 @@ jobs:
wranglerVersion: 4.63.0
env:
FORCE_COLOR: 0
+ - name: Set deployment URL output
+ id: set-url
+ shell: bash
+ run: |
+ if [[ "${{ github.ref_name }}" == "master" ]]; then
+ echo "deployment-url=https://rescript-lang.org" >> "$GITHUB_OUTPUT"
+ else
+ echo "deployment-url=${{ env.VITE_DEPLOYMENT_URL }}" >> "$GITHUB_OUTPUT"
+ fi
- name: Comment PR with deployment link
uses: marocchino/sticky-pull-request-comment@v2
with:
@@ -72,3 +85,79 @@ jobs:
Deployment Environment: ${{ steps.deploy.outputs.pages-environment }}
${{ steps.deploy.outputs.command-output }}
+
+ e2e:
+ name: E2E Tests
+ runs-on: ubuntu-latest
+ needs: [deploy]
+ # Only run E2E on pull requests from non-fork branches; push-to-master runs
+ # are excluded here since production smoke tests are a separate concern.
+ if: ${{ github.event_name == 'pull_request' && needs.deploy.outputs.deployment-url != '' }}
+ permissions:
+ contents: read
+ pull-requests: write
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup node
+ uses: actions/setup-node@v4
+ with:
+ node-version-file: ".node-version"
+ cache: yarn
+
+ - name: Install dependencies
+ run: yarn install
+
+ # The e2e test files are written in ReScript and must be compiled to .jsx
+ # before Playwright can discover and run them.
+ - name: Build ReScript (includes e2e tests)
+ run: yarn build:res
+
+ - name: Install Playwright browsers (Chromium only)
+ run: yarn playwright install chromium --with-deps
+
+ - name: Run Playwright E2E tests
+ run: yarn e2e
+ env:
+ PLAYWRIGHT_BASE_URL: ${{ needs.deploy.outputs.deployment-url }}
+ CI: true
+
+ - name: Run Chromatic visual regression
+ # Always run Chromatic even when Playwright tests fail so that visual
+ # diffs are still captured and surfaced on the PR.
+ if: ${{ always() && needs.deploy.outputs.deployment-url != '' }}
+ run: yarn e2e:chromatic --exit-zero-on-changes
+ env:
+ CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
+ PLAYWRIGHT_BASE_URL: ${{ needs.deploy.outputs.deployment-url }}
+
+ - name: Upload Playwright report
+ if: ${{ !cancelled() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: playwright-report
+ path: playwright-report/
+ retention-days: 30
+
+ - name: Upload test results (traces / screenshots / videos)
+ if: ${{ failure() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: test-results
+ path: test-results/
+ retention-days: 7
+
+ - name: Comment PR with Playwright report link
+ if: ${{ failure() }}
+ uses: marocchino/sticky-pull-request-comment@v2
+ with:
+ recreate: true
+ header: e2e-report
+ message: |
+ ## E2E Test Failures
+
+ One or more Playwright tests failed against the preview deployment.
+
+ **Preview URL:** ${{ needs.deploy.outputs.deployment-url }}
+
+ Download the full HTML report from the [workflow run artifacts](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml
index e6a8c4a08..30934211c 100644
--- a/.github/workflows/pull-request.yml
+++ b/.github/workflows/pull-request.yml
@@ -47,3 +47,8 @@ jobs:
run: yarn playwright install --with-deps
- name: Vitest
run: yarn ci:test
+ - name: Commit and Push changes
+ uses: stefanzweifel/git-auto-commit-action@v5
+ with:
+ commit_message: "chore: update vitest screenshots [skip ci]"
+ file_pattern: "**/__screenshots__/**/*.png"
diff --git a/.gitignore b/.gitignore
index 59dc8d7ad..6fab8c2f2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -50,8 +50,11 @@ functions/**/*.mjs
functions/**/*.jsx
__tests__/**/*.mjs
__tests__/**/*.jsx
+e2e/**/*.mjs
+e2e/**/*.jsx
!_shims.mjs
!_shims.jsx
+!src/bindings/playwright-shim.mjs
# Yarn
.yarn/*
@@ -68,4 +71,12 @@ __tests__/**/*.jsx
_scripts
# Local env files
-.env.local
\ No newline at end of file
+.env.local
+
+# Vitest screenshots
+!__tests__/__screenshots__/**/*
+.vitest-attachments
+
+# Playwright artifacts
+playwright-report/
+test-results/
diff --git a/.prettierignore b/.prettierignore
index 07a2ecfba..8072b2cce 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,3 +1,7 @@
+e2e/**/*.mjs
+e2e/**/*.jsx
+playwright-report/
+test-results/
!_shims.mjs
!public/_redirects
.DS_Store
diff --git a/__tests__/Example.test.res b/__tests__/Example.test.res
deleted file mode 100644
index df140a2db..000000000
--- a/__tests__/Example.test.res
+++ /dev/null
@@ -1,28 +0,0 @@
-open Vitest
-
-module Example = {
- @react.component
- let make = (~handleClick) =>
-
-
-
-}
-
-test("basic assertions", async () => {
- expect("foo")->toBe("foo")
-
- expect(true)->toBe(true)
-})
-
-test("component rendering", async () => {
- let callback = fn()
- let screen = await render()
-
- await element(screen->getByText("testing"))->toBeVisible
-
- let button = await screen->getByRole(#button)
-
- await button->click
-
- expect(callback)->toHaveBeenCalled
-})
diff --git a/__tests__/NavbarPrimary_.test.res b/__tests__/NavbarPrimary_.test.res
new file mode 100644
index 000000000..23c7b50e3
--- /dev/null
+++ b/__tests__/NavbarPrimary_.test.res
@@ -0,0 +1,101 @@
+open ReactRouter
+open Vitest
+
+test("desktop has everything visible", async () => {
+ await viewport(1440, 500)
+
+ let screen = await render(
+
+
+ ,
+ )
+
+ let leftContent = await screen->getByTestId("navbar-primary-left-content")
+
+ await element(await leftContent->getByText("Docs"))->toBeVisible
+ await element(await leftContent->getByText("Playground"))->toBeVisible
+ await element(await leftContent->getByText("Blog"))->toBeVisible
+ await element(await leftContent->getByText("Community"))->toBeVisible
+
+ let rightContent = await screen->getByTestId("navbar-primary-right-content")
+
+ await element(await rightContent->getByLabelText("GitHub"))->toBeVisible
+ await element(await rightContent->getByLabelText("X (formerly Twitter)"))->toBeVisible
+ await element(await rightContent->getByLabelText("Bluesky"))->toBeVisible
+ await element(await rightContent->getByLabelText("Forum"))->toBeVisible
+
+ let navbar = await screen->getByTestId("navbar-primary")
+
+ await element(navbar)->toMatchScreenshot("desktop-navbar-primary")
+})
+
+test("tablet has everything visible", async () => {
+ await viewport(900, 500)
+
+ let screen = await render(
+
+
+ ,
+ )
+
+ let leftContent = await screen->getByTestId("navbar-primary-left-content")
+
+ await element(await leftContent->getByText("Docs"))->toBeVisible
+ await element(await leftContent->getByText("Playground"))->toBeVisible
+ await element(await leftContent->getByText("Blog"))->toBeVisible
+ await element(await leftContent->getByText("Community"))->toBeVisible
+
+ let rightContent = await screen->getByTestId("navbar-primary-right-content")
+
+ await element(await rightContent->getByLabelText("GitHub"))->toBeVisible
+ await element(await rightContent->getByLabelText("X (formerly Twitter)"))->toBeVisible
+ await element(await rightContent->getByLabelText("Bluesky"))->toBeVisible
+ await element(await rightContent->getByLabelText("Forum"))->toBeVisible
+
+ let navbar = await screen->getByTestId("navbar-primary")
+
+ await element(navbar)->toMatchScreenshot("tablet-navbar-primary")
+})
+
+test("phone has some things hidden and a mobile nav that can be toggled", async () => {
+ await viewport(600, 1200)
+
+ let screen = await render(
+
+
+ ,
+ )
+
+ let leftContent = await screen->getByTestId("navbar-primary-left-content")
+
+ await element(await leftContent->getByText("Docs"))->toBeVisible
+ await element(await leftContent->getByText("Playground"))->notToBeVisible
+ await element(await leftContent->getByText("Blog"))->notToBeVisible
+ await element(await leftContent->getByText("Community"))->notToBeVisible
+
+ let rightContent = await screen->getByTestId("navbar-primary-right-content")
+
+ await element(await rightContent->getByLabelText("GitHub"))->notToBeVisible
+ await element(await rightContent->getByLabelText("X (formerly Twitter)"))->notToBeVisible
+ await element(await rightContent->getByLabelText("Bluesky"))->notToBeVisible
+ await element(await rightContent->getByLabelText("Forum"))->notToBeVisible
+
+ let mobileNav = await screen->getByTestId("mobile-nav")
+ await element(mobileNav)->notToBeVisible
+
+ let button = await screen->getByTestId("toggle-mobile-overlay")
+
+ await element(button)->toBeVisible
+
+ await button->click
+
+ let mobileNavAfterOpen = await screen->getByTestId("mobile-nav")
+
+ await element(mobileNavAfterOpen)->toBeVisible
+
+ let navbar = await screen->getByTestId("navbar-primary")
+
+ await element(navbar)->toMatchScreenshot("mobile-navbar-primary")
+
+ await element(mobileNavAfterOpen)->toMatchScreenshot("mobile-overlay-navbar-primary")
+})
diff --git a/__tests__/NavbarTertiary_.test.res b/__tests__/NavbarTertiary_.test.res
new file mode 100644
index 000000000..33f319232
--- /dev/null
+++ b/__tests__/NavbarTertiary_.test.res
@@ -0,0 +1,93 @@
+open ReactRouter
+open Vitest
+
+let sidebarContent =
+
+
+let breadcrumbs =
+ {React.string("Docs / Language Manual / Installation")}
+
+let editLink = {React.string("Edit")}
+
+test("desktop shows breadcrumbs and edit link", async () => {
+ await viewport(1440, 500)
+
+ let screen = await render(
+
+
+ breadcrumbs
+ editLink
+
+ ,
+ )
+
+ let navbar = await screen->getByTestId("navbar-tertiary")
+
+ await element(navbar)->toBeVisible
+
+ let crumbs = await screen->getByTestId("breadcrumbs")
+ await element(crumbs)->toBeVisible
+
+ let edit = await screen->getByTestId("edit-link")
+ await element(edit)->toBeVisible
+
+ await element(navbar)->toMatchScreenshot("desktop-navbar-tertiary")
+})
+
+test("mobile shows breadcrumbs and drawer button", async () => {
+ await viewport(600, 1200)
+
+ let screen = await render(
+
+
+ breadcrumbs
+ editLink
+
+ ,
+ )
+
+ let navbar = await screen->getByTestId("navbar-tertiary")
+ await element(navbar)->toBeVisible
+
+ let crumbs = await screen->getByTestId("breadcrumbs")
+ await element(crumbs)->toBeVisible
+
+ let edit = await screen->getByTestId("edit-link")
+ await element(edit)->toBeVisible
+
+ await element(navbar)->toMatchScreenshot("mobile-navbar-tertiary")
+})
+
+test("mobile drawer can be toggled open", async () => {
+ await viewport(600, 1200)
+
+ let screen = await render(
+
+
+ breadcrumbs
+ editLink
+
+ ,
+ )
+
+ // Sidebar dialog should not be visible initially
+ let sidebar = await screen->getByTestId("sidebar-categories")
+ await element(sidebar)->notToBeVisible
+
+ // Click the drawer toggle button
+ let drawerButton = await screen->getByRole(#button)
+ await drawerButton->click
+
+ // Sidebar content should now be visible
+ let sidebarAfter = await screen->getByTestId("sidebar-categories")
+ await element(sidebarAfter)->toBeVisible
+
+ let versionSelect = await screen->getByTestId("sidebar-version-select")
+ await element(versionSelect)->toBeVisible
+})
diff --git a/__tests__/VersionSelect_.test.res b/__tests__/VersionSelect_.test.res
new file mode 100644
index 000000000..54943bdae
--- /dev/null
+++ b/__tests__/VersionSelect_.test.res
@@ -0,0 +1,80 @@
+open Vitest
+
+test("renders current version label", async () => {
+ let screen = await render()
+
+ let el = await screen->getByTestId("version-select")
+ await element(el)->toBeVisible
+
+ let label = await screen->getByText("v12 (latest)")
+ await element(label)->toBeVisible
+})
+
+test("clicking button shows older versions", async () => {
+ let screen = await render()
+
+ // Menu should be hidden initially
+ let v11 = await screen->getByText("v11")
+ await element(v11)->notToBeVisible
+
+ // Click the trigger button
+ let button = await screen->getByRole(#button)
+ await button->click
+
+ // Older versions should now be visible
+ let v11After = await screen->getByText("v11")
+ await element(v11After)->toBeVisible
+
+ let v9 = await screen->getByText("v9.1 - v10.1")
+ await element(v9)->toBeVisible
+
+ let v8 = await screen->getByText("v8.2 - v9.0")
+ await element(v8)->toBeVisible
+
+ let v6 = await screen->getByText("v6.0 - v8.1")
+ await element(v6)->toBeVisible
+})
+
+test("clicking button again closes older versions", async () => {
+ let screen = await render()
+
+ let button = await screen->getByRole(#button)
+
+ // Open
+ await button->click
+ let v11 = await screen->getByText("v11")
+ await element(v11)->toBeVisible
+
+ // Close
+ await button->click
+ let v11After = await screen->getByText("v11")
+ await element(v11After)->notToBeVisible
+})
+
+test("multiple instances have unique popover IDs", async () => {
+ let screen = await render(
+ ,
+ )
+
+ let first = await screen->getByTestId("first")
+ let second = await screen->getByTestId("second")
+
+ // Click the button in the first instance
+ let firstButton = await first->getByRole(#button)
+ await firstButton->click
+
+ // First instance menu should be visible
+ let firstV11 = await first->getByText("v11")
+ await element(firstV11)->toBeVisible
+
+ // Second instance menu should remain hidden
+ let secondV11 = await second->getByText("v11")
+ await element(secondV11)->notToBeVisible
+})
diff --git a/__tests__/__screenshots__/NavbarPrimary_.test.jsx/desktop-navbar-primary-chromium-linux.png b/__tests__/__screenshots__/NavbarPrimary_.test.jsx/desktop-navbar-primary-chromium-linux.png
new file mode 100644
index 000000000..9f2555de1
Binary files /dev/null and b/__tests__/__screenshots__/NavbarPrimary_.test.jsx/desktop-navbar-primary-chromium-linux.png differ
diff --git a/__tests__/__screenshots__/NavbarPrimary_.test.jsx/mobile-navbar-primary-chromium-linux.png b/__tests__/__screenshots__/NavbarPrimary_.test.jsx/mobile-navbar-primary-chromium-linux.png
new file mode 100644
index 000000000..e6b54e021
Binary files /dev/null and b/__tests__/__screenshots__/NavbarPrimary_.test.jsx/mobile-navbar-primary-chromium-linux.png differ
diff --git a/__tests__/__screenshots__/NavbarPrimary_.test.jsx/mobile-overlay-navbar-primary-chromium-linux.png b/__tests__/__screenshots__/NavbarPrimary_.test.jsx/mobile-overlay-navbar-primary-chromium-linux.png
new file mode 100644
index 000000000..37f28fd3d
Binary files /dev/null and b/__tests__/__screenshots__/NavbarPrimary_.test.jsx/mobile-overlay-navbar-primary-chromium-linux.png differ
diff --git a/__tests__/__screenshots__/NavbarPrimary_.test.jsx/tablet-navbar-primary-chromium-linux.png b/__tests__/__screenshots__/NavbarPrimary_.test.jsx/tablet-navbar-primary-chromium-linux.png
new file mode 100644
index 000000000..16c5d079f
Binary files /dev/null and b/__tests__/__screenshots__/NavbarPrimary_.test.jsx/tablet-navbar-primary-chromium-linux.png differ
diff --git a/__tests__/__screenshots__/NavbarTertiary_.test.jsx/desktop-navbar-tertiary-chromium-linux.png b/__tests__/__screenshots__/NavbarTertiary_.test.jsx/desktop-navbar-tertiary-chromium-linux.png
new file mode 100644
index 000000000..a6be3b920
Binary files /dev/null and b/__tests__/__screenshots__/NavbarTertiary_.test.jsx/desktop-navbar-tertiary-chromium-linux.png differ
diff --git a/__tests__/__screenshots__/NavbarTertiary_.test.jsx/mobile-navbar-tertiary-chromium-linux.png b/__tests__/__screenshots__/NavbarTertiary_.test.jsx/mobile-navbar-tertiary-chromium-linux.png
new file mode 100644
index 000000000..249d18863
Binary files /dev/null and b/__tests__/__screenshots__/NavbarTertiary_.test.jsx/mobile-navbar-tertiary-chromium-linux.png differ
diff --git a/app/root.res b/app/root.res
index 255797d01..cbddcf72d 100644
--- a/app/root.res
+++ b/app/root.res
@@ -38,17 +38,6 @@ open ReactRouter
@react.component
let default = () => {
- let {pathname} = ReactRouter.useLocation()
- let (isOverlayOpen, setOverlayOpen) = React.useState(_ => false)
- let (isScrollLockEnabled, setIsScrollLockEnabled) = React.useState(_ => false)
-
- React.useEffect(() => {
- // When the path changes close the sidebar and disable scroll lock
- setOverlayOpen(_ => false)
- setIsScrollLockEnabled(_ => false)
- None
- }, [pathname])
-
@@ -65,15 +54,11 @@ let default = () => {
/>
-
-
-
-
-
-
-
-
-
+
+
+
+
+
}
diff --git a/app/routes.res b/app/routes.res
index fd039b386..656db708c 100644
--- a/app/routes.res
+++ b/app/routes.res
@@ -28,6 +28,8 @@ let stdlibRoutes =
let beltRoutes =
beltPaths->Array.map(path => route(path, "./routes/ApiRoute.jsx", ~options={id: path}))
+let mdxRoutes = mdxRoutes("./routes/MdxRoute.jsx")
+
let default = [
index("./routes/LandingPageRoute.jsx"),
route("packages", "./routes/PackagesRoute.jsx"),
@@ -42,6 +44,6 @@ let default = [
route("docs/manual/api/dom", "./routes/ApiRoute.jsx", ~options={id: "api-dom"}),
...stdlibRoutes,
...beltRoutes,
- ...mdxRoutes("./routes/MdxRoute.jsx"),
+ ...mdxRoutes,
route("*", "./routes/NotFoundRoute.jsx"),
]
diff --git a/app/routes/ApiRoute.res b/app/routes/ApiRoute.res
index 8a6dc90cf..6ee7c0345 100644
--- a/app/routes/ApiRoute.res
+++ b/app/routes/ApiRoute.res
@@ -160,8 +160,36 @@ let default = () => {
->Array.at(0)
->Option.flatMap(str => String.split(str, ".")[0])
+ let breadcrumbs = {
+ let prefix = {Url.name: "API", href: "/docs/manual/api"}
+ let crumbs = ApiLayout.makeBreadcrumbs(~prefix, pathname)
+ list{{Url.name: "Docs", href: "/docs/manual/api"}, ...crumbs}
+ }
+
+ let sidebarContent = switch loaderData {
+ | Ok({toctree, module_: {items}}) =>
+
+
+
+
+
+
+
+
+
+ | Error(_) => React.null
+ }
+
<>
+
+
+
+
>
}
diff --git a/app/routes/DocsOverview.res b/app/routes/DocsOverview.res
index 946326571..e5df82e57 100644
--- a/app/routes/DocsOverview.res
+++ b/app/routes/DocsOverview.res
@@ -31,7 +31,7 @@ let default = (~showVersionSelect=true) => {
]
-
+
//
{React.string("Docs")}
diff --git a/app/routes/MdxRoute.res b/app/routes/MdxRoute.res
index c3b495b7c..55f1bf4fb 100644
--- a/app/routes/MdxRoute.res
+++ b/app/routes/MdxRoute.res
@@ -291,8 +291,43 @@ let default = () => {
<>
{if (pathname :> string) == "/docs/manual/api" {
+ let breadcrumbs = list{
+ {Url.name: "Docs", href: `/docs/manual/api`},
+ {name: "API", href: `/docs/manual/api`},
+ }
+ let sidebarContent =
+
+
<>
Nullable.getOr("ReScript API")} />
+
+
+
+
{component()}
@@ -304,10 +339,9 @@ let default = () => {
) {
<>
Nullable.getOr("")} />
-
Option.map(crumbs =>
+
+ {
+ let breadcrumbs = loaderData.breadcrumbs->Option.map(crumbs =>
List.mapWithIndex(crumbs, (item, index) => {
if index === 0 {
if (pathname :> string)->String.includes("docs/manual") {
@@ -321,11 +355,64 @@ let default = () => {
item
}
})
- )}
- editHref={`https://github.com/rescript-lang/rescript-lang.org/blob/master${loaderData.filePath->Option.getOrThrow}`}
- >
- {component()}
-
+ )
+ let editHref = `https://github.com/rescript-lang/rescript-lang.org/blob/master${loaderData.filePath->Option.getOrThrow}`
+
+ let sidebarContent =
+
+
+ <>
+
+ {breadcrumbs->Option.mapOr(React.null, crumbs =>
+
+ )}
+
+ {React.string("Edit")}
+
+
+
+ {component()}
+
+ >
+ }
>
} else if (pathname :> string)->String.includes("community") {
@@ -347,7 +434,7 @@ let default = () => {
->Option.getOr("Syntax Lookup | ReScript API")}
description={attributes.description->Nullable.getOr("")}
/>
-
+
{component()}
diff --git a/app/routes/SyntaxLookupRoute.res b/app/routes/SyntaxLookupRoute.res
index ff71bc73a..b2a0e8e25 100644
--- a/app/routes/SyntaxLookupRoute.res
+++ b/app/routes/SyntaxLookupRoute.res
@@ -34,6 +34,7 @@ let default = () => {
let {mdxSources} = useLoaderData()
<>
{React.string("Syntax Lookup | ReScript API")}
+
>
}
diff --git a/e2e/DocsPage.test.res b/e2e/DocsPage.test.res
new file mode 100644
index 000000000..536f443fc
--- /dev/null
+++ b/e2e/DocsPage.test.res
@@ -0,0 +1,69 @@
+open Playwright
+
+describe("Docs Page", () => {
+ test("has the correct page title", async ({page}) => {
+ let _ = await page->goto("/docs")
+ await page->expect->toHaveTitle("ReScript Documentation")
+ })
+
+ test("renders the docs overview heading", async ({page}) => {
+ let _ = await page->goto("/docs")
+ let heading = page->getByRole("heading", ~options={name: "Documentation"})
+ await heading->expect->toBeVisible
+ })
+
+ test("docs navigation sidebar is present on the introduction page", async ({page}) => {
+ let _ = await page->goto("/docs/manual/latest/introduction")
+ let sidebar = page->getByRole("navigation")
+ await sidebar->expect->toBeVisible
+ })
+
+ test("can navigate from the overview to the introduction", async ({page}) => {
+ let _ = await page->goto("/docs")
+ let introLink = page->getByRole("link", ~options={name: "Introduction"})
+ await introLink->first->click
+ await page->expect->toHaveURL("/docs/manual/latest/introduction")
+ })
+
+ test("introduction page renders main content with h1", async ({page}) => {
+ let _ = await page->goto("/docs/manual/latest/introduction")
+ let mainContent = page->locator("main")
+ await mainContent->expect->toBeVisible
+ let heading = page->getByRole("heading", ~options={name: "Introduction", level: 1})
+ await heading->expect->toBeVisible
+ })
+
+ test("syntax lookup page loads with heading", async ({page}) => {
+ let _ = await page->goto("/syntax-lookup")
+ let heading = page->getByRole("heading", ~options={name: "Syntax Lookup"})
+ await heading->expect->toBeVisible
+ })
+
+ test("packages page loads", async ({page}) => {
+ let _ = await page->goto("/packages")
+ let heading = page->getByRole("heading", ~options={name: "Packages"})
+ await heading->expect->toBeVisible
+ })
+
+ test("has no accessibility violations on the docs overview", async ({page}) => {
+ let _ = await page->goto("/docs")
+ await page->assertNoA11yViolations
+ })
+
+ test("has no accessibility violations on the introduction page", async ({page}) => {
+ let _ = await page->goto("/docs/manual/latest/introduction")
+ await page->assertNoA11yViolations
+ })
+
+ test("visual snapshot — docs overview", async ({page}) => {
+ let _ = await page->goto("/docs")
+ await page->waitForLoadState("networkidle")
+ await takeSnapshot(page, "Docs Overview")
+ })
+
+ test("visual snapshot — introduction page", async ({page}) => {
+ let _ = await page->goto("/docs/manual/latest/introduction")
+ await page->waitForLoadState("networkidle")
+ await takeSnapshot(page, "Docs Introduction")
+ })
+})
diff --git a/e2e/LandingPage.test.res b/e2e/LandingPage.test.res
new file mode 100644
index 000000000..c2dd670d8
--- /dev/null
+++ b/e2e/LandingPage.test.res
@@ -0,0 +1,63 @@
+open Playwright
+
+describe("Landing Page", () => {
+ test("has the correct page title", async ({page}) => {
+ let _ = await page->goto("/")
+ await page
+ ->expect
+ ->toHaveTitle(
+ "ReScript - A robustly typed language that compiles to efficient and human-readable JavaScript.",
+ )
+ })
+
+ test("renders the hero section heading", async ({page}) => {
+ let _ = await page->goto("/")
+ let hero =
+ page->getByRole(
+ "heading",
+ ~options={name: "Fast, Simple, Fully Typed JavaScript from the Future"},
+ )
+ await hero->expect->toBeVisible
+ })
+
+ test("primary navigation links are present and visible", async ({page}) => {
+ let _ = await page->goto("/")
+
+ await page->getByRole("link", ~options={name: "Docs"})->expect->toBeVisible
+ await page->getByRole("link", ~options={name: "Playground"})->expect->toBeVisible
+ await page->getByRole("link", ~options={name: "Blog"})->expect->toBeVisible
+ await page->getByRole("link", ~options={name: "Community"})->expect->toBeVisible
+ })
+
+ test("Get Started link navigates to the introduction", async ({page}) => {
+ let _ = await page->goto("/")
+
+ let getStarted = page->getByRole("link", ~options={name: "Get Started"})
+ await getStarted->first->click
+
+ await page->expect->toHaveURL("/docs/manual/latest/introduction")
+ })
+
+ test("GitHub social link is present", async ({page}) => {
+ let _ = await page->goto("/")
+ let githubLink = page->getByRole("link", ~options={name: "GitHub"})
+ await githubLink->expect->toBeVisible
+ })
+
+ test("has no accessibility violations", async ({page}) => {
+ let _ = await page->goto("/")
+ await page->assertNoA11yViolations
+ })
+
+ test("visual snapshot — desktop", async ({page}) => {
+ let _ = await page->goto("/")
+ await page->waitForLoadState("networkidle")
+ await takeSnapshot(page, "Landing Page — Desktop")
+ })
+
+ test("visual snapshot — mobile", async ({page}) => {
+ let _ = await page->goto("/")
+ await page->waitForLoadState("networkidle")
+ await takeSnapshot(page, "Landing Page — Mobile")
+ })
+})
diff --git a/functions/ogimage/[[path]]/index.png.res b/functions/ogimage/[[path]]/index.png.res
index bdc524e7b..0b215994f 100644
--- a/functions/ogimage/[[path]]/index.png.res
+++ b/functions/ogimage/[[path]]/index.png.res
@@ -16,8 +16,6 @@ let loadGoogleFont = async (family: string) => {
type context = {request: FetchAPI.request, params: {path: array}}
let onRequest = async ({params}: context) => {
- Console.log(params.path)
-
let title = params.path[0]->Option.getOr("ReScript")->decodeURIComponent
// let url = WebAPI.URL.make(~url=request.url)
// let title = url.searchParams->URLSearchParams.get("title")
diff --git a/functions/yoga.wasm b/functions/yoga.wasm
new file mode 100644
index 000000000..7da77dea1
Binary files /dev/null and b/functions/yoga.wasm differ
diff --git a/package.json b/package.json
index 39407f269..5f3f5ea29 100644
--- a/package.json
+++ b/package.json
@@ -18,7 +18,10 @@
"build:vite": "react-router build",
"build": "yarn build:res && yarn build:scripts && yarn build:update-index && yarn build:vite",
"ci:format": "prettier . --check --experimental-cli",
- "ci:test": "yarn vitest --run --browser.headless",
+ "ci:test": "yarn vitest --run --browser.headless --update",
+ "e2e": "playwright test",
+ "e2e:chromatic": "chromatic --playwright",
+ "e2e:report": "playwright show-report",
"clean:res": "rescript clean",
"convert-images": "auto-convert-images",
"dev:res": "rescript watch",
@@ -77,10 +80,15 @@
"remark-gfm": "^4.0.1",
"remark-validate-links": "^13.1.0",
"rescript": "^12.0.0",
+ "satori": "^0.19.2",
+ "shiki": "^3.22.0",
"unified": "^11.0.5",
"vfile-matter": "^5.0.0"
},
"devDependencies": {
+ "@axe-core/playwright": "^4.11.1",
+ "@chromatic-com/playwright": "^0.12.8",
+ "@playwright/test": "^1.58.2",
"@prettier/plugin-oxc": "^0.0.4",
"@react-router/dev": "^7.8.1",
"@tailwindcss/vite": "^4.1.13",
diff --git a/playwright.config.mjs b/playwright.config.mjs
new file mode 100644
index 000000000..05e687d68
--- /dev/null
+++ b/playwright.config.mjs
@@ -0,0 +1,116 @@
+import { defineConfig, devices } from "@playwright/test";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+/**
+ * The base URL for all page.goto('/') calls in tests.
+ *
+ * • CI (PR preview): set to the Cloudflare Pages preview URL, e.g.
+ * https://my-feature-branch.rescript-lang.pages.dev
+ * • Local: not set — Playwright starts Wrangler automatically and points
+ * tests at http://localhost:8788.
+ */
+const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:8788";
+
+/**
+ * Whether we should let Playwright manage the local dev server.
+ * We only do this when there is no external URL to point at (i.e. local runs).
+ */
+const useLocalServer = !process.env.PLAYWRIGHT_BASE_URL;
+
+export default defineConfig({
+ /**
+ * Start Wrangler Pages dev server automatically for local runs so that
+ * `yarn e2e` works after `yarn build` with zero extra setup.
+ *
+ * The server is NOT started in CI because `PLAYWRIGHT_BASE_URL` is always
+ * set there to the Cloudflare preview URL.
+ *
+ * `reuseExistingServer` lets you keep a Wrangler process running in another
+ * terminal and have Playwright attach to it rather than spawning a new one.
+ */
+ webServer: useLocalServer
+ ? {
+ command: `yarn wrangler pages dev ${path.join(__dirname, "out")} --port 8788`,
+ url: "http://localhost:8788",
+ reuseExistingServer: true,
+ stdout: "pipe",
+ stderr: "pipe",
+ timeout: 60_000,
+ }
+ : undefined,
+
+ testDir: "./e2e",
+
+ /**
+ * Include compiled ReScript output (.jsx) as well as plain .js / .ts files.
+ * ReScript compiles *.res → *.jsx (in-source), so Playwright must discover
+ * those generated files.
+ */
+ testMatch: "**/*.test.{js,jsx,ts,tsx}",
+
+ /** Run each test file in parallel. */
+ fullyParallel: true,
+
+ /**
+ * Fail the suite immediately when a test.only() call is left in source —
+ * this is enforced only in CI so local debugging is unaffected.
+ */
+ forbidOnly: !!process.env.CI,
+
+ /** Retry flaky tests twice in CI; never locally so failures are obvious. */
+ retries: process.env.CI ? 2 : 0,
+
+ /** Limit parallelism in CI to avoid overwhelming the preview deployment. */
+ workers: process.env.CI ? 2 : undefined,
+
+ reporter: process.env.CI
+ ? [
+ ["github"], // Annotate PR checks with inline failure messages.
+ ["html", { open: "never", outputFolder: "playwright-report" }],
+ ["json", { outputFile: "test-results/results.json" }],
+ ]
+ : [["html", { open: "on-failure" }]],
+
+ use: {
+ baseURL,
+
+ /**
+ * Collect a Playwright trace on the first retry so failures can be
+ * inspected in the Playwright trace viewer without slowing down the
+ * initial run.
+ */
+ trace: "on-first-retry",
+
+ /**
+ * Capture a screenshot automatically on test failure.
+ */
+ screenshot: "only-on-failure",
+
+ /**
+ * Record a video on the first retry alongside the trace.
+ */
+ video: "on-first-retry",
+ },
+
+ projects: [
+ {
+ name: "chromium",
+ use: {
+ ...devices["Desktop Chrome"],
+ viewport: { width: 1440, height: 900 },
+ },
+ },
+ {
+ name: "mobile-chrome",
+ use: {
+ ...devices["Pixel 5"],
+ },
+ },
+ ],
+
+ /** Where Playwright writes screenshots, traces, and videos. */
+ outputDir: "test-results",
+});
diff --git a/rescript.json b/rescript.json
index 9214ceafd..9b50b9b33 100644
--- a/rescript.json
+++ b/rescript.json
@@ -13,6 +13,11 @@
"subdirs": true,
"type": "dev"
},
+ {
+ "dir": "e2e",
+ "subdirs": true,
+ "type": "dev"
+ },
{
"dir": "app",
"subdirs": true
diff --git a/src/ApiDocs.res b/src/ApiDocs.res
index f718abca3..20817bd4f 100644
--- a/src/ApiDocs.res
+++ b/src/ApiDocs.res
@@ -95,7 +95,7 @@ module RightSidebar = {
module SidebarTree = {
@react.component
- let make = (~isOpen: bool, ~toggle: unit => unit, ~node: node, ~items: array- ) => {
+ let make = (~node: node, ~items: array
- ) => {
open ReactRouter
let location = useLocation()
@@ -115,7 +115,7 @@ module SidebarTree = {
| true =>
| false => React.null
@@ -176,54 +176,26 @@ module SidebarTree = {
}
}
- let preludeSection =
-
-
-
-
-