From 1081465028b81a5a58875c08350b41ce9cd721a1 Mon Sep 17 00:00:00 2001 From: Shevchik Igor Date: Sun, 30 Aug 2026 05:02:52 +0000 Subject: [PATCH 1/2] test(harness): mount hand-written wrappers into the document too, and unmount them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #512 attached the cases `renderEach` builds. The 293 hand-written `mountSuspended` calls across 112 files stayed detached, which is what kept the dialog family in `console-gate.ts`'s register with nothing left to fix in it. None of the 293 call sites is edited. Each project sets the default in the one place it already had a seam: the `vue` project in its shim, `test/utils/mount.ts`, and the `nuxt` project through a `resolveId` in `vitest.config.ts` pointing `@vue/test-utils` at the new `test/utils/attach-mount.ts`. `defu` and a spread leave `attachTo` overridable, so a case that wants a detached tree still can. `@nuxt/test-utils/runtime` is deliberately not redirected: `mockNuxtImport` and `mockComponent` are macros keyed on that specifier and would stop being transpiled. No spec imports both, which is why aliasing the Vue package rather than the Nuxt one is safe. `enableAutoUnmount(afterEach)` in both setup files is the other half, and is not optional: attaching without it leaves every case's tree in `document.body`, and reka-ui's focus scope from an earlier case then steals the focus a later one just set. `Modal`'s two focus tests failed exactly that way on the first attempt. `componentRender` no longer unmounts by hand — doing both would call `app.unmount()` twice and Vue warns on the second. Two things only became reachable once trees were in the document: - `getComputedStyle` returns a real declaration instead of an empty one, and reka-ui's `usePresence` keeps it in a `ref`, which deep-wraps it in a proxy. happy-dom's getters check their receiver, so `Drawer` threw `TypeError: Receiver must be an instance of class CSSStyleDeclaration` four times per run — uncaught, so it failed the run without failing a test. The same shape as `Countdown`'s `ref`-wrapped `Immediate` in #512, this time in third-party code. `test/utils/patchComputedStyle.ts` hands the declaration back with `markRaw`; removing that call brings all four back. - reka-ui's `hideOthers` marks the trigger `aria-hidden` while a popup is open and leaves it focusable, which axe reports as `aria-hidden-focus` in `Select` and `Table`. Measured: with the production default `portal: true` there is no violation — it needs `portal: false`, which these specs pass so the content lands inside the wrapper and can be asserted on. Auditing `document.body` instead is worse: it then trips on reka-ui's own `data-reka-focus-guard` spans, which carry `tabindex="0"` beside `aria-hidden="true"` by design. The rule is disabled in those two cases with that measurement recorded. The register drops from 37 entries to 25. The dialog-family group is gone entirely: every `nuxt` entry in it went quiet, and its `vue` entries remain only because the plain-Vue test router does not declare the routes those fixtures navigate to. Message volume moved the other way — 366 to 569 — because the two files that stay noisiest, `DropdownMenu` and `ContextMenu`, now really mount their menu content, so reka-ui's `textValue` warning fires once per item rather than once per test. Same third-party causes, more items reaching them. Six snapshot files moved. The `#454` baseline is regenerated at 292 groups and 880 entries; the entries that joined it are reference renders, not props that stopped rendering — `DashboardSearch > renders with groups` passes the shared fixture that already carries `groups`. Closes #513 --- .github/contributing/testing.md | 34 +++++- test/component-render.ts | 8 +- test/components/Select.spec.ts | 16 ++- test/components/Table.spec.ts | 13 ++- .../ContextMenu-vue.spec.ts.snap | 78 ++++++------- .../__snapshots__/ContextMenu.spec.ts.snap | 78 ++++++------- .../DashboardSearch-vue.spec.ts.snap | 106 +++++++++--------- .../DashboardSearch.spec.ts.snap | 54 ++++----- .../DashboardSidebar-vue.spec.ts.snap | 22 ++-- .../DashboardSidebar.spec.ts.snap | 20 ++-- test/nuxt/setup.ts | 20 +++- .../indistinguishable-snapshots.json | 14 ++- test/utils/attach-mount.ts | 18 +++ test/utils/console-gate.ts | 53 +++------ test/utils/mount.ts | 5 + test/utils/patchComputedStyle.ts | 32 ++++++ test/utils/setup.ts | 20 +++- vitest.config.ts | 22 +++- 18 files changed, 381 insertions(+), 232 deletions(-) create mode 100644 test/utils/attach-mount.ts create mode 100644 test/utils/patchComputedStyle.ts diff --git a/.github/contributing/testing.md b/.github/contributing/testing.md index cc2236674..14d309d9a 100644 --- a/.github/contributing/testing.md +++ b/.github/contributing/testing.md @@ -345,10 +345,36 @@ setup half now fails: `EditorDragHandle` and `EditorToolbar` mocked tiptap's `beforeUnmount` did. If a component you are testing acquires something on mount, the mock owes you the release too. -Tests that call `mountSuspended` by hand do not go through `componentRender` -and are still detached — 293 call sites across 112 files. That is why the -dialog specs stay in the register below even though their snapshot matrices -went quiet. +This applies to hand-written `mountSuspended` calls too, not only to the cases +`renderEach` builds — 293 call sites across 112 files, none of which had to be +edited. Both projects set the default in one place: the `vue` project in its +shim, `test/utils/mount.ts`, and the `nuxt` project through a `resolveId` in +`vitest.config.ts` that points `@vue/test-utils` at `test/utils/attach-mount.ts`. +`@nuxt/test-utils/runtime` is deliberately left alone, because `mockNuxtImport` +and `mockComponent` are macros keyed on that specifier and would stop being +transpiled. + +Unmounting is not done by hand either. `enableAutoUnmount(afterEach)` in both +setup files unmounts every wrapper `mount` created, so nothing accumulates in +`document.body` between cases. Without it, attaching makes cases interfere: +reka-ui's focus scope from an earlier case steals the focus a later one just +set, which is how `Modal`'s two focus tests failed on the first attempt. + +Two things only became reachable once trees were in the document, and both are +worth knowing about before writing a test that touches either: + +- `getComputedStyle` returns a real declaration rather than an empty one. reka-ui + keeps it in a `ref`, which deep-wraps it in a proxy, and happy-dom's getters + check their receiver — so `Drawer` threw + `TypeError: Receiver must be an instance of class CSSStyleDeclaration` four + times per run, uncaught. `test/utils/patchComputedStyle.ts` hands the + declaration back with `markRaw`. +- reka-ui's `hideOthers` marks the trigger `aria-hidden` while a popup is open + and leaves it focusable, which axe reports as `aria-hidden-focus`. It is an + artefact of `portal: false`, which the specs pass so the popup lands inside + the wrapper: measured, the production default `portal: true` has no violation. + The rule is disabled in `Select` and `Table`'s accessibility cases, with that + measurement recorded there. ## Console warnings fail the test diff --git a/test/component-render.ts b/test/component-render.ts index 69d8aaecf..37f55038c 100644 --- a/test/component-render.ts +++ b/test/component-render.ts @@ -26,13 +26,9 @@ async function componentRender(nameOrHtml: string, options: MountSuspendedOpt template: nameOrHtml, components: { [`B24${name}`]: component } } - const result = await mountSuspended(app, { attachTo: document.body }) - html = result.html() - result.unmount() + html = (await mountSuspended(app, { attachTo: document.body })).html() } else { - const cResult = await mountSuspended(component, { ...options, attachTo: document.body }) - html = cResult.html() - cResult.unmount() + html = (await mountSuspended(component, { ...options, attachTo: document.body })).html() } return html } diff --git a/test/components/Select.spec.ts b/test/components/Select.spec.ts index 5370b4412..626377d4f 100644 --- a/test/components/Select.spec.ts +++ b/test/components/Select.spec.ts @@ -142,7 +142,21 @@ describe('Select', () => { } }) - expect(await axe(wrapper.element)).toHaveNoViolations() + expect(await axe(wrapper.element, { + rules: { + // reka-ui's `hideOthers` marks the trigger `aria-hidden` while the + // popup is open, and leaves it focusable — which is what this rule + // catches. Measured: with the production default `portal: true` the + // violation is gone, because the content teleports out and the + // trigger is no longer its sibling. These specs pass `portal: false` + // so the content lands inside the wrapper and can be asserted on, and + // that arrangement is the whole reason the rule fires. Auditing + // `document.body` instead is worse, not better: it then trips on + // reka-ui's own `data-reka-focus-guard` spans, which carry + // `tabindex="0"` next to `aria-hidden="true"` by design. + 'aria-hidden-focus': { enabled: false } + } + })).toHaveNoViolations() }) describe('it should display correct label', () => { diff --git a/test/components/Table.spec.ts b/test/components/Table.spec.ts index 4fdd5babe..806bfedbf 100644 --- a/test/components/Table.spec.ts +++ b/test/components/Table.spec.ts @@ -308,7 +308,18 @@ describe('Table', () => { 'empty-table-header': { enabled: false }, // Checkbox buttons inside table are labelled via