diff --git a/.gitignore b/.gitignore index 37876f84..983fb768 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,12 @@ authn.localhost-access.log authn.localhost-access1.log bedrock static/images/logo-*.png + +# Playwright +/test-results +/playwright-report +/blob-report +/test/e2e/gallery + +# local working notes / manual-test artifacts +scratchpad diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f177e01..4db3fc0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # authn.io ChangeLog +## 7.5.0 - 2026-06-dd + +### Added +- Add an automated visual/layout test suite for the first party wallet + chooser dialog. A dev-only `/test/wallet-chooser` harness route + renders the dialog with fake state across wallet counts and the + cross-device QR section (excluded from production builds); + `npm run test:e2e` asserts layout invariants on desktop Chromium, + WebKit, and Firefox plus emulated iPhone and Pixel phone sizes, and + `npm run gallery` produces a browsable screenshot gallery. No + production behavior or styles change. One overflow assertion is + skipped pending a later CSS rework. + ## 7.4.4 - 2026-06-13 ### Fixed diff --git a/README.md b/README.md index 30f8fcc4..06f244d0 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,48 @@ Access the server at the following URL: * https://authn.localhost:33443/ +## Testing + +The first party wallet chooser dialog has an automated visual/layout test +suite. It drives a dev-only harness route +(`/test/wallet-chooser?hints=N&qr=1`) that renders the dialog with fake state, +so the layout can be exercised across wallet counts (0, 1, 5, 15) and the +cross-device QR section without any CHAPI registration, wallets, or popups. The +harness route is excluded from production builds. + +Install the browser engines once: + + npx playwright install chromium webkit firefox + +Run the layout regression suite: + + npm run test:e2e + +This asserts structural invariants — the expected wallets render, no horizontal +or window scrollbar appears, the header stays pinned while the list scrolls, and +the panel fills the popup — rather than comparing pixels. It starts the dev +server automatically and runs five projects: desktop Chromium, WebKit, and +Firefox at the 500px popup width, plus emulated **iPhone 15** and **Pixel 7**. +The phone projects matter because on a phone the popup is clamped to the screen +width and crosses the dialog's 430px "small screen" CSS breakpoint, exercising +layout branches the desktop width does not. + +Run a single project with, e.g., `npm run test:e2e -- --project=iphone`. The +project names are `chromium`, `webkit`, `firefox`, `iphone`, and +`android-pixel`. + +Generate a browsable screenshot gallery of every state (wallet count × theme × +engine, collapsed and expanded), with an `index.html` contact sheet: + + npm run gallery + +Output is written to `test/e2e/gallery/` (git-ignored). + +> **Brave:** the Chromium engine covers Brave's rendering (Brave is Chromium +> plus "shields", which do not affect the dialog CSS). Brave's storage/shields +> behavior is a CHAPI plumbing concern, verified in the manual mobile pass, not +> by this layout suite. Real mobile/Safari-on-iOS is likewise a manual pass. + ## Production Full instructions for running this code in production are beyond the scope of diff --git a/package-lock.json b/package-lock.json index 5f51f662..e58f2e25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "authio", - "version": "7.4.4", + "version": "7.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "authio", - "version": "7.4.4", + "version": "7.5.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@bedrock/config-yaml": "^4.3.3", @@ -30,6 +30,7 @@ }, "devDependencies": { "@digitalbazaar/eslint-config": "^8.0.1", + "@playwright/test": "^1.60.0", "eslint": "^9.39.4" }, "engines": { @@ -2624,6 +2625,22 @@ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@sindresorhus/base62": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@sindresorhus/base62/-/base62-1.0.0.tgz", @@ -5195,6 +5212,21 @@ "node": ">= 0.6" } }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -6763,6 +6795,38 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", diff --git a/package.json b/package.json index 8ec0b654..7b1878bb 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ }, "scripts": { "start": "node authn.localhost.js", - "lint": "eslint" + "lint": "eslint", + "test:e2e": "playwright test wallet-chooser", + "gallery": "playwright test gallery && node test/e2e/build-gallery-index.js" }, "repository": { "type": "git", @@ -37,6 +39,7 @@ }, "devDependencies": { "@digitalbazaar/eslint-config": "^8.0.1", + "@playwright/test": "^1.60.0", "eslint": "^9.39.4" }, "engines": { diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 00000000..34ffd700 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,59 @@ +/*! + * New BSD License (3-clause) + * Copyright (c) 2026, Digital Bazaar, Inc. + */ +import {defineConfig, devices} from '@playwright/test'; + +const BASE_URL = 'https://authn.localhost:33443'; + +export default defineConfig({ + testDir: './test/e2e', + // the dialog suite is layout-only and deterministic; fail fast in CI + forbidOnly: !!process.env.CI, + retries: 0, + reporter: 'list', + use: { + baseURL: BASE_URL, + // the dev server uses a self-signed localhost certificate + ignoreHTTPSErrors: true, + screenshot: 'off', + trace: 'on-first-retry' + }, + // the wallet chooser popups are 500px wide; emulate that as the default + // viewport so layout matches the real popup + projects: [ + { + name: 'chromium', + use: {...devices['Desktop Chrome'], viewport: {width: 500, height: 640}} + }, + { + name: 'webkit', + use: {...devices['Desktop Safari'], viewport: {width: 500, height: 640}} + }, + { + name: 'firefox', + use: {...devices['Desktop Firefox'], viewport: {width: 500, height: 640}} + }, + // phone-sized projects: on a phone the popup is clamped to the screen + // width (narrower than the 500px desktop popup) and crosses the + // dialog's 430px "small screen" CSS breakpoint, so these exercise + // layout branches the desktop projects do not. Device descriptors also + // set a touch-capable, mobile-UA context. + { + name: 'iphone', + use: {...devices['iPhone 15']} + }, + { + name: 'android-pixel', + use: {...devices['Pixel 7']} + } + ], + // start the authn.io dev server automatically; reuse one already running + webServer: { + command: 'node authn.localhost.js', + url: `${BASE_URL}/test/wallet-chooser?hints=1`, + ignoreHTTPSErrors: true, + reuseExistingServer: true, + timeout: 120 * 1000 + } +}); diff --git a/test/e2e/build-gallery-index.js b/test/e2e/build-gallery-index.js new file mode 100644 index 00000000..ff447d3a --- /dev/null +++ b/test/e2e/build-gallery-index.js @@ -0,0 +1,70 @@ +/*! + * New BSD License (3-clause) + * Copyright (c) 2026, Digital Bazaar, Inc. + */ +import {fileURLToPath} from 'node:url'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +/* Builds an `index.html` contact sheet from the PNGs the gallery spec wrote +to `test/e2e/gallery///`. Run after `playwright test gallery` +(the `gallery` npm script chains them). Filesystem-driven so it sees every +engine's output regardless of which Playwright worker produced it. */ + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const GALLERY_DIR = path.join(__dirname, 'gallery'); + +async function findPngs(dir, base = dir) { + const out = []; + let entries; + try { + entries = await fs.readdir(dir, {withFileTypes: true}); + } catch { + return out; + } + for(const entry of entries) { + const full = path.join(dir, entry.name); + if(entry.isDirectory()) { + out.push(...await findPngs(full, base)); + } else if(entry.name.endsWith('.png')) { + out.push(path.relative(base, full)); + } + } + return out; +} + +const pngs = (await findPngs(GALLERY_DIR)).sort((a, b) => a.localeCompare(b)); +if(pngs.length === 0) { + console.error('No gallery PNGs found. Run `playwright test gallery` first.'); + process.exit(1); +} + +const cards = pngs.map(rel => { + // rel = //.png + const [engine, theme, file] = rel.split(path.sep); + const label = file.replace(/\.png$/, '').replace(/-/g, ' '); + return ` +
+ ${label} +
${engine} / ${theme} — ${label}
+
`; +}).join(''); + +const html = ` + +authn.io wallet chooser gallery + +

authn.io wallet chooser — ${pngs.length} shots

+
${cards}
`; + +const indexPath = path.join(GALLERY_DIR, 'index.html'); +await fs.writeFile(indexPath, html); +console.log(`Gallery contact sheet: ${indexPath}`); diff --git a/test/e2e/gallery.spec.js b/test/e2e/gallery.spec.js new file mode 100644 index 00000000..7c8d40ff --- /dev/null +++ b/test/e2e/gallery.spec.js @@ -0,0 +1,73 @@ +/*! + * New BSD License (3-clause) + * Copyright (c) 2026, Digital Bazaar, Inc. + */ +import {expect, test} from '@playwright/test'; +import {fileURLToPath} from 'node:url'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +/* Generates a labelled, human-viewable gallery of the first party wallet +chooser dialog across wallet counts, the cross-device QR section, themes, and +viewports — the screenshots we otherwise capture by hand for review. Output +goes to `test/e2e/gallery///.png` plus an `index.html` +contact sheet. This is NOT a regression gate (see `wallet-chooser.spec.js` for +that); run it with `npm run gallery`. */ + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const GALLERY_DIR = path.join(__dirname, 'gallery'); + +const STATES = [ + {name: 'hints-0-qr', query: 'hints=0&qr=1', label: '0 wallets + QR'}, + {name: 'hints-1-qr', query: 'hints=1&qr=1', label: '1 wallet + QR'}, + {name: 'hints-5-qr', query: 'hints=5&qr=1', label: '5 wallets + QR'}, + {name: 'hints-5-noqr', query: 'hints=5&qr=0', label: '5 wallets, no QR'}, + {name: 'hints-15-qr', query: 'hints=15&qr=1', label: '15 wallets + QR'} +]; +const THEMES = ['light', 'dark']; + +test.describe('gallery', () => { + for(const theme of THEMES) { + test.describe(theme, () => { + test.use({colorScheme: theme}); + + for(const state of STATES) { + test(`${state.label}`, async ({page}, testInfo) => { + const engine = testInfo.project.name; + await page.goto(`/test/wallet-chooser?${state.query}`); + await page.locator('.wrm-modal-1p .wrm-modal-content').waitFor(); + + const dir = path.join(GALLERY_DIR, engine, theme); + await fs.mkdir(dir, {recursive: true}); + + // collapsed (default) shot + await page.screenshot({path: path.join(dir, `${state.name}.png`)}); + + // for states with the expander, also capture it expanded + const toggle = page.locator('.cross-device-toggle'); + if(await toggle.count() > 0) { + await toggle.click(); + const qr = page.locator('img[alt*="QR"]'); + await expect(qr).toBeVisible(); + await page.screenshot( + {path: path.join(dir, `${state.name}-expanded.png`)}); + + // when the expanded QR sits below the fold (many wallets on a + // short/phone viewport), also capture a shot scrolled to the + // QR, so the gallery shows it is reachable + const qrInView = await qr.evaluate(el => { + const r = el.getBoundingClientRect(); + return r.bottom <= window.innerHeight && r.top >= 0; + }); + if(!qrInView) { + await qr.scrollIntoViewIfNeeded(); + await expect(qr).toBeInViewport(); + await page.screenshot( + {path: path.join(dir, `${state.name}-expanded-scrolled.png`)}); + } + } + }); + } + }); + } +}); diff --git a/test/e2e/wallet-chooser.spec.js b/test/e2e/wallet-chooser.spec.js new file mode 100644 index 00000000..ede7795d --- /dev/null +++ b/test/e2e/wallet-chooser.spec.js @@ -0,0 +1,166 @@ +/*! + * New BSD License (3-clause) + * Copyright (c) 2026, Digital Bazaar, Inc. + */ +import {expect, test} from '@playwright/test'; + +/* Geometric-invariant tests for the first party wallet chooser dialog, +exercised via the dev-only `/test/wallet-chooser` harness route. These assert +structural properties (no horizontal scroll, header pinned, panel fills the +popup) rather than pixels, so they catch the layout regressions seen in the +7.4.x series without screenshot-diff noise. */ + +// the states the dialog must handle, keyed for readable test titles +const STATES = [ + {name: '0 wallets + QR', query: 'hints=0&qr=1', hints: 0, qr: true}, + {name: '1 wallet + QR', query: 'hints=1&qr=1', hints: 1, qr: true}, + {name: '5 wallets + QR', query: 'hints=5&qr=1', hints: 5, qr: true}, + {name: '5 wallets, no QR', query: 'hints=5&qr=0', hints: 5, qr: false}, + {name: '15 wallets + QR', query: 'hints=15&qr=1', hints: 15, qr: true} +]; + +async function gotoChooser(page, query) { + await page.goto(`/test/wallet-chooser?${query}`); + // wait for the dialog content to render + await page.locator('.wrm-modal-1p .wrm-modal-content').waitFor(); +} + +for(const state of STATES) { + test.describe(state.name, () => { + test.beforeEach(async ({page}) => gotoChooser(page, state.query)); + + test('renders the expected number of wallet hints', async ({page}) => { + await expect(page.locator('.wrm-hint-list .wrm-selectable')) + .toHaveCount(state.hints); + }); + + test('shows no horizontal scrollbar', async ({page}) => { + // the 7.4.1 regression produced a horizontal scrollbar on the 1p + // content. Assert the document does not scroll horizontally. + const scrolls = await page.evaluate(() => + document.documentElement.scrollWidth > window.innerWidth); + expect(scrolls).toBe(false); + }); + + // Stricter than the scrollbar check: detects the horizontal OVERFLOW + // condition itself (an element whose content is wider than its box), + // not just the resulting scrollbar — so the `overflow-x: hidden` + // band-aid in the 1p dialog CSS cannot make it a false pass. + // + // KNOWN FAILING: this fails today because the dialog still relies on + // that band-aid to hide a real overflow (the headers and separators + // bleed their border edge-to-edge with a negative side margin that + // overhangs the border-box flex layout). It is left active, not + // skipped, so it shows up as a red test when the suite is run locally + // — a standing reminder of the deferred CSS work. The fix comes after + // the components we use are pulled out of `vue-web-request-mediator` + // and that dependency is removed; this test then turns green and + // proves the band-aid can be removed. (CI does not run this suite + // yet, so this failure does not gate anything.) + test('has no horizontal overflow', async ({page}) => { + const offenders = await page.evaluate(() => { + const out = []; + for(const el of document.querySelectorAll('.wrm-modal-1p *')) { + // only inspect elements with a real content box. Inline + // elements (e.g. , ) report `clientWidth: 0` but + // a nonzero `scrollWidth`, which Firefox surfaces and Chromium + // does not; that difference is not overflow, so skip them. + const cs = getComputedStyle(el); + if(cs.display === 'inline' || el.clientWidth === 0) { + continue; + } + if(el.scrollWidth - el.clientWidth > 1) { + out.push(`${el.className || el.tagName} ` + + `(scrollW ${el.scrollWidth} > clientW ${el.clientWidth})`); + } + } + return out; + }); + expect(offenders, offenders.join('; ')).toEqual([]); + }); + + test('does not scroll the popup window', async ({page}) => { + // only an inner region may scroll; the window itself must not (that + // scrolled the header/Close button away and stacked scrollbars) + const windowScrolls = await page.evaluate(() => + document.documentElement.scrollHeight > window.innerHeight + 1); + expect(windowScrolls).toBe(false); + }); + + test('keeps the header pinned while the list scrolls', async ({page}) => { + const header = page.locator('.wrm-modal-1p .wrm-modal-content-header') + .first(); + const before = await header.boundingBox(); + // scroll the wallet list to its end, if it scrolls at all + await page.evaluate(() => { + const list = document.querySelector('.wrm-hint-list'); + if(list) { + list.scrollTop = list.scrollHeight; + } + }); + const after = await header.boundingBox(); + expect(after.y).toBeCloseTo(before.y, 0); + }); + + test('panel fills the popup width (no background bleed)', + async ({page}) => { + // the content panel must span the full popup width; gaps showed the + // page background as dark bands + const fills = await page.evaluate(() => { + const panel = document.querySelector( + '.wrm-modal-1p .wrm-modal-content'); + return Math.abs(panel.getBoundingClientRect().width - + window.innerWidth) <= 1; + }); + expect(fills).toBe(true); + }); + + test('header border bleeds edge to edge', async ({page}) => { + // the dialog header ("Choose a Wallet") is full-bleed by design: its + // bottom border spans the popup edge to edge, distinguishing it from + // an inset in-page separator. This guards that bleed directly, so a + // future CSS rework cannot silently shrink the border to the padded + // content width (a change the geometric-overflow checks would miss, + // since shrinking it removes no overflow). The header is the panel's + // direct-child header, not the empty separator div the body reuses + // the class for. + const flush = await page.evaluate(() => { + const header = document.querySelector( + '.wrm-modal-1p .wrm-modal-content > .wrm-modal-content-header'); + const panel = document.querySelector( + '.wrm-modal-1p .wrm-modal-content'); + const h = header.getBoundingClientRect(); + const p = panel.getBoundingClientRect(); + return Math.abs(h.left - p.left) <= 1 && + Math.abs(h.right - p.right) <= 1; + }); + expect(flush).toBe(true); + }); + + if(state.qr && state.hints > 0) { + test('shows the cross-device expander, collapsed', async ({page}) => { + await expect(page.locator('.cross-device-toggle')).toBeVisible(); + await expect(page.locator('img[alt*="QR"]')).toHaveCount(0); + }); + + test('expands the QR code when the prompt is clicked', async ({page}) => { + await page.locator('.cross-device-toggle').click(); + await expect(page.locator('img[alt*="QR"]')).toBeVisible(); + }); + } + + if(state.qr && state.hints === 0) { + test('shows the QR code immediately, no expander', async ({page}) => { + await expect(page.locator('.cross-device-toggle')).toHaveCount(0); + await expect(page.locator('img[alt*="QR"]')).toBeVisible(); + }); + } + + if(!state.qr) { + test('shows no cross-device section', async ({page}) => { + await expect(page.locator('.cross-device-toggle')).toHaveCount(0); + await expect(page.locator('img[alt*="QR"]')).toHaveCount(0); + }); + } + }); +} diff --git a/web/router.js b/web/router.js index 11a3deab..06a22301 100644 --- a/web/router.js +++ b/web/router.js @@ -1,5 +1,5 @@ /*! - * Copyright (c) 2023 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2023-2026 Digital Bazaar, Inc. */ import {createRouter as _createRouter, createWebHistory} from 'vue-router'; @@ -30,6 +30,15 @@ export async function createRouter() { /* webpackChunkName: "FirstPartyMediatorPage" */ './routes/FirstPartyMediatorPage.vue'), meta: {title: 'Allow Wallet'} - }] + }, + // dev-only harness for exercising the wallet chooser dialog layout + // without CHAPI; excluded from production builds + ...(process.env.NODE_ENV !== 'production' ? [{ + path: '/test/wallet-chooser', + component: () => import( + /* webpackChunkName: "TestWalletChooser" */ + './routes/TestWalletChooser.vue'), + meta: {title: 'Test Wallet Chooser'} + }] : [])] }); } diff --git a/web/routes/TestWalletChooser.vue b/web/routes/TestWalletChooser.vue new file mode 100644 index 00000000..03e279b3 --- /dev/null +++ b/web/routes/TestWalletChooser.vue @@ -0,0 +1,102 @@ + + + + +