carlos / todomvc-bakeoff Public
test: TodoMVC behaviour suite, JS on and off, plus robustness and visual checks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9 files changed,
+502
−2
scripts/test-app.sh+13 −0tests/behaviour/README.md+25 −0tests/behaviour/actions.ts+74 −0tests/behaviour/package.json+7 −2tests/behaviour/playwright.config.ts+18 −0tests/behaviour/robustness.spec.ts+87 −0tests/behaviour/todomvc.spec.ts+256 −0tests/behaviour/visual.spec.ts+22 −0tests/behaviour/visual.spec.ts-snapshots/reference-js-on-linux.pngbinary
diff --git a/scripts/test-app.sh b/scripts/test-app.sh| new file mode 100755 |
| index 0000000..53fb612 |
| --- /dev/null |
| +++ b/scripts/test-app.sh |
| @@ -0,0 +1,13 @@ |
| +#!/usr/bin/env bash |
| +# Runs the shared behaviour suite against one app, with JavaScript on and off. |
| +# Usage: scripts/test-app.sh <base-url> [results.json] |
| +set -euo pipefail |
| +[ $# -ge 1 ] || { echo "usage: $0 <base-url> [results.json]" >&2; exit 2; } |
| +here=$(cd "$(dirname "$0")/../tests/behaviour" && pwd) |
| +results=${2:-} |
| +[ -z "$results" ] || results=$(cd "$(dirname "$results")" && pwd)/$(basename "$results") |
| +cd "$here" |
| +if [ ! -d node_modules/@playwright/test ]; then |
| + npm ci --cache "${TMPDIR:-/tmp}/npm-cache" --no-audit --no-fund >/dev/null |
| +fi |
| +BASE_URL="$1" RESULTS_FILE="$results" exec npx playwright test --project js-on --project js-off |
diff --git a/tests/behaviour/README.md b/tests/behaviour/README.md| new file mode 100644 |
| index 0000000..ceb799f |
| --- /dev/null |
| +++ b/tests/behaviour/README.md |
| @@ -0,0 +1,25 @@ |
| +# 🤖 Behaviour suite |
| + |
| +The same tests for every app, run twice: once with JavaScript on |
| +(`js-on`) and once with it off (`js-off`). |
| + |
| + scripts/test-app.sh <base-url> [results.json] |
| + |
| +- `todomvc.spec.ts`: the 29 tests of TodoMVC's own Cypress suite |
| + (`tastejs/todomvc/cypress/e2e/spec.cy.js`), ported. Each test gets a |
| + fresh browser context, so a fresh cookie and an empty list. |
| + Persistence is checked by reloading, and routing uses real paths. |
| + With JS off, "save edits on blur" and "cancel edits on escape" are |
| + skipped, and editing goes through the edit link. |
| +- `robustness.spec.ts`: HTML in titles, UTF-8 and `Content-Length`, |
| + blank and very long titles, per-visitor isolation and garbled cookies, |
| + and cross-site and cross-list POSTs. |
| +- `visual.spec.ts`: with JS on, the page must match a screenshot of |
| + `shared/reference.html` within 2% of pixels, with `footer.info` |
| + masked. To regenerate the golden image: `npm run golden`. |
| + |
| +Against the reference fixture (2026-10-23): **67 passed, 3 skipped** |
| +(the two JS-only edit tests and the screenshot test with JS off). Each |
| +of these deliberate breaks was caught: a 500 on a foreign id, unescaped |
| +titles, `#/` filter links, untrimmed edits, and no-JS controls visible |
| +with JS on. |
diff --git a/tests/behaviour/actions.ts b/tests/behaviour/actions.ts| new file mode 100644 |
| index 0000000..bd47643 |
| --- /dev/null |
| +++ b/tests/behaviour/actions.ts |
| @@ -0,0 +1,74 @@ |
| +// Every user action, performed the way a person would with JavaScript on |
| +// (double-click, checkbox clicks) or off (the contract's submit controls |
| +// and edit link). Each one returns once the server has answered. |
| +import { expect, type Page, type Locator, type TestInfo } from '@playwright/test'; |
| + |
| +export const jsOn = (ti: TestInfo) => ti.project.name === 'js-on'; |
| +export const todos = (page: Page) => page.locator('.todo-list li'); |
| + |
| +// Runs fn and waits for what it causes: a page load with JS off, the POST |
| +// response with JS on. |
| +async function settle(page: Page, ti: TestInfo, fn: () => Promise<void>) { |
| + if (jsOn(ti)) { |
| + await Promise.all([page.waitForResponse((r) => r.request().method() === 'POST'), fn()]); |
| + } else { |
| + await Promise.all([page.waitForEvent('load'), fn()]); |
| + } |
| +} |
| + |
| +export async function addTodo(page: Page, ti: TestInfo, title: string) { |
| + const input = page.locator('.new-todo'); |
| + await input.fill(title); |
| + await settle(page, ti, () => input.press('Enter')); |
| +} |
| + |
| +export async function addDefaultTodos(page: Page, ti: TestInfo, titles: string[]) { |
| + for (const t of titles) await addTodo(page, ti, t); |
| + await expect(todos(page)).toHaveCount(titles.length); |
| +} |
| + |
| +export async function toggle(page: Page, ti: TestInfo, item: Locator) { |
| + const control = jsOn(ti) ? item.locator('.toggle') : item.locator('[data-action="toggle"]'); |
| + await settle(page, ti, () => control.click()); |
| +} |
| + |
| +export async function toggleAll(page: Page, ti: TestInfo) { |
| + const control = jsOn(ti) |
| + ? page.locator('label[for="toggle-all"]') |
| + : page.locator('[data-action="toggle-all"]'); |
| + await settle(page, ti, () => control.click()); |
| +} |
| + |
| +export async function destroy(page: Page, ti: TestInfo, item: Locator) { |
| + await item.hover(); |
| + await settle(page, ti, () => item.locator('.destroy').click()); |
| +} |
| + |
| +export async function clearCompleted(page: Page, ti: TestInfo) { |
| + await settle(page, ti, () => page.locator('.clear-completed').click()); |
| +} |
| + |
| +// Opens the item's editor: double-click with JS, the edit link without. |
| +// Returns the edit input. |
| +export async function startEditing(page: Page, ti: TestInfo, index: number): Promise<Locator> { |
| + const item = todos(page).nth(index); |
| + if (jsOn(ti)) { |
| + await item.locator('label').dblclick(); |
| + } else { |
| + await Promise.all([page.waitForEvent('load'), item.locator('a[data-action="edit"]').click()]); |
| + } |
| + const input = todos(page).nth(index).locator('.edit'); |
| + await expect(input).toBeVisible(); |
| + return input; |
| +} |
| + |
| +export async function submitEdit(page: Page, ti: TestInfo, input: Locator, title: string) { |
| + await input.fill(title); |
| + await settle(page, ti, () => input.press('Enter')); |
| +} |
| + |
| +export async function gotoFilter(page: Page, name: 'All' | 'Active' | 'Completed') { |
| + const path = { All: '/', Active: '/active', Completed: '/completed' }[name]; |
| + await page.locator('.filters a', { hasText: name }).click(); |
| + await expect(page).toHaveURL((u) => u.pathname === path); |
| +} |
diff --git a/tests/behaviour/package.json b/tests/behaviour/package.json| index 1281cd1..7503533 100644 |
| --- a/tests/behaviour/package.json |
| +++ b/tests/behaviour/package.json |
| @@ -2,6 +2,11 @@ |
| "name": "bakeoff-behaviour", |
| "private": true, |
| "type": "module", |
| - "scripts": { "test": "playwright test" }, |
| - "devDependencies": { "@playwright/test": "1.62.1" } |
| + "scripts": { |
| + "test": "playwright test", |
| + "golden": "GOLDEN=1 playwright test visual.spec.ts --project js-on --update-snapshots" |
| + }, |
| + "devDependencies": { |
| + "@playwright/test": "1.62.1" |
| + } |
| } |
diff --git a/tests/behaviour/playwright.config.ts b/tests/behaviour/playwright.config.ts| new file mode 100644 |
| index 0000000..ce8ed44 |
| --- /dev/null |
| +++ b/tests/behaviour/playwright.config.ts |
| @@ -0,0 +1,18 @@ |
| +import { defineConfig } from '@playwright/test'; |
| + |
| +const reporter: any[] = [['list']]; |
| +if (process.env.RESULTS_FILE) reporter.push(['json', { outputFile: process.env.RESULTS_FILE }]); |
| + |
| +export default defineConfig({ |
| + testDir: '.', |
| + workers: 4, |
| + retries: 0, |
| + timeout: 30_000, |
| + expect: { timeout: 5_000 }, |
| + reporter, |
| + use: { baseURL: process.env.BASE_URL, browserName: 'chromium', viewport: { width: 1280, height: 720 } }, |
| + projects: [ |
| + { name: 'js-on', use: { javaScriptEnabled: true } }, |
| + { name: 'js-off', use: { javaScriptEnabled: false } }, |
| + ], |
| +}); |
diff --git a/tests/behaviour/robustness.spec.ts b/tests/behaviour/robustness.spec.ts| new file mode 100644 |
| index 0000000..43c4949 |
| --- /dev/null |
| +++ b/tests/behaviour/robustness.spec.ts |
| @@ -0,0 +1,87 @@ |
| +// Inputs the TodoMVC suite never tries, which a real visitor will. |
| +import { test, expect } from '@playwright/test'; |
| +import { todos, addTodo } from './actions'; |
| + |
| +test.beforeEach(async ({ page }) => { |
| + await page.goto('/'); |
| +}); |
| + |
| +test('escapes HTML in titles', async ({ page }, ti) => { |
| + const title = '<img src=x onerror=alert(1)> & "q"'; |
| + let dialogs = 0; |
| + page.on('dialog', async (d) => { dialogs++; await d.dismiss(); }); |
| + await addTodo(page, ti, title); |
| + await expect(todos(page).nth(0).locator('label')).toHaveText(title); |
| + await page.reload(); |
| + await expect(todos(page).nth(0).locator('label')).toHaveText(title); |
| + await expect(page.locator('.todo-list img')).toHaveCount(0); |
| + expect(dialogs).toBe(0); |
| +}); |
| + |
| +test('round-trips UTF-8 and sends correct Content-Length', async ({ page }, ti) => { |
| + const title = 'naïve 🚀 日本語'; |
| + await addTodo(page, ti, title); |
| + await page.reload(); |
| + expect(await todos(page).nth(0).locator('label').textContent()).toBe(title); |
| + const res = await page.request.get('/'); |
| + const body = await res.body(); |
| + const declared = res.headers()['content-length']; |
| + if (declared !== undefined) expect(Number(declared)).toBe(body.length); |
| + expect(body.toString('utf8')).toContain(title); |
| +}); |
| + |
| +test('rejects blank and accepts very long titles', async ({ page }, ti) => { |
| + await addTodo(page, ti, ' '); |
| + await expect(todos(page)).toHaveCount(0); |
| + const long = 'x'.repeat(10_000); |
| + await addTodo(page, ti, long); |
| + await expect(todos(page)).toHaveCount(1); |
| + expect((await todos(page).nth(0).locator('label').textContent())?.length).toBe(10_000); |
| +}); |
| + |
| +test('isolates lists per visitor and survives a garbled cookie', async ({ page, browser, baseURL }, ti) => { |
| + await addTodo(page, ti, 'a'); |
| + await expect(todos(page)).toHaveCount(1); |
| + const js = ti.project.use.javaScriptEnabled; |
| + |
| + const other = await browser.newContext({ baseURL, javaScriptEnabled: js }); |
| + const b = await other.newPage(); |
| + await b.goto('/'); |
| + await expect(todos(b)).toHaveCount(0); |
| + |
| + const names = (await page.context().cookies()).map((c) => c.name); |
| + expect(names.length).toBeGreaterThan(0); |
| + const garbled = await browser.newContext({ baseURL, javaScriptEnabled: js }); |
| + await garbled.addCookies(names.map((name) => ({ name, value: '%%%', url: baseURL! }))); |
| + const g = await garbled.newPage(); |
| + const res = await g.goto('/'); |
| + expect(res?.status()).toBe(200); |
| + await expect(todos(g)).toHaveCount(0); |
| + await other.close(); |
| + await garbled.close(); |
| +}); |
| + |
| +test('rejects cross-site and cross-list posts', async ({ page, browser, baseURL }, ti) => { |
| + const evil = await page.request.post('/todos', { |
| + form: { title: 'evil', filter: 'all' }, |
| + headers: { Origin: 'https://evil.example' }, |
| + maxRedirects: 0, |
| + }); |
| + expect(evil.status()).toBe(403); |
| + await page.reload(); |
| + await expect(todos(page)).toHaveCount(0); |
| + |
| + await addTodo(page, ti, 'mine'); |
| + const id = await todos(page).nth(0).getAttribute('data-id'); |
| + expect(id).toBeTruthy(); |
| + |
| + const other = await browser.newContext({ baseURL }); |
| + const b = await other.newPage(); |
| + await b.goto('/'); |
| + const res = await b.request.post(`/todos/${id}/toggle`, { form: { filter: 'all' }, maxRedirects: 0 }); |
| + expect(res.status()).toBe(404); |
| + await other.close(); |
| + |
| + await page.reload(); |
| + await expect(todos(page).nth(0)).not.toHaveClass(/\bcompleted\b/); |
| +}); |
diff --git a/tests/behaviour/todomvc.spec.ts b/tests/behaviour/todomvc.spec.ts| new file mode 100644 |
| index 0000000..28df196 |
| --- /dev/null |
| +++ b/tests/behaviour/todomvc.spec.ts |
| @@ -0,0 +1,256 @@ |
| +// Ported from tastejs/todomvc cypress/e2e/spec.cy.js. Each test runs in a |
| +// fresh browser context: a fresh cookie, so a fresh empty list. Persistence |
| +// is checked by reloading, not by reading localStorage, and routing uses |
| +// real paths. Only "save edits on blur" and "cancel edits on escape" are |
| +// skipped with JavaScript off. |
| +import { test, expect } from '@playwright/test'; |
| +import { |
| + jsOn, todos, addTodo, addDefaultTodos, toggle, toggleAll, clearCompleted, |
| + startEditing, submitEdit, gotoFilter, |
| +} from './actions'; |
| + |
| +const ONE = 'buy some cheese'; |
| +const TWO = 'feed the cat'; |
| +const THREE = 'book a doctors appointment'; |
| +const DEFAULTS = [ONE, TWO, THREE]; |
| + |
| +test.beforeEach(async ({ page }) => { |
| + await page.goto('/'); |
| + await expect(page.locator('h1')).toHaveText(/^todos$/i); |
| + await expect(todos(page)).toHaveCount(0); |
| +}); |
| + |
| +test.describe('When page is initially opened', () => { |
| + test('should focus on the todo input field', async ({ page }) => { |
| + await expect(page.locator('.new-todo')).toBeFocused(); |
| + }); |
| +}); |
| + |
| +test.describe('No Todos', () => { |
| + test('starts with nothing', async ({ page }) => { |
| + await expect(todos(page)).toHaveCount(0); |
| + }); |
| + |
| + test('should hide #main and #footer', async ({ page }) => { |
| + await expect(page.locator('.main')).toBeHidden(); |
| + await expect(page.locator('.footer')).toBeHidden(); |
| + }); |
| +}); |
| + |
| +test.describe('New Todo', () => { |
| + test('should allow me to add todo items', async ({ page }, ti) => { |
| + await addTodo(page, ti, ONE); |
| + await expect(todos(page).nth(0).locator('label')).toHaveText(ONE); |
| + await addTodo(page, ti, TWO); |
| + await expect(todos(page).nth(1).locator('label')).toHaveText(TWO); |
| + }); |
| + |
| + test('should clear text input field when an item is added', async ({ page }, ti) => { |
| + await addTodo(page, ti, ONE); |
| + await expect(page.locator('.new-todo')).toHaveValue(''); |
| + }); |
| + |
| + test('should append new items to the bottom of the list', async ({ page }, ti) => { |
| + await addDefaultTodos(page, ti, DEFAULTS); |
| + await expect(page.locator('.todo-count')).toContainText('3'); |
| + await expect(todos(page).locator('label')).toHaveText(DEFAULTS); |
| + }); |
| + |
| + test('should trim text input', async ({ page }, ti) => { |
| + await addTodo(page, ti, ` ${ONE} `); |
| + await expect(todos(page)).toHaveCount(1); |
| + expect(await todos(page).nth(0).locator('label').textContent()).toBe(ONE); |
| + }); |
| + |
| + test('should show #main and #footer when items added', async ({ page }, ti) => { |
| + await addTodo(page, ti, ONE); |
| + await expect(page.locator('.main')).toBeVisible(); |
| + await expect(page.locator('.footer')).toBeVisible(); |
| + }); |
| +}); |
| + |
| +test.describe('Mark all as completed', () => { |
| + test.beforeEach(async ({ page }, ti) => addDefaultTodos(page, ti, DEFAULTS)); |
| + |
| + test('should allow me to mark all items as completed', async ({ page }, ti) => { |
| + await toggleAll(page, ti); |
| + for (let i = 0; i < 3; i++) await expect(todos(page).nth(i)).toHaveClass(/\bcompleted\b/); |
| + }); |
| + |
| + test('should allow me to clear the complete state of all items', async ({ page }, ti) => { |
| + await toggleAll(page, ti); |
| + await toggleAll(page, ti); |
| + for (let i = 0; i < 3; i++) await expect(todos(page).nth(i)).not.toHaveClass(/\bcompleted\b/); |
| + }); |
| + |
| + test('complete all checkbox should update state when items are completed / cleared', async ({ page }, ti) => { |
| + await toggleAll(page, ti); |
| + await expect(page.locator('.toggle-all')).toBeChecked(); |
| + await toggle(page, ti, todos(page).nth(0)); |
| + await expect(page.locator('.toggle-all')).not.toBeChecked(); |
| + await toggle(page, ti, todos(page).nth(0)); |
| + await expect(page.locator('.toggle-all')).toBeChecked(); |
| + }); |
| +}); |
| + |
| +test.describe('Item', () => { |
| + test('should allow me to mark items as complete', async ({ page }, ti) => { |
| + await addDefaultTodos(page, ti, [ONE, TWO]); |
| + await toggle(page, ti, todos(page).nth(0)); |
| + await expect(todos(page).nth(0)).toHaveClass(/\bcompleted\b/); |
| + await expect(todos(page).nth(1)).not.toHaveClass(/\bcompleted\b/); |
| + await toggle(page, ti, todos(page).nth(1)); |
| + await expect(todos(page).nth(0)).toHaveClass(/\bcompleted\b/); |
| + await expect(todos(page).nth(1)).toHaveClass(/\bcompleted\b/); |
| + }); |
| + |
| + test('should allow me to un-mark items as complete', async ({ page }, ti) => { |
| + await addDefaultTodos(page, ti, [ONE, TWO]); |
| + await toggle(page, ti, todos(page).nth(0)); |
| + await expect(todos(page).nth(0)).toHaveClass(/\bcompleted\b/); |
| + await expect(todos(page).nth(1)).not.toHaveClass(/\bcompleted\b/); |
| + await toggle(page, ti, todos(page).nth(0)); |
| + await expect(todos(page).nth(0)).not.toHaveClass(/\bcompleted\b/); |
| + await expect(todos(page).nth(1)).not.toHaveClass(/\bcompleted\b/); |
| + }); |
| + |
| + test('should allow me to edit an item', async ({ page }, ti) => { |
| + await addDefaultTodos(page, ti, DEFAULTS); |
| + const input = await startEditing(page, ti, 1); |
| + await expect(input).toHaveValue(TWO); |
| + await submitEdit(page, ti, input, 'buy some sausages'); |
| + await expect(todos(page).locator('label')).toHaveText([ONE, 'buy some sausages', THREE]); |
| + }); |
| +}); |
| + |
| +test.describe('Editing', () => { |
| + test.beforeEach(async ({ page }, ti) => addDefaultTodos(page, ti, DEFAULTS)); |
| + |
| + test('should hide other controls when editing', async ({ page }, ti) => { |
| + await startEditing(page, ti, 1); |
| + await expect(todos(page).nth(1).locator('.toggle')).toBeHidden(); |
| + await expect(todos(page).nth(1).locator('label')).toBeHidden(); |
| + }); |
| + |
| + test('should save edits on blur', async ({ page }, ti) => { |
| + test.skip(!jsOn(ti), 'blur saving needs JavaScript'); |
| + const input = await startEditing(page, ti, 1); |
| + await input.fill('buy some sausages'); |
| + await Promise.all([page.waitForResponse((r) => r.request().method() === 'POST'), input.blur()]); |
| + await expect(todos(page).locator('label')).toHaveText([ONE, 'buy some sausages', THREE]); |
| + }); |
| + |
| + test('should trim entered text', async ({ page }, ti) => { |
| + const input = await startEditing(page, ti, 1); |
| + await submitEdit(page, ti, input, ' buy some sausages '); |
| + await expect(todos(page).locator('label')).toHaveText([ONE, 'buy some sausages', THREE]); |
| + expect(await todos(page).nth(1).locator('label').textContent()).toBe('buy some sausages'); |
| + }); |
| + |
| + test('should remove the item if an empty text string was entered', async ({ page }, ti) => { |
| + const input = await startEditing(page, ti, 1); |
| + await submitEdit(page, ti, input, ''); |
| + await expect(todos(page)).toHaveCount(2); |
| + }); |
| + |
| + test('should cancel edits on escape', async ({ page }, ti) => { |
| + test.skip(!jsOn(ti), 'escape to cancel needs JavaScript'); |
| + const input = await startEditing(page, ti, 1); |
| + await input.fill('foo'); |
| + await input.press('Escape'); |
| + await expect(todos(page).locator('label')).toHaveText(DEFAULTS); |
| + }); |
| +}); |
| + |
| +test.describe('Counter', () => { |
| + test('should display the current number of todo items', async ({ page }, ti) => { |
| + await addTodo(page, ti, ONE); |
| + await expect(page.locator('.todo-count')).toHaveText('1 item left'); |
| + await addTodo(page, ti, TWO); |
| + await expect(page.locator('.todo-count')).toHaveText('2 items left'); |
| + }); |
| +}); |
| + |
| +test.describe('Clear completed button', () => { |
| + test.beforeEach(async ({ page }, ti) => addDefaultTodos(page, ti, DEFAULTS)); |
| + |
| + test('should display the correct text', async ({ page }, ti) => { |
| + await toggle(page, ti, todos(page).nth(0)); |
| + await expect(page.locator('.clear-completed')).toHaveText('Clear completed'); |
| + }); |
| + |
| + test('should remove completed items when clicked', async ({ page }, ti) => { |
| + await toggle(page, ti, todos(page).nth(1)); |
| + await clearCompleted(page, ti); |
| + await expect(todos(page).locator('label')).toHaveText([ONE, THREE]); |
| + }); |
| + |
| + test('should be hidden when there are no items that are completed', async ({ page }, ti) => { |
| + await toggle(page, ti, todos(page).nth(1)); |
| + await expect(page.locator('.clear-completed')).toBeVisible(); |
| + await clearCompleted(page, ti); |
| + await expect(page.locator('.clear-completed')).toBeHidden(); |
| + }); |
| +}); |
| + |
| +test.describe('Persistence', () => { |
| + test('should persist its data', async ({ page }, ti) => { |
| + await addDefaultTodos(page, ti, [ONE, TWO]); |
| + await toggle(page, ti, todos(page).nth(0)); |
| + const check = async () => { |
| + await expect(todos(page).nth(0)).toContainText(ONE); |
| + await expect(todos(page).nth(0)).toHaveClass(/\bcompleted\b/); |
| + await expect(todos(page).nth(1)).toContainText(TWO); |
| + await expect(todos(page).nth(1)).not.toHaveClass(/\bcompleted\b/); |
| + }; |
| + await check(); |
| + await page.reload(); |
| + await check(); |
| + }); |
| +}); |
| + |
| +test.describe('Routing', () => { |
| + test.beforeEach(async ({ page }, ti) => addDefaultTodos(page, ti, DEFAULTS)); |
| + |
| + test('should allow me to display active items', async ({ page }, ti) => { |
| + await toggle(page, ti, todos(page).nth(1)); |
| + await gotoFilter(page, 'Active'); |
| + await expect(todos(page).locator('label')).toHaveText([ONE, THREE]); |
| + }); |
| + |
| + test('should respect the back button', async ({ page }, ti) => { |
| + await toggle(page, ti, todos(page).nth(1)); |
| + await gotoFilter(page, 'All'); |
| + await expect(todos(page)).toHaveCount(3); |
| + await gotoFilter(page, 'Active'); |
| + await gotoFilter(page, 'Completed'); |
| + await expect(todos(page)).toHaveCount(1); |
| + await page.goBack(); |
| + await expect(todos(page)).toHaveCount(2); |
| + await page.goBack(); |
| + await expect(todos(page)).toHaveCount(3); |
| + }); |
| + |
| + test('should allow me to display completed items', async ({ page }, ti) => { |
| + await toggle(page, ti, todos(page).nth(1)); |
| + await gotoFilter(page, 'Completed'); |
| + await expect(todos(page)).toHaveCount(1); |
| + }); |
| + |
| + test('should allow me to display all items', async ({ page }, ti) => { |
| + await toggle(page, ti, todos(page).nth(1)); |
| + await gotoFilter(page, 'Active'); |
| + await gotoFilter(page, 'Completed'); |
| + await gotoFilter(page, 'All'); |
| + await expect(todos(page)).toHaveCount(3); |
| + }); |
| + |
| + test('should highlight the currently applied filter', async ({ page }) => { |
| + const link = (name: string) => page.locator('.filters a', { hasText: name }); |
| + await expect(link('All')).toHaveClass(/\bselected\b/); |
| + await gotoFilter(page, 'Active'); |
| + await expect(link('Active')).toHaveClass(/\bselected\b/); |
| + await gotoFilter(page, 'Completed'); |
| + await expect(link('Completed')).toHaveClass(/\bselected\b/); |
| + }); |
| +}); |
diff --git a/tests/behaviour/visual.spec.ts b/tests/behaviour/visual.spec.ts| new file mode 100644 |
| index 0000000..adbb346 |
| --- /dev/null |
| +++ b/tests/behaviour/visual.spec.ts |
| @@ -0,0 +1,22 @@ |
| +// With JavaScript on, an app must look like TodoMVC's own template. |
| +// The golden image is a screenshot of shared/reference.html, made with |
| +// `npm run golden`. Only footer.info (it names the language) is masked. |
| +import { test, expect } from '@playwright/test'; |
| +import path from 'node:path'; |
| +import { addDefaultTodos, toggle, todos } from './actions'; |
| + |
| +const shot = (page) => ({ mask: [page.locator('footer.info')], maxDiffPixelRatio: 0.02 }); |
| + |
| +test('matches the TodoMVC reference', async ({ page }, ti) => { |
| + test.skip(ti.project.name !== 'js-on', 'compared with JavaScript on'); |
| + if (process.env.GOLDEN) { |
| + await page.goto('file://' + path.resolve(import.meta.dirname, '../../shared/reference.html')); |
| + } else { |
| + await page.goto('/'); |
| + await addDefaultTodos(page, ti, ['Buy milk', 'Walk the dog', 'Write the report']); |
| + await toggle(page, ti, todos(page).nth(0)); |
| + await page.locator('.new-todo').focus(); |
| + await page.mouse.move(0, 0); |
| + } |
| + await expect(page).toHaveScreenshot('reference.png', shot(page)); |
| +}); |
diff --git a/tests/behaviour/visual.spec.ts-snapshots/reference-js-on-linux.png b/tests/behaviour/visual.spec.ts-snapshots/reference-js-on-linux.png| new file mode 100644 |
| index 0000000..6fca9bb |
| Binary files /dev/null and b/tests/behaviour/visual.spec.ts-snapshots/reference-js-on-linux.png differ |