Skip to content

Commit 46c2d1e

Browse files
authored
test: added e2e tests (#14)
1 parent 4c50cb1 commit 46c2d1e

10 files changed

Lines changed: 383 additions & 3 deletions

File tree

.github/workflows/build.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,41 @@ permissions:
99
contents: write
1010

1111
jobs:
12+
test:
13+
name: Tests
14+
runs-on: ubuntu-latest
15+
16+
steps:
17+
- name: Check out Git repository
18+
uses: actions/checkout@v4
19+
20+
- name: Install Node.js
21+
uses: actions/setup-node@v4
22+
with:
23+
node-version: 20
24+
cache: 'npm'
25+
26+
- name: Install dependencies
27+
run: npm ci
28+
29+
- name: Lint
30+
run: npm run lint
31+
32+
- name: Type check
33+
run: npx tsc -b
34+
35+
- name: Run tests
36+
run: npm run test
37+
38+
- name: Install Playwright browsers
39+
run: npx playwright install --with-deps chromium
40+
41+
- name: Run e2e tests
42+
run: npm run test:e2e
43+
1244
build:
1345
name: Build on ${{ matrix.os }}
46+
needs: test
1447
runs-on: ${{ matrix.os }}
1548
strategy:
1649
matrix:

.github/workflows/ci.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,11 @@ jobs:
3232
- name: Run tests
3333
run: npm run test
3434

35+
- name: Install Playwright browsers
36+
run: npx playwright install --with-deps chromium
37+
38+
- name: Run e2e tests
39+
run: npm run test:e2e
40+
3541
- name: Build
3642
run: npm run build

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ dist
1212
dist-ssr
1313
*.local
1414

15+
# Playwright
16+
playwright-report/
17+
test-results/
18+
1519
# Engine assets (downloaded locally)
1620
public/engine/
1721
public/maia/

e2e/chessboard.spec.ts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { test, expect } from '@playwright/test'
2+
import type { Page } from '@playwright/test'
3+
4+
const squareSelector = (square: string) => `#vs-maia-square-${square}`
5+
const pieceSelector = (square: string, piece: string) =>
6+
`#vs-maia-piece-${piece}-${square}`
7+
8+
const startGame = async (page: Page) => {
9+
const startButton = page.getByRole('button', { name: 'Start Game' })
10+
await expect(startButton).toBeEnabled()
11+
await startButton.click()
12+
await expect(page.getByRole('button', { name: 'Stop the Game' })).toBeVisible()
13+
}
14+
15+
const squareCenter = async (page: Page, square: string) => {
16+
const locator = page.locator(squareSelector(square))
17+
await expect(locator).toBeVisible()
18+
const box = await locator.boundingBox()
19+
if (!box) {
20+
throw new Error(`Unable to locate square ${square}`)
21+
}
22+
return {
23+
x: box.x + box.width / 2,
24+
y: box.y + box.height / 2,
25+
}
26+
}
27+
28+
const dragPiece = async (page: Page, from: string, to: string) => {
29+
const fromPoint = await squareCenter(page, from)
30+
const toPoint = await squareCenter(page, to)
31+
await page.mouse.move(fromPoint.x, fromPoint.y)
32+
await page.mouse.down()
33+
await page.mouse.move(toPoint.x, toPoint.y, { steps: 12 })
34+
await page.mouse.up()
35+
}
36+
37+
const openSettings = async (page: Page) => {
38+
await page.getByRole('button', { name: 'Settings' }).click()
39+
await expect(page.locator('.settings-container')).toBeVisible()
40+
}
41+
42+
const closeSettings = async (page: Page) => {
43+
await page.locator('.settings-close-button').click()
44+
await expect(page.locator('.settings-container')).toHaveCount(0)
45+
}
46+
47+
const openChessEngineSettings = async (page: Page) => {
48+
await page.getByRole('button', { name: 'Chess Engine' }).click()
49+
await expect(page.getByRole('heading', { name: 'Chess Engine' })).toBeVisible()
50+
}
51+
52+
const getBoardOrientation = async (page: Page) => {
53+
const a1Box = await page.locator(squareSelector('a1')).boundingBox()
54+
const h8Box = await page.locator(squareSelector('h8')).boundingBox()
55+
if (!a1Box || !h8Box) {
56+
throw new Error('Unable to read board orientation')
57+
}
58+
const isWhite = a1Box.x < h8Box.x && a1Box.y > h8Box.y
59+
const isBlack = a1Box.x > h8Box.x && a1Box.y < h8Box.y
60+
if (!isWhite && !isBlack) {
61+
throw new Error('Unexpected board orientation')
62+
}
63+
return isWhite ? 'white' : 'black'
64+
}
65+
66+
test.beforeEach(async ({ page }) => {
67+
await page.goto('/')
68+
await page.locator('#vs-maia-board').waitFor()
69+
})
70+
71+
test('drag and drop moves a pawn', async ({ page }) => {
72+
await startGame(page)
73+
await dragPiece(page, 'e2', 'e4')
74+
await expect(page.locator(pieceSelector('e4', 'wP'))).toBeVisible()
75+
await expect(page.locator(pieceSelector('e2', 'wP'))).toHaveCount(0)
76+
})
77+
78+
test('click-to-move still works', async ({ page }) => {
79+
await startGame(page)
80+
await page.locator(squareSelector('e2')).click()
81+
await page.locator(squareSelector('e4')).click()
82+
await expect(page.locator(pieceSelector('e4', 'wP'))).toBeVisible()
83+
})
84+
85+
test('takeback restores the last move (click-to-move)', async ({ page }) => {
86+
await startGame(page)
87+
await page.locator(squareSelector('e2')).click()
88+
await page.locator(squareSelector('e4')).click()
89+
await expect(page.locator(pieceSelector('e4', 'wP'))).toBeVisible()
90+
const takebackButton = page.getByRole('button', { name: /Take Back/ })
91+
await expect(takebackButton).toBeEnabled()
92+
await takebackButton.click()
93+
await expect(page.locator(pieceSelector('e2', 'wP'))).toBeVisible()
94+
await expect(page.locator(pieceSelector('e4', 'wP'))).toHaveCount(0)
95+
})
96+
97+
test('settings allow ELO changes mid-game', async ({ page }) => {
98+
await startGame(page)
99+
const slider = page.getByRole('slider')
100+
const eloInput = page.getByRole('spinbutton')
101+
await expect(slider).toBeDisabled()
102+
await expect(eloInput).toBeDisabled()
103+
104+
await openSettings(page)
105+
await openChessEngineSettings(page)
106+
const allowEloCheckbox = page.getByRole('checkbox', { name: 'Allow ELO change mid-game' })
107+
await allowEloCheckbox.check()
108+
await closeSettings(page)
109+
110+
await expect(slider).toBeEnabled()
111+
await expect(eloInput).toBeEnabled()
112+
})
113+
114+
test('settings takeback limit is enforced', async ({ page }) => {
115+
await openSettings(page)
116+
await openChessEngineSettings(page)
117+
const unlimitedCheckbox = page.getByRole('checkbox', { name: 'Unlimited' })
118+
await unlimitedCheckbox.uncheck()
119+
const takebackLimitInput = page
120+
.locator('.settings-section')
121+
.filter({ hasText: 'Takeback Limit' })
122+
.locator('input[type="number"]')
123+
await takebackLimitInput.fill('1')
124+
await closeSettings(page)
125+
126+
await startGame(page)
127+
await page.locator(squareSelector('e2')).click()
128+
await page.locator(squareSelector('e4')).click()
129+
const takebackButton = page.getByRole('button', { name: /Take Back/ })
130+
await expect(takebackButton).toHaveText(/Take Back \(1\)/)
131+
await takebackButton.click()
132+
await expect(takebackButton).toBeDisabled()
133+
})
134+
135+
test('analysis mode draws suggestion arrows', async ({ page }) => {
136+
await startGame(page)
137+
await page.locator(squareSelector('e2')).click()
138+
await page.locator(squareSelector('e4')).click()
139+
140+
const analyzeButton = page.getByRole('button', { name: 'Analyze Game' })
141+
await expect(analyzeButton).toBeEnabled()
142+
await analyzeButton.click()
143+
144+
await expect(page.locator('.info-panel h3')).toContainText('Analysis')
145+
await expect(page.locator('.analysis-arrow-canvas')).toBeVisible()
146+
await expect(page.locator('.analysis-arrow-canvas path')).toHaveCount(4)
147+
})
148+
149+
test('playing as black triggers Maia to move first', async ({ page }) => {
150+
await page.getByRole('button', { name: 'Black' }).click()
151+
await startGame(page)
152+
await expect(page.locator(pieceSelector('e4', 'wP'))).toBeVisible()
153+
await page.locator(squareSelector('d7')).click()
154+
await page.locator(squareSelector('d5')).click()
155+
await expect(page.locator(pieceSelector('d5', 'bP'))).toBeVisible()
156+
})
157+
158+
test('random color starts a playable game', async ({ page }) => {
159+
await page.getByRole('button', { name: 'Random' }).click()
160+
await startGame(page)
161+
162+
const orientation = await getBoardOrientation(page)
163+
if (orientation === 'white') {
164+
await page.locator(squareSelector('e2')).click()
165+
await page.locator(squareSelector('e4')).click()
166+
await expect(page.locator(pieceSelector('e4', 'wP'))).toBeVisible()
167+
return
168+
}
169+
170+
await expect(page.locator(pieceSelector('e4', 'wP'))).toBeVisible()
171+
await page.locator(squareSelector('e7')).click()
172+
await page.locator(squareSelector('e5')).click()
173+
await expect(page.locator(pieceSelector('e5', 'bP'))).toBeVisible()
174+
})

package-lock.json

Lines changed: 64 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
"build": "tsc -b && vite build",
1414
"engines:download": "node scripts/download-engines.js",
1515
"test": "vitest run",
16+
"test:e2e": "playwright test",
17+
"test:e2e:ui": "playwright test --ui",
1618
"lint": "eslint .",
1719
"electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:5173 && NODE_ENV=development electron .\"",
1820
"electron:build": "npm run build && node scripts/strip-engines.js && electron-builder --publish never"
@@ -58,6 +60,7 @@
5860
},
5961
"devDependencies": {
6062
"@eslint/js": "^9.39.1",
63+
"@playwright/test": "^1.57.0",
6164
"@types/node": "^24.10.1",
6265
"@types/pako": "^2.0.4",
6366
"@types/react": "^19.2.5",

playwright.config.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { defineConfig, devices } from '@playwright/test'
2+
3+
export default defineConfig({
4+
testDir: './e2e',
5+
timeout: 30 * 1000,
6+
expect: {
7+
timeout: 5 * 1000,
8+
},
9+
use: {
10+
baseURL: 'http://127.0.0.1:4173',
11+
trace: 'retain-on-failure',
12+
screenshot: 'only-on-failure',
13+
},
14+
webServer: {
15+
command: 'npm run dev -- --host 127.0.0.1 --port 4173',
16+
url: 'http://127.0.0.1:4173',
17+
reuseExistingServer: !process.env.CI,
18+
env: {
19+
VITE_E2E: '1',
20+
},
21+
},
22+
projects: [
23+
{
24+
name: 'chromium',
25+
use: { ...devices['Desktop Chrome'] },
26+
},
27+
],
28+
})

0 commit comments

Comments
 (0)