Skip to content

Commit e1270cb

Browse files
authored
Add Cypress E2E testing with ReScript bindings and CI integration (#1239)
* Add Cypress E2E testing with ReScript bindings and CI integration - Add Cypress config and support files for E2E tests - Add ReScript bindings for Cypress in e2e/bindings - Add navigation E2E test in e2e/Navigation_.cy.res - Update .gitignore for e2e artifacts - Add Cypress and E2E scripts to package.json - Add e2e to rescript.json dev sources - Update GitHub Actions to run E2E tests after deploy * Use Cypress GitHub Action for E2E tests in deploy workflow * pr feedback * wait again * configure retries * change type name
1 parent 1bc5605 commit e1270cb

9 files changed

Lines changed: 1474 additions & 19 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ jobs:
2020
contents: read
2121
deployments: write
2222
pull-requests: write
23+
outputs:
24+
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
2325
steps:
2426
- uses: actions/checkout@v6.0.2
2527
- name: Setup Node.js environment
@@ -76,3 +78,28 @@ jobs:
7678
Deployment Environment: ${{ steps.deploy.outputs.pages-environment }}
7779
7880
${{ steps.deploy.outputs.command-output }}
81+
82+
e2e:
83+
runs-on: ubuntu-latest
84+
name: E2E Tests
85+
needs: deploy
86+
if: ${{ github.actor != 'dependabot[bot]' }}
87+
steps:
88+
- uses: actions/checkout@v6.0.2
89+
- name: Setup Node.js environment
90+
uses: actions/setup-node@v6.3.0
91+
with:
92+
node-version-file: ".node-version"
93+
cache: "yarn"
94+
- name: Enable Corepack
95+
run: corepack enable
96+
- name: Install dependencies
97+
run: yarn install
98+
- name: Build ReScript
99+
run: yarn build:res
100+
- name: Cypress E2E tests
101+
uses: cypress-io/github-action@v7
102+
with:
103+
install: false
104+
browser: chrome
105+
config: baseUrl=${{ needs.deploy.outputs.deployment-url }}

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ functions/**/*.mjs
5050
functions/**/*.jsx
5151
__tests__/**/*.mjs
5252
__tests__/**/*.jsx
53+
e2e/**/*.mjs
54+
e2e/**/*.jsx
5355
!_shims.mjs
5456
!_shims.jsx
5557

@@ -72,4 +74,4 @@ _scripts
7274

7375
# Vitest screenshots
7476
!__tests__/__screenshots__/**/*
75-
.vitest-attachments
77+
.vitest-attachments

cypress.config.mjs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { defineConfig } from "cypress";
2+
3+
export default defineConfig({
4+
allowCypressEnv: false,
5+
retries: {
6+
runMode: 2,
7+
openMode: 0,
8+
},
9+
e2e: {
10+
baseUrl: "http://localhost:8080",
11+
specPattern: "e2e/**/*.cy.jsx",
12+
supportFile: "cypress/support/e2e.js",
13+
video: false,
14+
screenshotOnRunFailure: false,
15+
defaultCommandTimeout: 10000,
16+
pageLoadTimeout: 30000,
17+
},
18+
});

cypress/support/e2e.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
const knownHydrationErrors = [
2+
/Hydration failed because the initial UI does not match what was rendered on the server\.?/,
3+
/Text content does not match server-rendered HTML\.?/,
4+
/There was an error while hydrating\.?/,
5+
/Minified React error #418\b/,
6+
/Minified React error #423\b/,
7+
/Minified React error #425\b/,
8+
];
9+
10+
Cypress.on("uncaught:exception", (err) => {
11+
const message = err && err.message ? err.message : "";
12+
const isKnownHydrationError = knownHydrationErrors.some((pattern) =>
13+
pattern.test(message),
14+
);
15+
16+
if (isKnownHydrationError) {
17+
console.warn("Suppressing known React hydration exception in Cypress:", {
18+
message,
19+
error: err,
20+
});
21+
return false;
22+
}
23+
});

e2e/Navigation_.cy.res

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
open Cy
2+
3+
// Wait for the app to fully hydrate before interacting with links.
4+
// The production build is pre-rendered so React must attach event
5+
// handlers before client-side navigation works.
6+
let waitForHydration = () => {
7+
getByTestId("navbar-primary")->shouldBeVisible->ignore
8+
cyWindow()->its("document.readyState")->shouldWithValue("eq", "complete")->ignore
9+
wait(2000)
10+
}
11+
12+
// Use short, re-queryable selectors to avoid detached DOM issues.
13+
// When React re-renders during navigation, long chains can hold
14+
// references to stale elements. Separate cy.get() calls let Cypress
15+
// re-query from the DOM root on each retry.
16+
17+
let clickNavLink = (~testId, ~text) => {
18+
get(`[data-testid="${testId}"] a:visible`)
19+
->containsChainable(text)
20+
->click
21+
->ignore
22+
}
23+
24+
let clickMobileNavLink = text => {
25+
get(`[data-testid="mobile-nav"] a:visible`)
26+
->containsChainable(text)
27+
->click
28+
->ignore
29+
}
30+
31+
let openMobileMenu = () => {
32+
get(`[data-testid="toggle-mobile-overlay"]`)->should("be.visible")->click->ignore
33+
get("#mobile-overlay")->should("be.visible")->ignore
34+
}
35+
36+
// -- Desktop (1280x720) -------------------------------------------------------
37+
38+
describe("Desktop Navigation", () => {
39+
beforeEach(() => {
40+
viewport(1280, 720)
41+
visit("/")
42+
waitForHydration()
43+
})
44+
45+
describe("Primary navbar", () => {
46+
it(
47+
"should navigate to Docs via navbar link",
48+
() => {
49+
clickNavLink(~testId="navbar-primary-left-content", ~text="Docs")
50+
url()->shouldInclude("/docs/manual/introduction")->ignore
51+
get("h1")->shouldBeVisible->ignore
52+
},
53+
)
54+
55+
it(
56+
"should navigate to Playground via navbar link",
57+
() => {
58+
clickNavLink(~testId="navbar-primary-left-content", ~text="Playground")
59+
url()->shouldInclude("/try")->ignore
60+
},
61+
)
62+
63+
it(
64+
"should navigate to Blog via navbar link",
65+
() => {
66+
clickNavLink(~testId="navbar-primary-left-content", ~text="Blog")
67+
url()->shouldInclude("/blog")->ignore
68+
},
69+
)
70+
71+
it(
72+
"should navigate to Community via navbar link",
73+
() => {
74+
clickNavLink(~testId="navbar-primary-left-content", ~text="Community")
75+
url()->shouldInclude("/community")->ignore
76+
get("h1")->shouldBeVisible->ignore
77+
},
78+
)
79+
80+
it(
81+
"should navigate home via logo after clicking away",
82+
() => {
83+
clickNavLink(~testId="navbar-primary-left-content", ~text="Blog")
84+
url()->shouldInclude("/blog")->ignore
85+
86+
get("a[aria-label='homepage']")->should("be.visible")->first->click->ignore
87+
cyLocation("pathname")->shouldWithValue("eq", "/")->ignore
88+
},
89+
)
90+
})
91+
92+
describe("Secondary navbar", () => {
93+
it(
94+
"should navigate through all secondary nav links from Docs",
95+
() => {
96+
// Click Docs in primary nav to reveal the secondary nav
97+
clickNavLink(~testId="navbar-primary-left-content", ~text="Docs")
98+
url()->shouldInclude("/docs/manual/introduction")->ignore
99+
100+
// Language Manual
101+
clickNavLink(~testId="navbar-secondary", ~text="Language Manual")
102+
url()->shouldInclude("/docs/manual/introduction")->ignore
103+
104+
// API
105+
clickNavLink(~testId="navbar-secondary", ~text="API")
106+
url()->shouldInclude("/docs/manual/api")->ignore
107+
108+
// Syntax Lookup
109+
clickNavLink(~testId="navbar-secondary", ~text="Syntax Lookup")
110+
url()->shouldInclude("/syntax-lookup")->ignore
111+
112+
// React
113+
clickNavLink(~testId="navbar-secondary", ~text="React")
114+
url()->shouldInclude("/docs/react/introduction")->ignore
115+
},
116+
)
117+
})
118+
})
119+
120+
// -- Mobile (375x667) ---------------------------------------------------------
121+
122+
describe("Mobile Navigation", () => {
123+
beforeEach(() => {
124+
viewport(375, 667)
125+
visit("/")
126+
waitForHydration()
127+
})
128+
129+
describe("Primary navbar", () => {
130+
it(
131+
"should navigate to Docs via navbar link",
132+
() => {
133+
clickNavLink(~testId="navbar-primary-left-content", ~text="Docs")
134+
url()->shouldInclude("/docs/manual/introduction")->ignore
135+
get("h1")->shouldBeVisible->ignore
136+
},
137+
)
138+
139+
it(
140+
"should navigate home via logo after clicking away",
141+
() => {
142+
clickNavLink(~testId="navbar-primary-left-content", ~text="Docs")
143+
url()->shouldInclude("/docs/manual/introduction")->ignore
144+
145+
get("a[aria-label='homepage']")->should("be.visible")->first->click->ignore
146+
cyLocation("pathname")->shouldWithValue("eq", "/")->ignore
147+
},
148+
)
149+
})
150+
151+
describe("Mobile overlay navigation", () => {
152+
it(
153+
"should navigate to Playground via mobile menu",
154+
() => {
155+
openMobileMenu()
156+
clickMobileNavLink("Playground")
157+
url()->shouldInclude("/try")->ignore
158+
},
159+
)
160+
161+
it(
162+
"should navigate to Blog via mobile menu",
163+
() => {
164+
openMobileMenu()
165+
clickMobileNavLink("Blog")
166+
url()->shouldInclude("/blog")->ignore
167+
},
168+
)
169+
170+
it(
171+
"should navigate to Community via mobile menu",
172+
() => {
173+
openMobileMenu()
174+
clickMobileNavLink("Community")
175+
url()->shouldInclude("/community")->ignore
176+
get("h1")->shouldBeVisible->ignore
177+
},
178+
)
179+
})
180+
181+
describe("Secondary navbar", () => {
182+
it(
183+
"should navigate through all secondary nav links from Docs",
184+
() => {
185+
// Click Docs in primary nav to reveal the secondary nav
186+
clickNavLink(~testId="navbar-primary-left-content", ~text="Docs")
187+
url()->shouldInclude("/docs/manual/introduction")->ignore
188+
189+
// Scroll to top so the secondary nav is visible
190+
cyScrollTo("top")
191+
192+
// Language Manual
193+
clickNavLink(~testId="navbar-secondary", ~text="Language Manual")
194+
url()->shouldInclude("/docs/manual/introduction")->ignore
195+
196+
// API
197+
cyScrollTo("top")
198+
clickNavLink(~testId="navbar-secondary", ~text="API")
199+
url()->shouldInclude("/docs/manual/api")->ignore
200+
201+
// Syntax Lookup
202+
cyScrollTo("top")
203+
clickNavLink(~testId="navbar-secondary", ~text="Syntax Lookup")
204+
url()->shouldInclude("/syntax-lookup")->ignore
205+
206+
// React
207+
cyScrollTo("top")
208+
clickNavLink(~testId="navbar-secondary", ~text="React")
209+
url()->shouldInclude("/docs/react/introduction")->ignore
210+
},
211+
)
212+
})
213+
})

0 commit comments

Comments
 (0)