Skip to content
Draft
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
16 changes: 15 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:
run: yarn lint
continue-on-error: true

- name: Run tests
- name: Run unit tests
run: yarn test --coverage

- name: Upload coverage reports
Expand All @@ -40,3 +40,17 @@ jobs:
name: coverage-report
path: coverage/
retention-days: 30

- name: Install Playwright browsers
run: yarn playwright install chromium --with-deps

- name: Run E2E tests
run: yarn test:e2e

- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 30
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,7 @@ dev-dist
*.sln
*.sw?

### /Vite ###
### /Vite ###
# Playwright
test-results/
playwright-report/
109 changes: 109 additions & 0 deletions e2e/board.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { test, expect } from '@playwright/test';
import { load, setGame } from './helpers';

test.describe('Board UI', () => {
test.describe('Structure', () => {
test.beforeEach(async ({ page }) => await load(page));

test('has 24 points', async ({ page }) => {
await expect(page.locator('.point')).toHaveCount(24);
});

test('has 2 bar areas', async ({ page }) => {
await expect(page.locator('.bar')).toHaveCount(2);
});

test('has 2 home areas', async ({ page }) => {
await expect(page.locator('.home')).toHaveCount(2);
});

test('shows 30 pieces in starting position', async ({ page }) => {
await expect(page.locator('.piece:not(.ghost)')).toHaveCount(30);
});

test('home areas are empty at the start', async ({ page }) => {
await expect(page.locator('.home .piece')).toHaveCount(0);
});
});

test.describe('Toolbar', () => {
test.beforeEach(async ({ page }) => await load(page));

test('shows Offline Game label when no opponent is loaded', async ({ page }) => {
await expect(page.locator('#toolbar h2')).toHaveText('Offline Game');
});

test('shows account icon when playing locally', async ({ page }) => {
await expect(page.locator('#toolbar .material-icons-svg')).toBeVisible();
});
});

test.describe('Color state', () => {
test.beforeEach(async ({ page }) => await load(page));

test('board has no color class before any moves', async ({ page }) => {
await expect(page.locator('#board')).not.toHaveClass(/white|black/);
});

test('board gains white class when game color is set to white', async ({ page }) => {
await setGame(page, { color: 'white' });
await expect(page.locator('#board')).toHaveClass(/white/);
});

test('board gains black class when game color is set to black', async ({ page }) => {
await setGame(page, { color: 'black' });
await expect(page.locator('#board')).toHaveClass(/black/);
});
});

test.describe('Dice', () => {
test.beforeEach(async ({ page }) => await load(page));

test('dice pulsate while waiting for a roll', async ({ page }) => {
await expect(page.locator('.dice.pulsate')).toBeVisible();
});

test('dice stop pulsating after rolling', async ({ page }) => {
await page.locator('.dice').click();
await expect(page.locator('.dice')).not.toHaveClass(/pulsate/);
});

test('undo button is hidden before any moves are made', async ({ page }) => {
await page.locator('.dice').click();
await expect(page.locator('.undo')).not.toBeVisible();
});
});

test.describe('Piece selection', () => {
test.beforeEach(async ({ page }) => await load(page));

test('valid source points are highlighted during a move', async ({ page }) => {
await setGame(page, { color: 'white', status: 'MOVING', dice: [3, 5] });
await expect(page.locator('.point.valid').first()).toBeVisible();
});

test('clicking a point selects it', async ({ page }) => {
const point = page.locator('.point').nth(18);
await point.click();
await expect(point).toHaveClass(/selected/);
});

test('clicking a selected point deselects it', async ({ page }) => {
const point = page.locator('.point').nth(18);
await point.click();
await expect(point).toHaveClass(/selected/);
await point.click();
await expect(point).not.toHaveClass(/selected/);
});

test('selecting a different point moves the selection highlight', async ({ page }) => {
const first = page.locator('.point').nth(0);
const second = page.locator('.point').nth(16);
await first.click();
await expect(first).toHaveClass(/selected/);
await second.click();
await expect(first).not.toHaveClass(/selected/);
await expect(second).toHaveClass(/selected/);
});
});
});
79 changes: 79 additions & 0 deletions e2e/dialogues.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { test, expect } from '@playwright/test';
import { load } from './helpers';

test.describe('Dialogues', () => {
test.describe('Login dialog', () => {
test.beforeEach(async ({ page }) => await load(page));

test('dialog is closed by default', async ({ page }) => {
await expect(page.locator('dialog')).not.toBeVisible();
});

test('toolbar click opens the login dialog', async ({ page }) => {
await page.locator('#toolbar').click();
await expect(page.locator('dialog[open]')).toBeVisible();
await expect(page.locator('#login')).toBeVisible();
});

test('login dialog title shows Play Online when no friend is loaded', async ({ page }) => {
await page.locator('#toolbar').click();
await expect(page.locator('#login h1')).toHaveText('Play Online');
});

test('login dialog has a FirebaseUI container', async ({ page }) => {
await page.locator('#toolbar').click();
// The div that FirebaseUI mounts into is always present
await expect(page.locator('#login > div')).toBeVisible();
});

test('clicking outside the dialog closes it', async ({ page }) => {
await page.locator('#toolbar').click();
await expect(page.locator('dialog[open]')).toBeVisible();
await page.locator('#board').click({ position: { x: 5, y: 5 }, force: true });
await expect(page.locator('dialog')).not.toBeVisible();
});
});

test.describe('Menu', () => {
test.beforeEach(async ({ page }) => {
await load(page);
await page.locator('#toolbar').click();
await expect(page.locator('#login')).toBeVisible();
});

test('menu button has correct accessibility attributes', async ({ page }) => {
const btn = page.locator('#login button[aria-haspopup="menu"]');
await expect(btn).toBeVisible();
await expect(btn).toHaveAttribute('aria-expanded', 'false');
});

test('menu button toggles aria-expanded on click', async ({ page }) => {
const btn = page.locator('#login button[aria-haspopup="menu"]');
await btn.click();
await expect(btn).toHaveAttribute('aria-expanded', 'true');
await btn.click();
await expect(btn).toHaveAttribute('aria-expanded', 'false');
});

test('menu contains a Reset Game option', async ({ page }) => {
await page.locator('#login button[aria-haspopup="menu"]').click();
await expect(page.locator('#login menu').getByText('Reset Game')).toBeVisible();
});

test('menu contains a Report Bug link', async ({ page }) => {
await page.locator('#login button[aria-haspopup="menu"]').click();
await expect(page.locator('#login menu').getByText('Report Bug')).toBeVisible();
});

test('menu contains an About link', async ({ page }) => {
await page.locator('#login button[aria-haspopup="menu"]').click();
await expect(page.locator('#login menu').getByText('About')).toBeVisible();
});

test('menu shows the app version', async ({ page }) => {
await page.locator('#login button[aria-haspopup="menu"]').click();
// Version component renders a version string like "1.1.0-abc1234"
await expect(page.locator('#login menu').getByText(/\d+\.\d+\.\d+/)).toBeVisible();
});
});
});
101 changes: 101 additions & 0 deletions e2e/game.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { test, expect } from '@playwright/test';
import { load } from './helpers';

test.describe('Game board', () => {
test.beforeEach(async ({ page }) => await load(page));

test('renders the board with toolbar and dice', async ({ page }) => {
await expect(page.locator('#board')).toBeVisible();
await expect(page.locator('#toolbar')).toBeVisible();
await expect(page.locator('.dice')).toBeVisible();
});

test('displays 24 points, 2 bar areas, and 2 home areas', async ({ page }) => {
await expect(page.locator('.point')).toHaveCount(24);
await expect(page.locator('.bar')).toHaveCount(2);
await expect(page.locator('.home')).toHaveCount(2);
});

test('toolbar shows offline game label when no opponent is loaded', async ({ page }) => {
await expect(page.locator('#toolbar h2')).toHaveText('Offline Game');
});

test('pieces are present on the starting board', async ({ page }) => {
const pieces = page.locator('.piece:not(.ghost)');
await expect(pieces).toHaveCount(30);
});
});

test.describe('Dice', () => {
test.beforeEach(async ({ page }) => await load(page));

test('dice start in pulsating rolling state', async ({ page }) => {
await expect(page.locator('.dice.pulsate')).toBeVisible();
});

test('rolling dice transitions from rolling to moving state', async ({ page }) => {
await expect(page.locator('.dice.pulsate')).toBeVisible();
await page.locator('.dice').click();
await expect(page.locator('.dice')).not.toHaveClass(/pulsate/);
});

test('rolling dice shows two die images', async ({ page }) => {
await page.locator('.dice').click();
await expect(page.locator('.dice img')).toHaveCount(2);
});
});

test.describe('Piece selection', () => {
test.beforeEach(async ({ page }) => await load(page));

test('clicking a point selects it in local mode', async ({ page }) => {
const point = page.locator('.point').first();
await point.click();
await expect(point).toHaveClass(/selected/);
});

test('clicking a selected point deselects it', async ({ page }) => {
const point = page.locator('.point').first();
await point.click();
await expect(point).toHaveClass(/selected/);
await point.click();
await expect(point).not.toHaveClass(/selected/);
});

test('selecting a different point moves the selection', async ({ page }) => {
const firstPoint = page.locator('.point').nth(0);
const secondPoint = page.locator('.point').nth(11);
await firstPoint.click();
await expect(firstPoint).toHaveClass(/selected/);
await secondPoint.click();
await expect(firstPoint).not.toHaveClass(/selected/);
await expect(secondPoint).toHaveClass(/selected/);
});
});

test.describe('Login dialog', () => {
test.beforeEach(async ({ page }) => await load(page));

test('dialog is initially closed', async ({ page }) => {
await expect(page.locator('dialog')).not.toBeVisible();
});

test('toolbar click opens the login dialog', async ({ page }) => {
await page.locator('#toolbar').click();
await expect(page.locator('dialog[open]')).toBeVisible();
await expect(page.locator('#login')).toBeVisible();
});

test('login dialog contains a menu button and title', async ({ page }) => {
await page.locator('#toolbar').click();
await expect(page.locator('#login button[aria-haspopup="menu"]')).toBeVisible();
await expect(page.locator('#login h1')).toBeVisible();
});

test('clicking outside the dialog closes it', async ({ page }) => {
await page.locator('#toolbar').click();
await expect(page.locator('dialog[open]')).toBeVisible();
await page.locator('#board').click({ position: { x: 5, y: 5 }, force: true });
await expect(page.locator('dialog')).not.toBeVisible();
});
});
19 changes: 19 additions & 0 deletions e2e/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { Page } from '@playwright/test';

export async function load(page: Page) {
await page.goto('/');
await page.locator('#board').waitFor({ state: 'visible' });
// Wait for the Firebase auth observer to have fired and for React to have
// committed all resulting state resets (setSelected(null), setUsedDice([])).
// Without this, tests that click immediately after load can race with the
// auth callback clearing their selection.
await page.waitForFunction(() => (window as any).__e2e__?.authReady === true);
}

export async function setGame(page: Page, state: Record<string, unknown>) {
await page.evaluate((s) => { (window as any).__e2e__?.setGame(s); }, state);
}

export async function setMatch(page: Page, match: Record<string, string> | null) {
await page.evaluate((m) => { (window as any).__e2e__?.setMatch(m); }, match);
}
Loading