-
Notifications
You must be signed in to change notification settings - Fork 0
Add Playwright UI tests, test harness and CI workflow #27
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
763a415
e1ba80a
0d949ea
17d81c7
9b7eeba
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| name: ui-tests | ||
|
|
||
| on: | ||
| push: | ||
| pull_request: | ||
|
|
||
| jobs: | ||
| playwright: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - name: Checkout | ||
| uses: actions/checkout@v4 | ||
|
|
||
| - name: Setup Node | ||
| uses: actions/setup-node@v4 | ||
| with: | ||
| node-version: 20 | ||
| cache: npm | ||
|
|
||
| - name: Install dependencies | ||
| run: npm install --no-package-lock | ||
|
|
||
| - name: Install Playwright browsers | ||
| run: npx playwright install --with-deps chromium | ||
|
|
||
| - name: Run UI tests | ||
| run: npm run test:ui | ||
|
|
||
| - name: Upload Playwright artifacts on failure | ||
| if: failure() | ||
| uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: playwright-artifacts | ||
| path: | | ||
| playwright-report/ | ||
| test-results/ | ||
| if-no-files-found: ignore |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,4 @@ | ||
| node_modules/ | ||
| package-lock.json | ||
| playwright-report/ | ||
| test-results/ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import { defineConfig, devices } from '@playwright/test'; | ||
|
|
||
| const PORT = 4173; | ||
| const BASE_URL = `http://127.0.0.1:${PORT}`; | ||
|
|
||
| export default defineConfig({ | ||
| testDir: './tests', | ||
| timeout: 45_000, | ||
| expect: { | ||
| timeout: 10_000 | ||
| }, | ||
| fullyParallel: true, | ||
| forbidOnly: !!process.env.CI, | ||
| retries: process.env.CI ? 1 : 0, | ||
| reporter: [['list'], ['html', { open: 'never' }]], | ||
| use: { | ||
| baseURL: BASE_URL, | ||
| trace: 'on-first-retry', | ||
| screenshot: 'only-on-failure', | ||
| video: 'retain-on-failure' | ||
| }, | ||
| webServer: { | ||
| command: 'npm run test:serve', | ||
| url: BASE_URL, | ||
| reuseExistingServer: !process.env.CI, | ||
| timeout: 30_000 | ||
| }, | ||
| projects: [ | ||
| { | ||
| name: 'chromium', | ||
| use: { ...devices['Desktop Chrome'] } | ||
| }, | ||
| { | ||
| name: 'mobile-chrome', | ||
| use: { ...devices['Pixel 7'] } | ||
| } | ||
| ] | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,7 @@ | |
| const RIFT_STATUS_DELAY_REDUCED_MOTION_MS = 500; | ||
| const RIFT_EVALUATE_DEBOUNCE_MS = 140; | ||
| const RIFT_NON_CONFLICT_CHECK_INTERVAL = 5; | ||
| const TEST_MODE = window.location.search.includes('e2e=1'); | ||
|
|
||
| const boardEl = document.getElementById('board'); | ||
| const statusEl = document.getElementById('status'); | ||
|
|
@@ -255,7 +256,7 @@ | |
|
|
||
| // ── UI updates ──────────────────────────────────────────────────────────── | ||
|
|
||
| const MIN_SPLASH_MS=800; | ||
| const MIN_SPLASH_MS=TEST_MODE?0:800; | ||
| const splashShownAt=Date.now(); | ||
| function hideSplash(){ | ||
| const splash=document.getElementById('splash'); | ||
|
|
@@ -833,6 +834,90 @@ | |
| saveGame(); | ||
| } | ||
|
|
||
| function normalizeTestBoard(board, fallbackValue=0){ | ||
| if(!Array.isArray(board)||board.length!==GRID_SIZE){ | ||
| return Array.from({length:GRID_SIZE},()=>Array.from({length:GRID_SIZE},()=>fallbackValue)); | ||
| } | ||
| return board.map(row=>{ | ||
| if(!Array.isArray(row)||row.length!==GRID_SIZE) return Array.from({length:GRID_SIZE},()=>fallbackValue); | ||
| return row.map(value=>{ | ||
| const n=Number(value); | ||
| if(Number.isInteger(n)&&n>=0&&n<=9) return n; | ||
| return fallbackValue; | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| function applyTestBoardState(payload={}){ | ||
| const nextGrid=normalizeTestBoard(payload.grid,0); | ||
| const nextStartingGrid=normalizeTestBoard(payload.startingGrid,0); | ||
| grid=cloneGrid(nextGrid); | ||
| startingGrid=cloneGrid(nextStartingGrid); | ||
| notes=Array.from({length:GRID_SIZE},()=>Array.from({length:GRID_SIZE},()=>new Set())); | ||
| selected=(payload.selected&&Number.isInteger(payload.selected.r)&&Number.isInteger(payload.selected.c)) | ||
| ?{r:Math.max(0,Math.min(8,payload.selected.r)),c:Math.max(0,Math.min(8,payload.selected.c))} | ||
| :null; | ||
| elapsed=normalizeElapsed(payload.elapsed); | ||
| notesMode=!!payload.notesMode; | ||
| autoCleanup=payload.autoCleanup!==false; | ||
| history=[]; future=[]; | ||
| boardShellEl.classList.remove('victory-glow'); | ||
| clearRiftVisualState(); | ||
| riftState={active:false,sequenceRunning:false,nodes:[],hasTriggered:false,cooldownUntil:0,copyKey:'pattern'}; | ||
| movesSinceSolvabilityCheck=0; | ||
| captureLastSolvableSnapshot(); | ||
| render(); | ||
| saveGame(); | ||
| } | ||
|
|
||
| function exposeTestApi(){ | ||
| window.__shandokuTest={ | ||
| resetStorage(){ | ||
| localStorage.removeItem(STORAGE_KEY); | ||
| }, | ||
| setBoardState(payload){ | ||
| applyTestBoardState(payload); | ||
| }, | ||
| getState(){ | ||
| return { | ||
| grid:cloneGrid(grid), | ||
| startingGrid:cloneGrid(startingGrid), | ||
| selected:selected?{...selected}:null, | ||
| notesMode, | ||
| autoCleanup, | ||
| errorCount:countErrors(), | ||
| status:statusEl.textContent | ||
| }; | ||
| }, | ||
| selectCell(r,c){ | ||
| selected={r:Math.max(0,Math.min(8,r)),c:Math.max(0,Math.min(8,c))}; | ||
| render(); | ||
| saveGame(); | ||
| }, | ||
| placeNumber(n){ | ||
| placeNumber(n); | ||
| }, | ||
| newGame(){ | ||
| newGame(); | ||
| }, | ||
| forceRift(payload={}){ | ||
| const r=Number.isInteger(payload.r)?Math.max(0,Math.min(8,payload.r)):4; | ||
| const c=Number.isInteger(payload.c)?Math.max(0,Math.min(8,payload.c)):4; | ||
| clearRiftVisualState(); | ||
| riftState.active=true; | ||
| riftState.sequenceRunning=false; | ||
| riftState.nodes=[{r,c}]; | ||
| riftState.hasTriggered=true; | ||
| riftState.cooldownUntil=Date.now()+RIFT_COOLDOWN_MS; | ||
| boardShellEl.classList.add('rift-active'); | ||
| statusEl.classList.add('rift-status'); | ||
| render(); | ||
| setStatus('Rift node found. Tap the marked cell.'); | ||
| saveGame(); | ||
| } | ||
| }; | ||
| } | ||
|
Comment on lines
+839
to
+921
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These helper functions are intended only for testing but are included in the main application script. While they are guarded by the |
||
|
|
||
| // ── Build digit pad ─────────────────────────────────────────────────────── | ||
|
|
||
| function buildDigitPad(){ | ||
|
|
@@ -949,6 +1034,7 @@ | |
|
|
||
| applyTheme(localStorage.getItem(THEME_KEY)||'dark'); | ||
| buildDigitPad(); | ||
| if(TEST_MODE) exposeTestApi(); | ||
|
|
||
| // Defer game init until after the first paint so the splash animates. | ||
| requestAnimationFrame(()=>requestAnimationFrame(()=>{ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| import { test, expect } from '@playwright/test'; | ||
|
|
||
| test.describe('Gameplay regression placeholders', () => { | ||
| test('deterministic Rift trigger is visible', async ({ page }) => { | ||
| await page.addInitScript(() => localStorage.clear()); | ||
| await page.goto('/?e2e=1'); | ||
|
|
||
| await expect(page.locator('#board .cell')).toHaveCount(81); | ||
| await page.evaluate(() => { | ||
| window.__shandokuTest.setBoardState({ | ||
| grid: Array.from({ length: 9 }, () => Array.from({ length: 9 }, () => 0)), | ||
| startingGrid: Array.from({ length: 9 }, () => Array.from({ length: 9 }, () => 0)) | ||
| }); | ||
| window.__shandokuTest.forceRift({ r: 0, c: 0 }); | ||
| }); | ||
|
|
||
| await expect(page.locator('.board-shell')).toHaveClass(/rift-active/); | ||
| await expect(page.locator('.cell.rift-node[data-r="0"][data-c="0"]')).toBeVisible(); | ||
| await expect(page.locator('#status')).toContainText('Rift node found'); | ||
| }); | ||
|
|
||
| test('restore last solvable state from Rift modal', async ({ page }) => { | ||
| await page.addInitScript(() => localStorage.clear()); | ||
| await page.goto('/?e2e=1'); | ||
| await expect(page.locator('#board .cell')).toHaveCount(81); | ||
|
|
||
| await page.evaluate(() => { | ||
| const blank = Array.from({ length: 9 }, () => Array.from({ length: 9 }, () => 0)); | ||
| blank[0][0] = 4; | ||
| window.__shandokuTest.setBoardState({ | ||
| grid: blank, | ||
| startingGrid: Array.from({ length: 9 }, () => Array.from({ length: 9 }, () => 0)), | ||
| selected: { r: 0, c: 1 } | ||
| }); | ||
| }); | ||
| await page.getByRole('button', { name: 'Enter 4' }).click(); | ||
| await expect(page.locator('.cell[data-r="0"][data-c="1"]')).toHaveText('4'); | ||
| await expect(page.locator('.cell[data-r="0"][data-c="1"]')).toHaveClass(/error/); | ||
|
|
||
| await page.evaluate(() => window.__shandokuTest.forceRift({ r: 0, c: 1 })); | ||
| await page.locator('.cell.rift-node[data-r="0"][data-c="1"]').click(); | ||
| await expect(page.locator('#riftModal')).toBeVisible(); | ||
| await page.getByRole('button', { name: 'Restore last solvable' }).click(); | ||
|
|
||
| await expect(page.locator('.cell[data-r="0"][data-c="1"]')).toHaveText(''); | ||
| await expect(page.locator('.board-shell')).not.toHaveClass(/rift-active/); | ||
| await expect(page.locator('#status')).toContainText('Restored to the last solvable state'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import { test, expect } from '@playwright/test'; | ||
|
|
||
| test.describe('Mobile viewport behavior', () => { | ||
| test('board renders and remains interactive on mobile profile', async ({ page, isMobile }) => { | ||
| test.skip(!isMobile, 'This test is intended for mobile projects.'); | ||
|
|
||
| await page.addInitScript(() => localStorage.clear()); | ||
| await page.goto('/?e2e=1'); | ||
|
|
||
| await expect(page.locator('#board .cell')).toHaveCount(81); | ||
| await expect(page.locator('.board-shell')).toBeVisible(); | ||
|
|
||
| const touchCell = page.locator('.cell:not(.fixed)').first(); | ||
| await touchCell.tap(); | ||
| await expect(touchCell).toHaveClass(/selected/); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| import { test, expect } from '@playwright/test'; | ||
|
|
||
| async function openFreshGame(page) { | ||
| await page.addInitScript(() => { | ||
| localStorage.clear(); | ||
| sessionStorage.clear(); | ||
| }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| await page.goto('/?e2e=1'); | ||
| await expect(page.locator('#board .cell')).toHaveCount(81); | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This |
||
|
|
||
| test.describe('Shandoku smoke + core interactions', () => { | ||
| test('app loads and board renders 81 cells', async ({ page }) => { | ||
| await openFreshGame(page); | ||
|
|
||
| await expect(page.locator('h1')).toContainText('Shandoku'); | ||
| await expect(page.locator('#status')).toContainText('Tap a cell'); | ||
| }); | ||
|
|
||
| test('cell selection works', async ({ page }) => { | ||
| await openFreshGame(page); | ||
|
|
||
| const targetCell = page.locator('.cell:not(.fixed)').first(); | ||
| await targetCell.click(); | ||
| await expect(targetCell).toHaveClass(/selected/); | ||
| }); | ||
|
|
||
| test('entering a number updates the cell', async ({ page }) => { | ||
| await openFreshGame(page); | ||
|
|
||
| await page.evaluate(() => { | ||
| window.__shandokuTest.setBoardState({ | ||
| grid: Array.from({ length: 9 }, () => Array.from({ length: 9 }, () => 0)), | ||
| startingGrid: Array.from({ length: 9 }, () => Array.from({ length: 9 }, () => 0)), | ||
| selected: { r: 0, c: 0 } | ||
| }); | ||
| }); | ||
|
|
||
| await page.getByRole('button', { name: 'Enter 5' }).click(); | ||
|
|
||
| const editedCell = page.locator('.cell[data-r="0"][data-c="0"]'); | ||
| await expect(editedCell).toHaveText('5'); | ||
| await expect(editedCell).toHaveClass(/user/); | ||
| }); | ||
|
|
||
| test('direct conflict is visibly indicated', async ({ page }) => { | ||
| await openFreshGame(page); | ||
|
|
||
| await page.evaluate(() => { | ||
| const emptyBoard = Array.from({ length: 9 }, () => Array.from({ length: 9 }, () => 0)); | ||
| emptyBoard[0][0] = 1; | ||
| window.__shandokuTest.setBoardState({ | ||
| grid: emptyBoard, | ||
| startingGrid: Array.from({ length: 9 }, () => Array.from({ length: 9 }, () => 0)), | ||
| selected: { r: 0, c: 1 } | ||
| }); | ||
| }); | ||
|
|
||
| await page.getByRole('button', { name: 'Enter 1' }).click(); | ||
|
|
||
| const conflictCell = page.locator('.cell[data-r="0"][data-c="1"]'); | ||
| await expect(conflictCell).toHaveClass(/error/); | ||
| await expect(page.locator('#errorStat')).toContainText('err'); | ||
| }); | ||
|
|
||
| test('notes mode can be toggled', async ({ page }) => { | ||
| await openFreshGame(page); | ||
|
|
||
| const notesBtn = page.locator('#notesModeBtn'); | ||
| await notesBtn.click(); | ||
| await expect(notesBtn).toContainText('Notes On'); | ||
| await expect(notesBtn).toHaveClass(/active/); | ||
| }); | ||
|
|
||
| test('saved game can be resumed', async ({ page }) => { | ||
| await openFreshGame(page); | ||
|
|
||
| await page.evaluate(() => { | ||
| window.__shandokuTest.setBoardState({ | ||
| grid: [ | ||
| [7, 0, 0, 0, 0, 0, 0, 0, 0], | ||
| [0, 0, 0, 0, 0, 0, 0, 0, 0], | ||
| [0, 0, 0, 0, 0, 0, 0, 0, 0], | ||
| [0, 0, 0, 0, 0, 0, 0, 0, 0], | ||
| [0, 0, 0, 0, 0, 0, 0, 0, 0], | ||
| [0, 0, 0, 0, 0, 0, 0, 0, 0], | ||
| [0, 0, 0, 0, 0, 0, 0, 0, 0], | ||
| [0, 0, 0, 0, 0, 0, 0, 0, 0], | ||
| [0, 0, 0, 0, 0, 0, 0, 0, 0] | ||
| ], | ||
| startingGrid: Array.from({ length: 9 }, () => Array.from({ length: 9 }, () => 0)) | ||
| }); | ||
| }); | ||
|
|
||
| await page.reload(); | ||
|
|
||
| await expect(page.locator('#resumeModal')).toBeVisible(); | ||
| await page.getByRole('button', { name: 'Resume' }).click(); | ||
|
|
||
| await expect(page.locator('.cell[data-r="0"][data-c="0"]')).toHaveText('7'); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using
window.location.search.includes('e2e=1')can be brittle as it might lead to false positives (e.g., with a URL like?some_other_param_e2e=1). A more robust and standard approach is to use theURLSearchParamsAPI, which is specifically designed for parsing URL query strings.