Skip to content

Commit ebe61e3

Browse files
authored
Add Cypress e2e test suite (#25)
1 parent 78f7cd2 commit ebe61e3

File tree

14 files changed

+1894
-12
lines changed

14 files changed

+1894
-12
lines changed

.github/workflows/fly-preview.yml

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,101 @@ jobs:
7070
body,
7171
});
7272
}
73+
74+
e2e-test:
75+
name: E2E Tests
76+
runs-on: ubuntu-latest
77+
needs: preview
78+
if: github.event.action != 'closed'
79+
continue-on-error: true
80+
81+
steps:
82+
- name: Checkout
83+
uses: actions/checkout@v4
84+
85+
- name: Setup pnpm
86+
uses: pnpm/action-setup@v4
87+
with:
88+
version: latest
89+
90+
- name: Setup Node.js
91+
uses: actions/setup-node@v4
92+
with:
93+
node-version: 22
94+
cache: pnpm
95+
96+
- name: Install dependencies
97+
run: pnpm install --frozen-lockfile
98+
99+
- name: Install Cypress binary
100+
run: pnpm cypress install
101+
102+
- name: Wait for preview to be ready
103+
run: |
104+
echo "Waiting for preview deployment to be ready..."
105+
for i in {1..30}; do
106+
if curl -s -o /dev/null -w "%{http_code}" "${{ needs.preview.outputs.url }}" | grep -q "200"; then
107+
echo "Preview is ready!"
108+
exit 0
109+
fi
110+
echo "Attempt $i: Preview not ready yet, waiting 10s..."
111+
sleep 10
112+
done
113+
echo "Preview did not become ready in time"
114+
exit 1
115+
116+
- name: Run Cypress tests
117+
uses: cypress-io/github-action@v6
118+
with:
119+
browser: chrome
120+
install: false
121+
env:
122+
CYPRESS_BASE_URL: ${{ needs.preview.outputs.url }}
123+
124+
- name: Upload screenshots on failure
125+
uses: actions/upload-artifact@v4
126+
if: failure()
127+
with:
128+
name: cypress-screenshots
129+
path: cypress/screenshots
130+
retention-days: 7
131+
132+
- name: Upload videos on failure
133+
uses: actions/upload-artifact@v4
134+
if: failure()
135+
with:
136+
name: cypress-videos
137+
path: cypress/videos
138+
retention-days: 7
139+
140+
- name: Comment test results on PR
141+
if: always()
142+
uses: actions/github-script@v7
143+
with:
144+
script: |
145+
const status = '${{ job.status }}';
146+
const emoji = status === 'success' ? '✅' : '❌';
147+
const body = `### E2E Test Results\n\n${emoji} Tests ${status}\n\nRan against: ${{ needs.preview.outputs.url }}`;
148+
149+
const { data: comments } = await github.rest.issues.listComments({
150+
owner: context.repo.owner,
151+
repo: context.repo.repo,
152+
issue_number: context.issue.number,
153+
});
154+
const existing = comments.find(c => c.body.includes('### E2E Test Results'));
155+
156+
if (existing) {
157+
await github.rest.issues.updateComment({
158+
owner: context.repo.owner,
159+
repo: context.repo.repo,
160+
comment_id: existing.id,
161+
body,
162+
});
163+
} else {
164+
await github.rest.issues.createComment({
165+
owner: context.repo.owner,
166+
repo: context.repo.repo,
167+
issue_number: context.issue.number,
168+
body,
169+
});
170+
}

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,8 @@ examples/**/env.d.ts
4242
# want to share with others (see
4343
# https://github.com/withastro/astro/pull/11759#discussion_r1721444711)
4444
*.code-workspace
45+
46+
# Cypress
47+
cypress/screenshots
48+
cypress/videos
49+
cypress/downloads

cypress.config.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { defineConfig } from 'cypress';
2+
3+
export default defineConfig({
4+
e2e: {
5+
baseUrl: 'http://localhost:4321',
6+
viewportWidth: 1280,
7+
viewportHeight: 720,
8+
defaultCommandTimeout: 10000,
9+
pageLoadTimeout: 30000,
10+
video: false,
11+
screenshotOnRunFailure: true,
12+
retries: {
13+
runMode: 2,
14+
openMode: 0,
15+
},
16+
specPattern: 'cypress/e2e/**/*.cy.ts',
17+
supportFile: 'cypress/support/e2e.ts',
18+
setupNodeEvents(_on, config) {
19+
if (process.env.CYPRESS_BASE_URL) {
20+
config.baseUrl = process.env.CYPRESS_BASE_URL;
21+
}
22+
return config;
23+
},
24+
},
25+
});
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
describe('Code Tabs', () => {
2+
beforeEach(() => {
3+
// Visit a page with code tabs
4+
cy.visit('/quickstart');
5+
});
6+
7+
it('should display tab list with multiple options', () => {
8+
cy.get('[role="tablist"]').should('exist');
9+
cy.get('[role="tab"]').should('have.length.at.least', 2);
10+
});
11+
12+
it('should have first tab selected by default', () => {
13+
cy.get('[role="tab"]').first().should('have.attr', 'aria-selected', 'true');
14+
});
15+
16+
it('should switch tab content when clicking different tab', () => {
17+
// Get the tabs
18+
cy.get('[role="tablist"]')
19+
.first()
20+
.within(() => {
21+
cy.get('[role="tab"]').then(($tabs) => {
22+
if ($tabs.length >= 2) {
23+
// Click second tab
24+
cy.wrap($tabs[1]).click();
25+
// Second tab should now be selected
26+
cy.wrap($tabs[1]).should('have.attr', 'aria-selected', 'true');
27+
// First tab should be deselected
28+
cy.wrap($tabs[0]).should('have.attr', 'aria-selected', 'false');
29+
}
30+
});
31+
});
32+
});
33+
34+
it('should show corresponding content for selected tab', () => {
35+
cy.get('[role="tablist"]')
36+
.first()
37+
.within(() => {
38+
cy.get('[role="tab"]').then(($tabs) => {
39+
if ($tabs.length >= 2) {
40+
// Get text of second tab
41+
const tabText = $tabs[1].textContent;
42+
// Click it
43+
cy.wrap($tabs[1]).click();
44+
}
45+
});
46+
});
47+
// Tab panel should be visible
48+
cy.get('[role="tabpanel"]').should('be.visible');
49+
});
50+
51+
it('should maintain tab selection after scrolling', () => {
52+
cy.get('[role="tablist"]')
53+
.first()
54+
.within(() => {
55+
cy.get('[role="tab"]').eq(1).click();
56+
cy.get('[role="tab"]')
57+
.eq(1)
58+
.should('have.attr', 'aria-selected', 'true');
59+
});
60+
// Scroll down and back up
61+
cy.scrollTo('bottom');
62+
cy.scrollTo('top');
63+
// Tab should still be selected
64+
cy.get('[role="tablist"]')
65+
.first()
66+
.within(() => {
67+
cy.get('[role="tab"]')
68+
.eq(1)
69+
.should('have.attr', 'aria-selected', 'true');
70+
});
71+
});
72+
});
73+
74+
describe('Code Blocks', () => {
75+
beforeEach(() => {
76+
cy.visit('/quickstart');
77+
});
78+
79+
it('should display code blocks with syntax highlighting', () => {
80+
cy.get('pre code, .astro-code').should('have.length.at.least', 1);
81+
});
82+
83+
it('should have copy button on code blocks', () => {
84+
// Starlight adds copy buttons to code blocks
85+
cy.get('pre').first().realHover
86+
? cy.get('pre').first().realHover()
87+
: cy.get('pre').first().trigger('mouseenter');
88+
// Look for copy button (Starlight uses data-copy-button or similar)
89+
cy.get('button[data-copy], .copy-button, button:has(svg)').should('exist');
90+
});
91+
});
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
describe('Theme Toggle', () => {
2+
beforeEach(() => {
3+
// Clear theme preference before each test
4+
cy.clearLocalStorage('starlight-theme');
5+
cy.visit('/');
6+
// Wait for React hydration
7+
cy.wait(500);
8+
});
9+
10+
it('should have a theme toggle button', () => {
11+
// The theme switcher button has sr-only text "Toggle theme"
12+
cy.get('button')
13+
.contains('Toggle theme', { matchCase: false })
14+
.should('exist');
15+
});
16+
17+
it('should open theme dropdown when clicked', () => {
18+
cy.get('button')
19+
.filter(':has(svg)')
20+
.filter(
21+
(_, el) =>
22+
el.textContent?.includes('Toggle theme') ||
23+
el.getAttribute('aria-label')?.includes('theme'),
24+
)
25+
.first()
26+
.click();
27+
// Dropdown should appear with Light, Dark, System options
28+
cy.contains('Light').should('be.visible');
29+
cy.contains('Dark').should('be.visible');
30+
cy.contains('System').should('be.visible');
31+
});
32+
33+
it('should switch to light theme', () => {
34+
// Open dropdown
35+
cy.get('button')
36+
.filter(':has(svg)')
37+
.filter(
38+
(_, el) =>
39+
el.textContent?.includes('Toggle theme') ||
40+
el.getAttribute('aria-label')?.includes('theme'),
41+
)
42+
.first()
43+
.click();
44+
// Click Light option
45+
cy.contains('Light').click();
46+
// Check that data-theme is set to light
47+
cy.get('html').should('have.attr', 'data-theme', 'light');
48+
});
49+
50+
it('should switch to dark theme', () => {
51+
// Open dropdown
52+
cy.get('button')
53+
.filter(':has(svg)')
54+
.filter(
55+
(_, el) =>
56+
el.textContent?.includes('Toggle theme') ||
57+
el.getAttribute('aria-label')?.includes('theme'),
58+
)
59+
.first()
60+
.click();
61+
// Click Dark option
62+
cy.contains('Dark').click();
63+
// Check that data-theme is set to dark
64+
cy.get('html').should('have.attr', 'data-theme', 'dark');
65+
});
66+
67+
it('should persist theme preference in localStorage', () => {
68+
// Open dropdown and select Light
69+
cy.get('button')
70+
.filter(':has(svg)')
71+
.filter(
72+
(_, el) =>
73+
el.textContent?.includes('Toggle theme') ||
74+
el.getAttribute('aria-label')?.includes('theme'),
75+
)
76+
.first()
77+
.click();
78+
cy.contains('Light').click();
79+
80+
// Check localStorage
81+
cy.window().then((win) => {
82+
expect(win.localStorage.getItem('starlight-theme')).to.eq('light');
83+
});
84+
});
85+
86+
it('should maintain theme preference after page reload', () => {
87+
// Set theme to light
88+
cy.get('button')
89+
.filter(':has(svg)')
90+
.filter(
91+
(_, el) =>
92+
el.textContent?.includes('Toggle theme') ||
93+
el.getAttribute('aria-label')?.includes('theme'),
94+
)
95+
.first()
96+
.click();
97+
cy.contains('Light').click();
98+
cy.get('html').should('have.attr', 'data-theme', 'light');
99+
100+
// Reload page
101+
cy.reload();
102+
cy.wait(500);
103+
104+
// Theme should still be light
105+
cy.get('html').should('have.attr', 'data-theme', 'light');
106+
});
107+
});

cypress/e2e/smoke/content.cy.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
describe('Content Rendering', () => {
2+
it('should render markdown content on homepage', () => {
3+
cy.visit('/');
4+
cy.get('main').should('be.visible');
5+
cy.get('h1, h2, p').should('have.length.greaterThan', 0);
6+
});
7+
8+
it('should render code blocks on quickstart page', () => {
9+
cy.visit('/quickstart');
10+
cy.get('pre code, .astro-code').should('have.length.greaterThan', 0);
11+
});
12+
13+
it('should render links correctly', () => {
14+
cy.visit('/');
15+
cy.get('a[href]').should('have.length.greaterThan', 0);
16+
});
17+
18+
it('should load documentation pages without errors', () => {
19+
const pages = ['/', '/quickstart'];
20+
21+
pages.forEach((page) => {
22+
cy.visit(page);
23+
cy.get('main').should('be.visible');
24+
});
25+
});
26+
});

cypress/e2e/smoke/homepage.cy.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
describe('Homepage', () => {
2+
beforeEach(() => {
3+
cy.visit('/');
4+
});
5+
6+
it('should load the homepage successfully', () => {
7+
cy.title().should('contain', 'Sprites');
8+
cy.get('main').should('be.visible');
9+
});
10+
11+
it('should display the header', () => {
12+
cy.get('header').should('be.visible');
13+
});
14+
15+
it('should display sidebar navigation', () => {
16+
cy.get('nav').should('exist');
17+
});
18+
19+
it('should have main content area', () => {
20+
cy.get('main').within(() => {
21+
cy.get('h1, h2').should('exist');
22+
});
23+
});
24+
});

0 commit comments

Comments
 (0)