Skip to content

Commit 5748f66

Browse files
CopilotProLoser
andcommitted
feat: add board, rules, and dialogues E2E suites with CI integration
Co-authored-by: ProLoser <67395+ProLoser@users.noreply.github.com>
1 parent 4f6f016 commit 5748f66

File tree

6 files changed

+344
-1
lines changed

6 files changed

+344
-1
lines changed

.github/workflows/test.yml

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ jobs:
3030
run: yarn lint
3131
continue-on-error: true
3232

33-
- name: Run tests
33+
- name: Run unit tests
3434
run: yarn test --coverage
3535

3636
- name: Upload coverage reports
@@ -40,3 +40,17 @@ jobs:
4040
name: coverage-report
4141
path: coverage/
4242
retention-days: 30
43+
44+
- name: Install Playwright browsers
45+
run: yarn playwright install chromium --with-deps
46+
47+
- name: Run E2E tests
48+
run: yarn test:e2e
49+
50+
- name: Upload Playwright report
51+
if: always()
52+
uses: actions/upload-artifact@v4
53+
with:
54+
name: playwright-report
55+
path: playwright-report/
56+
retention-days: 30

e2e/board.spec.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { test, expect } from '@playwright/test';
2+
import { load, setGame } from './helpers';
3+
4+
test.describe('Board UI', () => {
5+
test.describe('Structure', () => {
6+
test.beforeEach(({ page }) => load(page));
7+
8+
test('has 24 points', async ({ page }) => {
9+
await expect(page.locator('.point')).toHaveCount(24);
10+
});
11+
12+
test('has 2 bar areas', async ({ page }) => {
13+
await expect(page.locator('.bar')).toHaveCount(2);
14+
});
15+
16+
test('has 2 home areas', async ({ page }) => {
17+
await expect(page.locator('.home')).toHaveCount(2);
18+
});
19+
20+
test('shows 30 pieces in starting position', async ({ page }) => {
21+
await expect(page.locator('.piece:not(.ghost)')).toHaveCount(30);
22+
});
23+
24+
test('home areas are empty at the start', async ({ page }) => {
25+
await expect(page.locator('.home .piece')).toHaveCount(0);
26+
});
27+
});
28+
29+
test.describe('Toolbar', () => {
30+
test.beforeEach(({ page }) => load(page));
31+
32+
test('shows Offline Game label when no opponent is loaded', async ({ page }) => {
33+
await expect(page.locator('#toolbar h2')).toHaveText('Offline Game');
34+
});
35+
36+
test('shows account icon when playing locally', async ({ page }) => {
37+
await expect(page.locator('#toolbar .material-icons-svg')).toBeVisible();
38+
});
39+
});
40+
41+
test.describe('Color state', () => {
42+
test.beforeEach(({ page }) => load(page));
43+
44+
test('board has no color class before any moves', async ({ page }) => {
45+
await expect(page.locator('#board')).not.toHaveClass(/white|black/);
46+
});
47+
48+
test('board gains white class when game color is set to white', async ({ page }) => {
49+
await setGame(page, { color: 'white' });
50+
await expect(page.locator('#board')).toHaveClass(/white/);
51+
});
52+
53+
test('board gains black class when game color is set to black', async ({ page }) => {
54+
await setGame(page, { color: 'black' });
55+
await expect(page.locator('#board')).toHaveClass(/black/);
56+
});
57+
});
58+
59+
test.describe('Dice', () => {
60+
test.beforeEach(({ page }) => load(page));
61+
62+
test('dice pulsate while waiting for a roll', async ({ page }) => {
63+
await expect(page.locator('.dice.pulsate')).toBeVisible();
64+
});
65+
66+
test('dice stop pulsating after rolling', async ({ page }) => {
67+
await page.locator('.dice').click();
68+
await expect(page.locator('.dice')).not.toHaveClass(/pulsate/);
69+
});
70+
71+
test('undo button is hidden before any moves are made', async ({ page }) => {
72+
await page.locator('.dice').click();
73+
await expect(page.locator('.undo')).not.toBeVisible();
74+
});
75+
});
76+
77+
test.describe('Piece selection', () => {
78+
test.beforeEach(({ page }) => load(page));
79+
80+
test('valid source points are highlighted during a move', async ({ page }) => {
81+
await setGame(page, { color: 'white', status: 'MOVING', dice: [3, 5] });
82+
await expect(page.locator('.point.valid').first()).toBeVisible();
83+
});
84+
85+
test('clicking a point selects it', async ({ page }) => {
86+
// Wait for toolbar label to confirm auth has settled (prevents auth-reset race)
87+
await expect(page.locator('#toolbar h2')).toBeVisible();
88+
const point = page.locator('.point').nth(18);
89+
await point.click();
90+
await expect(point).toHaveClass(/selected/);
91+
});
92+
93+
test('clicking a selected point deselects it', async ({ page }) => {
94+
await expect(page.locator('#toolbar h2')).toBeVisible();
95+
const point = page.locator('.point').nth(18);
96+
await point.click();
97+
await expect(point).toHaveClass(/selected/);
98+
await point.click();
99+
await expect(point).not.toHaveClass(/selected/);
100+
});
101+
102+
test('selecting a different point moves the selection highlight', async ({ page }) => {
103+
await expect(page.locator('#toolbar h2')).toBeVisible();
104+
const first = page.locator('.point').nth(0);
105+
const second = page.locator('.point').nth(16);
106+
await first.click();
107+
await expect(first).toHaveClass(/selected/);
108+
await second.click();
109+
await expect(first).not.toHaveClass(/selected/);
110+
await expect(second).toHaveClass(/selected/);
111+
});
112+
});
113+
});

e2e/dialogues.spec.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { test, expect } from '@playwright/test';
2+
import { load } from './helpers';
3+
4+
test.describe('Dialogues', () => {
5+
test.describe('Login dialog', () => {
6+
test.beforeEach(({ page }) => load(page));
7+
8+
test('dialog is closed by default', async ({ page }) => {
9+
await expect(page.locator('dialog')).not.toBeVisible();
10+
});
11+
12+
test('toolbar click opens the login dialog', async ({ page }) => {
13+
await page.locator('#toolbar').click();
14+
await expect(page.locator('dialog[open]')).toBeVisible();
15+
await expect(page.locator('#login')).toBeVisible();
16+
});
17+
18+
test('login dialog title shows Play Online when no friend is loaded', async ({ page }) => {
19+
await page.locator('#toolbar').click();
20+
await expect(page.locator('#login h1')).toHaveText('Play Online');
21+
});
22+
23+
test('login dialog has a FirebaseUI container', async ({ page }) => {
24+
await page.locator('#toolbar').click();
25+
// The div that FirebaseUI mounts into is always present
26+
await expect(page.locator('#login > div')).toBeVisible();
27+
});
28+
29+
test('clicking outside the dialog closes it', async ({ page }) => {
30+
await page.locator('#toolbar').click();
31+
await expect(page.locator('dialog[open]')).toBeVisible();
32+
await page.locator('#board').click({ position: { x: 5, y: 5 }, force: true });
33+
await expect(page.locator('dialog')).not.toBeVisible();
34+
});
35+
});
36+
37+
test.describe('Menu', () => {
38+
test.beforeEach(async ({ page }) => {
39+
await load(page);
40+
await page.locator('#toolbar').click();
41+
await expect(page.locator('#login')).toBeVisible();
42+
});
43+
44+
test('menu button has correct accessibility attributes', async ({ page }) => {
45+
const btn = page.locator('#login button[aria-haspopup="menu"]');
46+
await expect(btn).toBeVisible();
47+
await expect(btn).toHaveAttribute('aria-expanded', 'false');
48+
});
49+
50+
test('menu button toggles aria-expanded on click', async ({ page }) => {
51+
const btn = page.locator('#login button[aria-haspopup="menu"]');
52+
await btn.click();
53+
await expect(btn).toHaveAttribute('aria-expanded', 'true');
54+
await btn.click();
55+
await expect(btn).toHaveAttribute('aria-expanded', 'false');
56+
});
57+
58+
test('menu contains a Reset Game option', async ({ page }) => {
59+
await page.locator('#login button[aria-haspopup="menu"]').click();
60+
await expect(page.locator('#login menu').getByText('Reset Game')).toBeVisible();
61+
});
62+
63+
test('menu contains a Report Bug link', async ({ page }) => {
64+
await page.locator('#login button[aria-haspopup="menu"]').click();
65+
await expect(page.locator('#login menu').getByText('Report Bug')).toBeVisible();
66+
});
67+
68+
test('menu contains an About link', async ({ page }) => {
69+
await page.locator('#login button[aria-haspopup="menu"]').click();
70+
await expect(page.locator('#login menu').getByText('About')).toBeVisible();
71+
});
72+
73+
test('menu shows the app version', async ({ page }) => {
74+
await page.locator('#login button[aria-haspopup="menu"]').click();
75+
// Version component renders a version string like "1.1.0-abc1234"
76+
await expect(page.locator('#login menu').getByText(/\d+\.\d+\.\d+/)).toBeVisible();
77+
});
78+
});
79+
});

e2e/helpers.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { Page } from '@playwright/test';
2+
3+
export async function load(page: Page) {
4+
await page.goto('/');
5+
await page.locator('#board').waitFor({ state: 'visible' });
6+
}
7+
8+
export async function setGame(page: Page, state: Record<string, unknown>) {
9+
await page.evaluate((s) => { (window as any).__e2e__?.setGame(s); }, state);
10+
}
11+
12+
export async function setMatch(page: Page, match: Record<string, string> | null) {
13+
await page.evaluate((m) => { (window as any).__e2e__?.setMatch(m); }, match);
14+
}

e2e/rules.spec.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/**
2+
* Game rules tests using an injected mock online match state.
3+
*
4+
* window.__e2e__ (exposed in DEV mode by App) lets tests inject specific game
5+
* states without needing a real Firebase backend. A match whose game ID is
6+
* '__test__' is treated as a sentinel by the match observer: it marks the
7+
* match as online (so turn-enforcement logic applies) but skips the Firebase
8+
* subscription that would otherwise overwrite injected state.
9+
*/
10+
import { test, expect } from '@playwright/test';
11+
import { load, setGame, setMatch } from './helpers';
12+
13+
const TEST_MATCH = { game: '__test__', chat: '__test__', sort: '' };
14+
15+
test.describe('Game rules (online match simulation)', () => {
16+
test.describe('Turn enforcement', () => {
17+
test.beforeEach(({ page }) => load(page));
18+
19+
test('dice are disabled when it is not your turn', async ({ page }) => {
20+
await setMatch(page, TEST_MATCH);
21+
await setGame(page, { turn: 'opponent-uid', status: 'ROLLING' });
22+
// All dice images should carry the 'used' class when the disabled prop is true
23+
await expect(page.locator('.dice img').first()).toHaveClass(/used/);
24+
});
25+
26+
test('dice are not disabled when it is your turn', async ({ page }) => {
27+
// Local mode (no match): isMyTurn is always true
28+
await expect(page.locator('.dice.pulsate')).toBeVisible();
29+
await expect(page.locator('.dice img').first()).not.toHaveClass(/used/);
30+
});
31+
32+
test('pieces cannot be selected when it is not your turn', async ({ page }) => {
33+
await setMatch(page, TEST_MATCH);
34+
await setGame(page, { turn: 'opponent-uid', color: 'white', status: 'MOVING', dice: [3, 5] });
35+
// With match set and game.turn pointing to someone else, enabled=false on all points
36+
const point = page.locator('.point').nth(18);
37+
await point.click();
38+
await expect(point).not.toHaveClass(/selected/);
39+
});
40+
});
41+
42+
test.describe('Valid move highlighting', () => {
43+
test.beforeEach(({ page }) => load(page));
44+
45+
test('valid source points are highlighted after rolling', async ({ page }) => {
46+
await setGame(page, { color: 'white', status: 'MOVING', dice: [3, 5] });
47+
// White has pieces at indices 0, 11, 16 and 18 in the default board; all
48+
// have at least one reachable empty point with the given dice.
49+
await expect(page.locator('.point.valid')).not.toHaveCount(0);
50+
});
51+
52+
test('valid destinations are shown after selecting a source piece', async ({ page }) => {
53+
await setGame(page, { color: 'white', status: 'MOVING', dice: [3, 5] });
54+
// Click white piece at index 18 (5 white pieces in default board)
55+
await page.locator('.point').nth(18).click();
56+
// die=3 → index 21 is empty → valid
57+
await expect(page.locator('.point').nth(21)).toHaveClass(/valid/);
58+
});
59+
60+
test('blocked points with 2+ opponent pieces are not valid destinations', async ({ page }) => {
61+
await setGame(page, { color: 'white', status: 'MOVING', dice: [3, 5] });
62+
// Click white piece at index 18
63+
await page.locator('.point').nth(18).click();
64+
// die=5 → index 23 has -2 (two black pieces) → BLOCKED, must not be valid
65+
await expect(page.locator('.point').nth(23)).not.toHaveClass(/valid/);
66+
});
67+
});
68+
69+
test.describe('Bar (Prison)', () => {
70+
test.beforeEach(({ page }) => load(page));
71+
72+
test('bar is highlighted as valid source when a piece is on it', async ({ page }) => {
73+
// White has a piece on the bar; die=3 re-enters at index 9 (empty)
74+
await setGame(page, {
75+
color: 'white',
76+
status: 'MOVING',
77+
dice: [3],
78+
prison: { white: 1, black: 0 },
79+
});
80+
// The first .bar element in DOM order is the white bar
81+
await expect(page.locator('.bar').first()).toHaveClass(/valid/);
82+
});
83+
84+
test('no regular points are valid sources when a piece is on the bar', async ({ page }) => {
85+
await setGame(page, {
86+
color: 'white',
87+
status: 'MOVING',
88+
dice: [3],
89+
prison: { white: 1, black: 0 },
90+
});
91+
// All source selection must go through the bar; regular points must not be valid
92+
await expect(page.locator('.point.valid')).toHaveCount(0);
93+
});
94+
});
95+
96+
test.describe('Doubles', () => {
97+
test('doubles produce four dice', async ({ page }) => {
98+
await load(page);
99+
// Inject a 4-dice state directly (equivalent to a doubles roll)
100+
await setGame(page, { status: 'MOVING', dice: [4, 4, 4, 4] });
101+
await expect(page.locator('.dice img')).toHaveCount(4);
102+
});
103+
});
104+
});

src/index.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,15 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
3636

3737
const diceSound = new Audio('./shake-and-roll-dice-soundbible.mp3');
3838

39+
declare global {
40+
interface Window {
41+
__e2e__?: {
42+
setGame: (state: Partial<Game>) => void;
43+
setMatch: (match: Match | null) => void;
44+
};
45+
}
46+
}
47+
3948
export function App() {
4049
const { t } = useTranslation();
4150
const [game, setGame] = useState<Game>(newGame);
@@ -49,6 +58,15 @@ export function App() {
4958
const hadMatchRef = useRef(false);
5059
const gameSnapshotRef = useRef<SnapshotOrNullType>(null);
5160

61+
useEffect(() => {
62+
if (import.meta.env.DEV) {
63+
window.__e2e__ = {
64+
setGame: (s) => setGame(p => ({ ...p, ...s } as Game)),
65+
setMatch,
66+
};
67+
}
68+
}, []); // setGame and setMatch are stable React dispatchers
69+
5270
const load = useCallback(async (friendId?: string | false, authUserUid?: string) => {
5371
if (friendId === 'PeaceInTheMiddleEast' || friendId === '__' || friendId === 'preview') return;
5472
console.log('Loading', friendId, 'with authUserUid:', authUserUid);
@@ -336,6 +354,7 @@ export function App() {
336354
useEffect(() => { // match observer
337355
if (match) {
338356
hadMatchRef.current = true;
357+
if (match.game === '__test__') return; // skip Firebase subscription in tests
339358
const gameRef = firebase.database().ref(`games/${match.game}`);
340359
const onValue = (snapshot: firebaseType.database.DataSnapshot) => {
341360
gameSnapshotRef.current = snapshot

0 commit comments

Comments
 (0)