diff --git a/packages/v4/migration/Accordion/Accordion.spec.ts b/packages/v4/migration/Accordion/Accordion.spec.ts index 1bb4bb6e..ab97d5e2 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 f275c693..fad610f5 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(`
`); return { root, target: root.querySelector('#one') as HTMLElement }; } @@ -55,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 () => { @@ -69,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); }); @@ -93,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/AnchorNavLink.spec.ts b/packages/v4/migration/AnchorNav/AnchorNavLink.spec.ts index 81d7770d..4c0c1b30 100644 --- a/packages/v4/migration/AnchorNav/AnchorNavLink.spec.ts +++ b/packages/v4/migration/AnchorNav/AnchorNavLink.spec.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } 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 { resetDom, settle } from '../../src/test/index.js'; import { AnchorNavLink } from './AnchorNavLink.js'; registerComponents(AnchorNavLink); diff --git a/packages/v4/migration/AnchorNav/AnchorNavTarget.spec.ts b/packages/v4/migration/AnchorNav/AnchorNavTarget.spec.ts index e7dc5a0c..f52a39b3 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-utils.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 b454565c..3fb280c9 100644 --- a/packages/v4/migration/Carousel/Carousel.spec.ts +++ b/packages/v4/migration/Carousel/Carousel.spec.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } 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 { resetDom, settle, waitFor } from '../../src/test/index.js'; import { Carousel } from './Carousel.js'; import { CarouselBtn } from './CarouselBtn.js'; import { CarouselDrag } from './CarouselDrag.js'; @@ -53,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(); } @@ -164,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']); }); @@ -173,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']); }); @@ -185,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]); }); @@ -202,7 +206,7 @@ describe('Carousel — the controls', () => { attributes: 'data-option-boundary="loop"', buttons: ``, }); - await settled(); + await quiet(); expect(el.querySelector('button')?.disabled).toBe(false); }); @@ -212,7 +216,7 @@ describe('Carousel — the controls', () => { count: 3, buttons: ``, }); - await settled(); + await waitFor(() => el.querySelector('button')); el.querySelector('button')?.click(); @@ -224,7 +228,7 @@ describe('Carousel — the controls', () => { count: 4, buttons: ``, }); - await settled(); + await waitFor(() => el.querySelector('button')); el.querySelector('button')?.click(); @@ -243,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); @@ -255,7 +259,7 @@ describe('Carousel — the controls', () => { 'afterbegin', `
${slides(3)}
`, ); - await settled(); + await waitFor(() => button.disabled); expect(button.disabled).toBe(true); }); @@ -265,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); @@ -281,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']); @@ -297,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); @@ -313,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]); }); @@ -346,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); @@ -364,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'); @@ -376,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); @@ -391,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); }); @@ -424,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; @@ -448,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); @@ -461,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/ClickOutside/ClickOutside.spec.ts b/packages/v4/migration/ClickOutside/ClickOutside.spec.ts index ec51bdd6..9c9d366d 100644 --- a/packages/v4/migration/ClickOutside/ClickOutside.spec.ts +++ b/packages/v4/migration/ClickOutside/ClickOutside.spec.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { Base, registerComponents, type BaseConfig, type DelegatedEvent } 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 { ClickOutside } from './ClickOutside.js'; /** Parent probe for delegated `click-outside` events. */ diff --git a/packages/v4/migration/Cursor/Cursor.spec.ts b/packages/v4/migration/Cursor/Cursor.spec.ts index 774c0204..c6d9aa12 100644 --- a/packages/v4/migration/Cursor/Cursor.spec.ts +++ b/packages/v4/migration/Cursor/Cursor.spec.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { registerComponents } from '../../src/index.js'; -import { countRequestedFrames, getInstance, resetDom, settle } from '../../src/test-utils.js'; +import { countRequestedFrames, getInstance } from '../../src/test-utils.js'; +import { mount, resetDom, settle, waitFor } from '../../src/test/index.js'; import { Cursor } from './Cursor.js'; registerComponents(Cursor); @@ -11,19 +12,11 @@ afterEach(async () => { await resetDom(); }); -async function render(html: string): Promise { - const root = document.createElement('div'); - root.innerHTML = html; - document.body.append(root); - await settle(); - return root; -} - async function mountCursor( attributes = '', inner = '', ): Promise<{ root: HTMLElement; instance: Cursor }> { - const root = await render( + const root = await mount( `
${inner}
`, ); return { root, instance: getInstance(root.firstElementChild as HTMLElement, 'Cursor') }; @@ -41,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(); } @@ -75,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); @@ -149,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); }); @@ -158,7 +151,7 @@ describe('Cursor', () => { await mountCursor(); const requested = await countRequestedFrames(async () => { - await ticked(4); + await quiet(4); }); expect(requested).toBe(0); @@ -181,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)'); }); @@ -189,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/Data/DataBind.spec.ts b/packages/v4/migration/Data/DataBind.spec.ts index ebcfc57d..e047d88e 100644 --- a/packages/v4/migration/Data/DataBind.spec.ts +++ b/packages/v4/migration/Data/DataBind.spec.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { EVENTS, registerComponents } from '../../src/index.js'; -import { getInstance, resetDom, settle } from '../../src/test-utils.js'; +import { getInstance } from '../../src/test-utils.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'; @@ -48,20 +49,20 @@ 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; return `${name}-${counter}`; } -async function render(html: string): Promise { - const root = document.createElement('div'); - root.innerHTML = html; - document.body.append(root); - await settle(); - return root; -} - function el(root: HTMLElement, selector: string): T { return root.querySelector(selector) as T; } @@ -72,7 +73,7 @@ function at(root: HTMLElement, selector: string, name: string): T { describe('DataBind — the element half', () => { it('binds textContent by default and a named property on demand', async () => { - const root = await render(` + const root = await mount(`
`); @@ -91,7 +92,7 @@ describe('DataBind — the element half', () => { }); it('reads and writes typed input properties', async () => { - const root = await render(` + const root = await mount(` @@ -124,7 +125,7 @@ describe('DataBind — the element half', () => { it('unions the checked values of a [] checkbox group', async () => { const group = `${uniqueGroup('checkbox')}[]`; - const root = await render(` + const root = await mount(` `); @@ -142,7 +143,7 @@ describe('DataBind — the element half', () => { it('selects one or several options of a select', async () => { const single = uniqueGroup('select'); const multi = `${uniqueGroup('select')}[]`; - const root = await render(` + const root = await mount(` @@ -164,7 +165,7 @@ describe('DataBind — the element half', () => { }); it('applies every virtual binding kind', async () => { - const root = await render(` + const root = await mount(` `); @@ -227,7 +228,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 +249,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 +283,7 @@ describe('DataBind — the element half', () => { }; document.addEventListener(EVENTS.diagnostic, listener); - const root = await render(` + const root = await mount(`
`); @@ -296,7 +297,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 +310,7 @@ describe('DataBind — the element half', () => { }); it('toggles, increments and cycles', async () => { - const root = await render(` + const root = await mount(`
2
one
@@ -343,8 +344,8 @@ 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 log = captureDiagnostics(); + const root = await mount(`
current
{ 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(); }); }); 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 +401,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 +418,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 +437,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 +462,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 +477,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 +488,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 +513,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 +58,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 +68,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 +80,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 +91,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 +143,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 +156,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 +169,7 @@ describe('Track — payload resolution', () => { }); it('fires every data-track:* declared on one element', async () => { - const root = await render( + const root = await mount( ``, @@ -199,8 +185,8 @@ 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 log = captureDiagnostics(); + const root = await mount( ``, ); @@ -211,8 +197,8 @@ 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 log = captureDiagnostics(); + const root = await mount(` @@ -226,21 +212,25 @@ 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 log = captureDiagnostics(); + const root = await mount( ``, ); 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(); }); }); describe('Track — the `mounted` pseudo-event', () => { it('dispatches once the batch has settled, with the resolved context', async () => { - await render(` + await mount(`
@@ -256,13 +246,13 @@ describe('Track — the `mounted` pseudo-event', () => { root.innerHTML = `
`; document.body.append(root); root.innerHTML = ''; - await observed(); + await quiet(); expect(pushes()).toHaveLength(0); }); it('applies timing modifiers to the mounted event', async () => { - await render( + await mount( `
`, ); await settle(); @@ -275,7 +265,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,97 +285,96 @@ 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(); + 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' }); }); it('dispatches on every entry without the `.once` modifier', async () => { - const root = await render( + const root = await mount( `
`, ); 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); }); 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; - 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); }); it('dispatches for an element taller than the viewport at the default threshold', async () => { - await render( + await mount( `
`, ); - await observed(); + await waitFor(() => pushes().length > 0); expect(pushes()).toHaveLength(1); }); /** 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( `
`, ); - await observed(); + await quiet(); expect(pushes()).toHaveLength(0); }); it('applies timing modifiers to the view event', async () => { - const root = await render( + const root = await mount( `
`, ); 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); }); it('releases the observer with the mount cycle', async () => { - const root = await render( + const root = await mount( `
`, ); 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); @@ -394,7 +383,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 +402,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 +417,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 +434,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 +448,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 +464,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 +475,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 +491,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,8 +537,8 @@ describe('the intersection service under load', () => { ).join(''); const built = await countObservers(async () => { - await render(markup); - await observed(); + await mount(markup); + await waitFor(() => pushes().length === CARDS); }); expect(pushes()).toHaveLength(CARDS); @@ -559,12 +548,12 @@ 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( `
`, ); - await observed(); + await waitFor(() => pushes().length === 2); }); expect( @@ -577,11 +566,11 @@ 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( `
`, ); - await observed(); + await waitFor(() => pushes().length === 2); }); expect(built).toBe(2); @@ -593,7 +582,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'); @@ -603,7 +592,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); @@ -619,17 +608,17 @@ describe('the intersection service under load', () => { data-track:view.once='{"event": "impression", "id": "${index}"}'>
`, ).join(''); - const root = await render(markup); - await observed(); + const root = await mount(markup); + await waitFor(() => pushes().length === CARDS); expect(pushes()).toHaveLength(CARDS); root.remove(); - await observed(); + await quiet(); window.dataLayer = []; const built = await countObservers(async () => { - await render(markup); - await observed(); + await mount(markup); + await waitFor(() => pushes().length === CARDS); }); expect(built).toBe(CARDS); expect(pushes()).toHaveLength(CARDS); @@ -641,7 +630,7 @@ describe('TrackShopify — the dispatch seam', () => { const publish = vi.fn(); window.Shopify = { analytics: { publish } }; - const root = await render(` + const root = await mount(`
@@ -661,25 +650,27 @@ 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 render( + const root = await mount( ``, ); 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 root = await render( + 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 39e56cb9..ea4d9cc6 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 { afterEach, beforeEach, describe, expect, it } 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 { captureDiagnostics, 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[] { @@ -49,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/migration/Transition/withTransition.spec.ts b/packages/v4/migration/Transition/withTransition.spec.ts index 89282511..c3acac2b 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 82a0e84a..3100e5c9 100644 --- a/packages/v4/src/Base.spec.ts +++ b/packages/v4/src/Base.spec.ts @@ -13,15 +13,9 @@ 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 } from './test-utils.js'; +import { renderTodoList, TodoCount, TodoItem, TodoList } from './todo.fixtures.js'; +import { captureDiagnostics, resetDom, settle } from './test/index.js'; afterEach(resetDom); @@ -113,7 +107,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)); @@ -121,10 +115,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(); }); }); @@ -335,7 +332,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 = { @@ -347,13 +344,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', () => { @@ -627,23 +624,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', () => { @@ -1772,6 +1769,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[]; @@ -1830,14 +1832,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', @@ -1847,20 +1847,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/attribute-namespaces.spec.ts b/packages/v4/src/attribute-namespaces.spec.ts index 580828b6..40aa214b 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 4049e42b..9c0f61bb 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 { 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'; @@ -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); }); @@ -361,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 () => { @@ -470,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(); }); }); @@ -587,19 +598,19 @@ 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); }); 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}`; @@ -610,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(); }); }); @@ -682,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/coexistence.spec.ts b/packages/v4/src/coexistence.spec.ts index 05b26b65..8e2c135b 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 e449f355..ef490947 100644 --- a/packages/v4/src/config-extension.spec.ts +++ b/packages/v4/src/config-extension.spec.ts @@ -10,12 +10,14 @@ * 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, resetDom, settle, 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); @@ -177,16 +179,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/context-subscription.spec.ts b/packages/v4/src/context-subscription.spec.ts index f5722fef..f6685c6b 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 eb994518..6c5044b9 100644 --- a/packages/v4/src/context.spec.ts +++ b/packages/v4/src/context.spec.ts @@ -12,7 +12,9 @@ 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 } 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 03793620..c826e1e6 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,11 +11,14 @@ 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, resetDom, settle, 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'); @@ -426,11 +429,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 @@ -447,14 +450,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']); @@ -490,13 +493,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', @@ -510,7 +512,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(); }); /** @@ -519,7 +522,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', @@ -539,26 +542,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(); }); /** @@ -598,7 +600,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 { @@ -614,7 +616,6 @@ describe('@component', () => { class MixGrandChild extends MixChild { static config: BaseConfig = { name: 'MixGrandChild', refs: ['last'] }; } - warn.mockRestore(); expect(MixParent.config).toEqual({ name: 'MixParent', @@ -631,7 +632,8 @@ describe('@component', () => { refs: ['handle', 'extra', 'last'], options: { open: Boolean, size: Number }, }); - expect(warn).not.toHaveBeenCalled(); + expect(log.codes).toEqual([]); + log.stop(); }); }); @@ -807,7 +809,7 @@ describe('@on', () => {
`; document.body.append(root); - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = captureDiagnostics(); await settle(); const instance = getInstance(root, 'NsDots'); @@ -817,8 +819,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 () => { @@ -826,17 +828,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/dom-mutations.spec.ts b/packages/v4/src/dom-mutations.spec.ts index b780d29a..18bfe145 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 fc0e6c0e..b5ef34ac 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 51a61928..042fc64d 100644 --- a/packages/v4/src/instances.spec.ts +++ b/packages/v4/src/instances.spec.ts @@ -1,7 +1,9 @@ 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 } 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/manifest.spec.ts b/packages/v4/src/manifest.spec.ts index 89caab35..74703315 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 { captureDiagnostics, resetDom, settle } from './test/index.js'; class Widget {} class Other {} @@ -66,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 = { @@ -77,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/mount-strategies.spec.ts b/packages/v4/src/mount-strategies.spec.ts index 658bff2b..e49cb2d2 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, 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/negotiated-events.spec.ts b/packages/v4/src/negotiated-events.spec.ts index 1c814536..e9fae220 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 { 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/props.spec.ts b/packages/v4/src/props.spec.ts index 9546c0b5..aad1a1cb 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 705d8127..523abf42 100644 --- a/packages/v4/src/registry.spec.ts +++ b/packages/v4/src/registry.spec.ts @@ -1,10 +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, 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, resetDom, settle, 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); @@ -243,17 +245,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 334962ee..6e5efe02 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-utils.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 168b9aaf..ded20128 100644 --- a/packages/v4/src/responsive-options.spec.ts +++ b/packages/v4/src/responsive-options.spec.ts @@ -1,9 +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, resetDom, settle } from './test-utils.js'; +import { getInstance } from './test-utils.js'; +import { captureDiagnostics, resetDom, settle } from './test/index.js'; /** Select test breakpoints without changing the viewport. */ function atSmall(): void { @@ -449,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. @@ -457,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(); }); }); diff --git a/packages/v4/src/services/breakpoint.spec.ts b/packages/v4/src/services/breakpoint.spec.ts index 5d9d7e77..bb31e60f 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 9539282c..62b33de8 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 19175645..323d1d63 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 e45df062..8f315267 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 16ff2e90..dc6d5f91 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 96801972..7dfd35a0 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 54abeb36..e2675e15 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 7b1f60fd..255a6b3f 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 3555a68f..e7e5a49d 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 63f5ea83..45aefb56 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 a35e4857..2a68ef6a 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/test-utils.ts b/packages/v4/src/test-utils.ts index 1cc16930..412022e5 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 00000000..d738d077 --- /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; +} diff --git a/packages/v4/src/utils/smoothTo.spec.ts b/packages/v4/src/utils/smoothTo.spec.ts index a833ae8b..9bedc1b1 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, waitFor } from '../test/index.js'; describe('smoothTo', () => { it('starts at its start value and does not move on its own', async () => { @@ -191,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); @@ -226,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(); @@ -245,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(); }); @@ -277,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(); @@ -338,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(); }); diff --git a/packages/v4/src/utils/transition.spec.ts b/packages/v4/src/utils/transition.spec.ts index 7d17776a..7b32c972 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 841765cd..491db1eb 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 = () => {};