From af6727e9c90a0abddbdc1b06bfb61f7eb0d37e70 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Mon, 24 Aug 2026 15:52:34 +0200 Subject: [PATCH 1/6] refactor(v4): read settle, frames and resetDom from the shipped test module The specs predate `src/test/index.ts` and imported the same three helpers from `src/test-utils.ts`. Point all 74 of them at the shipped module instead, and delete the twelve hand-rolled `render(html)` copies that `mount()` now covers. `TrackEvent.spec.ts` and `withTransition.spec.ts` keep a one-line local wrapper, because both assert on the component's own element rather than on the wrapper `mount()` returns. `getInstance` and `countRequestedFrames` still come from `test-utils.ts`: `getInstance` reads the raw instances map with no `$isMounted` filter, so it is not the public `getInstances`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NnepYhqjPMcFCSb43PoHRM --- .../v4/migration/Accordion/Accordion.spec.ts | 3 +- packages/v4/migration/Action/Action.spec.ts | 103 ++++++++---------- .../v4/migration/AnchorNav/AnchorNav.spec.ts | 3 +- .../migration/AnchorNav/AnchorNavLink.spec.ts | 3 +- .../AnchorNav/AnchorNavTarget.spec.ts | 2 +- .../v4/migration/Carousel/Carousel.spec.ts | 3 +- .../ClickOutside/ClickOutside.spec.ts | 3 +- packages/v4/migration/Cursor/Cursor.spec.ts | 13 +-- packages/v4/migration/Data/DataBind.spec.ts | 65 +++++------ .../v4/migration/Data/DataDerived.spec.ts | 23 ++-- packages/v4/migration/Data/DataScope.spec.ts | 41 +++---- packages/v4/migration/Dialog/Dialog.spec.ts | 3 +- .../v4/migration/Draggable/Draggable.spec.ts | 3 +- packages/v4/migration/Fetch/Fetch.spec.ts | 59 +++++----- .../Fetch/FetchShopifyPartial.spec.ts | 3 +- .../migration/Figure/AbstractFigure.spec.ts | 3 +- .../v4/migration/Figure/FigureShopify.spec.ts | 3 +- .../migration/Figure/FigureTwicpics.spec.ts | 3 +- .../migration/FigureVideo/FigureVideo.spec.ts | 3 +- .../FigureVideo/FigureVideoTwicpics.spec.ts | 3 +- .../v4/migration/Hoverable/Hoverable.spec.ts | 3 +- packages/v4/migration/InView/InView.spec.ts | 2 +- .../migration/LazyInclude/LazyInclude.spec.ts | 41 +++---- packages/v4/migration/Menu/Menu.spec.ts | 3 +- packages/v4/migration/Menu/MenuBtn.spec.ts | 3 +- packages/v4/migration/Menu/MenuList.spec.ts | 3 +- .../v4/migration/Prefetch/Prefetch.spec.ts | 51 ++++----- .../ScrollAnimation/ScrollAnimation.spec.ts | 3 +- .../v4/migration/ScrollTo/ScrollTo.spec.ts | 3 +- .../v4/migration/Sentinel/Sentinel.spec.ts | 2 +- packages/v4/migration/Slider/Slider.spec.ts | 3 +- .../v4/migration/Slider/SliderDots.spec.ts | 3 +- .../v4/migration/Slider/SliderDrag.spec.ts | 3 +- .../migration/Slider/SliderProgress.spec.ts | 3 +- packages/v4/migration/Sticky/Sticky.spec.ts | 23 ++-- packages/v4/migration/Timer/Timer.spec.ts | 3 +- .../v4/migration/Timer/TimerProgress.spec.ts | 3 +- packages/v4/migration/Toaster/Toast.spec.ts | 3 +- packages/v4/migration/Toaster/Toaster.spec.ts | 3 +- packages/v4/migration/Track/Track.spec.ts | 91 +++++++--------- .../v4/migration/Track/TrackEvent.spec.ts | 13 ++- .../Transition/withTransition.spec.ts | 9 +- packages/v4/src/Base.spec.ts | 11 +- packages/v4/src/attribute-namespaces.spec.ts | 2 +- packages/v4/src/autoload.spec.ts | 2 +- packages/v4/src/coexistence.spec.ts | 2 +- packages/v4/src/config-extension.spec.ts | 3 +- packages/v4/src/context-subscription.spec.ts | 3 +- packages/v4/src/context.spec.ts | 3 +- packages/v4/src/decorators.spec.ts | 3 +- packages/v4/src/dom-mutations.spec.ts | 3 +- packages/v4/src/group.spec.ts | 29 ++--- packages/v4/src/instances.spec.ts | 3 +- packages/v4/src/manifest.spec.ts | 3 +- packages/v4/src/mount-strategies.spec.ts | 2 +- packages/v4/src/negotiated-events.spec.ts | 2 +- packages/v4/src/props.spec.ts | 3 +- packages/v4/src/registry.spec.ts | 3 +- packages/v4/src/responsive-components.spec.ts | 2 +- packages/v4/src/responsive-options.spec.ts | 3 +- packages/v4/src/services/breakpoint.spec.ts | 2 +- packages/v4/src/services/drag.spec.ts | 3 +- packages/v4/src/services/media.spec.ts | 2 +- packages/v4/src/services/mixin.spec.ts | 3 +- packages/v4/src/services/raf.spec.ts | 2 +- packages/v4/src/services/resize.spec.ts | 2 +- .../v4/src/services/scroll-progress.spec.ts | 2 +- packages/v4/src/services/scroll.spec.ts | 2 +- packages/v4/src/services/toggle.spec.ts | 2 +- packages/v4/src/services/until.spec.ts | 2 +- packages/v4/src/swap.spec.ts | 2 +- packages/v4/src/utils/smoothTo.spec.ts | 3 +- packages/v4/src/utils/transition.spec.ts | 2 +- packages/v4/src/viewTransition.spec.ts | 2 +- 74 files changed, 343 insertions(+), 389 deletions(-) diff --git a/packages/v4/migration/Accordion/Accordion.spec.ts b/packages/v4/migration/Accordion/Accordion.spec.ts index 1bb4bb6e0..ab97d5e2f 100644 --- a/packages/v4/migration/Accordion/Accordion.spec.ts +++ b/packages/v4/migration/Accordion/Accordion.spec.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { registerComponent } from '../../src/index.js'; -import { getInstance, resetDom, settle } from '../../src/test-utils.js'; +import { getInstance } from '../../src/test-utils.js'; +import { resetDom, settle } from '../../src/test/index.js'; import { Accordion } from './Accordion.js'; import { AccordionItem } from './AccordionItem.js'; diff --git a/packages/v4/migration/Action/Action.spec.ts b/packages/v4/migration/Action/Action.spec.ts index f275c693d..fad610f53 100644 --- a/packages/v4/migration/Action/Action.spec.ts +++ b/packages/v4/migration/Action/Action.spec.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Base, registerComponents, swap, SWAP_MODES, type BaseConfig } from '../../src/index.js'; -import { getInstance, resetDom, settle } from '../../src/test-utils.js'; +import { getInstance } from '../../src/test-utils.js'; +import { mount, resetDom, settle } from '../../src/test/index.js'; import { Dialog } from '../Dialog/Dialog.js'; import { Action } from './Action.js'; import { ActionEvent } from './ActionEvent.js'; @@ -35,14 +36,6 @@ registerComponents(Action, Target, Foo, Bar, Dialog, MountProbe); afterEach(resetDom); -async function render(html: string): Promise { - const root = document.createElement('div'); - root.innerHTML = html; - document.body.append(root); - await settle(); - return root; -} - function at(root: ParentNode, selector: string, name: string): T { return getInstance(root.querySelector(selector), name); } @@ -57,7 +50,7 @@ const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); describe('ActionEvent — parsing and the effect evaluator', () => { it('compiles a callable effect from the effect definition', async () => { - const root = await render('
'); + const root = await mount('
'); const action = at(root, '#action', 'Action'); const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); @@ -70,7 +63,7 @@ describe('ActionEvent — parsing and the effect evaluator', () => { }); it('returns a callable function from the effect property', async () => { - const root = await render('
'); + const root = await mount('
'); const action = at(root, '#action', 'Action'); const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); @@ -85,7 +78,7 @@ describe('ActionEvent — parsing and the effect evaluator', () => { }); it('parses modifiers and the debounce delay', async () => { - const root = await render('
'); + const root = await mount('
'); const action = at(root, '#action', 'Action'); const plain = new ActionEvent(action, 'click.prevent.stop', 'target'); @@ -101,7 +94,7 @@ describe('ActionEvent — parsing and the effect evaluator', () => { }); it('splits the target definition from the effect', async () => { - const root = await render('
'); + const root = await mount('
'); const action = at(root, '#action', 'Action'); const actionEvent = new ActionEvent(action, 'click', ' Target(#a) Foo -> target.fn() '); @@ -112,7 +105,7 @@ describe('ActionEvent — parsing and the effect evaluator', () => { describe('ActionEvent — target resolution', () => { it('resolves the target to the action itself when no target is set', async () => { - const root = await render('
'); + const root = await mount('
'); const action = at(root, '#action', 'Action'); const actionEvent = new ActionEvent(action, 'click', '(...args) => args'); @@ -120,7 +113,7 @@ describe('ActionEvent — target resolution', () => { }); it('resolves a single target', async () => { - const root = await render(` + const root = await mount(`
`); @@ -132,7 +125,7 @@ describe('ActionEvent — target resolution', () => { }); it('resolves multiple targets', async () => { - const root = await render(` + const root = await mount(`
@@ -146,7 +139,7 @@ describe('ActionEvent — target resolution', () => { }); it('resolves targets narrowed by a selector', async () => { - const root = await render(` + const root = await mount(`
@@ -159,7 +152,7 @@ describe('ActionEvent — target resolution', () => { }); it('ignores a target part it cannot parse', async () => { - const root = await render(` + const root = await mount(`
`); @@ -171,7 +164,7 @@ describe('ActionEvent — target resolution', () => { }); it('reaches a target that is neither a descendant nor an ancestor', async () => { - const root = await render(` + const root = await mount(`
@@ -183,7 +176,7 @@ describe('ActionEvent — target resolution', () => { }); it('resolves targets at event time, so a target mounting later is reached', async () => { - const root = await render(` + const root = await mount(` `); @@ -200,7 +193,7 @@ describe('ActionEvent — target resolution', () => { }); it('stops targeting a component once it is unmounted', async () => { - const root = await render(` + const root = await mount(`
`); @@ -219,7 +212,7 @@ describe('ActionEvent — target resolution', () => { describe('ActionEvent — modifiers', () => { it('prevents default and stops propagation', async () => { - const root = await render(` + const root = await mount(`
@@ -239,7 +232,7 @@ describe('ActionEvent — modifiers', () => { }); it('forwards capture, once and passive to the listener options', async () => { - const root = await render('
'); + const root = await mount('
'); const action = at(root, '#action', 'Action'); const spy = vi.spyOn(action.$el, 'addEventListener'); @@ -256,7 +249,7 @@ describe('ActionEvent — modifiers', () => { }); it('runs a `once` binding exactly once', async () => { - const root = await render(` + const root = await mount(`
`); @@ -269,7 +262,7 @@ describe('ActionEvent — modifiers', () => { }); it('debounces with the default delay', async () => { - const root = await render(` + const root = await mount(`
@@ -290,7 +283,7 @@ describe('ActionEvent — modifiers', () => { }); it('debounces with a custom delay', async () => { - const root = await render(` + const root = await mount(`
@@ -307,7 +300,7 @@ describe('ActionEvent — modifiers', () => { }); it('drops a pending debounced effect when the action is unmounted', async () => { - const root = await render(` + const root = await mount(`
@@ -325,7 +318,7 @@ describe('ActionEvent — modifiers', () => { describe('Action — the component', () => { it('reacts on click by default', async () => { - const root = await render(` + const root = await mount(`
@@ -336,7 +329,7 @@ describe('Action — the component', () => { }); it('reacts on the event given by the `on` option', async () => { - const root = await render(` + const root = await mount(`
@@ -350,7 +343,7 @@ describe('Action — the component', () => { }); it('does nothing when `on` is set without an `effect`', async () => { - const root = await render(` + const root = await mount(`
@@ -363,7 +356,7 @@ describe('Action — the component', () => { }); it('calls the effect with the documented arguments', async () => { - const root = await render(` + const root = await mount(`
@@ -381,7 +374,7 @@ describe('Action — the component', () => { }); it('calls a returned function with the same arguments', async () => { - const root = await render(` + const root = await mount(`
@@ -397,7 +390,7 @@ describe('Action — the component', () => { }); it('exposes the instances mounted on its own element by name', async () => { - const root = await render(` + const root = await mount(`
@@ -410,7 +403,7 @@ describe('Action — the component', () => { }); it('sees an instance mounted on the action element after it', async () => { - const root = await render(` + const root = await mount(`
@@ -429,7 +422,7 @@ describe('Action — the component', () => { }); it('binds every `data-on:` attribute', async () => { - const root = await render(` + const root = await mount(`
@@ -445,7 +438,7 @@ describe('Action — the component', () => { }); it('accepts a multiline binding', async () => { - const root = await render(` + const root = await mount(`
`); const details: Array> = []; @@ -491,7 +484,7 @@ describe('Action — the component', () => { describe('Action — the v4 lifecycle', () => { it('releases its listeners when the element leaves the DOM', async () => { - const root = await render(` + const root = await mount(`
`); @@ -509,7 +502,7 @@ describe('Action — the v4 lifecycle', () => { }); it('re-reads its bindings on every mount cycle', async () => { - const root = await render(` + const root = await mount(`
@@ -530,7 +523,7 @@ describe('Action — the v4 lifecycle', () => { }); it('binds once per cycle, not once per remount', async () => { - const root = await render(` + const root = await mount(`
@@ -551,7 +544,7 @@ describe('Action — the v4 lifecycle', () => { describe('Action — live rebinding through watchAttributes', () => { it('rebinds when a `data-on:*` attribute is rewritten in place', async () => { - const root = await render(` + const root = await mount(`
@@ -567,7 +560,7 @@ describe('Action — live rebinding through watchAttributes', () => { }); it('detaches the binding when its attribute is removed', async () => { - const root = await render(` + const root = await mount(` @@ -591,7 +584,7 @@ describe('Action — live rebinding through watchAttributes', () => { }); it('attaches a binding for an attribute added after mount', async () => { - const root = await render(` + const root = await mount(`
`); @@ -609,7 +602,7 @@ describe('Action — live rebinding through watchAttributes', () => { }); it('applies only the final value when one batch writes several times', async () => { - const root = await render(` + const root = await mount(`
@@ -626,7 +619,7 @@ describe('Action — live rebinding through watchAttributes', () => { }); it('keeps the binding through a rewrite that nets out', async () => { - const root = await render(` + const root = await mount(`
@@ -643,7 +636,7 @@ describe('Action — live rebinding through watchAttributes', () => { }); it('rebinds after a morph rewrites the attribute', async () => { - const root = await render(` + const root = await mount(`
@@ -672,7 +665,7 @@ describe('Action — live rebinding through watchAttributes', () => { }); it('stops watching once the element leaves the DOM', async () => { - const root = await render(` + const root = await mount(`
@@ -696,7 +689,7 @@ describe('Action — live rebinding through watchAttributes', () => { describe('Action — live rebinding of the option triple', () => { it('rebinds when the `effect` option changes', async () => { - const root = await render(` + const root = await mount(`
@@ -712,7 +705,7 @@ describe('Action — live rebinding of the option triple', () => { }); it('rebinds to the new event when the `on` option changes', async () => { - const root = await render(` + const root = await mount(`
@@ -732,7 +725,7 @@ describe('Action — live rebinding of the option triple', () => { }); it('rebinds to the new target when the `target` option changes', async () => { - const root = await render(` + const root = await mount(`
@@ -751,7 +744,7 @@ describe('Action — live rebinding of the option triple', () => { }); it('detaches when the `effect` option is removed', async () => { - const root = await render(` + const root = await mount(`
@@ -771,7 +764,7 @@ describe('Action — live rebinding of the option triple', () => { }); it('attaches when an `effect` option is added after mount', async () => { - const root = await render(` + const root = await mount(`
`); @@ -787,7 +780,7 @@ describe('Action — live rebinding of the option triple', () => { }); it('produces one binding when two of the three options change together', async () => { - const root = await render(` + const root = await mount(`
@@ -807,7 +800,7 @@ describe('Action — live rebinding of the option triple', () => { }); it('leaves the option binding alone when a `data-on:*` attribute changes', async () => { - const root = await render(` + const root = await mount(` @@ -828,7 +821,7 @@ describe('Action — interop with the ported Dialog', () => { let root: HTMLElement; beforeEach(async () => { - root = await render(` + root = await mount(` `); @@ -227,7 +220,7 @@ describe('DataBind — the element half', () => { }); it('follows a virtual binding rewritten in place', async () => { - const root = await render(` + const root = await mount(`
`); @@ -248,7 +241,7 @@ describe('DataBind — the element half', () => { }); it('picks up a virtual binding added after mount, and drops a removed one', async () => { - const root = await render(` + const root = await mount(`
`); @@ -282,7 +275,7 @@ describe('DataBind — the element half', () => { }; document.addEventListener(EVENTS.diagnostic, listener); - const root = await render(` + const root = await mount(`
`); @@ -296,7 +289,7 @@ describe('DataBind — the element half', () => { it('fails quietly when a virtual expression throws', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); - const root = await render(` + const root = await mount(`
`); @@ -309,7 +302,7 @@ describe('DataBind — the element half', () => { }); it('toggles, increments and cycles', async () => { - const root = await render(` + const root = await mount(`
2
one
@@ -344,7 +337,7 @@ describe('DataBind — the element half', () => { it('refuses the mutation helpers on computed values and effects', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const root = await render(` + const root = await mount(`
current
{ describe('DataBind — the group half', () => { it('dispatches a value to every peer of an unscoped group', async () => { const group = uniqueGroup('a'); - const root = await render(` + const root = await mount(`
foo
foo
{ it('delivers through the public set method, with dispatch false', async () => { const group = uniqueGroup('delivery'); - const root = await render(` + const root = await mount(`
`); @@ -400,7 +393,7 @@ describe('DataBind — the group half', () => { it('gives every peer the same page-wide channel when no scope is above them', async () => { const group = uniqueGroup('page'); - const root = await render(` + const root = await mount(`
`); @@ -417,7 +410,7 @@ describe('DataBind — the group half', () => { it('preserves the latest value during reentrant group updates', async () => { // A nested write must prevent later subscribers from receiving the superseded frame. const group = uniqueGroup('reentrant'); - const root = await render(` + const root = await mount(`
@@ -436,7 +429,7 @@ describe('DataBind — the group half', () => { it('leaves and rejoins its group across unmount/mount cycles', async () => { const group = uniqueGroup('lifecycle'); - const root = await render(` + const root = await mount(`
@@ -461,7 +454,7 @@ describe('DataBind — the group half', () => { it('forgets peers that left the document, synchronously', async () => { const group = `${uniqueGroup('checkbox')}[]`; - const root = await render(` + const root = await mount(` `); @@ -476,7 +469,7 @@ describe('DataBind — the group half', () => { it('propagates its own value on mount when immediate is set', async () => { const group = uniqueGroup('immediate'); - const root = await render(` + const root = await mount(` `); @@ -487,7 +480,7 @@ describe('DataBind — the group half', () => { it('runs an effect on mount only when immediate is set', async () => { const passive = uniqueGroup('passive'); const immediate = uniqueGroup('immediate'); - const root = await render(` + const root = await mount(`
{ it('updates a virtual subscriber even when its own value already matches', async () => { const group = uniqueGroup('equal'); - const root = await render(` + const root = await mount(`
foo
@@ -512,7 +505,7 @@ describe('DataBind — the group half', () => { it('uses the scoped $data inside virtual expressions', async () => { const group = uniqueGroup('tabs'); - const root = await render(` + const root = await mount(`
`, ); @@ -72,7 +65,7 @@ describe('Track — payload resolution', () => { }); it('treats a non-JSON attribute value as the event name', async () => { - const root = await render( + const root = await mount( ``, ); @@ -82,7 +75,7 @@ describe('Track — payload resolution', () => { }); it('fires an event declared with an empty value, carrying the context alone', async () => { - const root = await render(` + const root = await mount(`
@@ -94,7 +87,7 @@ describe('Track — payload resolution', () => { }); it('uses the `payload` option as the base payload', async () => { - const root = await render( + const root = await mount( ``, ); @@ -105,7 +98,7 @@ describe('Track — payload resolution', () => { }); it('lets the `payload` option override the `payload` ref, keeping the rest', async () => { - const root = await render(` + const root = await mount(` @@ -157,7 +150,7 @@ describe('Track — payload resolution', () => { }); it('replaces arrays on merge instead of concatenating them', async () => { - const root = await render(` + const root = await mount(`
@@ -170,7 +163,7 @@ describe('Track — payload resolution', () => { }); it('never shares an array instance between two dispatches', async () => { - const root = await render( + const root = await mount( ``, ); const button = root.querySelector('button') as HTMLButtonElement; @@ -183,7 +176,7 @@ describe('Track — payload resolution', () => { }); it('fires every data-track:* declared on one element', async () => { - const root = await render( + const root = await mount( ``, @@ -200,7 +193,7 @@ describe('Track — payload resolution', () => { describe('Track — malformed declarations', () => { it('drops an event whose JSON cannot be parsed, without throwing', async () => { const log = recordDiagnostics(); - const root = await render( + const root = await mount( ``, ); @@ -212,7 +205,7 @@ describe('Track — malformed declarations', () => { it('falls back to an empty payload when the `payload` ref is invalid JSON', async () => { const log = recordDiagnostics(); - const root = await render(` + const root = await mount(` @@ -227,7 +220,7 @@ describe('Track — malformed declarations', () => { it('falls back to an empty payload when `data-option-payload` is invalid JSON', async () => { const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const root = await render( + const root = await mount( ``, ); @@ -240,7 +233,7 @@ describe('Track — malformed declarations', () => { describe('Track — the `mounted` pseudo-event', () => { it('dispatches once the batch has settled, with the resolved context', async () => { - await render(` + await mount(`
@@ -262,7 +255,7 @@ describe('Track — the `mounted` pseudo-event', () => { }); it('applies timing modifiers to the mounted event', async () => { - await render( + await mount( `
`, ); await settle(); @@ -275,7 +268,7 @@ describe('Track — the `mounted` pseudo-event', () => { }); it('dispatches again with the new context when the component moves under a scope', async () => { - const root = await render( + const root = await mount( `
`, ); await settle(); @@ -295,7 +288,7 @@ describe('Track — the `mounted` pseudo-event', () => { describe('Track — the `view` pseudo-event', () => { it('dispatches when the element enters the viewport', async () => { - const root = await render( + const root = await mount( `
`, ); await observed(); @@ -308,7 +301,7 @@ describe('Track — the `view` pseudo-event', () => { }); it('dispatches on every entry without the `.once` modifier', async () => { - const root = await render( + const root = await mount( `
`, ); const el = root.firstElementChild as HTMLElement; @@ -323,7 +316,7 @@ describe('Track — the `view` pseudo-event', () => { }); it('dispatches once with the `.once` modifier and releases the subscription', async () => { - const root = await render( + const root = await mount( `
`, ); const el = root.firstElementChild as HTMLElement; @@ -338,7 +331,7 @@ describe('Track — the `view` pseudo-event', () => { }); it('dispatches for an element taller than the viewport at the default threshold', async () => { - await render( + await mount( `
`, @@ -350,7 +343,7 @@ describe('Track — the `view` pseudo-event', () => { /** A tall element that cannot reach its threshold never intersects. */ it('cannot dispatch for an element that can never reach its own threshold', async () => { - await render( + await mount( `
`, @@ -361,7 +354,7 @@ describe('Track — the `view` pseudo-event', () => { }); it('applies timing modifiers to the view event', async () => { - const root = await render( + const root = await mount( `
`, ); const el = root.firstElementChild as HTMLElement; @@ -376,7 +369,7 @@ describe('Track — the `view` pseudo-event', () => { }); it('releases the observer with the mount cycle', async () => { - const root = await render( + const root = await mount( `
`, ); const el = root.firstElementChild as HTMLElement; @@ -394,7 +387,7 @@ describe('Track — the `view` pseudo-event', () => { describe('Track — lifecycle', () => { it('stops dispatching a `.capture` binding after unmount and resumes on remount', async () => { - const root = await render( + const root = await mount( `
`, ); const el = root.firstElementChild as HTMLElement; @@ -413,7 +406,7 @@ describe('Track — lifecycle', () => { }); it('cancels a pending debounced dispatch on unmount, even after a remount', async () => { - const root = await render( + const root = await mount( `
`, ); const el = root.firstElementChild as HTMLElement; @@ -428,7 +421,7 @@ describe('Track — lifecycle', () => { }); it('re-reads the declarations on every mount cycle', async () => { - const root = await render( + const root = await mount( `
`, ); const el = root.firstElementChild as HTMLElement; @@ -445,7 +438,7 @@ describe('Track — lifecycle', () => { describe('Track — live rebinding through watchAttributes', () => { it('follows a data-track:* attribute rewritten in place', async () => { - const root = await render( + const root = await mount( `
`, ); const el = root.firstElementChild as HTMLElement; @@ -459,7 +452,7 @@ describe('Track — live rebinding through watchAttributes', () => { }); it('releases a binding whose attribute is removed', async () => { - const root = await render( + const root = await mount( `
`, ); const el = root.firstElementChild as HTMLElement; @@ -475,7 +468,7 @@ describe('Track — live rebinding through watchAttributes', () => { }); it('binds an attribute added after mount', async () => { - const root = await render(`
`); + const root = await mount(`
`); const el = root.firstElementChild as HTMLElement; el.setAttribute('data-track:click', '{"event": "late"}'); @@ -486,7 +479,7 @@ describe('Track — live rebinding through watchAttributes', () => { }); it('binds once when several attributes change in one batch', async () => { - const root = await render( + const root = await mount( `
`, ); const el = root.firstElementChild as HTMLElement; @@ -502,7 +495,7 @@ describe('Track — live rebinding through watchAttributes', () => { }); it('ends the subscription with the mount cycle', async () => { - const root = await render( + const root = await mount( `
`, ); const el = root.firstElementChild as HTMLElement; @@ -548,7 +541,7 @@ describe('the intersection service under load', () => { ).join(''); const built = await countObservers(async () => { - await render(markup); + await mount(markup); await observed(); }); @@ -559,7 +552,7 @@ describe('the intersection service under load', () => { it('shares one observer between two declarations on the same element', async () => { const built = await countObservers(async () => { - await render( + await mount( `
`, @@ -577,7 +570,7 @@ describe('the intersection service under load', () => { it('gives two declarations with different thresholds two observers', async () => { const built = await countObservers(async () => { - await render( + await mount( `
`, ); @@ -593,7 +586,7 @@ describe('the intersection service under load', () => { let secondRatio = -1; const built = await countObservers(async () => { - const root = await render(`
`); + const root = await mount(`
`); const el = root.querySelector('#probe') as HTMLElement; const { useInView } = await import('../../src/index.js'); @@ -619,7 +612,7 @@ describe('the intersection service under load', () => { data-track:view.once='{"event": "impression", "id": "${index}"}'>
`, ).join(''); - const root = await render(markup); + const root = await mount(markup); await observed(); expect(pushes()).toHaveLength(CARDS); @@ -628,7 +621,7 @@ describe('the intersection service under load', () => { window.dataLayer = []; const built = await countObservers(async () => { - await render(markup); + await mount(markup); await observed(); }); expect(built).toBe(CARDS); @@ -641,7 +634,7 @@ describe('TrackShopify — the dispatch seam', () => { const publish = vi.fn(); window.Shopify = { analytics: { publish } }; - const root = await render(` + const root = await mount(`
@@ -663,7 +656,7 @@ describe('TrackShopify — the dispatch seam', () => { window.Shopify = { analytics: { publish } }; const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const root = await render( + const root = await mount( ``, ); root.querySelector('button')?.click(); @@ -675,7 +668,7 @@ describe('TrackShopify — the dispatch seam', () => { it('does not throw when the Shopify analytics API is absent', async () => { const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const root = await render( + const root = await mount( ``, ); diff --git a/packages/v4/migration/Track/TrackEvent.spec.ts b/packages/v4/migration/Track/TrackEvent.spec.ts index 39e56cb96..1e665ed61 100644 --- a/packages/v4/migration/Track/TrackEvent.spec.ts +++ b/packages/v4/migration/Track/TrackEvent.spec.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { registerComponents } from '../../src/index.js'; -import { getInstance, resetDom, settle } from '../../src/test-utils.js'; +import { getInstance } from '../../src/test-utils.js'; +import { mount, resetDom } from '../../src/test/index.js'; import { parseEventDefinition } from '../event-modifiers.js'; import { Track } from './Track.js'; import { resolveDetailPlaceholders } from './TrackEvent.js'; @@ -13,12 +14,12 @@ beforeEach(() => { window.dataLayer = []; }); +/** + * Every assertion here dispatches at the `Track` element itself, so the wrapper + * `mount()` returns is not what the tests want — hence the one-line unwrap. + */ async function render(html: string): Promise { - const root = document.createElement('div'); - root.innerHTML = html; - document.body.append(root); - await settle(); - return root.firstElementChild as HTMLElement; + return (await mount(html)).firstElementChild as HTMLElement; } function pushes(): Record[] { diff --git a/packages/v4/migration/Transition/withTransition.spec.ts b/packages/v4/migration/Transition/withTransition.spec.ts index 89282511a..c3acac2ba 100644 --- a/packages/v4/migration/Transition/withTransition.spec.ts +++ b/packages/v4/migration/Transition/withTransition.spec.ts @@ -1,7 +1,8 @@ import { afterEach, describe, expect, it } from 'vitest'; import { Base, registerComponents, type BaseConfig } from '../../src/index.js'; import { resolveConfig } from '../../src/Base.js'; -import { getInstance, resetDom, settle } from '../../src/test-utils.js'; +import { getInstance } from '../../src/test-utils.js'; +import { mount, resetDom, settle } from '../../src/test/index.js'; import { Transition } from './Transition.js'; import { withTransition } from './withTransition.js'; @@ -35,11 +36,9 @@ registerComponents(Transition, TransitionProbe, ForcedProbe, MultiProbe); afterEach(resetDom); +/** The probes are asserted on directly, so the wrapper is unwrapped here. */ async function render(name: string, attributes = ''): Promise { - const root = document.createElement('div'); - root.innerHTML = `
`; - document.body.append(root); - await settle(); + const root = await mount(`
`); return root.firstElementChild as HTMLElement; } diff --git a/packages/v4/src/Base.spec.ts b/packages/v4/src/Base.spec.ts index 82a0e84aa..4c13b1a88 100644 --- a/packages/v4/src/Base.spec.ts +++ b/packages/v4/src/Base.spec.ts @@ -13,15 +13,8 @@ import { DIAGNOSTICS, type ToolkitDiagnosticDetail } from './diagnostic-contract import { EVENTS } from './events.js'; import { INSTANCES } from './protocol-symbols.js'; import { registerComponent } from './registry.js'; -import { - getInstance, - renderTodoList, - resetDom, - settle, - TodoCount, - TodoItem, - TodoList, -} from './test-utils.js'; +import { getInstance, renderTodoList, TodoCount, TodoItem, TodoList } from './test-utils.js'; +import { resetDom, settle } from './test/index.js'; afterEach(resetDom); diff --git a/packages/v4/src/attribute-namespaces.spec.ts b/packages/v4/src/attribute-namespaces.spec.ts index 580828b60..40aa214b6 100644 --- a/packages/v4/src/attribute-namespaces.spec.ts +++ b/packages/v4/src/attribute-namespaces.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { watchAttributeNamespace } from './attribute-namespaces.js'; import { EVENTS } from './events.js'; -import { resetDom, settle } from './test-utils.js'; +import { resetDom, settle } from './test/index.js'; import type { ToolkitDiagnosticDetail } from './diagnostic-contract.js'; const cleanups = new Set<() => void>(); diff --git a/packages/v4/src/autoload.spec.ts b/packages/v4/src/autoload.spec.ts index 4049e42be..bb4611f7e 100644 --- a/packages/v4/src/autoload.spec.ts +++ b/packages/v4/src/autoload.spec.ts @@ -6,7 +6,7 @@ import { EVENTS } from './events.js'; import { getInstances } from './instances.js'; import { INSTANCES } from './protocol-symbols.js'; import { registerComponent, registerManifest } from './registry.js'; -import { resetDom, settle } from './test-utils.js'; +import { resetDom, settle } from './test/index.js'; /** Positions used to control viewport strategies. */ const OFFSCREEN = 'position:absolute;top:300vh;left:0;width:50px;height:50px'; diff --git a/packages/v4/src/coexistence.spec.ts b/packages/v4/src/coexistence.spec.ts index 05b26b657..8e2c135bf 100644 --- a/packages/v4/src/coexistence.spec.ts +++ b/packages/v4/src/coexistence.spec.ts @@ -37,7 +37,7 @@ import { import { Base } from './Base.js'; import { INSTANCES } from './protocol-symbols.js'; import { registerComponent } from './registry.js'; -import { resetDom, settle } from './test-utils.js'; +import { resetDom, settle } from './test/index.js'; class V3Widget extends BaseV3 { static config: BaseConfigV3 = { name: 'Widget' }; diff --git a/packages/v4/src/config-extension.spec.ts b/packages/v4/src/config-extension.spec.ts index e449f355d..721468146 100644 --- a/packages/v4/src/config-extension.spec.ts +++ b/packages/v4/src/config-extension.spec.ts @@ -15,7 +15,8 @@ import { Base, type BaseConfig, type BaseProps } from './Base.js'; import { component } from './decorators.js'; import { DIAGNOSTICS } from './diagnostic-contract.js'; import { registerComponent } from './registry.js'; -import { getInstance, resetDom, settle, TodoItem } from './test-utils.js'; +import { getInstance, TodoItem } from './test-utils.js'; +import { resetDom, settle } from './test/index.js'; afterEach(resetDom); diff --git a/packages/v4/src/context-subscription.spec.ts b/packages/v4/src/context-subscription.spec.ts index f5722feff..f6685c6b1 100644 --- a/packages/v4/src/context-subscription.spec.ts +++ b/packages/v4/src/context-subscription.spec.ts @@ -5,7 +5,8 @@ import { createContext, provideContext, provideRootContext, type ContextKey } fr import { DIAGNOSTICS, type ToolkitDiagnosticDetail } from './diagnostic-contract.js'; import { EVENTS } from './events.js'; import { registerComponent } from './registry.js'; -import { getInstance, resetDom, settle } from './test-utils.js'; +import { getInstance } from './test-utils.js'; +import { resetDom, settle } from './test/index.js'; afterEach(resetDom); diff --git a/packages/v4/src/context.spec.ts b/packages/v4/src/context.spec.ts index eb9945181..bafbc4d74 100644 --- a/packages/v4/src/context.spec.ts +++ b/packages/v4/src/context.spec.ts @@ -12,7 +12,8 @@ import { import { DIAGNOSTICS, type ToolkitDiagnosticDetail } from './diagnostic-contract.js'; import { EVENTS } from './events.js'; import { registerComponent } from './registry.js'; -import { getInstance, renderTodoList, resetDom, settle } from './test-utils.js'; +import { getInstance, renderTodoList } from './test-utils.js'; +import { resetDom, settle } from './test/index.js'; afterEach(resetDom); diff --git a/packages/v4/src/decorators.spec.ts b/packages/v4/src/decorators.spec.ts index 037936202..c15c63716 100644 --- a/packages/v4/src/decorators.spec.ts +++ b/packages/v4/src/decorators.spec.ts @@ -15,7 +15,8 @@ import { createContext, signal, type Signal } from './context.js'; import { children, component, inject, on, provide, read, write } from './decorators.js'; import { registerComponent, registerComponents } from './registry.js'; import { defaultScheduler } from './scheduler.js'; -import { getInstance, resetDom, settle, TodoItem } from './test-utils.js'; +import { getInstance, TodoItem } from './test-utils.js'; +import { resetDom, settle } from './test/index.js'; const DecoContext = createContext>('deco-context'); diff --git a/packages/v4/src/dom-mutations.spec.ts b/packages/v4/src/dom-mutations.spec.ts index b780d29a7..18bfe1456 100644 --- a/packages/v4/src/dom-mutations.spec.ts +++ b/packages/v4/src/dom-mutations.spec.ts @@ -15,7 +15,8 @@ import { EVENTS } from './events.js'; import { INSTANCES } from './protocol-symbols.js'; import { registerComponent } from './registry.js'; import { SWAP_MODES, swap } from './swap.js'; -import { getInstance, resetDom } from './test-utils.js'; +import { getInstance } from './test-utils.js'; +import { resetDom } from './test/index.js'; let counter = 0; diff --git a/packages/v4/src/group.spec.ts b/packages/v4/src/group.spec.ts index fc0e6c0e9..b5ef34acf 100644 --- a/packages/v4/src/group.spec.ts +++ b/packages/v4/src/group.spec.ts @@ -4,7 +4,8 @@ import { createContext, type Signal } from './context.js'; import { subscribeContext } from './context-subscription.js'; import { createGroup, type Group } from './group.js'; import { registerComponents } from './registry.js'; -import { getInstance, resetDom, settle } from './test-utils.js'; +import { getInstance } from './test-utils.js'; +import { mount, resetDom, settle } from './test/index.js'; afterEach(resetDom); @@ -209,14 +210,6 @@ function disclosureMarkup(id: string, open = false): string { `; } -async function render(html: string): Promise { - const root = document.createElement('div'); - root.innerHTML = html; - document.body.append(root); - await settle(); - return root; -} - function disclosure(root: ParentNode, id: string): Disclosure { return getInstance(root.querySelector(`#${id}`), 'Disclosure'); } @@ -227,7 +220,7 @@ function group(root: ParentNode, id: string): DisclosureGroup { describe('a group of disclosures', () => { it('collects its members in document order', async () => { - const root = await render(` + const root = await mount(`
${disclosureMarkup('a')} ${disclosureMarkup('b')} @@ -239,7 +232,7 @@ describe('a group of disclosures', () => { }); it('keeps one open at a time', async () => { - const root = await render(` + const root = await mount(`
${disclosureMarkup('a')} ${disclosureMarkup('b')} @@ -255,7 +248,7 @@ describe('a group of disclosures', () => { }); it('works with no group above it', async () => { - const root = await render(disclosureMarkup('lonely')); + const root = await mount(disclosureMarkup('lonely')); const lonely = disclosure(root, 'lonely'); expect(lonely.group).toBeUndefined(); @@ -264,7 +257,7 @@ describe('a group of disclosures', () => { }); it('lets an open peer that mounts later lose to the one before it in the DOM', async () => { - const root = await render(` + const root = await mount(`
${disclosureMarkup('a', true)}
@@ -282,7 +275,7 @@ describe('a group of disclosures', () => { }); it('lets an open peer that mounts later win when it precedes the others', async () => { - const root = await render(` + const root = await mount(`
${disclosureMarkup('b', true)}
@@ -300,7 +293,7 @@ describe('a group of disclosures', () => { }); it('joins a group that mounts after its members', async () => { - const root = await render(` + const root = await mount(`
${disclosureMarkup('a')} ${disclosureMarkup('b')} @@ -317,7 +310,7 @@ describe('a group of disclosures', () => { }); it('gives a nested group its own members', async () => { - const root = await render(` + const root = await mount(`
${disclosureMarkup('o', true)}
@@ -339,7 +332,7 @@ describe('a group of disclosures', () => { }); it('hands a member over to a nearer group inserted later', async () => { - const root = await render(` + const root = await mount(`
${disclosureMarkup('a')} ${disclosureMarkup('b')} @@ -364,7 +357,7 @@ describe('a group of disclosures', () => { }); it('drops a member whose element leaves the DOM', async () => { - const root = await render(` + const root = await mount(`
${disclosureMarkup('a')} ${disclosureMarkup('b')} diff --git a/packages/v4/src/instances.spec.ts b/packages/v4/src/instances.spec.ts index 51a619280..800bf0897 100644 --- a/packages/v4/src/instances.spec.ts +++ b/packages/v4/src/instances.spec.ts @@ -1,7 +1,8 @@ import { afterEach, describe, expect, it } from 'vitest'; import { getInstances } from './instances.js'; import { INSTANCES } from './protocol-symbols.js'; -import { getInstance, renderTodoList, resetDom, settle, type TodoItem } from './test-utils.js'; +import { getInstance, renderTodoList, type TodoItem } from './test-utils.js'; +import { resetDom, settle } from './test/index.js'; afterEach(resetDom); diff --git a/packages/v4/src/manifest.spec.ts b/packages/v4/src/manifest.spec.ts index 89caab35b..0f4955903 100644 --- a/packages/v4/src/manifest.spec.ts +++ b/packages/v4/src/manifest.spec.ts @@ -10,7 +10,8 @@ import { type WebpackContextLike, } from './manifest.js'; import { registerManifest } from './registry.js'; -import { getInstance, resetDom, settle } from './test-utils.js'; +import { getInstance } from './test-utils.js'; +import { resetDom, settle } from './test/index.js'; class Widget {} class Other {} diff --git a/packages/v4/src/mount-strategies.spec.ts b/packages/v4/src/mount-strategies.spec.ts index 658bff2ba..b37546f84 100644 --- a/packages/v4/src/mount-strategies.spec.ts +++ b/packages/v4/src/mount-strategies.spec.ts @@ -5,7 +5,7 @@ import { EVENTS } from './events.js'; import { INSTANCES } from './protocol-symbols.js'; import { registerComponent } from './registry.js'; import { getSharedRuntimeSlot } from './shared-runtime.js'; -import { resetDom, settle } from './test-utils.js'; +import { resetDom, settle } from './test/index.js'; /** * The page-wide interaction signal is a fact about the visit, so it survives a diff --git a/packages/v4/src/negotiated-events.spec.ts b/packages/v4/src/negotiated-events.spec.ts index 1c8145366..73db1843a 100644 --- a/packages/v4/src/negotiated-events.spec.ts +++ b/packages/v4/src/negotiated-events.spec.ts @@ -10,7 +10,7 @@ import { type Extension, } from './negotiated-events.js'; import { nextFrame } from './scheduler.js'; -import { resetDom } from './test-utils.js'; +import { resetDom } from './test/index.js'; import { viewTransition } from './viewTransition.js'; afterEach(async () => { diff --git a/packages/v4/src/props.spec.ts b/packages/v4/src/props.spec.ts index 9546c0b5d..aad1a1cb5 100644 --- a/packages/v4/src/props.spec.ts +++ b/packages/v4/src/props.spec.ts @@ -1,7 +1,8 @@ import { describe, expect, expectTypeOf, it } from 'vitest'; import { Base, type BaseProps } from './Base.js'; import { registerComponent } from './registry.js'; -import { getInstance, resetDom, settle } from './test-utils.js'; +import { getInstance } from './test-utils.js'; +import { resetDom, settle } from './test/index.js'; /** * Assert assignability when a deferred type prevents `expectTypeOf().toExtend()`. diff --git a/packages/v4/src/registry.spec.ts b/packages/v4/src/registry.spec.ts index 705d8127d..b9f3cb013 100644 --- a/packages/v4/src/registry.spec.ts +++ b/packages/v4/src/registry.spec.ts @@ -4,7 +4,8 @@ import { DIAGNOSTICS, type ToolkitDiagnosticDetail } from './diagnostic-contract import { EVENTS } from './events.js'; import { INSTANCES } from './protocol-symbols.js'; import { registerComponent } from './registry.js'; -import { getInstance, renderTodoList, resetDom, settle, TodoItem, TodoList } from './test-utils.js'; +import { getInstance, renderTodoList, TodoItem, TodoList } from './test-utils.js'; +import { resetDom, settle } from './test/index.js'; afterEach(resetDom); diff --git a/packages/v4/src/responsive-components.spec.ts b/packages/v4/src/responsive-components.spec.ts index 334962ee4..15a0fdbbe 100644 --- a/packages/v4/src/responsive-components.spec.ts +++ b/packages/v4/src/responsive-components.spec.ts @@ -5,7 +5,7 @@ import { getInstances } from './instances.js'; import { INSTANCES } from './protocol-symbols.js'; import { registerComponent, registerManifest } from './registry.js'; import { BREAKPOINTS, setBreakpoints } from './services/breakpoint.js'; -import { resetDom, settle } from './test-utils.js'; +import { resetDom, settle } from './test/index.js'; let counter = 0; diff --git a/packages/v4/src/responsive-options.spec.ts b/packages/v4/src/responsive-options.spec.ts index 168b9aaf8..a33e0b753 100644 --- a/packages/v4/src/responsive-options.spec.ts +++ b/packages/v4/src/responsive-options.spec.ts @@ -3,7 +3,8 @@ import { Base, type OptionChange } from './Base.js'; import { EVENTS } from './events.js'; import { registerComponent, registerManifest } from './registry.js'; import { BREAKPOINTS, setBreakpoints } from './services/breakpoint.js'; -import { getInstance, resetDom, settle } from './test-utils.js'; +import { getInstance } from './test-utils.js'; +import { resetDom, settle } from './test/index.js'; /** Select test breakpoints without changing the viewport. */ function atSmall(): void { diff --git a/packages/v4/src/services/breakpoint.spec.ts b/packages/v4/src/services/breakpoint.spec.ts index 5d9d7e774..bb31e60f0 100644 --- a/packages/v4/src/services/breakpoint.spec.ts +++ b/packages/v4/src/services/breakpoint.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest'; -import { settle } from '../test-utils.js'; +import { settle } from '../test/index.js'; import { BREAKPOINTS, getBreakpoints, setBreakpoints, useBreakpoint } from './breakpoint.js'; afterEach(() => { diff --git a/packages/v4/src/services/drag.spec.ts b/packages/v4/src/services/drag.spec.ts index 9539282c8..62b33de87 100644 --- a/packages/v4/src/services/drag.spec.ts +++ b/packages/v4/src/services/drag.spec.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { userEvent } from '@vitest/browser/context'; -import { countRequestedFrames, frames } from '../test-utils.js'; +import { countRequestedFrames } from '../test-utils.js'; +import { frames } from '../test/index.js'; import { DRAG_MODES, useDrag, type DragMode, type DragProps } from './drag.js'; function render(): HTMLElement { diff --git a/packages/v4/src/services/media.spec.ts b/packages/v4/src/services/media.spec.ts index 191756456..323d1d639 100644 --- a/packages/v4/src/services/media.spec.ts +++ b/packages/v4/src/services/media.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { cdp } from 'vitest/browser'; import type {} from '@vitest/browser-playwright'; -import { settle } from '../test-utils.js'; +import { settle } from '../test/index.js'; import { useMediaQuery, usePrefersReducedMotion, type MediaQueryProps } from './media.js'; async function emulateReducedMotion(value: 'reduce' | 'no-preference'): Promise { diff --git a/packages/v4/src/services/mixin.spec.ts b/packages/v4/src/services/mixin.spec.ts index e45df0621..8f3152679 100644 --- a/packages/v4/src/services/mixin.spec.ts +++ b/packages/v4/src/services/mixin.spec.ts @@ -1,7 +1,8 @@ import { afterEach, describe, expect, it } from 'vitest'; import { Base } from '../Base.js'; import { registerComponent } from '../registry.js'; -import { countRequestedFrames, frames, getInstance, resetDom, settle } from '../test-utils.js'; +import { countRequestedFrames, getInstance } from '../test-utils.js'; +import { frames, resetDom, settle } from '../test/index.js'; import { useDrag, withDrag } from './drag.js'; import { withRaf } from './raf.js'; import { withResize } from './resize.js'; diff --git a/packages/v4/src/services/raf.spec.ts b/packages/v4/src/services/raf.spec.ts index 16ff2e90a..dc6d5f91d 100644 --- a/packages/v4/src/services/raf.spec.ts +++ b/packages/v4/src/services/raf.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { defaultScheduler } from '../scheduler.js'; -import { frames } from '../test-utils.js'; +import { frames } from '../test/index.js'; import { useRaf, type RafProps } from './raf.js'; describe('useRaf', () => { diff --git a/packages/v4/src/services/resize.spec.ts b/packages/v4/src/services/resize.spec.ts index 968019725..7dfd35a08 100644 --- a/packages/v4/src/services/resize.spec.ts +++ b/packages/v4/src/services/resize.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest'; -import { settle } from '../test-utils.js'; +import { settle } from '../test/index.js'; import { useResize, useWindowSize, type ResizeProps } from './resize.js'; function snapshot(props: ResizeProps): ResizeProps { diff --git a/packages/v4/src/services/scroll-progress.spec.ts b/packages/v4/src/services/scroll-progress.spec.ts index 54abeb36c..e2675e155 100644 --- a/packages/v4/src/services/scroll-progress.spec.ts +++ b/packages/v4/src/services/scroll-progress.spec.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, expectTypeOf, it } from 'vitest'; import { Base } from '../Base.js'; -import { settle } from '../test-utils.js'; +import { settle } from '../test/index.js'; import type { Toggle } from './toggle.js'; import { useScrollProgress, diff --git a/packages/v4/src/services/scroll.spec.ts b/packages/v4/src/services/scroll.spec.ts index 7b1f60fda..255a6b3fa 100644 --- a/packages/v4/src/services/scroll.spec.ts +++ b/packages/v4/src/services/scroll.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest'; -import { settle } from '../test-utils.js'; +import { settle } from '../test/index.js'; import { useScroll, useWindowScroll, type ScrollProps } from './scroll.js'; function snapshot(props: ScrollProps) { diff --git a/packages/v4/src/services/toggle.spec.ts b/packages/v4/src/services/toggle.spec.ts index 3555a68fb..e7e5a49d4 100644 --- a/packages/v4/src/services/toggle.spec.ts +++ b/packages/v4/src/services/toggle.spec.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest'; import { Base } from '../Base.js'; -import { frames, resetDom } from '../test-utils.js'; +import { frames, resetDom } from '../test/index.js'; import { useRaf } from './raf.js'; import { toggle } from './toggle.js'; import type { MountedReturn } from '../Base.js'; diff --git a/packages/v4/src/services/until.spec.ts b/packages/v4/src/services/until.spec.ts index 63f5ea83f..45aefb561 100644 --- a/packages/v4/src/services/until.spec.ts +++ b/packages/v4/src/services/until.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { settle } from '../test-utils.js'; +import { settle } from '../test/index.js'; import { createService, type MutableProps } from './service.js'; import { useScroll } from './scroll.js'; import { until } from './until.js'; diff --git a/packages/v4/src/swap.spec.ts b/packages/v4/src/swap.spec.ts index a35e48575..2a68ef6a6 100644 --- a/packages/v4/src/swap.spec.ts +++ b/packages/v4/src/swap.spec.ts @@ -5,7 +5,7 @@ import { EVENTS } from './events.js'; import { INSTANCES } from './protocol-symbols.js'; import { registerComponent } from './registry.js'; import { SWAP_MODES, swap } from './swap.js'; -import { resetDom } from './test-utils.js'; +import { resetDom } from './test/index.js'; let counter = 0; diff --git a/packages/v4/src/utils/smoothTo.spec.ts b/packages/v4/src/utils/smoothTo.spec.ts index a833ae8bc..c5d5e0d22 100644 --- a/packages/v4/src/utils/smoothTo.spec.ts +++ b/packages/v4/src/utils/smoothTo.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest'; import { smoothTo, type SmoothTo } from './smoothTo.js'; -import { countRequestedFrames, frames } from '../test-utils.js'; +import { countRequestedFrames } from '../test-utils.js'; +import { frames } from '../test/index.js'; describe('smoothTo', () => { it('starts at its start value and does not move on its own', async () => { diff --git a/packages/v4/src/utils/transition.spec.ts b/packages/v4/src/utils/transition.spec.ts index 7d17776ae..7b32c9728 100644 --- a/packages/v4/src/utils/transition.spec.ts +++ b/packages/v4/src/utils/transition.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest'; -import { frames } from '../test-utils.js'; +import { frames } from '../test/index.js'; import { enterTransition, leaveTransition, diff --git a/packages/v4/src/viewTransition.spec.ts b/packages/v4/src/viewTransition.spec.ts index 841765cd1..491db1ebb 100644 --- a/packages/v4/src/viewTransition.spec.ts +++ b/packages/v4/src/viewTransition.spec.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { defaultScheduler, nextFrame } from './scheduler.js'; -import { resetDom } from './test-utils.js'; +import { resetDom } from './test/index.js'; import { viewTransition, type ViewTransitionUpdate } from './viewTransition.js'; let restoreStartViewTransition = () => {}; From 9e5141cfe2e606bcdd87c2875cb86e27c0babc75 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Mon, 24 Aug 2026 16:08:27 +0200 Subject: [PATCH 2/6] test(v4): poll for the state each spec waits on, instead of looping over settle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifteen files carried a local `observed()`, `settled()`, `ticked()` or `included()` — a loop over `settle()` sized by guesswork, because an observer's delivery and a frame-driven write land on nobody's promise. Where the spec waits for a *condition*, `waitFor()` now polls for that exact condition and the wait is deterministic. The three hand-copied `waitForClass()` bodies go the same way, since a class landing is just another condition. Where the assertion is an *absence* — nothing emitted, nothing imported, nothing mounted, a scroll that never moved — the loop survives under the name `quiet()`, with a comment saying why. An absence cannot be polled for: the predicate is true before anything has happened. Three waits had to move to a later signal than the one they named, because the value lands a scheduler lane before the DOM write it feeds: `Draggable`'s transform, `Carousel`'s `--carousel-progress`, and `smoothTo`'s `isMoving`. The four `Slider*` `ready()` helpers stay: they compose the shipped `settle`/`frames` with per-file instance lookups rather than re-implement a wait. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NnepYhqjPMcFCSb43PoHRM --- .../v4/migration/AnchorNav/AnchorNav.spec.ts | 48 +++------- .../AnchorNav/AnchorNavTarget.spec.ts | 23 +++-- .../v4/migration/Carousel/Carousel.spec.ts | 92 ++++++++++--------- packages/v4/migration/Cursor/Cursor.spec.ts | 16 ++-- .../v4/migration/Draggable/Draggable.spec.ts | 32 ++++--- .../migration/Figure/AbstractFigure.spec.ts | 26 ++---- .../migration/FigureVideo/FigureVideo.spec.ts | 25 ++--- packages/v4/migration/InView/InView.spec.ts | 58 ++++++------ .../migration/LazyInclude/LazyInclude.spec.ts | 47 +++++----- packages/v4/migration/Menu/MenuList.spec.ts | 35 ++----- .../v4/migration/Prefetch/Prefetch.spec.ts | 46 +++++----- .../v4/migration/Sentinel/Sentinel.spec.ts | 23 ++--- packages/v4/migration/Track/Track.spec.ts | 56 +++++------ packages/v4/src/autoload.spec.ts | 57 ++++++------ packages/v4/src/mount-strategies.spec.ts | 81 ++++++++-------- packages/v4/src/utils/smoothTo.spec.ts | 17 +--- 16 files changed, 330 insertions(+), 352 deletions(-) diff --git a/packages/v4/migration/AnchorNav/AnchorNav.spec.ts b/packages/v4/migration/AnchorNav/AnchorNav.spec.ts index 3400f508a..cb59abbc9 100644 --- a/packages/v4/migration/AnchorNav/AnchorNav.spec.ts +++ b/packages/v4/migration/AnchorNav/AnchorNav.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { registerComponents } from '../../src/index.js'; import { getInstance } from '../../src/test-utils.js'; -import { resetDom, settle } from '../../src/test/index.js'; +import { mount, resetDom, settle, waitFor } from '../../src/test/index.js'; import { AnchorNav } from './AnchorNav.js'; import { AnchorNavLink } from './AnchorNavLink.js'; import { AnchorNavTarget } from './AnchorNavTarget.js'; @@ -13,37 +13,12 @@ registerComponents(AnchorNav, AnchorNavLink, AnchorNavTarget); afterEach(resetDom); -async function observed(): Promise { - for (let i = 0; i < 6; i += 1) { - await settle(); - } -} - -/** - * `AnchorNav` fire-and-forgets the link's transition, and a kept end state - * lands a few frames after that — so the class is polled rather than assumed - * present once the observer has delivered. `MenuList`'s spec has the same - * helper for the same reason, after this shape flaked under full-suite load. - */ -async function waitForClass(el: HTMLElement, className: string, timeout = 1000): Promise { - const deadline = Date.now() + timeout; - while (!el.classList.contains(className)) { - if (Date.now() > deadline) { - throw new Error(`waitForClass: "${className}" never landed on the element`); - } - await new Promise((resolve) => setTimeout(resolve, 10)); - } -} - async function render(): Promise<{ root: HTMLElement; target: HTMLElement }> { - const root = document.createElement('div'); - root.innerHTML = ` + const root = await mount(`
-
`; - document.body.append(root); - await settle(); +
`); return { root, target: root.querySelector('#one') as HTMLElement }; } @@ -56,10 +31,12 @@ describe('AnchorNav', () => { ); target.setAttribute('style', ONSCREEN); - await observed(); + await waitFor(() => link.state === 'entering'); expect(link.state).toBe('entering'); - await waitForClass(link.$el, 'active'); + // `AnchorNav` fire-and-forgets the transition, so the kept end state lands + // a few frames after the state change and has to be polled for. + await waitFor(() => link.$el.classList.contains('active')); }); it('leaves the matching link once its target scrolls back out of view', async () => { @@ -70,11 +47,13 @@ describe('AnchorNav', () => { ); target.setAttribute('style', ONSCREEN); - await observed(); + await waitFor(() => link.state === 'entering'); target.setAttribute('style', OFFSCREEN); - await observed(); + await waitFor(() => link.state === 'leaving'); expect(link.state).toBe('leaving'); + // A removal is asserted directly, never polled for: `leaveTransition()` + // clears the other direction's class before its first await. expect(link.$el.classList.contains('active')).toBe(false); }); @@ -94,7 +73,10 @@ describe('AnchorNav', () => { const target = root.querySelector('#one') as HTMLElement; target.setAttribute('style', ONSCREEN); - await observed(); + // An absence cannot be polled for, so this keeps a bounded quiet period. + for (let i = 0; i < 6; i += 1) { + await settle(); + } expect(link.state).toBeNull(); }); diff --git a/packages/v4/migration/AnchorNav/AnchorNavTarget.spec.ts b/packages/v4/migration/AnchorNav/AnchorNavTarget.spec.ts index 244d51be1..f52a39b32 100644 --- a/packages/v4/migration/AnchorNav/AnchorNavTarget.spec.ts +++ b/packages/v4/migration/AnchorNav/AnchorNavTarget.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { registerComponents } from '../../src/index.js'; import { INSTANCES } from '../../src/protocol-symbols.js'; -import { resetDom, settle } from '../../src/test/index.js'; +import { resetDom, settle, waitFor } from '../../src/test/index.js'; import { AnchorNavTarget } from './AnchorNavTarget.js'; const OFFSCREEN = 'position:absolute;top:300vh;left:0;width:50px;height:50px'; @@ -11,12 +11,15 @@ registerComponents(AnchorNavTarget); afterEach(resetDom); -async function observed(): Promise { +/** A bounded quiet period, for the assertion that nothing has mounted yet. */ +async function quiet(): Promise { for (let i = 0; i < 6; i += 1) { await settle(); } } +const mountedState = (el: HTMLElement) => el[INSTANCES]?.get('AnchorNavTarget')?.$isMounted; + function render(style: string): HTMLElement { const el = document.createElement('div'); el.setAttribute('data-component', 'AnchorNavTarget'); @@ -28,21 +31,21 @@ function render(style: string): HTMLElement { describe('AnchorNavTarget', () => { it('mounts once scrolled into view', async () => { const el = render(OFFSCREEN); - await observed(); - expect(el[INSTANCES]?.get('AnchorNavTarget')?.$isMounted).toBeUndefined(); + await quiet(); + expect(mountedState(el)).toBeUndefined(); el.setAttribute('style', ONSCREEN); - await observed(); - expect(el[INSTANCES]?.get('AnchorNavTarget')?.$isMounted).toBe(true); + await waitFor(() => mountedState(el)); + expect(mountedState(el)).toBe(true); }); it('unmounts once scrolled back out of view', async () => { const el = render(ONSCREEN); - await observed(); - expect(el[INSTANCES]?.get('AnchorNavTarget')?.$isMounted).toBe(true); + await waitFor(() => mountedState(el)); + expect(mountedState(el)).toBe(true); el.setAttribute('style', OFFSCREEN); - await observed(); - expect(el[INSTANCES]?.get('AnchorNavTarget')?.$isMounted).toBe(false); + await waitFor(() => mountedState(el) === false); + expect(mountedState(el)).toBe(false); }); }); diff --git a/packages/v4/migration/Carousel/Carousel.spec.ts b/packages/v4/migration/Carousel/Carousel.spec.ts index f2543a362..3fb280c9f 100644 --- a/packages/v4/migration/Carousel/Carousel.spec.ts +++ b/packages/v4/migration/Carousel/Carousel.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { registerComponents } from '../../src/index.js'; import { getInstance } from '../../src/test-utils.js'; -import { resetDom, settle } from '../../src/test/index.js'; +import { resetDom, settle, waitFor } from '../../src/test/index.js'; import { Carousel } from './Carousel.js'; import { CarouselBtn } from './CarouselBtn.js'; import { CarouselDrag } from './CarouselDrag.js'; @@ -54,8 +54,12 @@ async function render({ }; } -/** Let the scheduler, the frame loop and the smooth scroll settle. */ -async function settled(count = 10): Promise { +/** + * A bounded quiet period, for the states this file asserts are *unchanged* — + * a button still enabled, a scroll that never moved, a frame loop that never + * started. Everything positive is polled for with `waitFor` instead. + */ +async function quiet(count = 10): Promise { for (let i = 0; i < count; i += 1) { await settle(); } @@ -165,7 +169,7 @@ describe('Carousel — the index', () => { describe('Carousel — the controls', () => { it('marks the first slide active on mount, with no navigation at all', async () => { const { el } = await render({ count: 3 }); - await settled(); + await waitFor(() => activeFlags(el)[0] === '1'); expect(activeFlags(el)).toEqual(['1', '0', '0']); }); @@ -174,7 +178,7 @@ describe('Carousel — the controls', () => { const { el, carousel } = await render({ count: 3 }); await carousel.goTo(2); - await settled(); + await waitFor(() => activeFlags(el)[2] === '1'); expect(activeFlags(el)).toEqual(['0', '0', '1']); }); @@ -186,13 +190,12 @@ describe('Carousel — the controls', () => { `, }); - await settled(); - const [prev, next] = [...el.querySelectorAll('button')]; + await waitFor(() => prev.disabled); expect([prev.disabled, next.disabled]).toEqual([true, false]); await carousel.goTo(2); - await settled(); + await waitFor(() => next.disabled); expect([prev.disabled, next.disabled]).toEqual([false, true]); }); @@ -203,7 +206,7 @@ describe('Carousel — the controls', () => { attributes: 'data-option-boundary="loop"', buttons: ``, }); - await settled(); + await quiet(); expect(el.querySelector('button')?.disabled).toBe(false); }); @@ -213,7 +216,7 @@ describe('Carousel — the controls', () => { count: 3, buttons: ``, }); - await settled(); + await waitFor(() => el.querySelector('button')); el.querySelector('button')?.click(); @@ -225,7 +228,7 @@ describe('Carousel — the controls', () => { count: 4, buttons: ``, }); - await settled(); + await waitFor(() => el.querySelector('button')); el.querySelector('button')?.click(); @@ -244,7 +247,7 @@ describe('Carousel — the controls', () => {
`; document.body.append(root); - await settled(); + await quiet(); const button = root.querySelector('button') as HTMLButtonElement; expect(button.disabled).toBe(false); @@ -256,7 +259,7 @@ describe('Carousel — the controls', () => { 'afterbegin', `
${slides(3)}
`, ); - await settled(); + await waitFor(() => button.disabled); expect(button.disabled).toBe(true); }); @@ -266,13 +269,12 @@ describe('Carousel — the controls', () => { count: 3, buttons: ``, }); - await settled(); - const button = el.querySelector('button') as HTMLButtonElement; + const button = await waitFor(() => el.querySelector('button')); button.remove(); - await settled(); + await settle(); await carousel.goTo(2); - await settled(); + await quiet(); // Still the state it had when it left, rather than a post-teardown write. expect(button.disabled).toBe(false); @@ -282,14 +284,14 @@ describe('Carousel — the controls', () => { describe('Carousel — live slides', () => { it('picks up a slide added after mount', async () => { const { el, carousel, wrapper } = await render({ count: 2 }); - await settled(); + await waitFor(() => carousel.length === 2); expect(carousel.length).toBe(2); wrapper.insertAdjacentHTML( 'beforeend', `
`, ); - await settled(); + await waitFor(() => carousel.length === 3); expect(carousel.length).toBe(3); expect(activeFlags(el)).toEqual(['1', '0', '0']); @@ -298,11 +300,11 @@ describe('Carousel — live slides', () => { it('re-normalises the index when the slide it points at is removed', async () => { const { carousel, wrapper } = await render({ count: 3 }); await carousel.goTo(2); - await settled(); + await waitFor(() => carousel.currentIndex === 2); expect(carousel.currentIndex).toBe(2); wrapper.lastElementChild?.remove(); - await settled(); + await waitFor(() => carousel.length === 2); expect(carousel.length).toBe(2); expect(carousel.currentIndex).toBe(1); @@ -314,29 +316,29 @@ describe('Carousel — live slides', () => { buttons: ``, }); await carousel.goTo(1); - await settled(); const next = el.querySelector('button') as HTMLButtonElement; + await waitFor(() => next.disabled); expect(next.disabled).toBe(true); wrapper.insertAdjacentHTML( 'beforeend', `
`, ); - await settled(); + await waitFor(() => !next.disabled); expect(next.disabled).toBe(false); }); it('re-measures the slide positions when the list changes', async () => { const { carousel, wrapper } = await render({ count: 2 }); - await settled(); + await waitFor(() => carousel.positions.length === 2); expect(carousel.positions).toHaveLength(2); wrapper.insertAdjacentHTML( 'beforeend', `
`, ); - await settled(); + await waitFor(() => carousel.positions.length === 3); expect(carousel.positions.map(({ left }) => left)).toEqual([0, 200, 400]); }); @@ -347,17 +349,17 @@ describe('Carousel — the wrapper', () => { const { carousel, wrapper } = await render({ count: 3 }); await carousel.goTo(2); - await settled(20); + await waitFor(() => wrapper.scrollLeft === 400, { timeout: 2000 }); expect(wrapper.scrollLeft).toBe(400); }); it('reports the closest slide on a scroll, without scrolling back', async () => { const { carousel, wrapper } = await render({ count: 3 }); - await settled(); + await waitFor(() => carousel.positions.length === 3); wrapper.scrollTo({ left: 400, behavior: 'instant' }); - await settled(); + await waitFor(() => carousel.currentIndex === 2); expect(carousel.currentIndex).toBe(2); expect(wrapper.scrollLeft).toBe(400); @@ -365,11 +367,13 @@ describe('Carousel — the wrapper', () => { it('publishes its progress from 0 to 1', async () => { const { el, carousel, wrapper } = await render({ count: 3 }); - await settled(); + await waitFor(() => carousel.positions.length === 3); expect(carousel.progress).toBe(0); wrapper.scrollTo({ left: 400, behavior: 'instant' }); - await settled(); + // The custom property is written a lane after the value it mirrors, so it + // is the later of the two and the one worth polling for. + await waitFor(() => el.style.getPropertyValue('--carousel-progress') === '1'); expect(carousel.progress).toBe(1); expect(el.style.getPropertyValue('--carousel-progress')).toBe('1'); @@ -377,14 +381,14 @@ describe('Carousel — the wrapper', () => { it('emits `progress` while it changes and stops the loop once it settles', async () => { const { el, carousel, wrapper } = await render({ count: 3 }); - await settled(); + await waitFor(() => carousel.positions.length === 3); const seen: number[] = []; el.addEventListener('progress', (event) => { seen.push((event as unknown as CustomEvent<{ progress: number }>).detail.progress); }); wrapper.scrollTo({ left: 200, behavior: 'instant' }); - await settled(); + await waitFor(() => seen.length > 0 && !carousel.$services.ticked.isActive); expect(seen.at(-1)).toBeCloseTo(0.5, 5); expect(carousel.$services.ticked.isActive).toBe(false); @@ -392,17 +396,17 @@ describe('Carousel — the wrapper', () => { it('holds no frame loop once the progress has settled', async () => { const { carousel } = await render({ count: 3 }); - await settled(); + await quiet(); expect(carousel.$services.ticked.isActive).toBe(false); }); it('does nothing on `scrollToIndex` for a slide that does not exist', async () => { const { carousel, wrapper } = await render({ count: 2 }); - await settled(); + await waitFor(() => carousel.wrapper); carousel.wrapper?.scrollToIndex(9); - await settled(); + await quiet(); expect(wrapper.scrollLeft).toBe(0); }); @@ -425,7 +429,7 @@ describe('Carousel — the drag track', () => {
${slides(3)}
`; document.body.append(root); - await settled(); + await quiet(); const track = root.querySelector('[data-component~="CarouselDrag"]') as HTMLElement; const matches = window.matchMedia('(pointer: fine)').matches; @@ -449,10 +453,11 @@ describe('Carousel — orientation', () => { it('hands the orientation to its children through the context', async () => { const { el } = await render({ count: 3, attributes: 'data-option-axis="y"' }); - await settled(); - const wrapper = getInstance( - el.querySelector('[data-component~="CarouselWrapper"]') as HTMLElement, - 'CarouselWrapper', + const wrapper = await waitFor(() => + getInstance( + el.querySelector('[data-component~="CarouselWrapper"]') as HTMLElement, + 'CarouselWrapper', + ), ); expect(wrapper.isVertical).toBe(true); @@ -462,11 +467,8 @@ describe('Carousel — orientation', () => { const root = document.createElement('div'); root.innerHTML = `
`; document.body.append(root); - await settled(); - - const wrapper = getInstance( - root.firstElementChild as HTMLElement, - 'CarouselWrapper', + const wrapper = await waitFor(() => + getInstance(root.firstElementChild as HTMLElement, 'CarouselWrapper'), ); expect(wrapper.isHorizontal).toBe(true); expect(wrapper.carousel).toBeUndefined(); diff --git a/packages/v4/migration/Cursor/Cursor.spec.ts b/packages/v4/migration/Cursor/Cursor.spec.ts index 503ab67af..c6d9aa129 100644 --- a/packages/v4/migration/Cursor/Cursor.spec.ts +++ b/packages/v4/migration/Cursor/Cursor.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { registerComponents } from '../../src/index.js'; import { countRequestedFrames, getInstance } from '../../src/test-utils.js'; -import { mount, resetDom, settle } from '../../src/test/index.js'; +import { mount, resetDom, settle, waitFor } from '../../src/test/index.js'; import { Cursor } from './Cursor.js'; registerComponents(Cursor); @@ -34,8 +34,8 @@ function movePointer(target: EventTarget, x: number, y: number, buttons = 0): vo ); } -/** Let the frame loop run for a few frames. */ -async function ticked(count = 12): Promise { +/** A bounded quiet period, for the assertion that no frame was requested. */ +async function quiet(count = 12): Promise { for (let i = 0; i < count; i += 1) { await settle(); } @@ -68,7 +68,7 @@ describe('Cursor', () => { expect(instance.motion().x).toBeGreaterThan(0); expect(instance.motion().x).toBeLessThan(200); - await ticked(); + await waitFor(() => !instance.motion.isMoving); expect(instance.motion().x).toBe(200); expect(instance.motion().y).toBe(100); @@ -142,7 +142,7 @@ describe('Cursor', () => { await settle(); expect(instance.motion.isMoving).toBe(true); - await ticked(); + await waitFor(() => !instance.motion.isMoving); expect(instance.motion.isMoving).toBe(false); }); @@ -151,7 +151,7 @@ describe('Cursor', () => { await mountCursor(); const requested = await countRequestedFrames(async () => { - await ticked(4); + await quiet(4); }); expect(requested).toBe(0); @@ -174,7 +174,7 @@ describe('Cursor', () => { const { instance } = await mountCursor('data-option-scale="1"'); movePointer(document, 50, 25); - await ticked(); + await waitFor(() => instance.$el.style.transform.includes('matrix(1, 0, 0, 1, 50, 25)')); expect(instance.$el.style.transform).toContain('matrix(1, 0, 0, 1, 50, 25)'); }); @@ -182,7 +182,7 @@ describe('Cursor', () => { it('resets its state when the component mounts again', async () => { const { root, instance } = await mountCursor(); movePointer(document, 90, 90); - await ticked(); + await waitFor(() => instance.motion().x === 90); expect(instance.motion().x).toBe(90); const other = document.createElement('section'); diff --git a/packages/v4/migration/Draggable/Draggable.spec.ts b/packages/v4/migration/Draggable/Draggable.spec.ts index 86702f541..af9e2a2e0 100644 --- a/packages/v4/migration/Draggable/Draggable.spec.ts +++ b/packages/v4/migration/Draggable/Draggable.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { registerComponents } from '../../src/index.js'; import { getInstance } from '../../src/test-utils.js'; -import { resetDom, settle } from '../../src/test/index.js'; +import { resetDom, settle, waitFor } from '../../src/test/index.js'; import { Draggable, type DraggablePosition } from './Draggable.js'; registerComponents(Draggable); @@ -64,8 +64,8 @@ function release(): void { window.dispatchEvent(new PointerEvent('pointerup')); } -/** Let the drag service, the frame loop and the scheduler lanes run. */ -async function settled(count = 12): Promise { +/** A bounded quiet period, for the states that are asserted as unchanged. */ +async function quiet(count = 12): Promise { for (let i = 0; i < count; i += 1) { await settle(); } @@ -157,7 +157,7 @@ describe('Draggable — geometry', () => { const other = document.createElement('div'); document.body.append(other); other.append(el); - await settled(); + await waitFor(() => getInstance(el, 'Draggable')?.bounds.xMax === 200); expect(getInstance(el, 'Draggable').bounds.xMax).toBe(200); }); @@ -171,7 +171,7 @@ describe('Draggable — the drag', () => { grab(target, 10, 10); move(60, 40); release(); - await settled(); + await waitFor(() => events.some(({ type }) => type === 'drag-drop')); const types = events.map(({ type }) => type); expect(types).toContain('drag-start'); @@ -185,7 +185,7 @@ describe('Draggable — the drag', () => { grab(target, 10, 10); move(90, 60); - await settled(); + await waitFor(() => instance.props.x === 80); expect(instance.props.x).toBe(80); expect(instance.props.y).toBe(50); @@ -198,12 +198,12 @@ describe('Draggable — the drag', () => { grab(target, 10, 10); move(60, 10); release(); - await settled(); + await waitFor(() => instance.props.x === 50); const afterFirst = instance.props.x; grab(target, 0, 0); move(20, 0); - await settled(); + await waitFor(() => instance.props.x === afterFirst + 20); expect(instance.props.x).toBe(afterFirst + 20); release(); @@ -214,7 +214,9 @@ describe('Draggable — the drag', () => { grab(target, 10, 10); move(110, 10); - await settled(); + // The damped value lands a lane before the DOM write it feeds, so the + // written transform is the state worth polling for. + await waitFor(() => target.style.transform.includes('translate3d(')); expect(target.style.transform).toContain('translate3d('); expect(instance.props.dampedX).toBeGreaterThan(0); @@ -226,7 +228,7 @@ describe('Draggable — the drag', () => { grab(target, 10, 10); move(110, 110); - await settled(); + await quiet(); expect(target.style.transform).toContain('0px, 0px)'); release(); @@ -237,7 +239,7 @@ describe('Draggable — the drag', () => { grab(target, 0, 0); move(150, 125); - await settled(); + await waitFor(() => instance.props.progressX > 0); expect(instance.props.progressX).toBeCloseTo(0.5, 5); expect(instance.props.progressY).toBeCloseTo(0.5, 5); @@ -251,7 +253,7 @@ describe('Draggable — the bounds', () => { grab(target, 0, 0); move(900, 900); - await settled(); + await waitFor(() => instance.props.x === 300); expect(instance.props.x).toBe(300); expect(instance.props.y).toBe(250); @@ -263,7 +265,7 @@ describe('Draggable — the bounds', () => { grab(target, 0, 0); move(900, 0); - await settled(); + await waitFor(() => instance.props.x === 900); expect(instance.props.x).toBe(900); release(); @@ -280,7 +282,7 @@ describe('Draggable — the bounds', () => { grab(target, 0, 0); move(900, 0); release(); - await settled(40); + await waitFor(() => !instance.$services.ticked.isActive, { timeout: 2000 }); expect(instance.props.x).toBe(300); expect(instance.props.dampedX).toBe(300); @@ -305,7 +307,7 @@ describe('Draggable — the bounds', () => { expect(instance.$services.ticked.isActive).toBe(true); root.remove(); - await settled(); + await waitFor(() => !instance.$services.ticked.isActive); expect(instance.$services.ticked.isActive).toBe(false); }); diff --git a/packages/v4/migration/Figure/AbstractFigure.spec.ts b/packages/v4/migration/Figure/AbstractFigure.spec.ts index d77ef5007..a017366ea 100644 --- a/packages/v4/migration/Figure/AbstractFigure.spec.ts +++ b/packages/v4/migration/Figure/AbstractFigure.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { registerComponents } from '../../src/index.js'; import { getInstance } from '../../src/test-utils.js'; -import { resetDom, settle } from '../../src/test/index.js'; +import { resetDom, settle, waitFor } from '../../src/test/index.js'; import { Figure } from './Figure.js'; registerComponents(Figure); @@ -15,23 +15,13 @@ const ONSCREEN = 'position:absolute;top:0;left:0;width:50px;height:50px'; const PIXEL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; -async function observed(): Promise { +/** A bounded quiet period, for the assertions that nothing has loaded. */ +async function quiet(): Promise { for (let i = 0; i < 6; i += 1) { await settle(); } } -/** Poll for a transition's kept end state; see `MenuList.spec.ts` for why. */ -async function waitForClass(el: HTMLElement, className: string, timeout = 1000): Promise { - const deadline = Date.now() + timeout; - while (!el.classList.contains(className)) { - if (Date.now() > deadline) { - throw new Error(`waitForClass: "${className}" never landed on the element`); - } - await new Promise((resolve) => setTimeout(resolve, 10)); - } -} - // A tiny 1x1 white pixel, distinct from PIXEL, standing in for a placeholder. const PLACEHOLDER = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='; @@ -59,12 +49,12 @@ describe('Figure (AbstractFigure)', () => { const events: unknown[] = []; el.addEventListener('load', () => events.push(1)); - await observed(); + await quiet(); expect(events).toEqual([]); expect(img.src).toBe(PLACEHOLDER); el.setAttribute('style', ONSCREEN); - await observed(); + await waitFor(() => events.length > 0); expect(events).toEqual([1]); expect(img.src).toBe(PIXEL); @@ -75,7 +65,7 @@ describe('Figure (AbstractFigure)', () => { const events: unknown[] = []; el.addEventListener('load', () => events.push(1)); - await observed(); + await quiet(); expect(events).toEqual([]); }); @@ -86,11 +76,9 @@ describe('Figure (AbstractFigure)', () => { 'data-option-lazy="true" data-option-enter-to="visible" data-option-enter-keep="true"', ); - await observed(); - // Polled, not assumed: `mounted()` fire-and-forgets the transition, and a // kept end state lands a few frames after the image has loaded. - await waitForClass(img, 'visible'); + await waitFor(() => img.classList.contains('visible')); expect(getInstance
(el, 'Figure').state).toBe('entering'); }); }); diff --git a/packages/v4/migration/FigureVideo/FigureVideo.spec.ts b/packages/v4/migration/FigureVideo/FigureVideo.spec.ts index cb69663e7..e88270c35 100644 --- a/packages/v4/migration/FigureVideo/FigureVideo.spec.ts +++ b/packages/v4/migration/FigureVideo/FigureVideo.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { registerComponents } from '../../src/index.js'; import { getInstance } from '../../src/test-utils.js'; -import { resetDom, settle } from '../../src/test/index.js'; +import { resetDom, settle, waitFor } from '../../src/test/index.js'; import { FigureVideo } from './FigureVideo.js'; registerComponents(FigureVideo); @@ -15,7 +15,8 @@ const ONSCREEN = 'position:absolute;top:0;left:0;width:50px;height:50px'; const PIXEL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; -async function observed(): Promise { +/** A bounded quiet period, for the assertions that nothing has loaded. */ +async function quiet(): Promise { for (let i = 0; i < 6; i += 1) { await settle(); } @@ -53,14 +54,14 @@ describe('FigureVideo', () => { const events: unknown[] = []; el.addEventListener('load', () => events.push(1)); - await observed(); + await quiet(); expect(events).toEqual([]); expect(video.querySelector('source')?.src).toBe(''); el.setAttribute('style', ONSCREEN); await settle(); fireLoadedData(video); - await observed(); + await waitFor(() => events.length > 0); expect(events).toEqual([1]); expect(video.querySelector('source')?.src).toBe(PIXEL); @@ -72,9 +73,9 @@ describe('FigureVideo', () => { const events: unknown[] = []; el.addEventListener('load', () => events.push(1)); - await observed(); + await quiet(); fireLoadedData(video); - await observed(); + await quiet(); expect(events).toEqual([]); }); @@ -83,9 +84,11 @@ describe('FigureVideo', () => { const { el, video } = render(ONSCREEN); await settle(); fireLoadedData(video); - await observed(); - - const instance = getInstance(el, 'FigureVideo'); + const instance = await waitFor(() => + getInstance(el, 'FigureVideo')?.hasLoaded + ? getInstance(el, 'FigureVideo') + : null, + ); const spy = vi.spyOn(instance, 'load'); // A later mount cycle on the same instance — the in-view strategy can @@ -109,7 +112,7 @@ describe('FigureVideo', () => { // v3 waits on `loadeddata` alone, so this never settled and `mounted()` // never returned. video.dispatchEvent(new Event('error')); - await observed(); + await waitFor(() => details.length > 0); expect(details.map((detail) => detail.code)).toContain('figure-video.load-failed'); // Left un-loaded, so a later mount cycle can retry. @@ -124,7 +127,7 @@ describe('FigureVideo', () => { document.body.append(root); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - await expect(observed()).resolves.toBeUndefined(); + await expect(quiet()).resolves.toBeUndefined(); expect(warnSpy).toHaveBeenCalled(); warnSpy.mockRestore(); diff --git a/packages/v4/migration/InView/InView.spec.ts b/packages/v4/migration/InView/InView.spec.ts index e559af803..a97380622 100644 --- a/packages/v4/migration/InView/InView.spec.ts +++ b/packages/v4/migration/InView/InView.spec.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { Base, registerComponents, type BaseConfig } from '../../src/index.js'; import { INSTANCES } from '../../src/protocol-symbols.js'; -import { resetDom, settle } from '../../src/test/index.js'; +import { resetDom, settle, waitFor } from '../../src/test/index.js'; import { InView } from './InView.js'; import { InViewOnce } from './InViewOnce.js'; @@ -17,8 +17,13 @@ registerComponents(InView, InViewOnce, InViewSubclass); afterEach(resetDom); -/** Give the observer a few frames to deliver. */ -async function observed(): Promise { +/** + * A bounded quiet period. Every *positive* wait in this file polls for the + * event it expects, but an absence cannot be polled for — so the assertions + * that nothing was emitted, and that nothing was instantiated, still need a + * span long enough for the observer to have spoken if it were going to. + */ +async function quiet(): Promise { for (let i = 0; i < 6; i += 1) { await settle(); } @@ -63,34 +68,34 @@ afterEach(() => { describe('InView', () => { it('emits `in-view` when the element enters the viewport', async () => { const el = render('InView', OFFSCREEN); - await observed(); + await quiet(); expect(log.events).toEqual([]); el.setAttribute('style', ONSCREEN); - await observed(); + await waitFor(() => log.events.length > 0); expect(log.events).toEqual(['in-view']); }); it('emits `out-of-view` when the element leaves the viewport', async () => { const el = render('InView', ONSCREEN); - await observed(); + await waitFor(() => log.events.length > 0); el.setAttribute('style', OFFSCREEN); - await observed(); + await waitFor(() => log.events.length > 1); expect(log.events).toEqual(['in-view', 'out-of-view']); }); it('re-emits `in-view` on each re-entry, from the same instance', async () => { const el = render('InView', ONSCREEN); - await observed(); + await waitFor(() => log.events.length > 0); const instance = el[INSTANCES]?.get('InView'); el.setAttribute('style', OFFSCREEN); - await observed(); + await waitFor(() => log.events.length > 1); el.setAttribute('style', ONSCREEN); - await observed(); + await waitFor(() => log.events.length > 2); expect(log.events).toEqual(['in-view', 'out-of-view', 'in-view']); expect(el[INSTANCES]?.get('InView')).toBe(instance); @@ -98,7 +103,7 @@ describe('InView', () => { it('does not instantiate the component until it is first seen', async () => { const el = render('InView', OFFSCREEN); - await observed(); + await quiet(); expect(el[INSTANCES]?.get('InView')).toBeUndefined(); }); @@ -107,43 +112,42 @@ describe('InView', () => { describe('InViewOnce', () => { it('emits `in-view` when the element enters the viewport', async () => { const el = render('InViewOnce', OFFSCREEN); - await observed(); + await quiet(); el.setAttribute('style', ONSCREEN); - await observed(); + await waitFor(() => log.events.length > 0); expect(log.events).toEqual(['in-view']); }); it('never emits `out-of-view`, and does not re-emit on a later entry', async () => { const el = render('InViewOnce', ONSCREEN); - await observed(); + await waitFor(() => log.events.length > 0); el.setAttribute('style', OFFSCREEN); - await observed(); + await quiet(); el.setAttribute('style', ONSCREEN); - await observed(); + await quiet(); expect(log.events).toEqual(['in-view']); }); it('stays mounted after leaving the viewport, where v3 terminated', async () => { const el = render('InViewOnce', ONSCREEN); - await observed(); - const instance = el[INSTANCES]?.get('InViewOnce'); + const instance = await waitFor(() => el[INSTANCES]?.get('InViewOnce')); el.setAttribute('style', OFFSCREEN); - await observed(); + await quiet(); expect(instance?.$isMounted).toBe(true); }); it('still emits `out-of-view` never, when the element is removed from the DOM', async () => { const el = render('InViewOnce', ONSCREEN); - await observed(); + await waitFor(() => log.events.length > 0); el.remove(); - await observed(); + await quiet(); expect(log.events).toEqual(['in-view']); }); @@ -153,11 +157,11 @@ describe('mount strategy gaps found by the port', () => { /** The root margin is part of the strategy string used before instantiation. */ it('accepts a rootMargin in the per-element strategy', async () => { const el = render('InView', OFFSCREEN, { 'data-mount': 'in-view:400px' }); - await observed(); + await quiet(); expect(log.events).toEqual([]); el.setAttribute('style', ONSCREEN); - await observed(); + await waitFor(() => log.events.length > 0); expect(log.events).toEqual(['in-view']); expect(el[INSTANCES]?.get('InView')?.$isMounted).toBe(true); @@ -166,7 +170,7 @@ describe('mount strategy gaps found by the port', () => { /** Subclasses must inherit the resolved mount strategy. */ it('inherits the mount strategy in a subclass that declares its own config', async () => { const el = render('InViewSubclass', OFFSCREEN); - await observed(); + await quiet(); expect(el[INSTANCES]?.get('InViewSubclass')).toBeUndefined(); }); @@ -184,7 +188,7 @@ describe('the strategy is per element, which the decorator never was', () => { it('lets `data-mount` override the class default on one element', async () => { const el = render('InView', OFFSCREEN, { 'data-mount': 'eager' }); - await observed(); + await waitFor(() => el[INSTANCES]?.get('InView')?.$isMounted); expect(el[INSTANCES]?.get('InView')?.$isMounted).toBe(true); expect(log.events).toEqual(['in-view']); @@ -192,11 +196,11 @@ describe('the strategy is per element, which the decorator never was', () => { it('lets `data-mount="in-view"` give the strategy to a component that never asked', async () => { const el = render('InViewEagerProbe', OFFSCREEN, { 'data-mount': 'in-view' }); - await observed(); + await quiet(); expect(el[INSTANCES]?.get('InViewEagerProbe')).toBeUndefined(); el.setAttribute('style', ONSCREEN); - await observed(); + await waitFor(() => el[INSTANCES]?.get('InViewEagerProbe')); expect((el[INSTANCES]?.get('InViewEagerProbe') as Eager | undefined)?.mounts).toBe(1); }); }); diff --git a/packages/v4/migration/LazyInclude/LazyInclude.spec.ts b/packages/v4/migration/LazyInclude/LazyInclude.spec.ts index f392a5631..181af59d6 100644 --- a/packages/v4/migration/LazyInclude/LazyInclude.spec.ts +++ b/packages/v4/migration/LazyInclude/LazyInclude.spec.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Base, registerComponents, type BaseConfig } from '../../src/index.js'; import { getInstance } from '../../src/test-utils.js'; -import { mount, resetDom, settle } from '../../src/test/index.js'; +import { mount, resetDom, settle, waitFor } from '../../src/test/index.js'; import { LazyInclude } from './LazyInclude.js'; /** A probe, so an injected component can prove it mounted. */ @@ -70,8 +70,11 @@ function stubFailure(): ReturnType { return client; } -/** A few settles, because the include lands across several microtask turns. */ -async function included(): Promise { +/** + * A bounded quiet period, for the assertions that no request was made and no + * load was recorded. Everything else polls for the state it expects. + */ +async function quiet(): Promise { for (let i = 0; i < 4; i += 1) { await settle(); } @@ -83,7 +86,7 @@ describe('LazyInclude', () => { const root = await mount( `
`, ); - await included(); + await waitFor(() => client.mock.calls.length > 0); expect(client).toHaveBeenCalledWith('/lazy.html'); expect(root.firstElementChild?.innerHTML.trim()).toBe('

remote

'); @@ -100,7 +103,7 @@ describe('LazyInclude', () => { expect(loading?.style.display).toBe(''); deferred.resolve('

remote

'); - await included(); + await waitFor(() => loading?.style.display === 'none'); // The ref was hidden before the swap removed it with the rest. expect(loading?.style.display).toBe('none'); @@ -113,7 +116,9 @@ describe('LazyInclude', () => { Boom
`, ); - await included(); + await waitFor( + () => root.querySelector('[data-ref="error"]')?.style.display === 'block', + ); expect(root.querySelector('[data-ref="error"]')?.style.display).toBe('block'); }); @@ -125,7 +130,7 @@ describe('LazyInclude', () => { document.addEventListener('always', () => seen.push('always')); await mount(`
`); - await included(); + await waitFor(() => seen.length === 2); expect(seen).toEqual(['content', 'always']); }); @@ -138,7 +143,7 @@ describe('LazyInclude', () => { }); await mount(`
`); - await included(); + await waitFor(() => contentWhenAlways); // `$emit()` returns before an async listener has finished, so an `always` // announced from the fetch chain alone would arrive on an empty element. @@ -152,7 +157,7 @@ describe('LazyInclude', () => { document.addEventListener('always', () => seen.push('always')); await mount(`
`); - await included(); + await waitFor(() => seen.length === 2); expect(seen).toEqual(['error', 'always']); }); @@ -165,7 +170,7 @@ describe('LazyInclude', () => { }); await mount(`
`); - await included(); + await waitFor(() => detail); expect(detail?.content).toBe('

remote

'); }); @@ -179,7 +184,7 @@ describe('LazyInclude', () => { expect(instance.hasLoaded).toBe(false); deferred.resolve('

remote

'); - await included(); + await waitFor(() => instance.hasLoaded); // The component is not ended: it remembers, which is what the option // means and what survives the move the next spec makes. @@ -195,7 +200,7 @@ describe('LazyInclude', () => { const instance = getInstance(root.firstElementChild as HTMLElement, 'LazyInclude'); deferred.resolve('

remote

'); - await included(); + await quiet(); expect(instance.hasLoaded).toBe(false); expect(instance.$isMounted).toBe(true); @@ -206,7 +211,7 @@ describe('LazyInclude', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); await mount(`
`); - await included(); + await quiet(); expect(client).not.toHaveBeenCalled(); expect(warn).toHaveBeenCalled(); @@ -221,7 +226,7 @@ describe('LazyInclude', () => { stubFetch(''); await mount(`
`); - await included(); + await waitFor(() => window.__lazyScriptRuns === 1); expect(window.__lazyScriptRuns).toBe(1); }); @@ -230,7 +235,7 @@ describe('LazyInclude', () => { stubFetch(''); await mount(`
`); - await included(); + await waitFor(() => Probe.mounts === 1); expect(Probe.mounts).toBe(1); }); @@ -245,13 +250,13 @@ describe('LazyInclude', () => { const root = await mount( `
`, ); - await included(); + await waitFor(() => client.mock.calls.length === 1); expect(client).toHaveBeenCalledTimes(1); const other = document.createElement('section'); document.body.append(other); other.append(root.firstElementChild as HTMLElement); - await included(); + await waitFor(() => client.mock.calls.length === 2); expect(client).toHaveBeenCalledTimes(2); }); @@ -268,13 +273,13 @@ describe('LazyInclude', () => { `
`, ); const el = root.firstElementChild as HTMLElement; - await included(); + await waitFor(() => client.mock.calls.length === 1); expect(getInstance(el, 'LazyInclude').hasLoaded).toBe(false); const other = document.createElement('section'); document.body.append(other); other.append(el); - await included(); + await waitFor(() => client.mock.calls.length === 2); // A request that failed is the one worth retrying on the next mount. expect(client).toHaveBeenCalledTimes(2); @@ -286,12 +291,12 @@ describe('LazyInclude', () => { `
`, ); const el = root.firstElementChild as HTMLElement; - await included(); + await waitFor(() => getInstance(el, 'LazyInclude').hasLoaded); const other = document.createElement('section'); document.body.append(other); other.append(el); - await included(); + await quiet(); expect(client).toHaveBeenCalledTimes(1); }); diff --git a/packages/v4/migration/Menu/MenuList.spec.ts b/packages/v4/migration/Menu/MenuList.spec.ts index 90294ffb3..d3b05012d 100644 --- a/packages/v4/migration/Menu/MenuList.spec.ts +++ b/packages/v4/migration/Menu/MenuList.spec.ts @@ -1,30 +1,13 @@ import { afterEach, describe, expect, it } from 'vitest'; import { registerComponents } from '../../src/index.js'; import { getInstance } from '../../src/test-utils.js'; -import { resetDom, settle } from '../../src/test/index.js'; +import { mount, resetDom, settle, waitFor } from '../../src/test/index.js'; import { MenuList } from './MenuList.js'; registerComponents(MenuList); afterEach(resetDom); -/** - * `open()`/`close()` start their transition and do not return it, and a kept - * end state only lands a few frames later — after `nextFrame`, the `from` and - * `active` states, and either a `transitionend` or one more frame. A single - * `settle()` is usually enough and is not a guarantee: under full-suite load - * these assertions failed intermittently. Poll for the class instead. - */ -async function waitForClass(el: HTMLElement, className: string, timeout = 1000): Promise { - const deadline = Date.now() + timeout; - while (!el.classList.contains(className)) { - if (Date.now() > deadline) { - throw new Error(`waitForClass: "${className}" never landed on the element`); - } - await new Promise((resolve) => setTimeout(resolve, 10)); - } -} - async function render(): Promise<{ root: HTMLElement; outer: MenuList; @@ -32,8 +15,7 @@ async function render(): Promise<{ outerLink: HTMLElement; nestedLink: HTMLElement; }> { - const root = document.createElement('div'); - root.innerHTML = ` + const root = await mount(` - `; - document.body.append(root); - await settle(); + `); return { root, outer: getInstance(root.querySelector('#outer-list'), 'MenuList'), @@ -66,7 +46,12 @@ describe('MenuList', () => { outer.$el.addEventListener('items-open', () => events.push('items-open')); outer.open(); - await waitForClass(outer.$el, 'open'); + // `open()`/`close()` start their transition and do not return it, and a + // kept end state only lands a few frames later — after `nextFrame`, the + // `from` and `active` states, and either a `transitionend` or one more + // frame. A single `settle()` is generous, not a guarantee: under + // full-suite load these assertions failed intermittently. + await waitFor(() => outer.$el.classList.contains('open')); expect(outer.isOpen).toBe(true); expect(outer.$el.getAttribute('aria-hidden')).toBe('false'); @@ -93,7 +78,7 @@ describe('MenuList', () => { }); outer.close(); - await waitForClass(outer.$el, 'closed'); + await waitFor(() => outer.$el.classList.contains('closed')); expect(outer.isOpen).toBe(false); expect(outer.$el.getAttribute('aria-hidden')).toBe('true'); diff --git a/packages/v4/migration/Prefetch/Prefetch.spec.ts b/packages/v4/migration/Prefetch/Prefetch.spec.ts index 302907d61..1bd7ba3a6 100644 --- a/packages/v4/migration/Prefetch/Prefetch.spec.ts +++ b/packages/v4/migration/Prefetch/Prefetch.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { registerComponents } from '../../src/index.js'; import { getInstance } from '../../src/test-utils.js'; -import { mount, resetDom, settle } from '../../src/test/index.js'; +import { mount, resetDom, settle, waitFor } from '../../src/test/index.js'; import { AbstractPrefetch } from './AbstractPrefetch.js'; import { PrefetchOnInteraction } from './PrefetchOnInteraction.js'; import { PrefetchWhenVisible } from './PrefetchWhenVisible.js'; @@ -40,8 +40,11 @@ afterEach(async () => { await resetDom(); }); -/** Give the observer and the link a few frames. */ -async function observed(): Promise { +/** + * A bounded quiet period, for the assertions that no hint was appended and no + * component was instantiated. Everything positive is polled for instead. + */ +async function quiet(): Promise { for (let i = 0; i < 8; i += 1) { await settle(); } @@ -133,7 +136,7 @@ describe('AbstractPrefetch — the hint', () => { ); instance.prefetch(); - await observed(); + await waitFor(() => hasPrefetchLink(href)); expect(hasPrefetchLink(href)).toBe(true); }); @@ -149,7 +152,7 @@ describe('AbstractPrefetch — the hint', () => { ); instance.prefetch(); - await observed(); + await quiet(); expect(prefetchLinks()).toHaveLength(before); }); @@ -167,7 +170,7 @@ describe('AbstractPrefetch — the hint', () => { }); instance.prefetch(); - await observed(); + await waitFor(() => detail); expect(detail?.url.href).toBe(href); }); @@ -188,7 +191,7 @@ describe('AbstractPrefetch — the hint', () => { first.prefetch(); second.prefetch(); - await observed(); + await waitFor(() => hasPrefetchLink(href)); expect(prefetchLinks().filter((link) => link.href === href)).toHaveLength(1); }); @@ -215,7 +218,7 @@ describe('AbstractPrefetch — the hint', () => { for (const instance of instances) { instance.prefetch(); } - await observed(); + await waitFor(() => count === 2); expect(count).toBe(2); }); @@ -234,7 +237,7 @@ describe('AbstractPrefetch — the hint', () => { instance.prefetch(); root.remove(); - await observed(); + await quiet(); expect(count).toBe(0); }); @@ -246,7 +249,7 @@ describe('PrefetchOnInteraction', () => { const root = await mount(``); root.firstElementChild?.dispatchEvent(new PointerEvent('pointerenter')); - await observed(); + await waitFor(() => hasPrefetchLink(href)); expect(hasPrefetchLink(href)).toBe(true); }); @@ -262,7 +265,7 @@ describe('PrefetchOnInteraction', () => { tap.dispatchEvent(new PointerEvent('pointerdown')); focus.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); - await observed(); + await waitFor(() => hasPrefetchLink(tapped) && hasPrefetchLink(focused)); expect(hasPrefetchLink(tapped)).toBe(true); expect(hasPrefetchLink(focused)).toBe(true); @@ -271,7 +274,7 @@ describe('PrefetchOnInteraction', () => { it('does not instantiate the component before the intent arrives', async () => { const href = uniqueHref(); const root = await mount(``); - await observed(); + await quiet(); expect( getInstance( @@ -289,7 +292,7 @@ describe('PrefetchOnInteraction', () => { ); root.firstElementChild?.dispatchEvent(new PointerEvent('pointerenter')); - await observed(); + await quiet(); expect(hasPrefetchLink(href)).toBe(false); }); @@ -301,7 +304,7 @@ describe('PrefetchWhenVisible', () => { const root = await mount( ``, ); - await observed(); + await quiet(); expect( getInstance( @@ -315,7 +318,7 @@ describe('PrefetchWhenVisible', () => { it('prefetches the first time the link is seen', async () => { const href = uniqueHref(); await mount(``); - await observed(); + await waitFor(() => hasPrefetchLink(href)); expect(hasPrefetchLink(href)).toBe(true); }); @@ -330,13 +333,14 @@ describe('PrefetchWhenVisible', () => { const root = await mount( ``, ); - await observed(); const el = root.firstElementChild as HTMLElement; - const instance = getInstance(el, 'PrefetchWhenVisible'); + const instance = await waitFor(() => + getInstance(el, 'PrefetchWhenVisible'), + ); expect(instance?.$isMounted).toBe(true); el.setAttribute('style', OFFSCREEN); - await observed(); + await quiet(); expect(getInstance(el, 'PrefetchWhenVisible')?.$isMounted).toBe(true); }); @@ -346,13 +350,13 @@ describe('PrefetchWhenVisible', () => { const root = await mount( ``, ); - await observed(); + await waitFor(() => hasPrefetchLink(href)); const el = root.firstElementChild as HTMLElement; el.setAttribute('style', OFFSCREEN); - await observed(); + await quiet(); el.setAttribute('style', ONSCREEN); - await observed(); + await quiet(); expect(prefetchLinks().filter((link) => link.href === href)).toHaveLength(1); }); diff --git a/packages/v4/migration/Sentinel/Sentinel.spec.ts b/packages/v4/migration/Sentinel/Sentinel.spec.ts index 372b1e2f4..50084ab07 100644 --- a/packages/v4/migration/Sentinel/Sentinel.spec.ts +++ b/packages/v4/migration/Sentinel/Sentinel.spec.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest'; import { registerComponents, type InViewProps } from '../../src/index.js'; -import { resetDom, settle } from '../../src/test/index.js'; +import { resetDom, settle, waitFor } from '../../src/test/index.js'; import { Sentinel } from './Sentinel.js'; const OFFSCREEN = 'position:absolute;top:300vh;left:0;width:50px;height:50px'; @@ -10,13 +10,6 @@ registerComponents(Sentinel); afterEach(resetDom); -/** Give the observer a few frames to deliver. */ -async function observed(): Promise { - for (let i = 0; i < 6; i += 1) { - await settle(); - } -} - function render(style: string): HTMLElement { const el = document.createElement('div'); el.setAttribute('data-component', 'Sentinel'); @@ -32,7 +25,9 @@ describe('Sentinel', () => { el.addEventListener('intersected', (event) => { events.push((event as CustomEvent).detail); }); - await observed(); + await waitFor(() => events.length > 0); + // One more turn, so an extra delivery would show up in the count below. + await settle(); expect(events).toHaveLength(1); expect(events[0].isInView).toBe(false); @@ -44,10 +39,10 @@ describe('Sentinel', () => { el.addEventListener('intersected', (event) => { events.push((event as CustomEvent).detail); }); - await observed(); + await waitFor(() => events.length > 0); el.setAttribute('style', ONSCREEN); - await observed(); + await waitFor(() => events.at(-1)?.isInView === true); const last = events.at(-1); expect(last?.isInView).toBe(true); @@ -60,10 +55,10 @@ describe('Sentinel', () => { el.addEventListener('intersected', (event) => { events.push((event as CustomEvent).detail); }); - await observed(); + await waitFor(() => events.at(-1)?.isInView === true); el.setAttribute('style', OFFSCREEN); - await observed(); + await waitFor(() => events.length > 1 && events.at(-1)?.isInView === false); const last = events.at(-1); expect(last?.isInView).toBe(false); @@ -80,7 +75,7 @@ describe('Sentinel', () => { el.addEventListener('intersected', (event) => { lastProps = (event as CustomEvent).detail; }); - await observed(); + await waitFor(() => lastProps); expect(lastProps?.entry).toBeTruthy(); expect(typeof lastProps?.entry?.boundingClientRect.y).toBe('number'); diff --git a/packages/v4/migration/Track/Track.spec.ts b/packages/v4/migration/Track/Track.spec.ts index e5b43e6d9..c1c236e60 100644 --- a/packages/v4/migration/Track/Track.spec.ts +++ b/packages/v4/migration/Track/Track.spec.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { registerComponents } from '../../src/index.js'; import { getInstance } from '../../src/test-utils.js'; -import { mount, resetDom, settle } from '../../src/test/index.js'; +import { mount, resetDom, settle, waitFor } from '../../src/test/index.js'; import { Track } from './Track.js'; import { TrackContext } from './TrackContext.js'; import { TrackShopify } from './TrackShopify.js'; @@ -35,8 +35,11 @@ beforeEach(() => { window.dataLayer = []; }); -/** Give the observer a few frames to deliver. */ -async function observed(): Promise { +/** + * A bounded quiet period, for the assertions that nothing was pushed. A push + * that is expected is polled for instead — see the `waitFor` calls below. + */ +async function quiet(): Promise { for (let i = 0; i < 6; i += 1) { await settle(); } @@ -249,7 +252,7 @@ describe('Track — the `mounted` pseudo-event', () => { root.innerHTML = `
`; document.body.append(root); root.innerHTML = ''; - await observed(); + await quiet(); expect(pushes()).toHaveLength(0); }); @@ -291,11 +294,11 @@ describe('Track — the `view` pseudo-event', () => { const root = await mount( `
`, ); - await observed(); + await quiet(); expect(pushes()).toHaveLength(0); (root.firstElementChild as HTMLElement).setAttribute('style', ONSCREEN); - await observed(); + await waitFor(() => pushes().length > 0); expect(lastPush()).toEqual({ event: 'impression', id: '123' }); }); @@ -305,12 +308,12 @@ describe('Track — the `view` pseudo-event', () => { `
`, ); const el = root.firstElementChild as HTMLElement; - await observed(); + await waitFor(() => pushes().length > 0); el.setAttribute('style', OFFSCREEN); - await observed(); + await quiet(); el.setAttribute('style', ONSCREEN); - await observed(); + await waitFor(() => pushes().length > 1); expect(pushes()).toHaveLength(2); }); @@ -320,12 +323,12 @@ describe('Track — the `view` pseudo-event', () => { `
`, ); const el = root.firstElementChild as HTMLElement; - await observed(); + await waitFor(() => pushes().length > 0); el.setAttribute('style', OFFSCREEN); - await observed(); + await quiet(); el.setAttribute('style', ONSCREEN); - await observed(); + await quiet(); expect(pushes()).toHaveLength(1); }); @@ -336,7 +339,7 @@ describe('Track — the `view` pseudo-event', () => { style="position:absolute;top:0;left:0;width:50px;height:400vh" data-track:view='{"event": "impression"}'>
`, ); - await observed(); + await waitFor(() => pushes().length > 0); expect(pushes()).toHaveLength(1); }); @@ -348,7 +351,7 @@ describe('Track — the `view` pseudo-event', () => { style="position:absolute;top:0;left:0;width:50px;height:400vh" data-track:view='{"event": "impression"}'>
`, ); - await observed(); + await quiet(); expect(pushes()).toHaveLength(0); }); @@ -358,12 +361,12 @@ describe('Track — the `view` pseudo-event', () => { `
`, ); const el = root.firstElementChild as HTMLElement; - await observed(); + await waitFor(() => pushes().length > 0); el.setAttribute('style', OFFSCREEN); - await observed(); + await quiet(); el.setAttribute('style', ONSCREEN); - await observed(); + await quiet(); expect(pushes()).toHaveLength(1); }); @@ -373,12 +376,11 @@ describe('Track — the `view` pseudo-event', () => { `
`, ); const el = root.firstElementChild as HTMLElement; - await observed(); + const track = await waitFor(() => getInstance(el, 'Track')); - const track = getInstance(el, 'Track'); track.$unmount(); el.setAttribute('style', ONSCREEN); - await observed(); + await quiet(); expect(pushes()).toHaveLength(0); expect(track.$isMounted).toBe(false); @@ -542,7 +544,7 @@ describe('the intersection service under load', () => { const built = await countObservers(async () => { await mount(markup); - await observed(); + await waitFor(() => pushes().length === CARDS); }); expect(pushes()).toHaveLength(CARDS); @@ -557,7 +559,7 @@ describe('the intersection service under load', () => { data-track:view='{"event": "a"}' data-track:view.once='{"event": "b"}'>
`, ); - await observed(); + await waitFor(() => pushes().length === 2); }); expect( @@ -574,7 +576,7 @@ describe('the intersection service under load', () => { `
`, ); - await observed(); + await waitFor(() => pushes().length === 2); }); expect(built).toBe(2); @@ -596,7 +598,7 @@ describe('the intersection service under load', () => { useInView(el, { threshold: 0.9 }).subscribe(({ entry }) => { secondRatio = entry?.intersectionRatio ?? -1; }); - await observed(); + await waitFor(() => firstRatio > 0 && secondRatio > 0); }); expect(built).toBe(2); @@ -613,16 +615,16 @@ describe('the intersection service under load', () => { ).join(''); const root = await mount(markup); - await observed(); + await waitFor(() => pushes().length === CARDS); expect(pushes()).toHaveLength(CARDS); root.remove(); - await observed(); + await quiet(); window.dataLayer = []; const built = await countObservers(async () => { await mount(markup); - await observed(); + await waitFor(() => pushes().length === CARDS); }); expect(built).toBe(CARDS); expect(pushes()).toHaveLength(CARDS); diff --git a/packages/v4/src/autoload.spec.ts b/packages/v4/src/autoload.spec.ts index bb4611f7e..59ab9d061 100644 --- a/packages/v4/src/autoload.spec.ts +++ b/packages/v4/src/autoload.spec.ts @@ -6,7 +6,7 @@ import { EVENTS } from './events.js'; import { getInstances } from './instances.js'; import { INSTANCES } from './protocol-symbols.js'; import { registerComponent, registerManifest } from './registry.js'; -import { resetDom, settle } from './test/index.js'; +import { resetDom, settle, waitFor } from './test/index.js'; /** Positions used to control viewport strategies. */ const OFFSCREEN = 'position:absolute;top:300vh;left:0;width:50px;height:50px'; @@ -68,8 +68,12 @@ function instanceOf(el: Element, name: string): T | undefined { return el[INSTANCES]?.get(name) as T | undefined; } -/** Wait for observer delivery. */ -async function observed(): Promise { +/** + * A bounded quiet period. Waiting for something to arrive is a poll — see the + * `waitFor` calls below — but an assertion that nothing was imported cannot be + * polled for, so it keeps a span long enough for the trigger to have fired. + */ +async function quiet(): Promise { for (let i = 0; i < 6; i += 1) { await settle(); } @@ -145,13 +149,13 @@ describe('a lazy declaration before its class arrives', () => { const { name, load } = defineLazy(); const el = render(name, {}, OFFSCREEN); registerManifest({ [name]: { load, mountStrategy: 'visible' } }); - await observed(); + await quiet(); expect(el[INSTANCES]).toBeUndefined(); expect(getInstances(name)).toEqual([]); el.setAttribute('style', ONSCREEN); - await observed(); + await waitFor(() => getInstances(name).length > 0); expect(getInstances(name)).toHaveLength(1); expect(instanceOf(el, name)?.$isMounted).toBe(true); @@ -161,12 +165,12 @@ describe('a lazy declaration before its class arrives', () => { const { name, load, importCount } = defineLazy(); const el = render(name, {}, OFFSCREEN); registerManifest({ [name]: { load, mountStrategy: 'visible' } }); - await observed(); + await quiet(); el.removeAttribute('data-component'); await settle(); el.setAttribute('style', ONSCREEN); - await observed(); + await quiet(); expect(importCount()).toBe(0); }); @@ -175,16 +179,16 @@ describe('a lazy declaration before its class arrives', () => { const { name, load, importCount } = defineLazy(); const el = render(name, {}, OFFSCREEN); registerManifest({ [name]: { load, mountStrategy: 'visible:200px' } }); - await observed(); + await quiet(); el.remove(); el.setAttribute('style', ONSCREEN); - await observed(); + await quiet(); expect(importCount()).toBe(0); document.body.append(el); - await observed(); + await waitFor(() => importCount() > 0); expect(importCount()).toBe(1); expect(instanceOf(el, name)?.$isMounted).toBe(true); @@ -196,12 +200,12 @@ describe('the strategy that triggers the import', () => { const { name, load, importCount } = defineLazy(); const el = render(name, {}, OFFSCREEN); registerManifest({ [name]: { load, mountStrategy: 'visible:200px 0px' } }); - await observed(); + await quiet(); expect(importCount()).toBe(0); el.setAttribute('style', ONSCREEN); - await observed(); + await waitFor(() => importCount() > 0); expect(importCount()).toBe(1); expect(instanceOf(el, name)?.$isMounted).toBe(true); @@ -212,7 +216,7 @@ describe('the strategy that triggers the import', () => { // Keep the element away from the pointer because `pointerenter` counts as interaction. const el = render(name, { 'data-mount': 'interaction' }, OFFSCREEN); registerManifest({ [name]: { load, mountStrategy: 'visible' } }); - await observed(); + await quiet(); expect(importCount()).toBe(0); @@ -235,7 +239,7 @@ describe('the strategy that triggers the import', () => { diagnostics.push((event as CustomEvent).detail); }); registerManifest({ [name]: load }); - await observed(); + await waitFor(() => diagnostics.length > 0); expect(importCount()).toBe(0); expect(el[INSTANCES]?.get(name)).toBeUndefined(); @@ -252,7 +256,7 @@ describe('the strategy that triggers the import', () => { const { name, load, importCount } = defineLazy(); const el = render(name); registerManifest({ [name]: { load, mountStrategy: 'media:(min-width: 1px)' } }); - await observed(); + await waitFor(() => instanceOf(el, name)?.$isMounted); expect(importCount()).toBe(1); expect(instanceOf(el, name)?.$isMounted).toBe(true); @@ -262,7 +266,7 @@ describe('the strategy that triggers the import', () => { const { name, load, importCount } = defineLazy(); const el = render(name, { 'data-mount': 'media:(max-width: 1px)' }); registerManifest({ [name]: load }); - await observed(); + await quiet(); expect(importCount()).toBe(0); expect(el[INSTANCES]).toBeUndefined(); @@ -272,23 +276,22 @@ describe('the strategy that triggers the import', () => { const { name, load, importCount } = defineLazy(); const el = render(name, { 'data-mount': 'in-view:200px 0px' }, OFFSCREEN); registerManifest({ [name]: load }); - await observed(); + await quiet(); expect(importCount()).toBe(0); el.setAttribute('style', ONSCREEN); - await observed(); + const instance = await waitFor(() => instanceOf(el, name)); - const instance = instanceOf(el, name); expect(importCount()).toBe(1); expect(instance?.$isMounted).toBe(true); el.setAttribute('style', OFFSCREEN); - await observed(); + await waitFor(() => instance?.$isMounted === false); expect(instance?.$isMounted).toBe(false); el.setAttribute('style', ONSCREEN); - await observed(); + await waitFor(() => instance?.$isMounted === true); expect(importCount()).toBe(1); expect(instance?.$isMounted).toBe(true); @@ -311,17 +314,17 @@ describe('the strategy that triggers the import', () => { return Lazy; }, }); - await observed(); + await quiet(); el.setAttribute('style', OFFSCREEN); - await observed(); + await quiet(); release(); - await observed(); + await quiet(); expect(el[INSTANCES]?.get(name)).toBeUndefined(); el.setAttribute('style', ONSCREEN); - await observed(); + await waitFor(() => instanceOf(el, name)?.$isMounted); expect(instanceOf(el, name)?.$isMounted).toBe(true); }); @@ -587,12 +590,12 @@ describe('a dynamic import declared in config.components', () => { registerComponent(Parent); const el = render(child.name, { 'data-mount': 'visible' }, OFFSCREEN); - await observed(); + await quiet(); expect(child.importCount()).toBe(0); el.setAttribute('style', ONSCREEN); - await observed(); + await waitFor(() => child.importCount() > 0); expect(child.importCount()).toBe(1); expect(instanceOf(el, child.name)?.$isMounted).toBe(true); diff --git a/packages/v4/src/mount-strategies.spec.ts b/packages/v4/src/mount-strategies.spec.ts index b37546f84..e49cb2d2b 100644 --- a/packages/v4/src/mount-strategies.spec.ts +++ b/packages/v4/src/mount-strategies.spec.ts @@ -5,7 +5,7 @@ import { EVENTS } from './events.js'; import { INSTANCES } from './protocol-symbols.js'; import { registerComponent } from './registry.js'; import { getSharedRuntimeSlot } from './shared-runtime.js'; -import { resetDom, settle } from './test/index.js'; +import { resetDom, settle, waitFor } from './test/index.js'; /** * The page-wide interaction signal is a fact about the visit, so it survives a @@ -71,7 +71,12 @@ function instanceOf(el: Element, name: string): T | undefined { return el[INSTANCES]?.get(name) as T | undefined; } -async function observed(): Promise { +/** + * A bounded quiet period, for the assertions that nothing mounted. Every + * positive wait below polls for the state it expects; an absence cannot be + * polled for, so those keep a span long enough for the strategy to have acted. + */ +async function quiet(): Promise { for (let i = 0; i < 6; i += 1) { await settle(); } @@ -96,7 +101,7 @@ describe('data-mount="visible"', () => { it('leaves the component uninstantiated until it is seen', async () => { const { name } = defineTracked(); const el = render(name, { 'data-mount': 'visible' }, OFFSCREEN); - await observed(); + await quiet(); expect(el[INSTANCES]?.get(name)).toBeUndefined(); }); @@ -104,15 +109,14 @@ describe('data-mount="visible"', () => { it('mounts once with a root margin and stays mounted afterwards', async () => { const { name } = defineTracked(); const el = render(name, { 'data-mount': 'visible:200px' }, OFFSCREEN); - await observed(); + await quiet(); el.setAttribute('style', ONSCREEN); - await observed(); - const instance = instanceOf(el, name); + const instance = await waitFor(() => instanceOf(el, name)); expect(instance?.$isMounted).toBe(true); el.setAttribute('style', OFFSCREEN); - await observed(); + await quiet(); expect(instance?.$isMounted).toBe(true); }); }); @@ -122,19 +126,18 @@ describe('data-mount="in-view"', () => { const { name, Tracked } = defineTracked(); type Tracked = InstanceType; const el = render(name, { 'data-mount': 'in-view' }, ONSCREEN); - await observed(); + const instance = await waitFor(() => instanceOf(el, name)); - const instance = instanceOf(el, name); expect(instance?.$isMounted).toBe(true); expect(instance?.mounts).toBe(1); el.setAttribute('style', OFFSCREEN); - await observed(); + await waitFor(() => instance?.$isMounted === false); expect(instance?.$isMounted).toBe(false); expect(instance?.unmounts).toBe(1); el.setAttribute('style', ONSCREEN); - await observed(); + await waitFor(() => instance?.mounts === 2); expect(instanceOf(el, name)).toBe(instance); expect(instance?.$isMounted).toBe(true); expect(instance?.mounts).toBe(2); @@ -254,7 +257,7 @@ describe('data-mount="idle"', () => { it('mounts when the main thread goes idle', async () => { const { name } = defineTracked(); const el = render(name, { 'data-mount': 'idle' }); - await observed(); + await waitFor(() => instanceOf(el, name)?.$isMounted); expect(instanceOf(el, name)?.$isMounted).toBe(true); }); @@ -266,7 +269,13 @@ describe('data-mount="media:…"', () => { const failing = defineTracked(); render(matching.name, { 'data-mount': 'media:(min-width: 1px)' }); const narrow = render(failing.name, { 'data-mount': 'media:(max-width: 1px)' }); - await observed(); + // The matching query mounting proves the media pass ran, which is what + // makes the failing query's absence below mean something. + await waitFor( + () => + instanceOf(document.querySelector(`[data-component="${matching.name}"]`)!, matching.name) + ?.$isMounted, + ); expect( instanceOf(document.querySelector(`[data-component="${matching.name}"]`)!, matching.name) @@ -280,11 +289,11 @@ describe('config.mountStrategy', () => { it('sets a parameterized default for every instance of a component', async () => { const { name } = defineTracked({ mountStrategy: 'visible:200px 0px' }); const el = render(name, {}, OFFSCREEN); - await observed(); + await quiet(); expect(el[INSTANCES]?.get(name)).toBeUndefined(); el.setAttribute('style', ONSCREEN); - await observed(); + await waitFor(() => instanceOf(el, name)?.$isMounted); expect(instanceOf(el, name)?.$isMounted).toBe(true); }); @@ -299,16 +308,15 @@ describe('config.mountStrategy', () => { it('lets a parameterized data-mount override the component config', async () => { const { name } = defineTracked({ mountStrategy: 'eager' }); const el = render(name, { 'data-mount': 'in-view:200px 0px' }, OFFSCREEN); - await observed(); + await quiet(); expect(el[INSTANCES]?.get(name)).toBeUndefined(); el.setAttribute('style', ONSCREEN); - await observed(); - const instance = instanceOf(el, name); + const instance = await waitFor(() => instanceOf(el, name)); expect(instance?.$isMounted).toBe(true); el.setAttribute('style', OFFSCREEN); - await observed(); + await waitFor(() => instance?.$isMounted === false); expect(instance?.$isMounted).toBe(false); }); @@ -320,7 +328,7 @@ describe('config.mountStrategy', () => { el.removeAttribute('data-mount'); el.setAttribute('style', ONSCREEN); - await observed(); + await waitFor(() => instanceOf(el, name)?.$isMounted); expect(instanceOf(el, name)?.$isMounted).toBe(true); }); @@ -335,11 +343,11 @@ describe('config.mountStrategy', () => { registerComponent(Heir); const el = render(name, {}, OFFSCREEN); - await observed(); + await quiet(); expect(el[INSTANCES]?.get(name)).toBeUndefined(); el.setAttribute('style', ONSCREEN); - await observed(); + await waitFor(() => instanceOf(el, name)?.$isMounted); expect(instanceOf(el, name)?.$isMounted).toBe(true); }); @@ -363,7 +371,7 @@ describe('dynamic data-mount', () => { it('replaces a waiting strategy when the attribute changes', async () => { const { name } = defineTracked(); const el = render(name, { 'data-mount': 'visible' }, OFFSCREEN); - await observed(); + await quiet(); expect(el[INSTANCES]?.get(name)).toBeUndefined(); el.setAttribute('data-mount', 'eager'); @@ -387,7 +395,7 @@ describe('invalid data-mount', () => { }); document.body.append(el); - await observed(); + await waitFor(() => events.length > 0); expect(el[INSTANCES]?.get(name)).toBeUndefined(); expect(events).toHaveLength(1); @@ -409,7 +417,7 @@ describe('invalid data-mount', () => { // A same-value mutation reconciles the element, but the inert controller // remains current and must not report or schedule itself again. el.setAttribute('data-mount', strategy); - await observed(); + await quiet(); expect(events).toHaveLength(1); el.setAttribute('data-mount', 'eager'); @@ -434,7 +442,7 @@ describe('invalid data-mount', () => { healthyEl.setAttribute('data-component', healthy.name); document.body.append(brokenEl, healthyEl); - await observed(); + await waitFor(() => instanceOf(healthyEl, healthy.name)?.$isMounted); expect(brokenEl[INSTANCES]?.get(broken.name)).toBeUndefined(); expect(diagnostics).toHaveLength(1); @@ -446,12 +454,12 @@ describe('teardown', () => { it('stops a parameterized viewport strategy when the element leaves the document', async () => { const { name } = defineTracked(); const el = render(name, { 'data-mount': 'visible:200px' }, OFFSCREEN); - await observed(); + await quiet(); el.remove(); - await observed(); + await quiet(); el.setAttribute('style', ONSCREEN); - await observed(); + await quiet(); expect(el[INSTANCES]?.get(name)).toBeUndefined(); }); @@ -464,15 +472,14 @@ describe('teardown', () => { const el = document.createElement('div'); el.setAttribute('data-component', name); from.append(el); - await observed(); - const instance = instanceOf(el, name); + const instance = await waitFor(() => instanceOf(el, name)); expect(instance?.$isMounted).toBe(true); expect(instance?.mounts).toBe(1); // A move is one removal record plus one addition record. It ends one // mount cycle and starts another without replacing the instance. to.append(el); - await observed(); + await waitFor(() => instance?.mounts === 2); expect(instanceOf(el, name)).toBe(instance); expect(instance?.$isMounted).toBe(true); expect(instance?.unmounts).toBe(1); @@ -483,13 +490,13 @@ describe('teardown', () => { it('re-schedules an element that comes back', async () => { const { name } = defineTracked(); const el = render(name, { 'data-mount': 'visible' }, ONSCREEN); - await observed(); + await waitFor(() => instanceOf(el, name)?.$isMounted); expect(instanceOf(el, name)?.$isMounted).toBe(true); el.remove(); - await observed(); + await waitFor(() => instanceOf(el, name)?.$isMounted === false); document.body.append(el); - await observed(); + await waitFor(() => instanceOf(el, name)?.$isMounted); expect(instanceOf(el, name)?.$isMounted).toBe(true); }); }); @@ -504,13 +511,13 @@ describe('several components on one element', () => { el.setAttribute('data-mount', 'visible'); el.setAttribute('style', OFFSCREEN); document.body.append(el); - await observed(); + await quiet(); expect(el[INSTANCES]?.get(first.name)).toBeUndefined(); expect(el[INSTANCES]?.get(second.name)).toBeUndefined(); el.setAttribute('style', ONSCREEN); - await observed(); + await waitFor(() => instanceOf(el, first.name)?.$isMounted); expect(instanceOf(el, first.name)?.$isMounted).toBe(true); expect(instanceOf(el, second.name)?.$isMounted).toBe(true); }); diff --git a/packages/v4/src/utils/smoothTo.spec.ts b/packages/v4/src/utils/smoothTo.spec.ts index c5d5e0d22..9bedc1b1a 100644 --- a/packages/v4/src/utils/smoothTo.spec.ts +++ b/packages/v4/src/utils/smoothTo.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest'; import { smoothTo, type SmoothTo } from './smoothTo.js'; import { countRequestedFrames } from '../test-utils.js'; -import { frames } from '../test/index.js'; +import { frames, waitFor } from '../test/index.js'; describe('smoothTo', () => { it('starts at its start value and does not move on its own', async () => { @@ -192,13 +192,6 @@ describe('smoothTo', () => { }); }); -/** Wait for every channel to arrive, bounded so a stuck value fails the test. */ -async function settled(motion: { readonly isMoving: boolean }, max = 300): Promise { - for (let index = 0; index < max && motion.isMoving; index += 1) { - await frames(1); - } -} - describe('smoothTo — a record of named channels', () => { it('narrows to a number or to a record from what it was started with', () => { const scalar = smoothTo(0); @@ -227,7 +220,7 @@ describe('smoothTo — a record of named channels', () => { expect(seen[0].y).toBeGreaterThan(0); expect(seen[0].scale).toBeGreaterThan(1); - await settled(motion); + await waitFor(() => !motion.isMoving, { timeout: 5000 }); expect(motion()).toEqual({ x: 100, y: 50, scale: 2 }); expect(motion.isMoving).toBe(false); motion.destroy(); @@ -246,7 +239,7 @@ describe('smoothTo — a record of named channels', () => { await frames(1); expect(motion().y).toBeGreaterThan(travellingY); - await settled(motion); + await waitFor(() => !motion.isMoving, { timeout: 5000 }); expect(motion()).toEqual({ x: 0, y: 100 }); motion.destroy(); }); @@ -278,7 +271,7 @@ describe('smoothTo — a record of named channels', () => { expect(motion().lazy).toBeLessThan(10); expect(motion.isMoving).toBe(true); - await settled(motion); + await waitFor(() => !motion.isMoving, { timeout: 5000 }); expect(motion()).toEqual({ quick: 10, lazy: 10 }); expect(motion.isMoving).toBe(false); motion.destroy(); @@ -339,7 +332,7 @@ describe('smoothTo — a record of named channels', () => { expect(motion().x).toBe(0); expect(motion.isMoving).toBe(true); - await settled(motion); + await waitFor(() => !motion.isMoving, { timeout: 5000 }); expect(motion()).toEqual({ x: 0, y: 100 }); motion.destroy(); }); From b362fea430c1abdfddbfae9ba50270fbdc8c320a Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Mon, 24 Aug 2026 16:20:13 +0200 Subject: [PATCH 3/6] test(v4): assert on the diagnostic channel, not on the console sink it writes to Thirty-four of the forty-one `vi.spyOn(console, 'warn')` sites become `captureDiagnostics()`. A spy on the sink cannot see the code, the severity or the reporting component, passes for a diagnostic carrying the wrong code, and breaks when the sink's wording changes. Where a site only asserted "something was warned", the assertion is now the code itself. `Track.spec.ts`'s local `recordDiagnostics()` was `captureDiagnostics()` written out by hand, and is deleted. Seven spies stay, all of them assertions about the sink rather than about a diagnostic: the three in `diagnostics.spec.ts`, the two `$warn`/`$error` sink tests in `Base.spec.ts`, `negotiated-events.spec.ts`'s "cancelling suppresses the sink", and the test module's own spec. `captureDiagnostics()` cancels every event it sees, so using it in those would make the assertion vacuous. `Base.spec.ts` keeps one hand-rolled listener: it asserts the element a diagnostic *started on*, which `captureDiagnostics()` does not expose. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NnepYhqjPMcFCSb43PoHRM --- packages/v4/migration/Data/DataBind.spec.ts | 14 ++--- .../migration/FigureVideo/FigureVideo.spec.ts | 8 +-- .../migration/LazyInclude/LazyInclude.spec.ts | 8 +-- packages/v4/migration/Track/Track.spec.ts | 34 +++++------ .../v4/migration/Track/TrackEvent.spec.ts | 20 +++---- packages/v4/src/Base.spec.ts | 57 ++++++++++--------- packages/v4/src/autoload.spec.ts | 56 +++++++++++------- packages/v4/src/config-extension.spec.ts | 18 +++--- packages/v4/src/decorators.spec.ts | 56 +++++++++--------- packages/v4/src/manifest.spec.ts | 17 ++++-- packages/v4/src/negotiated-events.spec.ts | 15 +++-- packages/v4/src/registry.spec.ts | 18 +++--- packages/v4/src/responsive-components.spec.ts | 21 ++++--- packages/v4/src/responsive-options.spec.ts | 14 ++--- 14 files changed, 192 insertions(+), 164 deletions(-) diff --git a/packages/v4/migration/Data/DataBind.spec.ts b/packages/v4/migration/Data/DataBind.spec.ts index 89e0441ae..51fcb20a8 100644 --- a/packages/v4/migration/Data/DataBind.spec.ts +++ b/packages/v4/migration/Data/DataBind.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { EVENTS, registerComponents } from '../../src/index.js'; import { getInstance } from '../../src/test-utils.js'; -import { mount, resetDom, settle } from '../../src/test/index.js'; +import { captureDiagnostics, mount, resetDom, settle } from '../../src/test/index.js'; import { DataBind } from './DataBind.js'; import { DataComputed } from './DataComputed.js'; import { DataEffect } from './DataEffect.js'; @@ -336,7 +336,7 @@ describe('DataBind — the element half', () => { }); it('refuses the mutation helpers on computed values and effects', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = captureDiagnostics(); const root = await mount(`
current
@@ -349,8 +349,8 @@ describe('DataBind — the element half', () => { at(root, '#e', 'DataEffect').increment(); expect(el(root, '#e').dataset.called).toBeUndefined(); - expect(warn).toHaveBeenCalledTimes(2); - warn.mockRestore(); + expect(log.codes).toEqual(['data-bind.unsupported-mutation', 'data-bind.unsupported-mutation']); + log.stop(); }); }); @@ -600,14 +600,14 @@ describe('DataBind — the data-bind:if template protocol', () => { }); it('warns when the if binding sits on something other than a template', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = captureDiagnostics(); const root = await mount(`
`); at(root, '#d', 'DataBind').set(true); - expect(warn).toHaveBeenCalledTimes(1); - warn.mockRestore(); + expect(log.codes).toEqual(['data-bind.invalid-if-target']); + log.stop(); }); }); diff --git a/packages/v4/migration/FigureVideo/FigureVideo.spec.ts b/packages/v4/migration/FigureVideo/FigureVideo.spec.ts index e88270c35..4e4706d05 100644 --- a/packages/v4/migration/FigureVideo/FigureVideo.spec.ts +++ b/packages/v4/migration/FigureVideo/FigureVideo.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { registerComponents } from '../../src/index.js'; import { getInstance } from '../../src/test-utils.js'; -import { resetDom, settle, waitFor } from '../../src/test/index.js'; +import { captureDiagnostics, resetDom, settle, waitFor } from '../../src/test/index.js'; import { FigureVideo } from './FigureVideo.js'; registerComponents(FigureVideo); @@ -125,11 +125,11 @@ describe('FigureVideo', () => { const root = document.createElement('div'); root.innerHTML = `
`; document.body.append(root); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = captureDiagnostics(); await expect(quiet()).resolves.toBeUndefined(); - expect(warnSpy).toHaveBeenCalled(); - warnSpy.mockRestore(); + expect(log.codes).toEqual(['figure-video.invalid-ref']); + log.stop(); }); }); diff --git a/packages/v4/migration/LazyInclude/LazyInclude.spec.ts b/packages/v4/migration/LazyInclude/LazyInclude.spec.ts index 181af59d6..e16683e05 100644 --- a/packages/v4/migration/LazyInclude/LazyInclude.spec.ts +++ b/packages/v4/migration/LazyInclude/LazyInclude.spec.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Base, registerComponents, type BaseConfig } from '../../src/index.js'; import { getInstance } from '../../src/test-utils.js'; -import { mount, resetDom, settle, waitFor } from '../../src/test/index.js'; +import { captureDiagnostics, mount, resetDom, settle, waitFor } from '../../src/test/index.js'; import { LazyInclude } from './LazyInclude.js'; /** A probe, so an injected component can prove it mounted. */ @@ -208,14 +208,14 @@ describe('LazyInclude', () => { it('warns and fetches nothing without a `src` option', async () => { const client = stubFetch('

remote

'); - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = captureDiagnostics(); await mount(`
`); await quiet(); expect(client).not.toHaveBeenCalled(); - expect(warn).toHaveBeenCalled(); - warn.mockRestore(); + expect(log.codes).toEqual(['lazy-include.missing-src']); + log.stop(); }); /** diff --git a/packages/v4/migration/Track/Track.spec.ts b/packages/v4/migration/Track/Track.spec.ts index c1c236e60..eede45a23 100644 --- a/packages/v4/migration/Track/Track.spec.ts +++ b/packages/v4/migration/Track/Track.spec.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { registerComponents } from '../../src/index.js'; import { getInstance } from '../../src/test-utils.js'; -import { mount, resetDom, settle, waitFor } from '../../src/test/index.js'; +import { captureDiagnostics, mount, resetDom, settle, waitFor } from '../../src/test/index.js'; import { Track } from './Track.js'; import { TrackContext } from './TrackContext.js'; import { TrackShopify } from './TrackShopify.js'; @@ -16,16 +16,6 @@ registerComponents(Track, TrackContext, TrackShopify); * default sink is `reportError()`, not `console.warn`, and the assertion goes * to the channel rather than to the console. */ -function recordDiagnostics(): { codes: string[]; stop: () => void } { - const codes: string[] = []; - const listener = (event: Event) => { - codes.push((event as CustomEvent<{ code: string }>).detail.code); - event.preventDefault(); - }; - document.addEventListener('js-toolkit:diagnostic', listener); - return { codes, stop: () => document.removeEventListener('js-toolkit:diagnostic', listener) }; -} - const OFFSCREEN = 'position:absolute;top:300vh;left:0;width:50px;height:50px'; const ONSCREEN = 'position:absolute;top:0;left:0;width:50px;height:50px'; @@ -195,7 +185,7 @@ describe('Track — payload resolution', () => { describe('Track — malformed declarations', () => { it('drops an event whose JSON cannot be parsed, without throwing', async () => { - const log = recordDiagnostics(); + const log = captureDiagnostics(); const root = await mount( ``, ); @@ -207,7 +197,7 @@ describe('Track — malformed declarations', () => { }); it('falls back to an empty payload when the `payload` ref is invalid JSON', async () => { - const log = recordDiagnostics(); + const log = captureDiagnostics(); const root = await mount(` `, @@ -230,7 +220,11 @@ describe('Track — malformed declarations', () => { expect(() => root.querySelector('button')?.click()).not.toThrow(); expect(lastPush()).toEqual({ event: 'x' }); - spy.mockRestore(); + // Nothing is reported here, unlike the two cases above: `readJSON()` in + // `Base` swallows a malformed option attribute and hands back the + // declared default, so `optionPayload`'s own catch never runs. + expect(log.codes).toEqual([]); + log.stop(); }); }); @@ -656,7 +650,7 @@ describe('TrackShopify — the dispatch seam', () => { it('publishes nothing without a string `event` name', async () => { const publish = vi.fn(); window.Shopify = { analytics: { publish } }; - const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = captureDiagnostics(); const root = await mount( ``, @@ -664,17 +658,19 @@ describe('TrackShopify — the dispatch seam', () => { root.querySelector('button')?.click(); expect(publish).not.toHaveBeenCalled(); - spy.mockRestore(); + expect(log.codes).toContain('track.missing-event-name'); + log.stop(); delete window.Shopify; }); it('does not throw when the Shopify analytics API is absent', async () => { - const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = captureDiagnostics(); const root = await mount( ``, ); expect(() => root.querySelector('button')?.click()).not.toThrow(); - spy.mockRestore(); + expect(log.codes).toContain('track.shopify-unavailable'); + log.stop(); }); }); diff --git a/packages/v4/migration/Track/TrackEvent.spec.ts b/packages/v4/migration/Track/TrackEvent.spec.ts index 1e665ed61..ea4d9cc6b 100644 --- a/packages/v4/migration/Track/TrackEvent.spec.ts +++ b/packages/v4/migration/Track/TrackEvent.spec.ts @@ -1,7 +1,7 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { registerComponents } from '../../src/index.js'; import { getInstance } from '../../src/test-utils.js'; -import { mount, resetDom } from '../../src/test/index.js'; +import { captureDiagnostics, mount, resetDom } from '../../src/test/index.js'; import { parseEventDefinition } from '../event-modifiers.js'; import { Track } from './Track.js'; import { resolveDetailPlaceholders } from './TrackEvent.js'; @@ -50,27 +50,27 @@ describe('parseEventDefinition', () => { }); it('warns for a modifier that names nothing instead of binding it', () => { - const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = captureDiagnostics(); const { event, modifiers } = parseEventDefinition('click.prevnet.stop'); expect(event).toBe('click'); // The typo is dropped; the modifiers that parsed still apply. expect([...modifiers]).toEqual(['stop']); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy.mock.calls[0].join(' ')).toContain('prevnet'); - spy.mockRestore(); + expect(log.codes).toEqual(['event-modifiers.unknown-modifier']); + expect(log.entries[0].message).toContain('prevnet'); + log.stop(); }); it('rejects a malformed timed delay instead of parsing it to NaN', () => { // `debounceoops` used to match on the `debounce` prefix alone, so its // suffix went through `Number.parseInt` and produced `NaN` — a timeout // browsers run immediately rather than warn about. - const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = captureDiagnostics(); const { modifiers, delay } = parseEventDefinition('click.debounceoops'); expect([...modifiers]).toEqual([]); expect(delay('debounce')).toBeUndefined(); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy.mock.calls[0].join(' ')).toContain('debounceoops'); - spy.mockRestore(); + expect(log.codes).toEqual(['event-modifiers.unknown-modifier']); + expect(log.entries[0].message).toContain('debounceoops'); + log.stop(); }); it('applies the family default through the bound declaration', async () => { diff --git a/packages/v4/src/Base.spec.ts b/packages/v4/src/Base.spec.ts index 4c13b1a88..616c4736b 100644 --- a/packages/v4/src/Base.spec.ts +++ b/packages/v4/src/Base.spec.ts @@ -14,7 +14,7 @@ import { EVENTS } from './events.js'; import { INSTANCES } from './protocol-symbols.js'; import { registerComponent } from './registry.js'; import { getInstance, renderTodoList, TodoCount, TodoItem, TodoList } from './test-utils.js'; -import { resetDom, settle } from './test/index.js'; +import { captureDiagnostics, resetDom, settle } from './test/index.js'; afterEach(resetDom); @@ -106,7 +106,7 @@ describe('$emit and delegation', () => { const li = root.querySelector('[data-component="TodoItem"]'); const instance = getInstance(li, 'TodoItem'); - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = captureDiagnostics(); const seen: unknown[] = []; root.addEventListener('ping', (event) => seen.push((event as CustomEvent).detail)); @@ -114,10 +114,13 @@ describe('$emit and delegation', () => { (instance.$emit as (type: string, payload?: unknown) => void)('ping', 2); (instance.$emit as (type: string, payload?: unknown) => void)('pong', 3); - expect(warn).toHaveBeenCalledTimes(2); - expect(warn.mock.calls[0][0]).toContain('one payload object'); + expect(log.codes).toEqual([ + DIAGNOSTICS.event.invalidEmitPayload, + DIAGNOSTICS.event.invalidEmitPayload, + ]); + expect(log.entries[0].message).toContain('one payload object'); expect(seen).toEqual([1, 2]); - warn.mockRestore(); + log.stop(); }); }); @@ -328,7 +331,7 @@ describe('$options', () => { }); it('warns about a literal default instead of repairing it', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = captureDiagnostics(); class LiteralDefault extends Base<{ $options: { tween: Record } }> { static config = { @@ -340,13 +343,13 @@ describe('$options', () => { const first = new LiteralDefault(document.createElement('div')); const second = new LiteralDefault(document.createElement('div')); - expect(warn).toHaveBeenCalledOnce(); - expect(warn.mock.calls[0][0]).toContain('LiteralDefault'); - expect(warn.mock.calls[0][0]).toContain('tween'); - expect(warn.mock.calls[0][0]).toContain('default: () => (…)'); + expect(log.codes).toEqual([DIAGNOSTICS.option.literalDefault]); + expect(log.entries[0].component).toBe('LiteralDefault'); + expect(log.entries[0].message).toContain('tween'); + expect(log.entries[0].message).toContain('default: () => (…)'); expect(first.$options.tween).toBe(second.$options.tween); - warn.mockRestore(); + log.stop(); }); it('memoises the default, so a mutation of it persists on that instance', () => { @@ -620,23 +623,23 @@ describe('$refs', () => { const el = document.createElement('div'); el.innerHTML = ''; document.body.append(el); - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = captureDiagnostics(); const instance = new Dropped(el).$mount(); expect(instance.$refs.dots).toEqual([]); - expect(warn).toHaveBeenCalledOnce(); - expect(warn.mock.calls[0][0]).toContain('data-ref="dots[]"'); - expect(warn.mock.calls[0][0]).toContain('Dropped'); + expect(log.codes).toEqual([DIAGNOSTICS.ref.mismatch]); + expect(log.entries[0].message).toContain('data-ref="dots[]"'); + expect(log.entries[0].component).toBe('Dropped'); void instance.$refs.dots; void instance.$refs.dots; instance.$unmount().$mount(); void instance.$refs.dots; - expect(warn).toHaveBeenCalledOnce(); + expect(log.codes).toHaveLength(1); expect(instance.$refs.title).toEqual([]); - expect(warn).toHaveBeenCalledOnce(); - warn.mockRestore(); + expect(log.codes).toHaveLength(1); + log.stop(); }); it('resolves a namespaced ref across an intervening component', () => { @@ -1765,6 +1768,11 @@ describe('$warn and $error', () => { return new Reporter(el).$mount(); } + /** + * `captureDiagnostics()` covers every other test here. This one asserts the + * element the event *started on*, which the shipped helper does not expose, + * so it keeps its own listener. + */ function record(): { details: ToolkitDiagnosticDetail[]; targets: EventTarget[]; @@ -1823,14 +1831,12 @@ describe('$warn and $error', () => { it('carries the original value on $error, which a reporter needs', () => { const instance = mount(); - const log = record(); - const cancel = (event: Event) => event.preventDefault(); - document.addEventListener('js-toolkit:diagnostic', cancel); + const log = captureDiagnostics(); const cause = new Error('the cause'); instance.$error('reporter.load-failed', 'Loading failed.', cause); - expect(log.details).toEqual([ + expect(log.entries).toEqual([ { severity: 'error', code: 'reporter.load-failed', @@ -1840,20 +1846,17 @@ describe('$warn and $error', () => { }, ]); - document.removeEventListener('js-toolkit:diagnostic', cancel); log.stop(); }); it('accepts a code from the enumerated core set too', () => { const instance = mount(); - const log = record(); - const sink = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = captureDiagnostics(); instance.$warn(DIAGNOSTICS.ref.mismatch, 'A declared ref is missing.'); - expect(log.details[0].code).toBe('ref.mismatch'); + expect(log.codes).toEqual(['ref.mismatch']); - sink.mockRestore(); log.stop(); }); }); diff --git a/packages/v4/src/autoload.spec.ts b/packages/v4/src/autoload.spec.ts index 59ab9d061..9c0f61bb0 100644 --- a/packages/v4/src/autoload.spec.ts +++ b/packages/v4/src/autoload.spec.ts @@ -6,7 +6,7 @@ import { EVENTS } from './events.js'; import { getInstances } from './instances.js'; import { INSTANCES } from './protocol-symbols.js'; import { registerComponent, registerManifest } from './registry.js'; -import { resetDom, settle, waitFor } from './test/index.js'; +import { captureDiagnostics, resetDom, settle, waitFor } from './test/index.js'; /** Positions used to control viewport strategies. */ const OFFSCREEN = 'position:absolute;top:300vh;left:0;width:50px;height:50px'; @@ -364,33 +364,37 @@ describe('registerManifest collisions and failures', () => { } registerComponent(Owned); - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = captureDiagnostics(); const load = vi.fn(); registerManifest({ [name]: load }); const el = render(name); await settle(); expect(load).not.toHaveBeenCalled(); - expect(warn).toHaveBeenCalledWith( - `[js-toolkit:${DIAGNOSTICS.registry.conflict}] "${name}" is already registered; the incoming declaration was ignored.`, - ); + expect(log.entries).toMatchObject([ + { + severity: 'warning', + code: DIAGNOSTICS.registry.conflict, + message: `"${name}" is already registered; the incoming declaration was ignored.`, + }, + ]); expect(instanceOf(el, name)).toBeInstanceOf(Owned); - warn.mockRestore(); + log.stop(); }); it('ignores a token an earlier manifest already owns', async () => { const { name, load, importCount } = defineLazy(); const later = vi.fn(); registerManifest({ [name]: load }); - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = captureDiagnostics(); registerManifest({ [name]: later }); render(name); await settle(); expect(importCount()).toBe(1); expect(later).not.toHaveBeenCalled(); - expect(warn).toHaveBeenCalled(); - warn.mockRestore(); + expect(log.codes).toEqual([DIAGNOSTICS.registry.conflict]); + log.stop(); }); it('reports an import failure once and leaves the page running', async () => { @@ -473,17 +477,21 @@ describe('registerManifest collisions and failures', () => { const { name, Lazy } = defineLazy(); counter += 1; const token = `Alias${counter}`; - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = captureDiagnostics(); registerManifest({ [token]: async () => Lazy }); const el = render(token); await settle(); - expect(warn).toHaveBeenCalledWith( - `[js-toolkit:${DIAGNOSTICS.registry.lazyNameMismatch}] "${token}" resolved to a component named "${name}".`, - ); + expect(log.entries).toMatchObject([ + { + severity: 'warning', + code: DIAGNOSTICS.registry.lazyNameMismatch, + message: `"${token}" resolved to a component named "${name}".`, + }, + ]); expect(el[INSTANCES]).toBeUndefined(); - warn.mockRestore(); + log.stop(); }); }); @@ -602,7 +610,7 @@ describe('a dynamic import declared in config.components', () => { }); it('reports a value which is neither a class nor an importer', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = captureDiagnostics(); counter += 1; const childName = `NotAThunk${counter}`; @@ -613,10 +621,14 @@ describe('a dynamic import declared in config.components', () => { render(childName); await settle(); - expect(warn).toHaveBeenCalledWith( - `[js-toolkit:${DIAGNOSTICS.component.invalidFamilyDeclaration}] "${parentName}" declares "${childName}" as neither a component class nor an importer; the declaration was ignored.`, - ); - warn.mockRestore(); + expect(log.entries).toMatchObject([ + { + severity: 'warning', + code: DIAGNOSTICS.component.invalidFamilyDeclaration, + message: `"${parentName}" declares "${childName}" as neither a component class nor an importer; the declaration was ignored.`, + }, + ]); + log.stop(); }); }); @@ -685,16 +697,16 @@ describe('the family a subclass inherits', () => { const child = defineLazy(); const { Parent } = defineParent({ [child.name]: child.load }); const { Sub } = defineSubclass(Parent); - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = captureDiagnostics(); registerComponent(Parent); registerComponent(Sub); const el = render(child.name); await settle(); - expect(warn).not.toHaveBeenCalled(); + expect(log.codes).toEqual([]); expect(child.importCount()).toBe(1); expect(instanceOf(el, child.name)?.$isMounted).toBe(true); - warn.mockRestore(); + log.stop(); }); }); diff --git a/packages/v4/src/config-extension.spec.ts b/packages/v4/src/config-extension.spec.ts index 721468146..ce3eaa0aa 100644 --- a/packages/v4/src/config-extension.spec.ts +++ b/packages/v4/src/config-extension.spec.ts @@ -10,13 +10,13 @@ * v3 behaviours v4 deliberately dropped: the auto-rename on collision and the * deep merge of the config. */ -import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; +import { afterEach, describe, expect, expectTypeOf, it } from 'vitest'; import { Base, type BaseConfig, type BaseProps } from './Base.js'; import { component } from './decorators.js'; import { DIAGNOSTICS } from './diagnostic-contract.js'; import { registerComponent } from './registry.js'; import { getInstance, TodoItem } from './test-utils.js'; -import { resetDom, settle } from './test/index.js'; +import { captureDiagnostics, resetDom, settle } from './test/index.js'; afterEach(resetDom); @@ -178,16 +178,20 @@ describe('what v3 did and v4 does not', () => { } registerComponent(Widget); - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = captureDiagnostics(); registerComponent(UnnamedWidget); const el = render('Widget'); await settle(); - expect(warn).toHaveBeenCalledWith( - `[js-toolkit:${DIAGNOSTICS.registry.conflict}] "Widget" is already registered; the incoming declaration was ignored.`, - ); + expect(log.entries).toMatchObject([ + { + severity: 'warning', + code: DIAGNOSTICS.registry.conflict, + message: '"Widget" is already registered; the incoming declaration was ignored.', + }, + ]); expect(getInstance(el, 'Widget')).not.toBeInstanceOf(UnnamedWidget); - warn.mockRestore(); + log.stop(); }); it('does not deep merge an option definition it restates', () => { diff --git a/packages/v4/src/decorators.spec.ts b/packages/v4/src/decorators.spec.ts index c15c63716..02814e68c 100644 --- a/packages/v4/src/decorators.spec.ts +++ b/packages/v4/src/decorators.spec.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; +import { afterEach, describe, expect, expectTypeOf, it } from 'vitest'; import { Base, type BaseConfig, @@ -11,12 +11,13 @@ import { resolveConfig, } from './Base.js'; import { isBaseConstructor } from './component-brand.js'; +import { DIAGNOSTICS } from './diagnostic-contract.js'; import { createContext, signal, type Signal } from './context.js'; import { children, component, inject, on, provide, read, write } from './decorators.js'; import { registerComponent, registerComponents } from './registry.js'; import { defaultScheduler } from './scheduler.js'; import { getInstance, TodoItem } from './test-utils.js'; -import { resetDom, settle } from './test/index.js'; +import { captureDiagnostics, resetDom, settle } from './test/index.js'; const DecoContext = createContext>('deco-context'); @@ -427,11 +428,11 @@ describe('@component', () => { // Registering under the inherited name collides with the parent, which is // the loud first-wins path and not what this spec is about. - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = captureDiagnostics(); // @ts-expect-error `name` is missing, as it is in untyped sources. @component({ options: { extra: Boolean } }) class BrandChild extends BrandParent {} - warn.mockRestore(); + log.stop(); expect(BrandChild.config.name).toBe('BrandParent'); // Consumed by `config.components` entries, `@on(Class, type)` and the lazy @@ -448,14 +449,14 @@ describe('@component', () => { @component({ name: 'BrandFieldParent' }) class BrandFieldParent extends Base {} - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = captureDiagnostics(); // @ts-expect-error `name` is missing on both sides, as it is in untyped sources. @component({ refs: ['handle'] }) class BrandFieldChild extends BrandFieldParent { // @ts-expect-error `name` is missing. static config: BaseConfig = { options: { extra: Boolean } }; } - warn.mockRestore(); + log.stop(); expect(BrandFieldChild.config.name).toBe('BrandFieldParent'); expect(BrandFieldChild.config.refs).toEqual(['handle']); @@ -491,13 +492,12 @@ describe('@component', () => { * already applies to a chain, with the decorator last. */ it('merges a static config field on the same class instead of dropping one', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = captureDiagnostics(); @component({ name: 'BothSides', refs: ['btn'] }) class BothSides extends Base { static config: BaseConfig = { name: 'BothSides', options: { open: Boolean } }; } - warn.mockRestore(); expect(BothSides.config).toEqual({ name: 'BothSides', @@ -511,7 +511,8 @@ describe('@component', () => { }); // Declaring the same name on both sides is not a conflict, and disjoint // keys need no precedence rule, so nothing is reported. - expect(warn).not.toHaveBeenCalled(); + expect(log.codes).toEqual([]); + log.stop(); }); /** @@ -520,7 +521,7 @@ describe('@component', () => { * mistake, not a declaration the merge can honour. */ it('keeps the decorator value and reports a key declared differently on both sides', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = captureDiagnostics(); @component({ name: 'ConflictingConfig', @@ -540,26 +541,25 @@ describe('@component', () => { mountStrategy: 'visible', options: { open: Boolean, extra: String }, }); - expect(warn).toHaveBeenCalledOnce(); - expect(warn.mock.calls[0][0]).toContain('[js-toolkit:component.config-conflict]'); - expect(warn.mock.calls[0][0]).toContain('name, mountStrategy, options.open'); - warn.mockRestore(); + expect(log.codes).toEqual([DIAGNOSTICS.component.configConflict]); + expect(log.entries[0].message).toContain('name, mountStrategy, options.open'); + log.stop(); }); /** Refs union, so declaring one on both sides loses nothing to report. */ it('unions refs declared on both sides', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = captureDiagnostics(); @component({ name: 'RefUnion', refs: ['handle'] }) class RefUnion extends Base { static config: BaseConfig = { name: 'RefUnion', refs: ['label', 'handle'] }; } - warn.mockRestore(); // The field's refs come first because the decorator merges onto them; refs // are looked up by name, so the order carries nothing. expect(RefUnion.config.refs).toEqual(['label', 'handle']); - expect(warn).not.toHaveBeenCalled(); + expect(log.codes).toEqual([]); + log.stop(); }); /** @@ -599,7 +599,7 @@ describe('@component', () => { /** Each class merges its own two declarations; the chain merges the rest. */ it('merges both declarations on a decorated subclass of a decorated class', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = captureDiagnostics(); @component({ name: 'MixParent', refs: ['handle'] }) class MixParent extends Base { @@ -615,7 +615,6 @@ describe('@component', () => { class MixGrandChild extends MixChild { static config: BaseConfig = { name: 'MixGrandChild', refs: ['last'] }; } - warn.mockRestore(); expect(MixParent.config).toEqual({ name: 'MixParent', @@ -632,7 +631,8 @@ describe('@component', () => { refs: ['handle', 'extra', 'last'], options: { open: Boolean, size: Number }, }); - expect(warn).not.toHaveBeenCalled(); + expect(log.codes).toEqual([]); + log.stop(); }); }); @@ -808,7 +808,7 @@ describe('@on', () => {
`; document.body.append(root); - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = captureDiagnostics(); await settle(); const instance = getInstance(root, 'NsDots'); @@ -818,8 +818,8 @@ describe('@on', () => { (root.querySelector('h2') as HTMLElement).click(); expect(instance.titles).toEqual(['H2']); - expect(warn).not.toHaveBeenCalled(); - warn.mockRestore(); + expect(log.codes).toEqual([]); + log.stop(); }); it('warns instead of binding silently when @on drops a list ref suffix', async () => { @@ -827,17 +827,17 @@ describe('@on', () => { root.setAttribute('data-component', 'DotMismatch'); root.innerHTML = ''; document.body.append(root); - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = captureDiagnostics(); await settle(); const instance = getInstance(root, 'DotMismatch'); (root.querySelectorAll('i')[1] as HTMLElement).click(); expect(instance.clicked).toEqual([]); - expect(warn).toHaveBeenCalledOnce(); - expect(warn.mock.calls[0][0]).toContain('DotMismatch'); - expect(warn.mock.calls[0][0]).toContain("@on('dots[]', …)"); - warn.mockRestore(); + expect(log.codes).toEqual([DIAGNOSTICS.ref.mismatch]); + expect(log.entries[0].component).toBe('DotMismatch'); + expect(log.entries[0].message).toContain("@on('dots[]', …)"); + log.stop(); }); it('resolves a subclass to the name it mounts under, not to its parent', async () => { diff --git a/packages/v4/src/manifest.spec.ts b/packages/v4/src/manifest.spec.ts index 0f4955903..747033150 100644 --- a/packages/v4/src/manifest.spec.ts +++ b/packages/v4/src/manifest.spec.ts @@ -11,7 +11,7 @@ import { } from './manifest.js'; import { registerManifest } from './registry.js'; import { getInstance } from './test-utils.js'; -import { resetDom, settle } from './test/index.js'; +import { captureDiagnostics, resetDom, settle } from './test/index.js'; class Widget {} class Other {} @@ -67,7 +67,7 @@ describe('defineManifest', () => { }); it('warns for a duplicate token and keeps the first path and importer', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = captureDiagnostics(); const first = vi.fn(async () => ({ Widget })); const second = vi.fn(async () => ({ Widget: Other })); const modules = { @@ -78,10 +78,15 @@ describe('defineManifest', () => { defineManifest({ modules }); expect(manifest.Widget).toBe(first); - expect(warn).toHaveBeenCalledOnce(); - expect(warn).toHaveBeenCalledWith( - `[js-toolkit:${DIAGNOSTICS.manifest.duplicateToken}] "Widget" is already derived from "./first/Widget.ts"; ignoring "./second/Widget.ts".`, - ); + expect(log.entries).toMatchObject([ + { + severity: 'warning', + code: DIAGNOSTICS.manifest.duplicateToken, + message: + '"Widget" is already derived from "./first/Widget.ts"; ignoring "./second/Widget.ts".', + }, + ]); + log.stop(); }); it('applies one non-eager mount strategy to every generated entry', () => { diff --git a/packages/v4/src/negotiated-events.spec.ts b/packages/v4/src/negotiated-events.spec.ts index 73db1843a..e9fae220f 100644 --- a/packages/v4/src/negotiated-events.spec.ts +++ b/packages/v4/src/negotiated-events.spec.ts @@ -10,7 +10,7 @@ import { type Extension, } from './negotiated-events.js'; import { nextFrame } from './scheduler.js'; -import { resetDom } from './test/index.js'; +import { captureDiagnostics, resetDom } from './test/index.js'; import { viewTransition } from './viewTransition.js'; afterEach(async () => { @@ -159,6 +159,9 @@ describe('domUpdate()', () => { it('keeps the direct fallback when its warning is canceled', async () => { const { outer, target } = renderTarget(); + // The assertion here *is* about the console sink — that cancelling the + // event suppresses it — so this one keeps its spy. `captureDiagnostics()` + // cancels every event it sees, which would make the assertion vacuous. const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const diagnostics: ToolkitDiagnosticDetail[] = []; target.addEventListener(EVENTS.diagnostic, (event) => { @@ -178,7 +181,7 @@ describe('domUpdate()', () => { it('warns and ignores a wrap() registration made after dispatch', async () => { const { outer, target } = renderTarget(); - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = captureDiagnostics(); let late: DomUpdateDetail['wrap'] | undefined; outer.addEventListener(EVENTS.dom.update, (event) => { late = detailOf(event).wrap; @@ -192,7 +195,8 @@ describe('domUpdate()', () => { expect(target.dataset).toMatchObject({ applied: 'yes', appliedAgain: 'yes' }); expect(target.dataset.late).toBeUndefined(); expect(target.dataset.later).toBeUndefined(); - expect(warn).toHaveBeenCalledOnce(); + expect(log.codes).toEqual([DIAGNOSTICS.protocol.lateRegistration]); + log.stop(); }); it('accepts viewTransition() without an adapter', async () => { @@ -284,7 +288,7 @@ describe('emitExtendable()', () => { it('warns and ignores a waitUntil() registration made after dispatch', async () => { const { outer, target } = renderTarget(); - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = captureDiagnostics(); let late: ExtendableDetail['waitUntil'] | undefined; let calls = 0; outer.addEventListener('close', (event) => { @@ -300,6 +304,7 @@ describe('emitExtendable()', () => { }); expect(calls).toBe(0); - expect(warn).toHaveBeenCalledOnce(); + expect(log.codes).toEqual([DIAGNOSTICS.protocol.lateRegistration]); + log.stop(); }); }); diff --git a/packages/v4/src/registry.spec.ts b/packages/v4/src/registry.spec.ts index b9f3cb013..9071ddf59 100644 --- a/packages/v4/src/registry.spec.ts +++ b/packages/v4/src/registry.spec.ts @@ -1,11 +1,11 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { Base, type BaseConfig } from './Base.js'; import { DIAGNOSTICS, type ToolkitDiagnosticDetail } from './diagnostic-contract.js'; import { EVENTS } from './events.js'; import { INSTANCES } from './protocol-symbols.js'; import { registerComponent } from './registry.js'; import { getInstance, renderTodoList, TodoItem, TodoList } from './test-utils.js'; -import { resetDom, settle } from './test/index.js'; +import { captureDiagnostics, resetDom, settle } from './test/index.js'; afterEach(resetDom); @@ -244,17 +244,21 @@ describe('registry', () => { } registerComponent(Named); - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = captureDiagnostics(); registerComponent(Extended); const el = document.createElement('div'); el.setAttribute('data-component', 'MergedName'); document.body.append(el); await settle(); - expect(warn).toHaveBeenCalledWith( - `[js-toolkit:${DIAGNOSTICS.registry.conflict}] "MergedName" is already registered; the incoming declaration was ignored.`, - ); + expect(log.entries).toMatchObject([ + { + severity: 'warning', + code: DIAGNOSTICS.registry.conflict, + message: '"MergedName" is already registered; the incoming declaration was ignored.', + }, + ]); expect(getInstance(el, 'MergedName')).toBeInstanceOf(Named); - warn.mockRestore(); + log.stop(); }); }); diff --git a/packages/v4/src/responsive-components.spec.ts b/packages/v4/src/responsive-components.spec.ts index 15a0fdbbe..6e5efe02d 100644 --- a/packages/v4/src/responsive-components.spec.ts +++ b/packages/v4/src/responsive-components.spec.ts @@ -1,11 +1,12 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { Base, type BaseConfig } from './Base.js'; +import { DIAGNOSTICS } from './diagnostic-contract.js'; import { whenDOMSettled } from './dom-mutations.js'; import { getInstances } from './instances.js'; import { INSTANCES } from './protocol-symbols.js'; import { registerComponent, registerManifest } from './registry.js'; import { BREAKPOINTS, setBreakpoints } from './services/breakpoint.js'; -import { resetDom, settle } from './test/index.js'; +import { captureDiagnostics, resetDom, settle } from './test/index.js'; let counter = 0; @@ -347,7 +348,7 @@ describe('responsive component declarations', () => { it('discovers declarations from a custom setBreakpoints replacement and settles their mount', async () => { const feature = defineTracked('CustomBreakpoint'); register(feature); - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = captureDiagnostics(); const el = render({ 'data-component:desktop': feature.name }); await whenDOMSettled(); expect(instance(el, feature.name)).toBeUndefined(); @@ -361,14 +362,14 @@ describe('responsive component declarations', () => { await whenDOMSettled(); expect(mounted?.$isMounted).toBe(false); expect(instance(el, feature.name)).toBeUndefined(); - warn.mockRestore(); + log.stop(); }); it('ignores and warns once for a suffix naming no configured breakpoint', async () => { const base = defineTracked('WarningBase'); const invalid = defineTracked('Invalid'); register(base, invalid); - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = captureDiagnostics(); const el = render({ 'data-component': base.name, 'data-component:xxs:xs:s': invalid.name, @@ -377,15 +378,13 @@ describe('responsive component declarations', () => { expect(instance(el, base.name)?.$isMounted).toBe(true); expect(instance(el, invalid.name)).toBeUndefined(); - expect(warn).toHaveBeenCalledTimes(1); - expect(warn).toHaveBeenCalledWith( - expect.stringContaining('`data-component:xxs:xs:s` names no breakpoint'), - ); + expect(log.codes).toEqual([DIAGNOSTICS.responsive.unknownBreakpoint]); + expect(log.entries[0].message).toContain('`data-component:xxs:xs:s` names no breakpoint'); el.setAttribute('data-component', `${base.name} ${base.name}`); await whenDOMSettled(); - expect(warn).toHaveBeenCalledTimes(1); - warn.mockRestore(); + expect(log.codes).toHaveLength(1); + log.stop(); }); it('opens no breakpoint listener for pages with plain declarations only', async () => { diff --git a/packages/v4/src/responsive-options.spec.ts b/packages/v4/src/responsive-options.spec.ts index a33e0b753..ded20128d 100644 --- a/packages/v4/src/responsive-options.spec.ts +++ b/packages/v4/src/responsive-options.spec.ts @@ -1,10 +1,11 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { Base, type OptionChange } from './Base.js'; +import { DIAGNOSTICS } from './diagnostic-contract.js'; import { EVENTS } from './events.js'; import { registerComponent, registerManifest } from './registry.js'; import { BREAKPOINTS, setBreakpoints } from './services/breakpoint.js'; import { getInstance } from './test-utils.js'; -import { resetDom, settle } from './test/index.js'; +import { captureDiagnostics, resetDom, settle } from './test/index.js'; /** Select test breakpoints without changing the viewport. */ function atSmall(): void { @@ -450,7 +451,7 @@ describe('responsive options', () => { }); it('reports a suffix that names no breakpoint, which is what v3 markup is', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = captureDiagnostics(); atSmall(); render( // A combined suffix is one unknown breakpoint name. @@ -458,10 +459,9 @@ describe('responsive options', () => { ); await settle(); - expect(warn).toHaveBeenCalledWith( - expect.stringContaining('`data-option-label:small:large` names no breakpoint'), - ); - warn.mockRestore(); + expect(log.codes).toEqual([DIAGNOSTICS.responsive.unknownBreakpoint]); + expect(log.entries[0].message).toContain('`data-option-label:small:large` names no breakpoint'); + log.stop(); }); }); From 06deb2de25aa70562f08adf65f17942e30db7889 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Mon, 24 Aug 2026 16:24:20 +0200 Subject: [PATCH 4/6] test(v4): record events with the shipped recorder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six spec files kept their own listener-attaching recorder, in two shapes: one that pushed the type alone and one that pushed `{ type, detail }`. `recordEvents()` returns the richer shape, so the four files that only want the names map down at the call site, and `Fetch` and `Draggable` — which already asserted on payloads — read it directly. `Timer.spec.ts` keeps a two-line `record()` over the shipped helper, because its eight call sites each name a different set of types and the mapping reads better once than eight times. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NnepYhqjPMcFCSb43PoHRM --- .../v4/migration/Draggable/Draggable.spec.ts | 36 ++++++---------- packages/v4/migration/Fetch/Fetch.spec.ts | 21 +++------ .../Fetch/FetchShopifyPartial.spec.ts | 16 ++----- packages/v4/migration/InView/InView.spec.ts | 43 +++++++------------ .../v4/migration/Slider/SliderDrag.spec.ts | 33 ++++++-------- packages/v4/migration/Timer/Timer.spec.ts | 36 ++++++++-------- 6 files changed, 69 insertions(+), 116 deletions(-) diff --git a/packages/v4/migration/Draggable/Draggable.spec.ts b/packages/v4/migration/Draggable/Draggable.spec.ts index af9e2a2e0..a7c328fad 100644 --- a/packages/v4/migration/Draggable/Draggable.spec.ts +++ b/packages/v4/migration/Draggable/Draggable.spec.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, it } from 'vitest'; import { registerComponents } from '../../src/index.js'; import { getInstance } from '../../src/test-utils.js'; -import { resetDom, settle, waitFor } from '../../src/test/index.js'; -import { Draggable, type DraggablePosition } from './Draggable.js'; +import { recordEvents, resetDom, settle, waitFor } from '../../src/test/index.js'; +import { Draggable } from './Draggable.js'; registerComponents(Draggable); @@ -71,24 +71,16 @@ async function quiet(count = 12): Promise { } } -/** Collect the drag lifecycle events the component emits. */ -function record(root: EventTarget): Array<{ type: string; detail: DraggablePosition }> { - const events: Array<{ type: string; detail: DraggablePosition }> = []; - for (const type of [ - 'drag-start', - 'drag-drag', - 'drag-drop', - 'drag-inertia', - 'drag-stop', - 'drag-fit', - 'drag-render', - ]) { - root.addEventListener(type, (event) => { - events.push({ type, detail: { ...(event as CustomEvent).detail } }); - }); - } - return events; -} +/** The drag lifecycle the component emits, in no particular order. */ +const DRAG_EVENTS = [ + 'drag-start', + 'drag-drag', + 'drag-drop', + 'drag-inertia', + 'drag-stop', + 'drag-fit', + 'drag-render', +]; describe('Draggable — geometry', () => { it('reads a single margin value into all four sides', async () => { @@ -166,7 +158,7 @@ describe('Draggable — geometry', () => { describe('Draggable — the drag', () => { it('emits the drag lifecycle, never the idle mode', async () => { const { el, target } = await render(); - const events = record(el); + const { events } = recordEvents(el, ...DRAG_EVENTS); grab(target, 10, 10); move(60, 40); @@ -277,7 +269,7 @@ describe('Draggable — the bounds', () => { */ it('animates back inside the bounds on drop with `fitBounds`', async () => { const { el, target, instance } = await render('data-option-fit-bounds'); - const events = record(el); + const { events } = recordEvents(el, ...DRAG_EVENTS); grab(target, 0, 0); move(900, 0); diff --git a/packages/v4/migration/Fetch/Fetch.spec.ts b/packages/v4/migration/Fetch/Fetch.spec.ts index 2cb5a55f1..8fc34cb48 100644 --- a/packages/v4/migration/Fetch/Fetch.spec.ts +++ b/packages/v4/migration/Fetch/Fetch.spec.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { registerComponents } from '../../src/index.js'; import { getInstance } from '../../src/test-utils.js'; -import { mount, resetDom, settle } from '../../src/test/index.js'; +import { mount, recordEvents, resetDom, settle } from '../../src/test/index.js'; import { Fetch, FETCH_EVENTS, type FetchEmits } from './Fetch.js'; import { FetchShopifySection } from './FetchShopifySection.js'; @@ -60,15 +60,6 @@ function stubClient( } /** Collect every `Fetch` event dispatched under `root`, in order. */ -function recordEvents(root: EventTarget): Array<{ type: string; detail: unknown }> { - const events: Array<{ type: string; detail: unknown }> = []; - for (const type of Object.values(FETCH_EVENTS)) { - root.addEventListener(type, (event) => { - events.push({ type, detail: (event as CustomEvent).detail }); - }); - } - return events; -} /** Take over `document.startViewTransition` and count the calls. */ function stubViewTransition(): { spy: ReturnType; restore: () => void } { @@ -374,7 +365,7 @@ describe('Fetch — the request', () => { const { root, instance } = await mountFetch( ``, ); - const events = recordEvents(root); + const { events } = recordEvents(root, ...Object.values(FETCH_EVENTS)); await instance.fetch(); await settle(); @@ -429,7 +420,7 @@ describe('Fetch — the request', () => { }), ); const { root, instance } = await mountFetch(``); - const events = recordEvents(root); + const { events } = recordEvents(root, ...Object.values(FETCH_EVENTS)); void instance.fetch(); await settle(); @@ -443,7 +434,7 @@ describe('Fetch — the request', () => { it('emits `fetch-abort` when `abort()` is called', async () => { stubClient(async () => new Promise(() => {})); const { root, instance } = await mountFetch(``); - const events = recordEvents(root); + const { events } = recordEvents(root, ...Object.values(FETCH_EVENTS)); void instance.fetch(); instance.abort('because'); @@ -470,7 +461,7 @@ describe('Fetch — the request', () => { it('emits `fetch-error` when the response is not ok', async () => { stubClient(async () => new Response('nope', { status: 500 })); const { root, instance } = await mountFetch(``); - const events = recordEvents(root); + const { events } = recordEvents(root, ...Object.values(FETCH_EVENTS)); await instance.fetch(); @@ -483,7 +474,7 @@ describe('Fetch — the request', () => { throw new Error('network down'); }); const { root, instance } = await mountFetch(``); - const events = recordEvents(root); + const { events } = recordEvents(root, ...Object.values(FETCH_EVENTS)); await instance.fetch(); diff --git a/packages/v4/migration/Fetch/FetchShopifyPartial.spec.ts b/packages/v4/migration/Fetch/FetchShopifyPartial.spec.ts index cb69446a9..af72221d6 100644 --- a/packages/v4/migration/Fetch/FetchShopifyPartial.spec.ts +++ b/packages/v4/migration/Fetch/FetchShopifyPartial.spec.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { registerComponents } from '../../src/index.js'; import { getInstance } from '../../src/test-utils.js'; -import { resetDom, settle } from '../../src/test/index.js'; +import { recordEvents, resetDom, settle } from '../../src/test/index.js'; import { FETCH_EVENTS } from './Fetch.js'; import { FetchShopifyPartial } from './FetchShopifyPartial.js'; @@ -52,23 +52,13 @@ function stubPartials(api: { FetchShopifyPartial.loadPartialsModule = async () => ({ partials: api }); } -function recordEvents(root: EventTarget): Array<{ type: string; detail: unknown }> { - const events: Array<{ type: string; detail: unknown }> = []; - for (const type of Object.values(FETCH_EVENTS)) { - root.addEventListener(type, (event) => { - events.push({ type, detail: (event as CustomEvent).detail }); - }); - } - return events; -} - describe('FetchShopifyPartial', () => { it('falls back to the base Fetch behaviour when no partials are configured', async () => { const client = stubClient(); const { root, instance } = await mount( `
old
`, ); - const events = recordEvents(root); + const { events } = recordEvents(root, ...Object.values(FETCH_EVENTS)); await instance.fetch(); await settle(); @@ -85,7 +75,7 @@ describe('FetchShopifyPartial', () => { const { root, instance } = await mount( ``, ); - const events = recordEvents(root); + const { events } = recordEvents(root, ...Object.values(FETCH_EVENTS)); await instance.fetch(); await settle(); diff --git a/packages/v4/migration/InView/InView.spec.ts b/packages/v4/migration/InView/InView.spec.ts index a97380622..92c99af5c 100644 --- a/packages/v4/migration/InView/InView.spec.ts +++ b/packages/v4/migration/InView/InView.spec.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { Base, registerComponents, type BaseConfig } from '../../src/index.js'; import { INSTANCES } from '../../src/protocol-symbols.js'; -import { resetDom, settle, waitFor } from '../../src/test/index.js'; +import { recordEvents, resetDom, settle, waitFor } from '../../src/test/index.js'; import { InView } from './InView.js'; import { InViewOnce } from './InViewOnce.js'; @@ -40,25 +40,14 @@ function render(name: string, style: string, attributes: Record return el; } -/** Record events on `document` because the component may not exist before entry. */ -function record(): { events: string[]; stop: () => void } { - const events: string[] = []; - const listener = (event: Event) => events.push(event.type); - document.addEventListener('in-view', listener); - document.addEventListener('out-of-view', listener); - return { - events, - stop() { - document.removeEventListener('in-view', listener); - document.removeEventListener('out-of-view', listener); - }, - }; -} +// Recorded on `document`, because the component may not exist before entry. +let log: ReturnType; -let log: ReturnType; +/** Only the order of the names is asserted here, so the payloads drop out. */ +const types = () => log.events.map(({ type }) => type); beforeEach(() => { - log = record(); + log = recordEvents(document, 'in-view', 'out-of-view'); }); afterEach(() => { @@ -69,12 +58,12 @@ describe('InView', () => { it('emits `in-view` when the element enters the viewport', async () => { const el = render('InView', OFFSCREEN); await quiet(); - expect(log.events).toEqual([]); + expect(types()).toEqual([]); el.setAttribute('style', ONSCREEN); await waitFor(() => log.events.length > 0); - expect(log.events).toEqual(['in-view']); + expect(types()).toEqual(['in-view']); }); it('emits `out-of-view` when the element leaves the viewport', async () => { @@ -84,7 +73,7 @@ describe('InView', () => { el.setAttribute('style', OFFSCREEN); await waitFor(() => log.events.length > 1); - expect(log.events).toEqual(['in-view', 'out-of-view']); + expect(types()).toEqual(['in-view', 'out-of-view']); }); it('re-emits `in-view` on each re-entry, from the same instance', async () => { @@ -97,7 +86,7 @@ describe('InView', () => { el.setAttribute('style', ONSCREEN); await waitFor(() => log.events.length > 2); - expect(log.events).toEqual(['in-view', 'out-of-view', 'in-view']); + expect(types()).toEqual(['in-view', 'out-of-view', 'in-view']); expect(el[INSTANCES]?.get('InView')).toBe(instance); }); @@ -117,7 +106,7 @@ describe('InViewOnce', () => { el.setAttribute('style', ONSCREEN); await waitFor(() => log.events.length > 0); - expect(log.events).toEqual(['in-view']); + expect(types()).toEqual(['in-view']); }); it('never emits `out-of-view`, and does not re-emit on a later entry', async () => { @@ -129,7 +118,7 @@ describe('InViewOnce', () => { el.setAttribute('style', ONSCREEN); await quiet(); - expect(log.events).toEqual(['in-view']); + expect(types()).toEqual(['in-view']); }); it('stays mounted after leaving the viewport, where v3 terminated', async () => { @@ -149,7 +138,7 @@ describe('InViewOnce', () => { el.remove(); await quiet(); - expect(log.events).toEqual(['in-view']); + expect(types()).toEqual(['in-view']); }); }); @@ -158,12 +147,12 @@ describe('mount strategy gaps found by the port', () => { it('accepts a rootMargin in the per-element strategy', async () => { const el = render('InView', OFFSCREEN, { 'data-mount': 'in-view:400px' }); await quiet(); - expect(log.events).toEqual([]); + expect(types()).toEqual([]); el.setAttribute('style', ONSCREEN); await waitFor(() => log.events.length > 0); - expect(log.events).toEqual(['in-view']); + expect(types()).toEqual(['in-view']); expect(el[INSTANCES]?.get('InView')?.$isMounted).toBe(true); }); @@ -191,7 +180,7 @@ describe('the strategy is per element, which the decorator never was', () => { await waitFor(() => el[INSTANCES]?.get('InView')?.$isMounted); expect(el[INSTANCES]?.get('InView')?.$isMounted).toBe(true); - expect(log.events).toEqual(['in-view']); + expect(types()).toEqual(['in-view']); }); it('lets `data-mount="in-view"` give the strategy to a component that never asked', async () => { diff --git a/packages/v4/migration/Slider/SliderDrag.spec.ts b/packages/v4/migration/Slider/SliderDrag.spec.ts index 6b3159988..4f3be7628 100644 --- a/packages/v4/migration/Slider/SliderDrag.spec.ts +++ b/packages/v4/migration/Slider/SliderDrag.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { registerComponents } from '../../src/index.js'; import { getInstance } from '../../src/test-utils.js'; -import { frames, resetDom, settle } from '../../src/test/index.js'; +import { frames, recordEvents, resetDom, settle } from '../../src/test/index.js'; import { Slider } from './Slider.js'; import { SliderDrag } from './SliderDrag.js'; import type { DragMode } from '../../src/index.js'; @@ -67,41 +67,34 @@ function release(): void { const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); -/** Collect the events SliderDrag re-emits, which bubble past the Slider. */ -function record(): { modes: string[]; stop: () => void } { - const modes: string[] = []; - const names: DragMode[] = ['idle', 'start', 'drag', 'drop', 'inertia', 'stop']; - const listeners = names.map((name) => { - const listener = () => modes.push(name); - document.addEventListener(name, listener); - return () => document.removeEventListener(name, listener); - }); - return { modes, stop: () => listeners.forEach((off) => off()) }; -} +/** Every drag mode is an event name; the events bubble past the Slider. */ +const DRAG_MODES: DragMode[] = ['idle', 'start', 'drag', 'drop', 'inertia', 'stop']; describe('SliderDrag', () => { it('re-emits the drag lifecycle, and never the idle mode', async () => { const root = render(); const { track } = await ready(root); - const { modes, stop } = record(); + const log = recordEvents(document, ...DRAG_MODES); + // Only the order of the modes is asserted, so the payloads drop out. + const modes = () => log.events.map(({ type }) => type); grab(track, 50); - expect(modes).toEqual(['start']); + expect(modes()).toEqual(['start']); // One pixel, so the coast this releases is short enough to watch end. move(49); - expect(modes.at(-1)).toBe('drag'); + expect(modes().at(-1)).toBe('drag'); release(); - expect(modes).toContain('drop'); + expect(modes()).toContain('drop'); // The coast runs on the scheduler tick, so real frames finish it. await frames(40); - stop(); + log.stop(); - expect(modes.at(-1)).toBe('stop'); - expect(modes).toContain('inertia'); - expect(modes).not.toContain('idle'); + expect(modes().at(-1)).toBe('stop'); + expect(modes()).toContain('inertia'); + expect(modes()).not.toContain('idle'); }); it('moves every slide with the pointer', async () => { diff --git a/packages/v4/migration/Timer/Timer.spec.ts b/packages/v4/migration/Timer/Timer.spec.ts index 904696bc6..63faa1b91 100644 --- a/packages/v4/migration/Timer/Timer.spec.ts +++ b/packages/v4/migration/Timer/Timer.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { registerComponents } from '../../src/index.js'; import { getInstance } from '../../src/test-utils.js'; -import { resetDom, settle } from '../../src/test/index.js'; +import { recordEvents, resetDom, settle } from '../../src/test/index.js'; import { Timer } from './Timer.js'; registerComponents(Timer); @@ -31,12 +31,10 @@ async function render(attributes = ''): Promise<{ el: HTMLElement; instance: Tim return { el, instance: getInstance(el, 'Timer') }; } -function record(el: HTMLElement, ...types: string[]): string[] { - const events: string[] = []; - for (const type of types) { - el.addEventListener(type, () => events.push(type)); - } - return events; +/** Only the order of the names is asserted here, so the payloads drop out. */ +function record(el: HTMLElement, ...types: string[]): () => string[] { + const log = recordEvents(el, ...types); + return () => log.events.map(({ type }) => type); } describe('Timer', () => { @@ -47,7 +45,7 @@ describe('Timer', () => { await wait(60); - expect(events).toEqual(['timer-start', 'timer-end']); + expect(events()).toEqual(['timer-start', 'timer-end']); }); it('does not start on mount when autostart is disabled', async () => { @@ -55,11 +53,11 @@ describe('Timer', () => { const events = record(el, 'timer-start', 'timer-end'); await wait(60); - expect(events).toEqual([]); + expect(events()).toEqual([]); instance.start(); await wait(60); - expect(events).toEqual(['timer-start', 'timer-end']); + expect(events()).toEqual(['timer-start', 'timer-end']); }); it('pauses and resumes, preserving the remaining time', async () => { @@ -72,15 +70,15 @@ describe('Timer', () => { const remainingAtPause = instance.remaining; await wait(50); - expect(events).toEqual(['timer-pause']); + expect(events()).toEqual(['timer-pause']); expect(instance.timerId).toBeNull(); instance.resume(); - expect(events).toEqual(['timer-pause', 'timer-resume']); + expect(events()).toEqual(['timer-pause', 'timer-resume']); expect(instance.remaining).toBeCloseTo(remainingAtPause, 0); await wait(120); - expect(events).toEqual(['timer-pause', 'timer-resume', 'timer-end']); + expect(events()).toEqual(['timer-pause', 'timer-resume', 'timer-end']); }); it('is a no-op to pause an idle timer or resume a running one', async () => { @@ -88,11 +86,11 @@ describe('Timer', () => { const events = record(el, 'timer-pause', 'timer-resume'); instance.resume(); - expect(events).toEqual([]); + expect(events()).toEqual([]); instance.stop(); instance.pause(); - expect(events).toEqual([]); + expect(events()).toEqual([]); }); it('stops without completing', async () => { @@ -102,7 +100,7 @@ describe('Timer', () => { instance.stop(); await wait(60); - expect(events).toEqual(['timer-stop']); + expect(events()).toEqual(['timer-stop']); expect(instance.remaining).toBe(0); }); @@ -114,7 +112,7 @@ describe('Timer', () => { await wait(30); instance.restart(); - expect(events).toEqual(['timer-start', 'timer-start']); + expect(events()).toEqual(['timer-start', 'timer-start']); expect(instance.remaining).toBeCloseTo(100, 0); }); @@ -128,7 +126,7 @@ describe('Timer', () => { await wait(150); - expect(events).toEqual(['timer-start', 'timer-end', 'timer-tick', 'timer-start']); + expect(events()).toEqual(['timer-start', 'timer-end', 'timer-tick', 'timer-start']); }); it('cancels the pending countdown when unmounted', async () => { @@ -138,6 +136,6 @@ describe('Timer', () => { instance.$unmount(); await wait(60); - expect(events).toEqual([]); + expect(events()).toEqual([]); }); }); From 5d8b04870ad89c84124d7241bcc94b2db7d28575 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Mon, 24 Aug 2026 16:27:30 +0200 Subject: [PATCH 5/6] refactor(v4): move the todo fixtures out of test-utils and strip what is left MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `test-utils.ts` was three things at once: a wait helper, an instance reader, and a registered component tree. The waits now come from `src/test/index.ts`, so the tree moves to `src/todo.fixtures.ts` — where its `registerComponent(TodoList)` side effect is visible in the filename — and `test-utils.ts` keeps only what the shipped module deliberately does not export. `getInstance()` stays because it reads the raw instances map with no `$isMounted` filter, which is what lets a spec look at an instance before it mounts or after it unmounts. `countRequestedFrames()` stays because it replaces a global. No build or packaging change: `scripts/build.js` already excludes `!test-utils.ts` and `**/*.fixtures.ts`, and `check-package.js` already guards both. Verified — `npm run build` emits neither, and `npm run check:package` passes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NnepYhqjPMcFCSb43PoHRM --- packages/v4/src/Base.spec.ts | 3 +- packages/v4/src/config-extension.spec.ts | 3 +- packages/v4/src/context.spec.ts | 3 +- packages/v4/src/decorators.spec.ts | 3 +- packages/v4/src/instances.spec.ts | 3 +- packages/v4/src/registry.spec.ts | 3 +- packages/v4/src/test-utils.ts | 115 +++-------------------- packages/v4/src/todo.fixtures.ts | 95 +++++++++++++++++++ 8 files changed, 122 insertions(+), 106 deletions(-) create mode 100644 packages/v4/src/todo.fixtures.ts diff --git a/packages/v4/src/Base.spec.ts b/packages/v4/src/Base.spec.ts index 616c4736b..3100e5c90 100644 --- a/packages/v4/src/Base.spec.ts +++ b/packages/v4/src/Base.spec.ts @@ -13,7 +13,8 @@ import { DIAGNOSTICS, type ToolkitDiagnosticDetail } from './diagnostic-contract import { EVENTS } from './events.js'; import { INSTANCES } from './protocol-symbols.js'; import { registerComponent } from './registry.js'; -import { getInstance, renderTodoList, TodoCount, TodoItem, TodoList } from './test-utils.js'; +import { getInstance } from './test-utils.js'; +import { renderTodoList, TodoCount, TodoItem, TodoList } from './todo.fixtures.js'; import { captureDiagnostics, resetDom, settle } from './test/index.js'; afterEach(resetDom); diff --git a/packages/v4/src/config-extension.spec.ts b/packages/v4/src/config-extension.spec.ts index ce3eaa0aa..ef490947c 100644 --- a/packages/v4/src/config-extension.spec.ts +++ b/packages/v4/src/config-extension.spec.ts @@ -15,7 +15,8 @@ import { Base, type BaseConfig, type BaseProps } from './Base.js'; import { component } from './decorators.js'; import { DIAGNOSTICS } from './diagnostic-contract.js'; import { registerComponent } from './registry.js'; -import { getInstance, TodoItem } from './test-utils.js'; +import { getInstance } from './test-utils.js'; +import { TodoItem } from './todo.fixtures.js'; import { captureDiagnostics, resetDom, settle } from './test/index.js'; afterEach(resetDom); diff --git a/packages/v4/src/context.spec.ts b/packages/v4/src/context.spec.ts index bafbc4d74..6c5044b90 100644 --- a/packages/v4/src/context.spec.ts +++ b/packages/v4/src/context.spec.ts @@ -12,7 +12,8 @@ import { import { DIAGNOSTICS, type ToolkitDiagnosticDetail } from './diagnostic-contract.js'; import { EVENTS } from './events.js'; import { registerComponent } from './registry.js'; -import { getInstance, renderTodoList } from './test-utils.js'; +import { getInstance } from './test-utils.js'; +import { renderTodoList } from './todo.fixtures.js'; import { resetDom, settle } from './test/index.js'; afterEach(resetDom); diff --git a/packages/v4/src/decorators.spec.ts b/packages/v4/src/decorators.spec.ts index 02814e68c..c826e1e67 100644 --- a/packages/v4/src/decorators.spec.ts +++ b/packages/v4/src/decorators.spec.ts @@ -16,7 +16,8 @@ import { createContext, signal, type Signal } from './context.js'; import { children, component, inject, on, provide, read, write } from './decorators.js'; import { registerComponent, registerComponents } from './registry.js'; import { defaultScheduler } from './scheduler.js'; -import { getInstance, TodoItem } from './test-utils.js'; +import { getInstance } from './test-utils.js'; +import { TodoItem } from './todo.fixtures.js'; import { captureDiagnostics, resetDom, settle } from './test/index.js'; const DecoContext = createContext>('deco-context'); diff --git a/packages/v4/src/instances.spec.ts b/packages/v4/src/instances.spec.ts index 800bf0897..042fc64d2 100644 --- a/packages/v4/src/instances.spec.ts +++ b/packages/v4/src/instances.spec.ts @@ -1,7 +1,8 @@ import { afterEach, describe, expect, it } from 'vitest'; import { getInstances } from './instances.js'; import { INSTANCES } from './protocol-symbols.js'; -import { getInstance, renderTodoList, type TodoItem } from './test-utils.js'; +import { getInstance } from './test-utils.js'; +import { renderTodoList, type TodoItem } from './todo.fixtures.js'; import { resetDom, settle } from './test/index.js'; afterEach(resetDom); diff --git a/packages/v4/src/registry.spec.ts b/packages/v4/src/registry.spec.ts index 9071ddf59..523abf425 100644 --- a/packages/v4/src/registry.spec.ts +++ b/packages/v4/src/registry.spec.ts @@ -4,7 +4,8 @@ import { DIAGNOSTICS, type ToolkitDiagnosticDetail } from './diagnostic-contract import { EVENTS } from './events.js'; import { INSTANCES } from './protocol-symbols.js'; import { registerComponent } from './registry.js'; -import { getInstance, renderTodoList, TodoItem, TodoList } from './test-utils.js'; +import { getInstance } from './test-utils.js'; +import { renderTodoList, TodoItem, TodoList } from './todo.fixtures.js'; import { captureDiagnostics, resetDom, settle } from './test/index.js'; afterEach(resetDom); diff --git a/packages/v4/src/test-utils.ts b/packages/v4/src/test-utils.ts index 1cc169301..412022e54 100644 --- a/packages/v4/src/test-utils.ts +++ b/packages/v4/src/test-utils.ts @@ -1,22 +1,19 @@ -import { Base } from './Base.js'; -import { createContext, signal, type Signal } from './context.js'; -import { INSTANCES } from './protocol-symbols.js'; -import { registerComponent } from './registry.js'; -import { nextFrame, defaultScheduler } from './scheduler.js'; -import type { DelegatedEvent } from './Base.js'; +/** + * The two helpers a spec needs that `@studiometa/js-toolkit-v4/test` does not + * ship, because neither belongs in a consumer's hands. + * + * `getInstance()` reads the raw instances map with no `$isMounted` filter, + * which is what lets a spec inspect an instance before it mounts or after it + * unmounts — precisely the window the public `getInstances()` hides. + * `countRequestedFrames()` replaces a global. + * + * Everything else moved: `settle`, `frames`, `mount`, `waitFor`, `resetDom` + * and the rest are in `src/test/index.ts`, and the todo component tree is in + * `src/todo.fixtures.ts`. + */ -export async function settle(): Promise { - for (let i = 0; i < 5; i += 1) { - await new Promise((resolve) => setTimeout(resolve, 10)); - await defaultScheduler.whenIdle(); - } -} - -export async function frames(count = 3): Promise { - for (let i = 0; i < count; i += 1) { - await nextFrame(); - } -} +import { INSTANCES } from './protocol-symbols.js'; +import type { Base } from './Base.js'; /** Count requested frames and always restore `requestAnimationFrame`. */ export async function countRequestedFrames(during: () => Promise | void): Promise { @@ -37,85 +34,3 @@ export async function countRequestedFrames(during: () => Promise | void): export function getInstance(el: Element | null, name: string): T { return el?.[INSTANCES]?.get(name) as T; } - -export const CountContext = createContext>('todo-count'); - -export class TodoItem extends Base { - static config = { name: 'TodoItem', refs: ['remove'] }; - - onClick(event: Event): void { - if (event.target === this.$refs.remove) { - this.$emit('remove'); - } - } -} - -export class TodoCount extends Base { - static config = { name: 'TodoCount' }; - - cleanupCalls = 0; - - async mounted() { - const count = await this.$inject(CountContext); - const unsubscribe = count.subscribe( - (value) => { - this.$el.textContent = String(value); - }, - { immediate: true }, - ); - return () => { - unsubscribe(); - this.cleanupCalls += 1; - }; - } -} - -export class TodoList extends Base { - static config = { - name: 'TodoList', - refs: ['list'], - components: { TodoItem, TodoCount }, - }; - - count = this.$provide(CountContext, signal(0)); - - items = this.$watchChildren('TodoItem', { - added: () => this.sync(), - removed: () => this.sync(), - }); - - removedEvents: Array> = []; - - sync(): void { - this.count.value = this.items?.size ?? 0; - } - - mounted(): void { - this.sync(); - } - - onTodoItemRemove(payload: DelegatedEvent): void { - this.removedEvents.push(payload); - payload.target.$el.remove(); - } -} - -registerComponent(TodoList); - -export function renderTodoList({ items = ['one', 'two'] }: { items?: string[] } = {}): HTMLElement { - const root = document.createElement('div'); - root.setAttribute('data-component', 'TodoList'); - root.innerHTML = ` -
    - ${items.map((item) => `
  • ${item}
  • `).join('')} -
- - `; - document.body.append(root); - return root; -} - -export async function resetDom(): Promise { - document.body.innerHTML = ''; - await settle(); -} diff --git a/packages/v4/src/todo.fixtures.ts b/packages/v4/src/todo.fixtures.ts new file mode 100644 index 000000000..d738d0770 --- /dev/null +++ b/packages/v4/src/todo.fixtures.ts @@ -0,0 +1,95 @@ +/** + * The todo list the core specs are written against. + * + * One fixture exercises the four things a component has to get right together + * — refs, a provided context, `$watchChildren` and a delegated child event — + * so a spec about any one of them can be written against a shape that is + * already familiar rather than against a new one per file. + * + * It lives outside `test-utils.ts` because the two are different kinds of + * thing: `test-utils.ts` holds helpers that read the framework, while this + * module *is* a component tree, registered on import. Both are excluded from + * the build — see `scripts/build.js` and `scripts/check-package.js`. + */ + +import { Base } from './Base.js'; +import { createContext, signal, type Signal } from './context.js'; +import { registerComponent } from './registry.js'; +import type { DelegatedEvent } from './Base.js'; + +export const CountContext = createContext>('todo-count'); + +export class TodoItem extends Base { + static config = { name: 'TodoItem', refs: ['remove'] }; + + onClick(event: Event): void { + if (event.target === this.$refs.remove) { + this.$emit('remove'); + } + } +} + +export class TodoCount extends Base { + static config = { name: 'TodoCount' }; + + cleanupCalls = 0; + + async mounted() { + const count = await this.$inject(CountContext); + const unsubscribe = count.subscribe( + (value) => { + this.$el.textContent = String(value); + }, + { immediate: true }, + ); + return () => { + unsubscribe(); + this.cleanupCalls += 1; + }; + } +} + +export class TodoList extends Base { + static config = { + name: 'TodoList', + refs: ['list'], + components: { TodoItem, TodoCount }, + }; + + count = this.$provide(CountContext, signal(0)); + + items = this.$watchChildren('TodoItem', { + added: () => this.sync(), + removed: () => this.sync(), + }); + + removedEvents: Array> = []; + + sync(): void { + this.count.value = this.items?.size ?? 0; + } + + mounted(): void { + this.sync(); + } + + onTodoItemRemove(payload: DelegatedEvent): void { + this.removedEvents.push(payload); + payload.target.$el.remove(); + } +} + +registerComponent(TodoList); + +export function renderTodoList({ items = ['one', 'two'] }: { items?: string[] } = {}): HTMLElement { + const root = document.createElement('div'); + root.setAttribute('data-component', 'TodoList'); + root.innerHTML = ` +
    + ${items.map((item) => `
  • ${item}
  • `).join('')} +
+ + `; + document.body.append(root); + return root; +} From 50110102ad08988672f068b108aaf7fa14f32c91 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Mon, 24 Aug 2026 16:28:16 +0200 Subject: [PATCH 6/6] docs(v4): say which registry the Data group counters survive `resetRegistry()` is the obvious replacement for a per-test counter, and it is the wrong one here: the state that survives `resetDom()` in these three files is the page-wide `DataRegistry` on the root context, whose group records keep their values after the elements are gone. `resetRegistry()` clears the component registry and never touches it. Say so, so the next reader does not swap one for the other. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NnepYhqjPMcFCSb43PoHRM --- packages/v4/migration/Data/DataBind.spec.ts | 8 ++++++++ packages/v4/migration/Data/DataDerived.spec.ts | 8 ++++++++ packages/v4/migration/Data/DataScope.spec.ts | 9 ++++++++- 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/v4/migration/Data/DataBind.spec.ts b/packages/v4/migration/Data/DataBind.spec.ts index 51fcb20a8..e047d88e3 100644 --- a/packages/v4/migration/Data/DataBind.spec.ts +++ b/packages/v4/migration/Data/DataBind.spec.ts @@ -49,6 +49,14 @@ registerComponents( afterEach(resetDom); +/** + * Isolate tests from one another. The registry that survives `resetDom()` here + * is the page-wide `DataRegistry` — the one `resolveDataRegistry()` provides on + * the root context — and its group records keep their values and their latest + * payload after the elements are gone. `resetRegistry()` clears the *component* + * registry and does not touch it, so a unique group name per test is what keeps + * these apart. + */ let counter = 0; function uniqueGroup(name: string): string { counter += 1; diff --git a/packages/v4/migration/Data/DataDerived.spec.ts b/packages/v4/migration/Data/DataDerived.spec.ts index a58b60870..6f24cff7f 100644 --- a/packages/v4/migration/Data/DataDerived.spec.ts +++ b/packages/v4/migration/Data/DataDerived.spec.ts @@ -12,6 +12,14 @@ registerComponents(DataScope, DataBind, DataModel, DataComputed, DataEffect); afterEach(resetDom); +/** + * Isolate tests from one another. The registry that survives `resetDom()` here + * is the page-wide `DataRegistry` — the one `resolveDataRegistry()` provides on + * the root context — and its group records keep their values and their latest + * payload after the elements are gone. `resetRegistry()` clears the *component* + * registry and does not touch it, so a unique group name per test is what keeps + * these apart. + */ let counter = 0; function uniqueGroup(name: string): string { counter += 1; diff --git a/packages/v4/migration/Data/DataScope.spec.ts b/packages/v4/migration/Data/DataScope.spec.ts index 64edd1f6f..b86ed6ee3 100644 --- a/packages/v4/migration/Data/DataScope.spec.ts +++ b/packages/v4/migration/Data/DataScope.spec.ts @@ -13,7 +13,14 @@ registerComponents(DataScope, DataBind, DataModel, DataComputed, DataEffect); afterEach(resetDom); -/** Isolate tests because the page-wide registry survives `resetDom()`. */ +/** + * Isolate tests from one another. The registry that survives `resetDom()` here + * is the page-wide `DataRegistry` — the one `resolveDataRegistry()` provides on + * the root context — and its group records keep their values and their latest + * payload after the elements are gone. `resetRegistry()` clears the *component* + * registry and does not touch it, so a unique group name per test is what keeps + * these apart. + */ let counter = 0; function uniqueGroup(name: string): string { counter += 1;