diff --git a/CHANGELOG.md b/CHANGELOG.md index d112b01..fb34731 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # authn.io ChangeLog +## 7.6.1 - 2026-07-dd + +### Fixed +- Stop clipping the first party "Allow Wallet" permission dialog. The + popup height was a fixed guess (230px, plus a 30px URL bar allowance + applied only when the user agent looked like Firefox), so the wallet + row was cut off mid-line whenever the browser's popup chrome or the + greeting's wrapping exceeded that guess. The dialog fills the popup and + scrolls internally, so this surfaced as clipped content rather than a + scrollable window. The allowance now applies to every browser (Chrome + and Safari also show a URL bar in popups, and user agent sniffing + cannot predict the real chrome height), and the popup measures its own + content once loaded and resizes to fit, replacing both guesses with a + measurement. + +### Added +- Non-API change: Add unit tests for the popup self-sizing logic and a + geometric-invariant test suite for the "Allow Wallet" dialog. +- Non-API change: Let the dev-only wallet chooser test harness render an + origin manifest (name and icon), so it reproduces the taller greeting + the real dialog shows for a site with a web app manifest. + ## 7.6.0 - 2026-07-23 ### Changed diff --git a/package.json b/package.json index 030b423..60845b2 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "start": "node authn.localhost.js", "lint": "eslint", "test:e2e": "playwright test wallet-chooser", + "test:unit": "node --test \"test/unit/*.spec.js\"", "gallery": "playwright test gallery && node test/e2e/build-gallery-index.js" }, "repository": { diff --git a/test/e2e/allow-wallet.spec.js b/test/e2e/allow-wallet.spec.js new file mode 100644 index 0000000..034b591 --- /dev/null +++ b/test/e2e/allow-wallet.spec.js @@ -0,0 +1,136 @@ +/*! + * New BSD License (3-clause) + * Copyright (c) 2026, Digital Bazaar, Inc. + */ +import {expect, test} from '@playwright/test'; + +/* Geometric-invariant tests for the first party "Allow Wallet" permission +dialog, exercised via the dev-only `/test/wallet-chooser` harness route with +`type=permissionRequest`. The bug these guard: the popup height was a fixed +guess (230px + a Firefox-only 30px URL-bar allowance), so on any browser whose +popup chrome or content wrapping exceeded that guess, the wallet row and the +Block/Allow footer were clipped off the bottom of the window. + +These assert the dialog fits — and that its interactive footer is reachable — +at the popup viewport heights the mediator actually requests, rather than +diffing pixels. */ + +/* Popup viewport heights to exercise. `DEFAULT_ALLOW_WALLET_POPUP_HEIGHT` is +290 (260 + a 30px chrome allowance), so the viewport the mediator actually +gets is 290 minus whatever chrome the browser adds. These bracket that range +down to the point where the content genuinely cannot fit and the popup relies +on `fitWindowToContent` growing it instead. + +Playwright pins the viewport, so `resizeBy` is inert here by design: these +tests assert the CSS/content invariant at a given height. The resize logic +itself is unit-tested via `getWindowHeightAdjustment` (see +`test/unit/helpers.spec.js`). */ +const HEIGHTS = [ + {name: 'requested height', height: 290}, + {name: 'chrome-reduced height', height: 260} +]; + +// A long origin/name exercises the text-wrapping cause of the clipping, +// independent of the popup-chrome cause. +const LONG_ORIGIN = + 'https://wallet-with-a-very-long-hostname.example-organization.test'; + +async function gotoAllowWallet(page, {origin} = {}) { + const query = new URLSearchParams({ + hints: '1', + type: 'permissionRequest', + qr: '0' + }); + if(origin) { + query.set('origin', origin); + } + await page.goto(`/test/wallet-chooser?${query}`); + await page.locator('.wrm-modal-1p .wrm-modal-content').waitFor(); +} + +for(const {name, height} of HEIGHTS) { + test.describe(`allow-wallet dialog @ ${name} (${height}px)`, () => { + test.use({viewport: {width: 500, height}}); + + test.beforeEach(async ({page}) => gotoAllowWallet(page)); + + test('shows the Block and Allow buttons', async ({page}) => { + await expect(page.getByRole('button', {name: 'Block'})).toBeVisible(); + await expect(page.getByRole('button', {name: 'Allow'})).toBeVisible(); + }); + + // The footer is pinned outside the scrolling body, so it stays in the + // viewport at every height; this guards that pinning (a regression that + // let it scroll away would be caught here), not the clipping bug. + test('keeps the Block/Allow footer within the viewport', + async ({page}) => { + for(const label of ['Block', 'Allow']) { + const box = await page.getByRole('button', {name: label}) + .boundingBox(); + expect(box, `${label} button has no box`).not.toBeNull(); + expect(box.y + box.height, + `${label} button bottom (${box.y + box.height}) exceeds ` + + `viewport height (${height})`).toBeLessThanOrEqual(height + 1); + } + }); + + /* The real clipping condition. The 1p panel is sized to exactly fill the + popup and its body region scrolls internally, so the *document* never + overflows and the pinned footer is always inside the viewport — neither a + document-level scroll check nor a footer-position check can detect this + bug. What the user sees instead is the greeting's wallet row cut off + mid-line inside that scrolling body. Assert the body does not need to + scroll: the popup must be tall enough for the greeting to fit whole. */ + test('does not clip the greeting inside the scrolling body', + async ({page}) => { + const overflow = await page.evaluate(() => { + const body = + document.querySelector('.wrm-modal-content-header + div'); + return body ? body.scrollHeight - body.clientHeight : null; + }); + expect(overflow, 'dialog body region not found').not.toBeNull(); + expect(overflow, + `dialog body overflows its box by ${overflow}px, clipping the ` + + 'wallet row').toBeLessThanOrEqual(1); + }); + + // the greeting's origin card must be fully within the scrolling body's + // visible box, not cut off at its bottom edge + test('shows the whole origin card', async ({page}) => { + const cut = await page.evaluate(() => { + const body = document.querySelector('.wrm-modal-content-header + div'); + const card = [...document.querySelectorAll('.wrm-flex-row')] + .find(el => !el.classList.contains('wrm-modal-content-header')); + if(!body || !card) { + return null; + } + return Math.round(card.getBoundingClientRect().bottom - + body.getBoundingClientRect().bottom); + }); + expect(cut, 'origin card or body not found').not.toBeNull(); + expect(cut, `origin card is cut off by ${cut}px`) + .toBeLessThanOrEqual(1); + }); + + test('keeps the origin row on one line', async ({page}) => { + // the origin/name must ellipsize rather than wrap; wrapping is what + // pushed the footer out of the popup + const wraps = await page.evaluate(() => { + const el = document.querySelector('.wrm-ellipsis'); + if(!el) { + return 'missing'; + } + return el.scrollHeight > el.clientHeight + 1; + }); + expect(wraps).toBe(false); + }); + + test('with a long origin, still fits', async ({page}) => { + await gotoAllowWallet(page, {origin: LONG_ORIGIN}); + const box = await page.getByRole('button', {name: 'Allow'}) + .boundingBox(); + expect(box).not.toBeNull(); + expect(box.y + box.height).toBeLessThanOrEqual(height + 1); + }); + }); +} diff --git a/test/unit/helpers.spec.js b/test/unit/helpers.spec.js new file mode 100644 index 0000000..75f9098 --- /dev/null +++ b/test/unit/helpers.spec.js @@ -0,0 +1,130 @@ +/*! + * New BSD License (3-clause) + * Copyright (c) 2026, Digital Bazaar, Inc. + */ +import assert from 'node:assert/strict'; +import {getWindowHeightAdjustment} from '../../web/mediator/helpers.js'; +import {test} from 'node:test'; + +/* Unit tests for the pure core of the popup self-sizing fix. The mediator +opens the allow-wallet popup at a fixed height, but the browser subtracts its +own chrome by an unpredictable amount and the greeting reflows with the +wallet's name and origin, so the popup measures itself once loaded and +corrects. `getWindowHeightAdjustment` is that decision, isolated from the DOM: +given what the content needs and what the window has, how much should the +window grow or shrink? + +Run with: node --test test/unit/ */ + +// a typical popup: 290px requested, ~60px of browser chrome +const TYPICAL = { + viewportHeight: 230, + outerHeight: 290, + availableHeight: 1000 +}; + +test('grows the window when content overflows', () => { + // the shipped bug: 263px of content in a 230px viewport + const adjustment = getWindowHeightAdjustment({ + ...TYPICAL, contentHeight: 263 + }); + assert.equal(adjustment, 33); +}); + +test('shrinks the window when it is taller than the content', () => { + const adjustment = getWindowHeightAdjustment({ + ...TYPICAL, contentHeight: 180 + }); + assert.equal(adjustment, -50); +}); + +test('does nothing when the content already fits exactly', () => { + const adjustment = getWindowHeightAdjustment({ + ...TYPICAL, contentHeight: 230 + }); + assert.equal(adjustment, 0); +}); + +test('ignores sub-pixel differences', () => { + // fractional layout metrics must not trigger a pointless resize + for(const contentHeight of [230.4, 229.6, 231, 229]) { + assert.equal(getWindowHeightAdjustment({...TYPICAL, contentHeight}), 0, + `contentHeight ${contentHeight} should not resize`); + } +}); + +test('never grows the viewport beyond the available screen height', () => { + /* 60px of chrome on a 700px screen leaves a 640px viewport, so a window + with a 230px viewport can grow by at most 410 -- but the per-call cap of + 400 binds first. */ + const adjustment = getWindowHeightAdjustment({ + viewportHeight: 230, + outerHeight: 290, + availableHeight: 700, + contentHeight: 5000 + }); + assert.equal(adjustment, 400); +}); + +test('respects the screen limit when it is tighter than the cap', () => { + // 40px chrome on a 400px screen => max viewport 360, so at most +130 + const adjustment = getWindowHeightAdjustment({ + viewportHeight: 230, + outerHeight: 270, + availableHeight: 400, + contentHeight: 5000 + }); + assert.equal(adjustment, 130); +}); + +test('never shrinks below the minimum usable height', () => { + // content claims almost nothing; the window must stay >= 160px + const adjustment = getWindowHeightAdjustment({ + ...TYPICAL, contentHeight: 10 + }); + assert.equal(adjustment, -70); + assert.equal(TYPICAL.viewportHeight + adjustment, 160); +}); + +test('bounds a single correction to the maximum adjustment', () => { + const adjustment = getWindowHeightAdjustment({ + viewportHeight: 800, + outerHeight: 860, + availableHeight: 4000, + contentHeight: 8000 + }); + assert.equal(adjustment, 400); +}); + +test('returns 0 when measurements are missing or unusable', () => { + const cases = [ + undefined, + {}, + {contentHeight: 300, viewportHeight: 0, outerHeight: 290}, + {contentHeight: 0, viewportHeight: 230, outerHeight: 290}, + {contentHeight: 300, viewportHeight: 230, outerHeight: 0} + ]; + for(const input of cases) { + assert.equal(getWindowHeightAdjustment(input), 0, + `expected 0 for ${JSON.stringify(input)}`); + } +}); + +test('tolerates a missing screen height', () => { + // `availableHeight` unknown: still grows, bounded by the per-call cap + const adjustment = getWindowHeightAdjustment({ + contentHeight: 263, viewportHeight: 230, outerHeight: 290 + }); + assert.equal(adjustment, 33); +}); + +test('handles a window reporting no chrome', () => { + // `outerHeight === innerHeight` (some embedded/headless contexts) + const adjustment = getWindowHeightAdjustment({ + contentHeight: 300, + viewportHeight: 230, + outerHeight: 230, + availableHeight: 1000 + }); + assert.equal(adjustment, 70); +}); diff --git a/web/components/FirstPartyMediatorWizard.vue b/web/components/FirstPartyMediatorWizard.vue index cd9220f..3f0b959 100644 --- a/web/components/FirstPartyMediatorWizard.vue +++ b/web/components/FirstPartyMediatorWizard.vue @@ -28,10 +28,39 @@ * New BSD License (3-clause) * Copyright (c) 2017-2026, Digital Bazaar, Inc. */ -import {computed, onMounted, ref, toRaw} from 'vue'; +import {computed, nextTick, onMounted, ref, toRaw} from 'vue'; import {FirstPartyMediator} from '../mediator/FirstPartyMediator.js'; +import {fitWindowToContent} from '../mediator/helpers.js'; import MediatorWizard from './MediatorWizard.vue'; +// give up waiting on a slow wallet icon rather than delay the resize; a late +// image just means the popup is corrected without its height +const IMAGE_LOAD_TIMEOUT = 1000; + +/* Resolves once every in the document has settled (loaded or errored), +or the timeout elapses. An icon that has not loaded reports no height, so +measuring before it settles under-reports the content. */ +async function _waitForImages() { + const images = [...document.querySelectorAll('img')] + .filter(img => !img.complete); + if(images.length === 0) { + return; + } + const settled = images.map(img => new Promise(resolve => { + img.addEventListener('load', resolve, {once: true}); + img.addEventListener('error', resolve, {once: true}); + })); + let timer; + const timeout = new Promise(resolve => { + timer = setTimeout(resolve, IMAGE_LOAD_TIMEOUT); + }); + try { + await Promise.race([Promise.all(settled), timeout]); + } finally { + clearTimeout(timer); + } +} + export default { name: 'FirstPartyMediatorWizard', components: {MediatorWizard}, @@ -118,6 +147,17 @@ export default { await mediator.credentialRequestOriginManifestPromise; credentialRequestOrigin.value = mediator.credentialRequestOrigin; loading.value = false; + + /* Correct the popup height now that the real content is known. + The height requested when opening the popup is a guess: the + browser subtracts its own chrome by an unpredictable amount and + the greeting reflows with the wallet's name and origin. Measuring + here replaces both guesses. Waits for the DOM update and for the + wallet icon to load, since an image that has not loaded yet does + not contribute its height. */ + await nextTick(); + await _waitForImages(); + fitWindowToContent(); } }); } catch(e) { diff --git a/web/components/MediatorGreeting.vue b/web/components/MediatorGreeting.vue index 3782582..d69c7e2 100644 --- a/web/components/MediatorGreeting.vue +++ b/web/components/MediatorGreeting.vue @@ -5,7 +5,7 @@
- The following website {{websiteDesire}} + This site {{websiteDesire}}
0 && viewportHeight > 0 && outerHeight > 0)) { + return 0; + } + + // ignore sub-pixel noise from fractional layout metrics + const deficit = Math.round(contentHeight - viewportHeight); + if(Math.abs(deficit) <= 1) { + return 0; + } + + // the chrome is whatever the window has beyond its viewport; it stays + // constant as the window resizes, so the largest viewport available is the + // screen minus that chrome + const chromeHeight = Math.max(0, outerHeight - viewportHeight); + const maxViewport = Math.max(0, (availableHeight || 0) - chromeHeight); + + let adjustment = deficit; + // never exceed the screen (a window taller than the display is worse than + // a scrolling one -- its footer would be unreachable) + if(maxViewport > 0) { + adjustment = Math.min(adjustment, maxViewport - viewportHeight); + } + // keep the window usable, and bound how far a single correction can move it + adjustment = Math.max(adjustment, MIN_POPUP_HEIGHT - viewportHeight); + adjustment = Math.max(-MAX_POPUP_HEIGHT_ADJUSTMENT, + Math.min(MAX_POPUP_HEIGHT_ADJUSTMENT, adjustment)); + + return Math.abs(adjustment) <= 1 ? 0 : adjustment; +} + +/** + * Resizes a script-opened popup so its content fits without clipping. + * + * The imperative shell around `getWindowHeightAdjustment`. Safe to call in a + * regular tab: `resizeBy` is a no-op on windows the script did not open, and + * any failure is swallowed -- a popup that cannot be resized still works, it + * just scrolls. + * + * @param {object} options - The options to use. + * @param {Window} [options.win=window] - The window to fit. + * + * @returns {number} The adjustment actually applied, in px. + */ +export function fitWindowToContent({win = window} = {}) { + try { + const {documentElement: html, body} = win.document; + /* The document's own `scrollHeight` is not enough. The 1p dialog is laid + out to exactly fill the popup with one internally scrolling region, so + content that does not fit is clipped *inside* that region and the document + itself never overflows -- `html.scrollHeight` equals `innerHeight` no + matter how badly the greeting is cut off. Add the largest overflow found + among scrolling descendants so the window grows by enough to reveal it. */ + const documentHeight = Math.max( + html.scrollHeight, body ? body.scrollHeight : 0); + const contentHeight = documentHeight + _getMaxInnerOverflow(win); + const adjustment = getWindowHeightAdjustment({ + contentHeight, + viewportHeight: win.innerHeight, + outerHeight: win.outerHeight, + availableHeight: win.screen && win.screen.availHeight + }); + if(adjustment !== 0) { + win.resizeBy(0, adjustment); + } + return adjustment; + } catch { + // cross-origin, no permission to resize, or nothing to measure + return 0; + } +} + +/* Largest vertical overflow among the document's scrollable elements. +Exported for testing only. + +The wallet hint list is intentionally scrollable and can be arbitrarily long, +so it is skipped: growing the window to fit every wallet is not the goal (the +hint chooser has its own taller popup height for that). What this looks for is +a region that is clipped only because the popup is too short for its fixed +content -- the greeting and footer. */ +export function _getMaxInnerOverflow(win) { + let max = 0; + const elements = win.document.querySelectorAll('.wrm-modal-content *'); + for(const el of elements) { + if(el.classList.contains('wrm-hint-list')) { + continue; + } + const overflow = el.scrollHeight - el.clientHeight; + // ignore elements with no content box (inline elements report 0/0) and + // sub-pixel noise + if(el.clientHeight > 0 && overflow > max) { + max = overflow; + } + } + return Math.round(max); +} + export function getOriginName({origin, manifest} = {}) { const {host} = new URL(origin); if(!manifest) { diff --git a/web/routes/TestWalletChooser.vue b/web/routes/TestWalletChooser.vue index 03e279b..42d53b4 100644 --- a/web/routes/TestWalletChooser.vue +++ b/web/routes/TestWalletChooser.vue @@ -2,7 +2,7 @@ route.query.origin || 'https://verifier.example'); + /* A manifest makes `WrmOriginCard` render its full form: the large icon + plus a name line stacked above the origin line, instead of the origin + alone. That taller card is what the real dialog shows for any site with a + web app manifest, so the no-manifest default under-reports the greeting's + height. `name=` sets the displayed name; `manifest=0` forces the bare + origin form. */ + const credentialRequestOriginManifest = computed(() => { + const {manifest, name} = route.query; + if(manifest === '0' || manifest === 'false') { + return null; + } + return { + name: name || 'Demo Wallet', + /* A loadable icon so the card renders at its real (tallest) height. + Without `icons`, the icon falls back to `/favicon.ico`, which + cannot load for a fake test origin and collapses to a small default + glyph — under-reporting the greeting height for any real wallet that + does have an icon. */ + icons: [{ + src: ICON_URL, + sizes: '48x48', + type: 'image/svg+xml' + }] + }; + }); + /* mirror `FirstPartyMediatorWizard`: the chooser is shown for every request type except a permission request (which shows the allow/deny greeting instead). This is what surfaces the chooser with zero hints. */ @@ -91,8 +125,8 @@ export default { const noop = () => {}; return { - credentialRequestOrigin, hints, interactionUrl, noop, requestType, - showHintChooser + credentialRequestOrigin, credentialRequestOriginManifest, hints, + interactionUrl, noop, requestType, showHintChooser }; } };