Skip to content
Merged
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
27 changes: 27 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ jobs:
contents: read
deployments: write
pull-requests: write
outputs:
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
steps:
- uses: actions/checkout@v6.0.2
- name: Setup Node.js environment
Expand Down Expand Up @@ -76,3 +78,28 @@ jobs:
Deployment Environment: ${{ steps.deploy.outputs.pages-environment }}

${{ steps.deploy.outputs.command-output }}

e2e:
runs-on: ubuntu-latest
name: E2E Tests
needs: deploy
if: ${{ github.actor != 'dependabot[bot]' }}
steps:
- uses: actions/checkout@v6.0.2
- name: Setup Node.js environment
uses: actions/setup-node@v6.3.0
with:
node-version-file: ".node-version"
cache: "yarn"
- name: Enable Corepack
run: corepack enable
- name: Install dependencies
run: yarn install
- name: Build ReScript
run: yarn build:res
- name: Cypress E2E tests
uses: cypress-io/github-action@v7
with:
install: false
browser: chrome
config: baseUrl=${{ needs.deploy.outputs.deployment-url }}
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ functions/**/*.mjs
functions/**/*.jsx
__tests__/**/*.mjs
__tests__/**/*.jsx
e2e/**/*.mjs
e2e/**/*.jsx
!_shims.mjs
!_shims.jsx

Expand All @@ -72,4 +74,4 @@ _scripts

# Vitest screenshots
!__tests__/__screenshots__/**/*
.vitest-attachments
.vitest-attachments
18 changes: 18 additions & 0 deletions cypress.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { defineConfig } from "cypress";

export default defineConfig({
allowCypressEnv: false,
retries: {
runMode: 2,
openMode: 0,
},
e2e: {
baseUrl: "http://localhost:8080",
specPattern: "e2e/**/*.cy.jsx",
supportFile: "cypress/support/e2e.js",
video: false,
screenshotOnRunFailure: false,
defaultCommandTimeout: 10000,
pageLoadTimeout: 30000,
},
});
23 changes: 23 additions & 0 deletions cypress/support/e2e.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
const knownHydrationErrors = [
/Hydration failed because the initial UI does not match what was rendered on the server\.?/,
/Text content does not match server-rendered HTML\.?/,
/There was an error while hydrating\.?/,
/Minified React error #418\b/,
/Minified React error #423\b/,
/Minified React error #425\b/,
];

Cypress.on("uncaught:exception", (err) => {
const message = err && err.message ? err.message : "";
const isKnownHydrationError = knownHydrationErrors.some((pattern) =>
pattern.test(message),
);

if (isKnownHydrationError) {
console.warn("Suppressing known React hydration exception in Cypress:", {
message,
error: err,
});
return false;
}
});
213 changes: 213 additions & 0 deletions e2e/Navigation_.cy.res
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
open Cy

// Wait for the app to fully hydrate before interacting with links.
// The production build is pre-rendered so React must attach event
// handlers before client-side navigation works.
let waitForHydration = () => {
getByTestId("navbar-primary")->shouldBeVisible->ignore
cyWindow()->its("document.readyState")->shouldWithValue("eq", "complete")->ignore
wait(2000)
}

// Use short, re-queryable selectors to avoid detached DOM issues.
// When React re-renders during navigation, long chains can hold
// references to stale elements. Separate cy.get() calls let Cypress
// re-query from the DOM root on each retry.

let clickNavLink = (~testId, ~text) => {
get(`[data-testid="${testId}"] a:visible`)
->containsChainable(text)
->click
->ignore
}

let clickMobileNavLink = text => {
get(`[data-testid="mobile-nav"] a:visible`)
->containsChainable(text)
->click
->ignore
}

let openMobileMenu = () => {
get(`[data-testid="toggle-mobile-overlay"]`)->should("be.visible")->click->ignore
get("#mobile-overlay")->should("be.visible")->ignore
}

// -- Desktop (1280x720) -------------------------------------------------------

describe("Desktop Navigation", () => {
beforeEach(() => {
viewport(1280, 720)
visit("/")
waitForHydration()
})

describe("Primary navbar", () => {
it(
"should navigate to Docs via navbar link",
() => {
clickNavLink(~testId="navbar-primary-left-content", ~text="Docs")
url()->shouldInclude("/docs/manual/introduction")->ignore
get("h1")->shouldBeVisible->ignore
},
)

it(
"should navigate to Playground via navbar link",
() => {
clickNavLink(~testId="navbar-primary-left-content", ~text="Playground")
url()->shouldInclude("/try")->ignore
},
)

it(
"should navigate to Blog via navbar link",
() => {
clickNavLink(~testId="navbar-primary-left-content", ~text="Blog")
url()->shouldInclude("/blog")->ignore
},
)

it(
"should navigate to Community via navbar link",
() => {
clickNavLink(~testId="navbar-primary-left-content", ~text="Community")
url()->shouldInclude("/community")->ignore
get("h1")->shouldBeVisible->ignore
},
)

it(
"should navigate home via logo after clicking away",
() => {
clickNavLink(~testId="navbar-primary-left-content", ~text="Blog")
url()->shouldInclude("/blog")->ignore

get("a[aria-label='homepage']")->should("be.visible")->first->click->ignore
cyLocation("pathname")->shouldWithValue("eq", "/")->ignore
},
)
})

describe("Secondary navbar", () => {
it(
"should navigate through all secondary nav links from Docs",
() => {
// Click Docs in primary nav to reveal the secondary nav
clickNavLink(~testId="navbar-primary-left-content", ~text="Docs")
url()->shouldInclude("/docs/manual/introduction")->ignore

// Language Manual
clickNavLink(~testId="navbar-secondary", ~text="Language Manual")
url()->shouldInclude("/docs/manual/introduction")->ignore

// API
clickNavLink(~testId="navbar-secondary", ~text="API")
url()->shouldInclude("/docs/manual/api")->ignore

// Syntax Lookup
clickNavLink(~testId="navbar-secondary", ~text="Syntax Lookup")
url()->shouldInclude("/syntax-lookup")->ignore

// React
clickNavLink(~testId="navbar-secondary", ~text="React")
url()->shouldInclude("/docs/react/introduction")->ignore
},
)
})
})

// -- Mobile (375x667) ---------------------------------------------------------

describe("Mobile Navigation", () => {
beforeEach(() => {
viewport(375, 667)
visit("/")
waitForHydration()
})

describe("Primary navbar", () => {
it(
"should navigate to Docs via navbar link",
() => {
clickNavLink(~testId="navbar-primary-left-content", ~text="Docs")
url()->shouldInclude("/docs/manual/introduction")->ignore
get("h1")->shouldBeVisible->ignore
},
)

it(
"should navigate home via logo after clicking away",
() => {
clickNavLink(~testId="navbar-primary-left-content", ~text="Docs")
url()->shouldInclude("/docs/manual/introduction")->ignore

get("a[aria-label='homepage']")->should("be.visible")->first->click->ignore
cyLocation("pathname")->shouldWithValue("eq", "/")->ignore
},
)
})

describe("Mobile overlay navigation", () => {
it(
"should navigate to Playground via mobile menu",
() => {
openMobileMenu()
clickMobileNavLink("Playground")
url()->shouldInclude("/try")->ignore
},
)

it(
"should navigate to Blog via mobile menu",
() => {
openMobileMenu()
clickMobileNavLink("Blog")
url()->shouldInclude("/blog")->ignore
},
)

it(
"should navigate to Community via mobile menu",
() => {
openMobileMenu()
clickMobileNavLink("Community")
url()->shouldInclude("/community")->ignore
get("h1")->shouldBeVisible->ignore
},
)
})

describe("Secondary navbar", () => {
it(
"should navigate through all secondary nav links from Docs",
() => {
// Click Docs in primary nav to reveal the secondary nav
clickNavLink(~testId="navbar-primary-left-content", ~text="Docs")
url()->shouldInclude("/docs/manual/introduction")->ignore

// Scroll to top so the secondary nav is visible
cyScrollTo("top")

// Language Manual
clickNavLink(~testId="navbar-secondary", ~text="Language Manual")
url()->shouldInclude("/docs/manual/introduction")->ignore

// API
cyScrollTo("top")
clickNavLink(~testId="navbar-secondary", ~text="API")
url()->shouldInclude("/docs/manual/api")->ignore

// Syntax Lookup
cyScrollTo("top")
clickNavLink(~testId="navbar-secondary", ~text="Syntax Lookup")
url()->shouldInclude("/syntax-lookup")->ignore

// React
cyScrollTo("top")
clickNavLink(~testId="navbar-secondary", ~text="React")
url()->shouldInclude("/docs/react/introduction")->ignore
},
)
})
})
Loading
Loading