import { test, expect, type Page, type Request } from '@playwright/test'; import AxeBuilder from '@axe-core/playwright'; // ═══════════════════════════════════════════════════════════════════════ // v1.21.1 COMPATIBILITY SUITE // // Every expectation below is the observable behavior of the v1.21.1 // release. An app that works on v1.21.1 must keep working unchanged, so // this suite must stay green on every later build. // // Fixtures: e2e/examples/compat-v1-21/*.html // Run against any git ref: scripts/compat-check.sh // // Test names carry the PR whose change the scenario guards (#NNN). // ═══════════════════════════════════════════════════════════════════════ const FIXTURES = '/e2e/examples/compat-v1-21'; // ─── Network helpers ──────────────────────────────────────────────────── const CORS_HEADERS = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'authorization, content-type, x-app-csrf', }; type Captured = { method: string; path: string; search: string; headers: Record; body: string | null }; function capture(req: Request): Captured { const u = new URL(req.url()); return { method: req.method(), path: u.pathname, search: u.search, headers: req.headers(), body: req.postData() }; } async function setupHttpRoutes(page: Page) { const seen: Captured[] = []; await page.route('https://api.other.test/**', async (route) => { const req = route.request(); if (req.method() === 'OPTIONS') { await route.fulfill({ status: 204, headers: CORS_HEADERS }); return; } seen.push(capture(req)); await route.fulfill({ status: 200, headers: CORS_HEADERS, contentType: 'application/json', body: JSON.stringify({ source: 'ext' }) }); }); await page.route('**/compat/**', async (route) => { const req = route.request(); const c = capture(req); seen.push(c); const json = (status: number, data: unknown, headers: Record = {}) => route.fulfill({ status, headers, contentType: 'application/json', body: JSON.stringify(data) }); switch (c.path) { case '/compat/same': return json(200, { source: 'same' }); case '/compat/search': return json(200, { source: 'search' }); case '/compat/versioned': return json(200, { source: 'versioned' }, { 'X-Api-Version': '2', Authorization: 'Bearer server' }); case '/compat/fail': return json(500, { message: 'server exploded' }); case '/compat/missing': return json(404, { message: 'not here' }); default: return json(200, { ok: true }); } }); return { seen, to: (path: string) => seen.filter((c) => c.path === path), }; } // ═══════════════════════════════════════════════════════════════════════ // #379 — NoJS.global() // ═══════════════════════════════════════════════════════════════════════ test.describe('Compat v1.21.1 — NoJS.global() (#379)', () => { test.beforeEach(async ({ page }) => { await page.goto(`${FIXTURES}/globals.html`); await expect(page.getByTestId('g-mode')).toHaveText('light'); }); test('1 — #379 a global object is a reactive proxy: $set from a template updates bind', async ({ page }) => { await page.getByTestId('g-set-dark').click(); await expect(page.getByTestId('g-mode')).toHaveText('dark'); }); test('2 — #379 the global exposes $watch and $notify, and $watch fires on change', async ({ page }) => { await expect(page.getByTestId('g-has-watch')).toHaveText('function'); await expect(page.getByTestId('g-has-notify')).toHaveText('function'); await expect(page.getByTestId('g-watch-count')).toHaveText('0'); await page.getByTestId('g-set-dark').click(); await expect(page.getByTestId('g-watch-count')).toHaveText('1'); await expect(page.getByTestId('g-watch-mode')).toHaveText('dark'); }); test('3 — #379 a global holding a Date is JSON-cloned: the Date becomes an ISO string', async ({ page }) => { await expect(page.getByTestId('g-date')).toHaveText('2020-01-02T03:04:05.000Z'); await expect(page.getByTestId('g-date-type')).toHaveText('string'); }); test('4 — #379 a global whose data has a "constructor" field is accepted', async ({ page }) => { await expect(page.getByTestId('g-ctor-name')).toHaveText('x'); }); test('5 — #379 the global is a copy: mutating the source object afterwards does not change it', async ({ page }) => { await expect(page.getByTestId('g-copy-source')).toHaveText('mutated'); await expect(page.getByTestId('g-copy')).toHaveText('orig'); }); test('6 — #379 a plugin-registered global supports $set from a template', async ({ page }) => { await expect(page.getByTestId('g-plugin-count')).toHaveText('0'); await page.getByTestId('g-plugin-inc').click(); await expect(page.getByTestId('g-plugin-count')).toHaveText('1'); await page.getByTestId('g-plugin-inc').click(); await expect(page.getByTestId('g-plugin-count')).toHaveText('2'); }); }); // ═══════════════════════════════════════════════════════════════════════ // #356 / #366 / #369 / retries / CANCEL — HTTP // ═══════════════════════════════════════════════════════════════════════ test.describe('Compat v1.21.1 — HTTP (#356, #366, #369, retries, CANCEL)', () => { let net: Awaited>; test.beforeEach(async ({ page }) => { net = await setupHttpRoutes(page); await page.goto(`${FIXTURES}/http.html`); await expect(page.getByTestId('h-same')).toHaveText('same'); }); test('7 — #356 config headers are sent to an absolute URL on another origin', async ({ page }) => { await expect(page.getByTestId('h-ext')).toHaveText('ext'); const ext = net.seen.filter((c) => c.path === '/x' && c.method === 'GET'); expect(ext).toHaveLength(1); expect(ext[0].headers['authorization']).toBe('Bearer t'); }); test('8 — #356 control: config headers are sent to a same-origin URL', async () => { const same = net.to('/compat/same'); expect(same).toHaveLength(1); expect(same[0].headers['authorization']).toBe('Bearer t'); }); test('9 — #366 an app request interceptor sees the full URL, query tokens included', async ({ page }) => { await page.getByTestId('i-search-show').click(); await expect(page.getByTestId('i-search-data')).toHaveText('search'); await expect(page.getByTestId('i-req-url')).toHaveText('/compat/search?author=ann&keyword=x&token=abc'); expect(net.to('/compat/search')[0].search).toBe('?author=ann&keyword=x&token=abc'); }); test('10 — #366 an app response interceptor sees the X-Api-Version and Authorization response headers', async ({ page }) => { await expect(page.getByTestId('i-versioned-data')).toHaveText('versioned'); await expect(page.getByTestId('i-res-api-version')).toHaveText('2'); await expect(page.getByTestId('i-res-authorization')).toHaveText('Bearer server'); }); test('11 — #366 a custom csrf.header is visible to and settable by the request interceptor', async ({ page }) => { await page.getByTestId('i-csrf-send').click(); await expect(page.getByTestId('i-csrf-done')).toHaveText('yes'); await expect(page.getByTestId('i-csrf-seen')).toHaveText('csrf-1'); const sent = net.to('/compat/csrf'); expect(sent).toHaveLength(1); expect(sent[0].headers['x-app-csrf']).toBe('csrf-2'); expect(sent[0].headers['authorization']).toBe('Bearer t'); }); test('12 — #366 the fetch:error payload carries the raw URL', async ({ page }) => { await expect(page.getByTestId('i-error-url')).toHaveText('/compat/fail?author=ann&token=abc'); }); test('13 — #369 body="{v}" with v=".." sends the bytes ".."', async ({ page }) => { await page.getByTestId('b-dots').click(); await expect(page.getByTestId('b-sent')).toHaveText('dots'); expect(net.to('/compat/body/dots')[0].body).toBe('..'); }); test('14 — #369 body="name={n}&x=1" percent-encodes the placeholder value only', async ({ page }) => { await page.getByTestId('b-form').click(); await expect(page.getByTestId('b-sent')).toHaveText('form'); expect(net.to('/compat/body/form')[0].body).toBe('name=a%26b%3Dc%20d&x=1'); }); test('15 — #369 body="{msg}" percent-encodes spaces and %', async ({ page }) => { await page.getByTestId('b-msg').click(); await expect(page.getByTestId('b-sent')).toHaveText('msg'); expect(net.to('/compat/body/msg')[0].body).toBe('hello%20world%20100%25'); }); test('16 — retry="2" on a 404 GET makes 3 attempts, then renders the error template', async ({ page }) => { await expect(page.getByTestId('r-error')).toHaveText('404'); expect(net.to('/compat/missing')).toHaveLength(3); }); test('17 — NoJS.CANCEL from a request interceptor is silent: no request, no error template, no then=', async ({ page }) => { // The retry error template (section 10) renders above this section and shifts it down; // wait for it so the click cannot land on the old coordinates of the button await expect(page.getByTestId('r-error')).toHaveText('404'); await page.getByTestId('c-send').click(); await expect(page.getByTestId('c-interceptor')).toHaveText('cancelled'); // Sync point: a later request completes a full network round trip, so the // cancelled request's (microtask) error path has long settled by then. await page.getByTestId('b-msg').click(); await expect(page.getByTestId('b-sent')).toHaveText('msg'); await expect(page.getByTestId('c-error')).toHaveCount(0); await expect(page.getByTestId('c-state')).toHaveText('idle'); expect(net.to('/compat/cancel')).toHaveLength(0); }); }); // ═══════════════════════════════════════════════════════════════════════ // #357 — persist // ═══════════════════════════════════════════════════════════════════════ test.describe('Compat v1.21.1 — persist (#357)', () => { test.beforeEach(async ({ page }) => { await page.goto(`${FIXTURES}/persist.html`); await page.evaluate(() => localStorage.clear()); await page.reload(); await expect(page.getByTestId('p-empty-a')).toHaveText('1'); }); const storage = (page: Page, key: string) => page.evaluate((k) => JSON.parse(localStorage.getItem(k) || 'null'), key); test('18 — #357 persist-fields="" persists every field across a reload', async ({ page }) => { await page.getByTestId('p-empty-bump').click(); await expect(page.getByTestId('p-empty-a')).toHaveText('11'); await expect(page.getByTestId('p-empty-b')).toHaveText('22'); expect(await storage(page, 'nojs_state_compat-empty')).toEqual({ a: 11, b: 22 }); await page.reload(); await expect(page.getByTestId('p-empty-a')).toHaveText('11'); await expect(page.getByTestId('p-empty-b')).toHaveText('22'); }); test('19 — #357 a bare persist-fields attribute persists every field across a reload', async ({ page }) => { await page.getByTestId('p-bare-bump').click(); await expect(page.getByTestId('p-bare-a')).toHaveText('11'); expect(await storage(page, 'nojs_state_compat-bare')).toEqual({ a: 11, b: 22 }); await page.reload(); await expect(page.getByTestId('p-bare-a')).toHaveText('11'); await expect(page.getByTestId('p-bare-b')).toHaveText('22'); }); test('20 — #357 a declared $tab state key is written to storage with the other fields', async ({ page }) => { await expect(page.getByTestId('p-tab')).toHaveText('one'); await page.getByTestId('p-tab-bump').click(); await expect(page.getByTestId('p-tab-n')).toHaveText('1'); expect(await storage(page, 'nojs_state_compat-tab')).toEqual({ $tab: 'one', n: 1 }); }); test('21 — #357 a $tab changed by app code is persisted and restored after reload', async ({ page }) => { await page.getByTestId('p-tab-two').click(); await expect(page.getByTestId('p-tab')).toHaveText('two'); expect(await storage(page, 'nojs_state_compat-tab')).toEqual({ $tab: 'two', n: 0 }); await page.reload(); await expect(page.getByTestId('p-tab')).toHaveText('two'); }); test('22 — #357 a $tab saved by an earlier session is restored on load', async ({ page }) => { await page.evaluate(() => localStorage.setItem('nojs_state_compat-tab', JSON.stringify({ $tab: 'three', n: 5 }))); await page.reload(); await expect(page.getByTestId('p-tab-n')).toHaveText('5'); await expect(page.getByTestId('p-tab')).toHaveText('three'); }); }); // ═══════════════════════════════════════════════════════════════════════ // #362 —