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 = -
- -
- - +
{"submodules"->React.string}
+ {node.children + ->Array.toSorted((v1, v2) => String.compare(v1.name, v2.name)) + ->Array.filter(child => child.name !== node.name) + ->Array.map(renderNode) + ->React.array} + } } @@ -285,48 +257,8 @@ module DocstringsStylize = { } } -let useIsMobile = () => { - let query = switch Type.Classify.classify(globalThis["window"]) { - | Undefined => "(max-width: 768px)" // Fallback value for SSR - | _ => { - let documentElt = WebAPI.HTMLElement.asElement(document.documentElement) - let computedStyle = window->WebAPI.Window.getComputedStyle(~elt=documentElt) - let mdBreakpoint = - computedStyle->WebAPI.CSSStyleDeclaration.getPropertyValue("--breakpoint-md")->String.trim - `(max-width: ${mdBreakpoint})` - } - } - Hooks.useMediaQuery(query) -} - @react.componentWithProps let make = (props: props) => { - let (_, setScrollLock) = ScrollLockContext.useScrollLock() - let (isSidebarOpen, setSidebarOpen) = React.useState(_ => true) - let isMobile = useIsMobile() - - // Close sidebar and remove scroll lock when not on mobile - React.useEffect(() => { - if !isMobile { - setSidebarOpen(_ => true) - setScrollLock(_ => false) - } - - None - }, [isMobile]) - - let toggleSidebar = () => - setSidebarOpen(prev => { - // Only toggle on mobile devices, - // on desktop the sidebar is always open and shouldn't affect scroll lock - if isMobile && prev { - setScrollLock(_ => !prev) - !prev - } else { - prev - } - }) - let children = { open Markdown switch props { @@ -370,7 +302,7 @@ let make = (props: props) => {