Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
37 changes: 37 additions & 0 deletions .github/workflows/ui-tests.yml
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
node_modules/
package-lock.json
playwright-report/
test-results/
9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@
"type": "module",
"scripts": {
"lint": "eslint script.js sw.js",
"lint:fix": "eslint --fix script.js sw.js"
"lint:fix": "eslint --fix script.js sw.js",
"test:serve": "http-server . -p 4173 -c-1 --silent",
"test:ui": "playwright test",
"test:ui:headed": "playwright test --headed"
},
"devDependencies": {
"@playwright/test": "^1.54.2",
"eslint": "^9.0.0",
"globals": "^15.0.0"
"globals": "^15.0.0",
"http-server": "^14.1.1"
}
}
38 changes: 38 additions & 0 deletions playwright.config.js
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'] }
}
]
});
88 changes: 87 additions & 1 deletion script.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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 the URLSearchParams API, which is specifically designed for parsing URL query strings.

Suggested change
const TEST_MODE = window.location.search.includes('e2e=1');
const TEST_MODE = new URLSearchParams(window.location.search).has('e2e=1');


const boardEl = document.getElementById('board');
const statusEl = document.getElementById('status');
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

These helper functions are intended only for testing but are included in the main application script. While they are guarded by the TEST_MODE flag, they still add to the production bundle size and increase the application's potential attack surface. For larger applications, it's a best practice to use a build process (like tree-shaking with environment variables) to completely remove such test-specific code from production builds.


// ── Build digit pad ───────────────────────────────────────────────────────

function buildDigitPad(){
Expand Down Expand Up @@ -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(()=>{
Expand Down
2 changes: 1 addition & 1 deletion sw.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const CACHE_NAME = 'shandoku-wife-edition-v5';
const CACHE_NAME = 'shandoku-wife-edition-v6';
const STATIC_ASSETS = [
'./index.html',
'./style.css',
Expand Down
49 changes: 49 additions & 0 deletions tests/gameplay.placeholder.spec.js
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');
});
});
17 changes: 17 additions & 0 deletions tests/ui.mobile.spec.js
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/);
});
});
102 changes: 102 additions & 0 deletions tests/ui.smoke.spec.js
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();
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove persistent init script from fresh-game helper

openFreshGame uses page.addInitScript to clear storage, but Playwright runs init scripts before every navigation in that page, including page.reload(). In the "saved game can be resumed" test, the reload therefore clears localStorage right before app boot, so the resume modal cannot appear and the test fails for the wrong reason.

Useful? React with 👍 / 👎.

await page.goto('/?e2e=1');
await expect(page.locator('#board .cell')).toHaveCount(81);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This openFreshGame helper function is very useful. Similar setup logic (clearing storage, navigating to the test page) is duplicated in tests/gameplay.placeholder.spec.js and tests/ui.mobile.spec.js. To improve maintainability and reduce code duplication, consider extracting this helper into a shared utility file (e.g., tests/test-utils.js) and importing it where needed across your test suites.


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');
});
});
Loading