diff --git a/packages/v4/migration/Action/Action.spec.ts b/packages/v4/migration/Action/Action.spec.ts index ab7e5e2b5..12d9a633c 100644 --- a/packages/v4/migration/Action/Action.spec.ts +++ b/packages/v4/migration/Action/Action.spec.ts @@ -7,7 +7,7 @@ import { SWAP_MODES, type BaseConfig, } from '../../src/index.js'; -import { mount, resetDom, settle } from '../../src/test/index.js'; +import { captureDiagnostics, mount, resetDom, settle } from '../../src/test/index.js'; import { Dialog } from '../Dialog/Dialog.js'; import { Action } from './Action.js'; import { ActionEvent } from './ActionEvent.js'; @@ -43,7 +43,7 @@ registerComponents(Action, Target, Foo, Bar, Dialog, MountProbe); afterEach(resetDom); function at(root: ParentNode, selector: string, name: string): T { - return getInstance(root.querySelector(selector)!, name)!; + return getInstance(root.querySelector(selector), name)!; } function click(el: Element): Event { @@ -464,27 +464,22 @@ describe('Action — the component', () => { const root = await mount(`
`); - const details: Array> = []; - // Canceling suppresses the default sink, so the failure does not reach - // `reportError()` and fail the run. - const listener = (event: Event) => { - details.push((event as CustomEvent>).detail); - event.preventDefault(); - }; - document.addEventListener('js-toolkit:diagnostic', listener); + // The capture cancels every event, which suppresses the default sink, so + // the failure never reaches `reportError()` and fails the run. + const log = captureDiagnostics(); click(root.querySelector('#action') as Element); - expect(details).toHaveLength(1); - expect(details[0]).toMatchObject({ + expect(log.entries).toHaveLength(1); + expect(log.entries[0]).toMatchObject({ severity: 'error', code: 'action.effect-failed', component: 'Action', }); // The cause survives, which a bare `console.warn` never carried. - expect(details[0].error).toBeInstanceOf(Error); + expect(log.entries[0].error).toBeInstanceOf(Error); - document.removeEventListener('js-toolkit:diagnostic', listener); + log.stop(); }); }); diff --git a/packages/v4/migration/AnchorNav/AnchorNav.spec.ts b/packages/v4/migration/AnchorNav/AnchorNav.spec.ts index dbf14befc..61e40606b 100644 --- a/packages/v4/migration/AnchorNav/AnchorNav.spec.ts +++ b/packages/v4/migration/AnchorNav/AnchorNav.spec.ts @@ -25,7 +25,7 @@ describe('AnchorNav', () => { it('enters the matching link once its target scrolls into view', async () => { const { root, target } = await render(); const link = getInstance( - root.querySelector('[data-component="AnchorNavLink"]')!, + root.querySelector('[data-component="AnchorNavLink"]'), 'AnchorNavLink', )!; @@ -41,7 +41,7 @@ describe('AnchorNav', () => { it('leaves the matching link once its target scrolls back out of view', async () => { const { root, target } = await render(); const link = getInstance( - root.querySelector('[data-component="AnchorNavLink"]')!, + root.querySelector('[data-component="AnchorNavLink"]'), 'AnchorNavLink', )!; @@ -66,7 +66,7 @@ describe('AnchorNav', () => { document.body.append(root); await settle(); const link = getInstance( - root.querySelector('[data-component="AnchorNavLink"]')!, + root.querySelector('[data-component="AnchorNavLink"]'), 'AnchorNavLink', )!; const target = root.querySelector('#one') as HTMLElement; diff --git a/packages/v4/migration/AnchorNav/AnchorNavLink.spec.ts b/packages/v4/migration/AnchorNav/AnchorNavLink.spec.ts index fbfbee046..08a0f8cb1 100644 --- a/packages/v4/migration/AnchorNav/AnchorNavLink.spec.ts +++ b/packages/v4/migration/AnchorNav/AnchorNavLink.spec.ts @@ -23,7 +23,7 @@ async function render(): Promise { root.innerHTML = ``; document.body.append(root); await settle(); - return getInstance(root.firstElementChild!, 'AnchorNavLink')!; + return getInstance(root.firstElementChild, 'AnchorNavLink')!; } describe('AnchorNavLink', () => { diff --git a/packages/v4/migration/AnchorNav/AnchorNavTarget.spec.ts b/packages/v4/migration/AnchorNav/AnchorNavTarget.spec.ts index f52a39b32..886544ac4 100644 --- a/packages/v4/migration/AnchorNav/AnchorNavTarget.spec.ts +++ b/packages/v4/migration/AnchorNav/AnchorNavTarget.spec.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest'; -import { registerComponents } from '../../src/index.js'; -import { INSTANCES } from '../../src/protocol-symbols.js'; +import { getInstance, registerComponents } from '../../src/index.js'; import { resetDom, settle, waitFor } from '../../src/test/index.js'; import { AnchorNavTarget } from './AnchorNavTarget.js'; @@ -18,7 +17,7 @@ async function quiet(): Promise { } } -const mountedState = (el: HTMLElement) => el[INSTANCES]?.get('AnchorNavTarget')?.$isMounted; +const mountedState = (el: HTMLElement) => getInstance(el, 'AnchorNavTarget')?.$isMounted; function render(style: string): HTMLElement { const el = document.createElement('div'); diff --git a/packages/v4/migration/ClickOutside/ClickOutside.spec.ts b/packages/v4/migration/ClickOutside/ClickOutside.spec.ts index 717867f65..8bb95eefc 100644 --- a/packages/v4/migration/ClickOutside/ClickOutside.spec.ts +++ b/packages/v4/migration/ClickOutside/ClickOutside.spec.ts @@ -79,7 +79,7 @@ describe('ClickOutside', () => { outside.click(); const dropdown = getInstance( - root.querySelector('[data-component="Dropdown"]')!, + root.querySelector('[data-component="Dropdown"]'), 'Dropdown', )!; expect(dropdown.closed).toHaveLength(1); diff --git a/packages/v4/migration/Data/DataBind.spec.ts b/packages/v4/migration/Data/DataBind.spec.ts index 7b71708b1..661dbc890 100644 --- a/packages/v4/migration/Data/DataBind.spec.ts +++ b/packages/v4/migration/Data/DataBind.spec.ts @@ -67,7 +67,7 @@ function el(root: HTMLElement, selector: st } function at(root: HTMLElement, selector: string, name: string): T { - return getInstance(root.querySelector(selector)!, name)! as T; + return getInstance(root.querySelector(selector), name)! as T; } describe('DataBind — the element half', () => { @@ -272,15 +272,9 @@ describe('DataBind — the element half', () => { }); it('warns for a binding type that names nothing', async () => { - const details: string[] = []; - // Removed at the end: this listener cancels the default sink, and leaking - // it silenced every later diagnostic in the file. - const listener = (event: Event) => { - const { detail } = event as CustomEvent<{ code: string; message: string }>; - details.push(detail.code); - event.preventDefault(); - }; - document.addEventListener(EVENTS.diagnostic, listener); + // Stopped at the end: the capture cancels the default sink, and leaking it + // silenced every later diagnostic in the file. + const log = captureDiagnostics(); const root = await mount(`
{ `); // The typo used to be an attribute that silently did nothing at all. - expect(details).toContain('attribute.unknown-qualifier'); + expect(log.codes).toContain('attribute.unknown-qualifier'); expect(at(root, '#d', 'DataBind').hasVirtualBindings).toBe(false); - document.removeEventListener(EVENTS.diagnostic, listener); + log.stop(); }); it('fails quietly when a virtual expression throws', async () => { diff --git a/packages/v4/migration/Data/DataDerived.spec.ts b/packages/v4/migration/Data/DataDerived.spec.ts index 3f95defc3..8940d2ed9 100644 --- a/packages/v4/migration/Data/DataDerived.spec.ts +++ b/packages/v4/migration/Data/DataDerived.spec.ts @@ -30,7 +30,7 @@ function el(root: HTMLElement, selector: st } function at(root: HTMLElement, selector: string, name: string): T { - return getInstance(root.querySelector(selector)!, name)! as T; + return getInstance(root.querySelector(selector), name)! as T; } describe('DataModel', () => { diff --git a/packages/v4/migration/Data/DataScope.spec.ts b/packages/v4/migration/Data/DataScope.spec.ts index a4e3ee1c2..304d0ea53 100644 --- a/packages/v4/migration/Data/DataScope.spec.ts +++ b/packages/v4/migration/Data/DataScope.spec.ts @@ -27,7 +27,7 @@ function uniqueGroup(name: string): string { } function at(root: HTMLElement, selector: string, name: string): T { - return getInstance(root.querySelector(selector)!, name)! as T; + return getInstance(root.querySelector(selector), name)! as T; } function el(root: HTMLElement, selector: string): T { diff --git a/packages/v4/migration/Fetch/FetchShopifyPartial.spec.ts b/packages/v4/migration/Fetch/FetchShopifyPartial.spec.ts index 7717fc765..78e5b6ed0 100644 --- a/packages/v4/migration/Fetch/FetchShopifyPartial.spec.ts +++ b/packages/v4/migration/Fetch/FetchShopifyPartial.spec.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getInstance, registerComponents } from '../../src/index.js'; -import { recordEvents, resetDom, settle } from '../../src/test/index.js'; +import { mount, recordEvents, resetDom, settle } from '../../src/test/index.js'; import { FETCH_EVENTS } from './Fetch.js'; import { FetchShopifyPartial } from './FetchShopifyPartial.js'; @@ -27,13 +27,15 @@ afterEach(async () => { await resetDom(); }); -async function mount(html: string): Promise<{ root: HTMLElement; instance: FetchShopifyPartial }> { - const root = document.createElement('div'); - root.innerHTML = html; - document.body.append(root); - await settle(); - const el = root.firstElementChild as HTMLElement; - return { root, instance: getInstance(el, 'FetchShopifyPartial')! }; +/** {@link mount}, plus the one instance every test here goes on to drive. */ +async function mountWithInstance( + html: string, +): Promise<{ root: HTMLElement; instance: FetchShopifyPartial }> { + const root = await mount(html); + return { + root, + instance: getInstance(root.firstElementChild, 'FetchShopifyPartial')!, + }; } function stubClient( @@ -54,7 +56,7 @@ function stubPartials(api: { describe('FetchShopifyPartial', () => { it('falls back to the base Fetch behaviour when no partials are configured', async () => { const client = stubClient(); - const { root, instance } = await mount( + const { root, instance } = await mountWithInstance( `
old
`, ); const { events } = recordEvents(root, ...Object.values(FETCH_EVENTS)); @@ -71,7 +73,7 @@ describe('FetchShopifyPartial', () => { const apply = vi.fn(); const fetchPartials = vi.fn(async () => ({ shape: 'partial-update' })); stubPartials({ fetch: fetchPartials, apply }); - const { root, instance } = await mount( + const { root, instance } = await mountWithInstance( ``, ); const { events } = recordEvents(root, ...Object.values(FETCH_EVENTS)); @@ -106,7 +108,7 @@ describe('FetchShopifyPartial', () => { FetchShopifyPartial.loadPartialsModule = async () => { throw new Error('not installed'); }; - const { instance } = await mount( + const { instance } = await mountWithInstance( `
old
`, ); @@ -120,7 +122,7 @@ describe('FetchShopifyPartial', () => { const client = stubClient(); const fetchPartials = vi.fn(async () => ({})); stubPartials({ fetch: fetchPartials, apply: vi.fn() }); - const { instance } = await mount( + const { instance } = await mountWithInstance( `
old
`, ); @@ -135,7 +137,7 @@ describe('FetchShopifyPartial', () => { const client = stubClient(); const fetchPartials = vi.fn(async () => ({})); stubPartials({ fetch: fetchPartials, apply: vi.fn() }); - const { instance } = await mount( + const { instance } = await mountWithInstance( `
old
`, ); @@ -150,7 +152,7 @@ describe('FetchShopifyPartial', () => { const client = stubClient(); const fetchPartials = vi.fn(async () => ({})); stubPartials({ fetch: fetchPartials, apply: vi.fn() }); - const { instance } = await mount( + const { instance } = await mountWithInstance( `
old
`, ); @@ -166,7 +168,7 @@ describe('FetchShopifyPartial', () => { const client = stubClient(); const fetchPartials = vi.fn(async () => ({})); stubPartials({ fetch: fetchPartials, apply: vi.fn() }); - const { instance } = await mount( + const { instance } = await mountWithInstance( `
old
`, ); @@ -181,7 +183,7 @@ describe('FetchShopifyPartial', () => { const client = stubClient(); const fetchPartials = vi.fn(async () => ({})); stubPartials({ fetch: fetchPartials, apply: vi.fn() }); - const { instance } = await mount( + const { instance } = await mountWithInstance( ``, ); @@ -199,7 +201,7 @@ describe('FetchShopifyPartial', () => { fetch: async () => ({}), apply: () => Promise.reject(failure), }); - const { root, instance } = await mount( + const { root, instance } = await mountWithInstance( ``, ); const errors: unknown[] = []; @@ -215,7 +217,7 @@ describe('FetchShopifyPartial', () => { it('skips the history push for a popstate header given as a Headers instance', async () => { stubPartials({ fetch: async () => ({}), apply: vi.fn() }); - const { instance } = await mount( + const { instance } = await mountWithInstance( ``, ); const before = window.history.length; @@ -232,7 +234,7 @@ describe('FetchShopifyPartial', () => { it('still pushes history for a request that is not popstate-triggered', async () => { stubPartials({ fetch: async () => ({}), apply: vi.fn() }); - const { instance } = await mount( + const { instance } = await mountWithInstance( ``, ); const before = window.history.length; @@ -248,7 +250,7 @@ describe('FetchShopifyPartial', () => { partials: { fetch: vi.fn(async () => ({})), apply: vi.fn() }, })); FetchShopifyPartial.loadPartialsModule = loadSpy; - const { instance } = await mount( + const { instance } = await mountWithInstance( ``, ); diff --git a/packages/v4/migration/Figure/FigureShopify.spec.ts b/packages/v4/migration/Figure/FigureShopify.spec.ts index 0d9766c0b..9233ce424 100644 --- a/packages/v4/migration/Figure/FigureShopify.spec.ts +++ b/packages/v4/migration/Figure/FigureShopify.spec.ts @@ -19,7 +19,7 @@ async function render(attributes = ''): Promise {
`; document.body.append(root); await settle(); - return getInstance(root.firstElementChild!, 'FigureShopify')!; + return getInstance(root.firstElementChild, 'FigureShopify')!; } describe('FigureShopify', () => { diff --git a/packages/v4/migration/Figure/FigureTwicpics.spec.ts b/packages/v4/migration/Figure/FigureTwicpics.spec.ts index c365a33b4..ccdc9ecaf 100644 --- a/packages/v4/migration/Figure/FigureTwicpics.spec.ts +++ b/packages/v4/migration/Figure/FigureTwicpics.spec.ts @@ -20,7 +20,7 @@ async function render( `; document.body.append(root); await settle(); - return getInstance(root.firstElementChild!, 'FigureTwicpics')!; + return getInstance(root.firstElementChild, 'FigureTwicpics')!; } describe('FigureTwicpics', () => { diff --git a/packages/v4/migration/FigureVideo/FigureVideo.spec.ts b/packages/v4/migration/FigureVideo/FigureVideo.spec.ts index 55d5e6735..fe7f29b68 100644 --- a/packages/v4/migration/FigureVideo/FigureVideo.spec.ts +++ b/packages/v4/migration/FigureVideo/FigureVideo.spec.ts @@ -100,24 +100,18 @@ describe('FigureVideo', () => { it('settles and reports when the sources fail, instead of hanging forever', async () => { const { el, video } = render(ONSCREEN); - const details: Array> = []; - const listener = (event: Event) => { - details.push((event as CustomEvent>).detail); - event.preventDefault(); - }; - document.addEventListener('js-toolkit:diagnostic', listener); + const log = captureDiagnostics(); await settle(); // v3 waits on `loadeddata` alone, so this never settled and `mounted()` // never returned. video.dispatchEvent(new Event('error')); - await waitFor(() => details.length > 0); + await waitFor(() => log.codes.includes('figure-video.load-failed')); - expect(details.map((detail) => detail.code)).toContain('figure-video.load-failed'); // Left un-loaded, so a later mount cycle can retry. expect(getInstance(el, 'FigureVideo')!.hasLoaded).toBe(false); - document.removeEventListener('js-toolkit:diagnostic', listener); + log.stop(); }); it('warns and does not throw when the video ref is missing', async () => { diff --git a/packages/v4/migration/FigureVideo/FigureVideoTwicpics.spec.ts b/packages/v4/migration/FigureVideo/FigureVideoTwicpics.spec.ts index 060300f40..b035765c2 100644 --- a/packages/v4/migration/FigureVideo/FigureVideoTwicpics.spec.ts +++ b/packages/v4/migration/FigureVideo/FigureVideoTwicpics.spec.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest'; import { getInstance, registerComponents } from '../../src/index.js'; -import { resetDom, settle } from '../../src/test/index.js'; +import { captureDiagnostics, resetDom, settle, waitFor } from '../../src/test/index.js'; import { FigureVideoTwicpics } from './FigureVideoTwicpics.js'; registerComponents(FigureVideoTwicpics); @@ -20,7 +20,7 @@ async function render(attributes = ''): Promise { `; document.body.append(root); await settle(); - return getInstance(root.firstElementChild!, 'FigureVideoTwicpics')!; + return getInstance(root.firstElementChild, 'FigureVideoTwicpics')!; } describe('FigureVideoTwicpics — the loadSources override', () => { @@ -33,12 +33,7 @@ describe('FigureVideoTwicpics — the loadSources override', () => { `; document.body.append(root); - const details: Array> = []; - const listener = (event: Event) => { - details.push((event as CustomEvent>).detail); - event.preventDefault(); - }; - document.addEventListener('js-toolkit:diagnostic', listener); + const log = captureDiagnostics(); await settle(); const el = root.firstElementChild as HTMLElement; @@ -46,14 +41,11 @@ describe('FigureVideoTwicpics — the loadSources override', () => { // This override replaces the base entirely, so fixing the base alone left // it waiting on `canplaythrough` forever. video.dispatchEvent(new Event('error')); - for (let i = 0; i < 6; i += 1) { - await settle(); - } + await waitFor(() => log.codes.includes('figure-video.load-failed')); - expect(details.map((detail) => detail.code)).toContain('figure-video.load-failed'); expect(getInstance(el, 'FigureVideoTwicpics')!.hasLoaded).toBe(false); - document.removeEventListener('js-toolkit:diagnostic', listener); + log.stop(); }); }); diff --git a/packages/v4/migration/InView/InView.spec.ts b/packages/v4/migration/InView/InView.spec.ts index 92c99af5c..09844a615 100644 --- a/packages/v4/migration/InView/InView.spec.ts +++ b/packages/v4/migration/InView/InView.spec.ts @@ -1,6 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { Base, registerComponents, type BaseConfig } from '../../src/index.js'; -import { INSTANCES } from '../../src/protocol-symbols.js'; +import { Base, getInstance, registerComponents, type BaseConfig } from '../../src/index.js'; import { recordEvents, resetDom, settle, waitFor } from '../../src/test/index.js'; import { InView } from './InView.js'; import { InViewOnce } from './InViewOnce.js'; @@ -79,7 +78,7 @@ describe('InView', () => { it('re-emits `in-view` on each re-entry, from the same instance', async () => { const el = render('InView', ONSCREEN); await waitFor(() => log.events.length > 0); - const instance = el[INSTANCES]?.get('InView'); + const instance = getInstance(el, 'InView'); el.setAttribute('style', OFFSCREEN); await waitFor(() => log.events.length > 1); @@ -87,14 +86,14 @@ describe('InView', () => { await waitFor(() => log.events.length > 2); expect(types()).toEqual(['in-view', 'out-of-view', 'in-view']); - expect(el[INSTANCES]?.get('InView')).toBe(instance); + expect(getInstance(el, 'InView')).toBe(instance); }); it('does not instantiate the component until it is first seen', async () => { const el = render('InView', OFFSCREEN); await quiet(); - expect(el[INSTANCES]?.get('InView')).toBeUndefined(); + expect(getInstance(el, 'InView')).toBeUndefined(); }); }); @@ -123,7 +122,7 @@ describe('InViewOnce', () => { it('stays mounted after leaving the viewport, where v3 terminated', async () => { const el = render('InViewOnce', ONSCREEN); - const instance = await waitFor(() => el[INSTANCES]?.get('InViewOnce')); + const instance = await waitFor(() => getInstance(el, 'InViewOnce')); el.setAttribute('style', OFFSCREEN); await quiet(); @@ -153,7 +152,7 @@ describe('mount strategy gaps found by the port', () => { await waitFor(() => log.events.length > 0); expect(types()).toEqual(['in-view']); - expect(el[INSTANCES]?.get('InView')?.$isMounted).toBe(true); + expect(getInstance(el, 'InView')?.$isMounted).toBe(true); }); /** Subclasses must inherit the resolved mount strategy. */ @@ -161,7 +160,7 @@ describe('mount strategy gaps found by the port', () => { const el = render('InViewSubclass', OFFSCREEN); await quiet(); - expect(el[INSTANCES]?.get('InViewSubclass')).toBeUndefined(); + expect(getInstance(el, 'InViewSubclass')).toBeUndefined(); }); }); @@ -177,19 +176,19 @@ describe('the strategy is per element, which the decorator never was', () => { it('lets `data-mount` override the class default on one element', async () => { const el = render('InView', OFFSCREEN, { 'data-mount': 'eager' }); - await waitFor(() => el[INSTANCES]?.get('InView')?.$isMounted); + await waitFor(() => getInstance(el, 'InView')?.$isMounted); - expect(el[INSTANCES]?.get('InView')?.$isMounted).toBe(true); + expect(getInstance(el, 'InView')?.$isMounted).toBe(true); expect(types()).toEqual(['in-view']); }); it('lets `data-mount="in-view"` give the strategy to a component that never asked', async () => { const el = render('InViewEagerProbe', OFFSCREEN, { 'data-mount': 'in-view' }); await quiet(); - expect(el[INSTANCES]?.get('InViewEagerProbe')).toBeUndefined(); + expect(getInstance(el, 'InViewEagerProbe')).toBeUndefined(); el.setAttribute('style', ONSCREEN); - await waitFor(() => el[INSTANCES]?.get('InViewEagerProbe')); - expect((el[INSTANCES]?.get('InViewEagerProbe') as Eager | undefined)?.mounts).toBe(1); + await waitFor(() => getInstance(el, 'InViewEagerProbe')); + expect((getInstance(el, 'InViewEagerProbe') as Eager | undefined)?.mounts).toBe(1); }); }); diff --git a/packages/v4/migration/Menu/Menu.spec.ts b/packages/v4/migration/Menu/Menu.spec.ts index 521e5424d..c060fba1b 100644 --- a/packages/v4/migration/Menu/Menu.spec.ts +++ b/packages/v4/migration/Menu/Menu.spec.ts @@ -28,7 +28,7 @@ async function render(mode?: string): Promise<{ root: HTMLElement; menu: Menu }> root.innerHTML = menuMarkup(mode); document.body.append(root); await settle(); - return { root, menu: getInstance(root.querySelector('[data-component="Menu"]')!, 'Menu')! }; + return { root, menu: getInstance(root.querySelector('[data-component="Menu"]'), 'Menu')! }; } describe('Menu', () => { @@ -133,8 +133,8 @@ describe('Menu', () => { `; document.body.append(root); await settle(); - const subA = getInstance(root.querySelector('#sub-a')!, 'Menu')!; - const subB = getInstance(root.querySelector('#sub-b')!, 'Menu')!; + const subA = getInstance(root.querySelector('#sub-a'), 'Menu')!; + const subB = getInstance(root.querySelector('#sub-b'), 'Menu')!; subA.open(); expect(subA.menuList?.isOpen).toBe(true); diff --git a/packages/v4/migration/Menu/MenuList.spec.ts b/packages/v4/migration/Menu/MenuList.spec.ts index f50a42b62..8c03861c6 100644 --- a/packages/v4/migration/Menu/MenuList.spec.ts +++ b/packages/v4/migration/Menu/MenuList.spec.ts @@ -25,8 +25,8 @@ async function render(): Promise<{ `); return { root, - outer: getInstance(root.querySelector('#outer-list')!, 'MenuList')!, - nested: getInstance(root.querySelector('#nested-list')!, 'MenuList')!, + outer: getInstance(root.querySelector('#outer-list'), 'MenuList')!, + nested: getInstance(root.querySelector('#nested-list'), 'MenuList')!, outerLink: root.querySelector('#outer-link') as HTMLElement, nestedLink: root.querySelector('#nested-link') as HTMLElement, }; diff --git a/packages/v4/migration/Timer/TimerProgress.spec.ts b/packages/v4/migration/Timer/TimerProgress.spec.ts index 5cc3b7e52..866a84dfb 100644 --- a/packages/v4/migration/Timer/TimerProgress.spec.ts +++ b/packages/v4/migration/Timer/TimerProgress.spec.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest'; import { getInstance, registerComponents } from '../../src/index.js'; -import { resetDom, settle } from '../../src/test/index.js'; +import { recordEvents, resetDom, settle } from '../../src/test/index.js'; import { TimerProgress } from './TimerProgress.js'; registerComponents(TimerProgress); @@ -20,12 +20,10 @@ async function render(attributes = ''): Promise<{ el: HTMLElement; instance: Tim return { el, instance: getInstance(el, 'TimerProgress')! }; } -function recordProgress(el: HTMLElement): number[] { - const ratios: number[] = []; - el.addEventListener('timer-progress', (event) => { - ratios.push((event as CustomEvent<{ ratio: number }>).detail.ratio); - }); - return ratios; +/** {@link recordEvents}, projected to the ratio each `timer-progress` carried. */ +function recordProgress(el: HTMLElement): () => number[] { + const log = recordEvents(el, 'timer-progress'); + return () => log.events.map(({ detail }) => (detail as { ratio: number }).ratio); } describe('TimerProgress', () => { @@ -35,7 +33,7 @@ describe('TimerProgress', () => { await wait(60); - expect(ratios).toEqual([]); + expect(ratios()).toEqual([]); }); it('reports increasing progress while armed, ending at 1', async () => { @@ -44,9 +42,9 @@ describe('TimerProgress', () => { await wait(150); - expect(ratios.length).toBeGreaterThan(1); - expect(ratios.at(-1)).toBe(1); - expect([...ratios]).toEqual([...ratios].sort((a, b) => a - b)); + expect(ratios().length).toBeGreaterThan(1); + expect(ratios().at(-1)).toBe(1); + expect(ratios()).toEqual(ratios().sort((a, b) => a - b)); }); it('stops the frame loop once complete', async () => { @@ -54,10 +52,10 @@ describe('TimerProgress', () => { const ratios = recordProgress(el); await wait(60); - const countAtComplete = ratios.length; + const countAtComplete = ratios().length; await wait(60); - expect(ratios.length).toBe(countAtComplete); + expect(ratios().length).toBe(countAtComplete); }); it('resets progress to 0 when stopped', async () => { @@ -67,7 +65,7 @@ describe('TimerProgress', () => { await wait(30); instance.stop(); - expect(ratios.at(-1)).toBe(0); + expect(ratios().at(-1)).toBe(0); }); it('stops the frame loop while paused and resumes it', async () => { @@ -76,14 +74,14 @@ describe('TimerProgress', () => { await wait(20); instance.pause(); - const countAtPause = ratios.length; + const countAtPause = ratios().length; await wait(40); - expect(ratios.length).toBe(countAtPause); + expect(ratios().length).toBe(countAtPause); instance.resume(); await wait(150); - expect(ratios.at(-1)).toBe(1); + expect(ratios().at(-1)).toBe(1); }); }); diff --git a/packages/v4/migration/Toaster/Toast.spec.ts b/packages/v4/migration/Toaster/Toast.spec.ts index 011cf6fc7..0e8e49520 100644 --- a/packages/v4/migration/Toaster/Toast.spec.ts +++ b/packages/v4/migration/Toaster/Toast.spec.ts @@ -1,34 +1,12 @@ import { afterEach, describe, expect, it } from 'vitest'; import { getInstance, registerComponents } from '../../src/index.js'; -import { resetDom, settle } from '../../src/test/index.js'; +import { resetDom, settle, waitFor } from '../../src/test/index.js'; import { Toast } from './Toast.js'; registerComponents(Toast); afterEach(resetDom); -function wait(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -/** - * `viewTransition()` chains onto a module-level tail the scheduler does not - * track: `defaultScheduler`'s write task that flushes it returns as soon as - * `document.startViewTransition(...).finished` is *requested*, not once it - * settles, so `settle()` gives no guarantee the DOM mutation inside it has - * run yet. A real headless compositor can also take longer than usual to - * finish one. Poll instead of trusting a fixed wait. - */ -async function waitFor(predicate: () => boolean, timeout = 1000): Promise { - const deadline = Date.now() + timeout; - while (!predicate()) { - if (Date.now() > deadline) { - throw new Error('waitFor: timed out'); - } - await wait(10); - } -} - function renderUnmounted(attributes = ''): HTMLElement { const root = document.createElement('div'); root.innerHTML = ` diff --git a/packages/v4/migration/Toaster/Toaster.spec.ts b/packages/v4/migration/Toaster/Toaster.spec.ts index ed4de1c1c..522a93c03 100644 --- a/packages/v4/migration/Toaster/Toaster.spec.ts +++ b/packages/v4/migration/Toaster/Toaster.spec.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest'; import { getInstance, registerComponents } from '../../src/index.js'; -import { resetDom, settle } from '../../src/test/index.js'; +import { resetDom, settle, waitFor } from '../../src/test/index.js'; import { Toast } from './Toast.js'; import { Toaster } from './Toaster.js'; @@ -8,22 +8,6 @@ registerComponents(Toaster, Toast); afterEach(resetDom); -/** - * `viewTransition()` chains onto a module-level tail the scheduler does not - * track — `settle()` gives no guarantee the DOM mutation inside it has run — - * and a real headless compositor can take longer than usual to finish one. - * Poll instead of trusting a fixed wait. - */ -async function waitFor(predicate: () => boolean, timeout = 1000): Promise { - const deadline = Date.now() + timeout; - while (!predicate()) { - if (Date.now() > deadline) { - throw new Error('waitFor: timed out'); - } - await new Promise((resolve) => setTimeout(resolve, 10)); - } -} - async function render(): Promise<{ root: HTMLElement; instance: Toaster }> { const root = document.createElement('div'); root.innerHTML = ` @@ -41,7 +25,7 @@ async function render(): Promise<{ root: HTMLElement; instance: Toaster }> { await settle(); return { root, - instance: getInstance(root.querySelector('[data-component="Toaster"]')!, 'Toaster')!, + instance: getInstance(root.querySelector('[data-component="Toaster"]'), 'Toaster')!, }; } diff --git a/packages/v4/src/Base.spec.ts b/packages/v4/src/Base.spec.ts index 175823b52..05e36634e 100644 --- a/packages/v4/src/Base.spec.ts +++ b/packages/v4/src/Base.spec.ts @@ -76,7 +76,7 @@ describe('$emit and delegation', () => { await settle(); const li = root.querySelector('[data-component="TodoItem"]'); - const instance = getInstance(li!, 'TodoItem')!; + const instance = getInstance(li, 'TodoItem')!; const seen: unknown[] = []; root.addEventListener('ping', (event) => { seen.push((event as CustomEvent).detail); @@ -93,7 +93,7 @@ describe('$emit and delegation', () => { await settle(); const li = root.querySelector('[data-component="TodoItem"]'); - const instance = getInstance(li!, 'TodoItem')!; + const instance = getInstance(li, 'TodoItem')!; const seen: unknown[] = []; root.addEventListener('ping', (event) => seen.push((event as CustomEvent).detail)); @@ -106,7 +106,7 @@ describe('$emit and delegation', () => { await settle(); const li = root.querySelector('[data-component="TodoItem"]'); - const instance = getInstance(li!, 'TodoItem')!; + const instance = getInstance(li, 'TodoItem')!; const log = captureDiagnostics(); const seen: unknown[] = []; root.addEventListener('ping', (event) => seen.push((event as CustomEvent).detail)); @@ -463,7 +463,6 @@ describe('$options', () => { const calls: string[] = []; const cleanupFailure = new Error('expected cleanup failure'); const handlerFailure = new Error('expected handler failure'); - const events: CustomEvent[] = []; class ResilientOptions extends Base { static config = { @@ -501,10 +500,7 @@ describe('$options', () => { registerComponent(ReentrantOption); const resilient = document.createElement('div'); resilient.setAttribute('data-component', 'ResilientOptions'); - resilient.addEventListener(EVENTS.diagnostic, (event) => { - event.preventDefault(); - events.push(event as CustomEvent); - }); + const log = captureDiagnostics(resilient); const reentrant = document.createElement('div'); reentrant.setAttribute('data-component', 'ReentrantOption'); document.body.append(resilient, reentrant); @@ -516,12 +512,13 @@ describe('$options', () => { await settle(); expect(calls).toEqual(['second:0', 'second:2']); - expect(events.map((event) => event.detail.error)).toEqual([cleanupFailure, handlerFailure]); - expect( - events.every((event) => event.detail.code === DIAGNOSTICS.component.lifecycleFailed), - ).toBe(true); - expect(events.every((event) => event.detail.component === 'ResilientOptions')).toBe(true); + expect(log.entries.map(({ error }) => error)).toEqual([cleanupFailure, handlerFailure]); + expect(log.entries.every(({ code }) => code === DIAGNOSTICS.component.lifecycleFailed)).toBe( + true, + ); + expect(log.entries.every(({ component }) => component === 'ResilientOptions')).toBe(true); expect(getInstance(reentrant, 'ReentrantOption')!.$isMounted).toBe(false); + log.stop(); } finally { document.querySelectorAll('[data-component="ResilientOptions"]').forEach((el) => el.remove()); } @@ -797,7 +794,7 @@ describe('$refs', () => { expect(owner.$refs.item).toBe(root.querySelector('[data-ref="item"]')); await settle(); - expect(getInstance(root.lastElementChild!, 'RefReadInserted')!.$isMounted).toBe(true); + expect(getInstance(root.lastElementChild, 'RefReadInserted')!.$isMounted).toBe(true); }); }); @@ -1330,6 +1327,8 @@ describe('$watchChildren', () => { for (const instance of [unrelated, gamma, family, beta, alpha]) { instance.$mount(); } + // A raw write, so no lookup replaces it: the fixture needs one instance + // filed under a second name, and `getInstance()` only reads. alpha.$el[INSTANCES]?.set('WatchAlphaAlias', alpha); const owner = new Owner(root); @@ -1618,7 +1617,7 @@ describe('lifecycle', () => { expect(instance.$isMounted).toBe(false); // Unmount is reversible, so the instance stays on its element for a later // mount even when both its hooks threw. - expect(el[INSTANCES]?.get('TeardownFailure')).toBe(instance); + expect(getInstance(el, 'TeardownFailure')).toBe(instance); }); it('runs the mounted() cleanup on unmount', async () => { @@ -1626,7 +1625,7 @@ describe('lifecycle', () => { await settle(); const countInstance = getInstance( - root.querySelector('[data-component="TodoCount"]')!, + root.querySelector('[data-component="TodoCount"]'), 'TodoCount', )!; expect(countInstance.cleanupCalls).toBe(0); @@ -1661,14 +1660,14 @@ describe('lifecycle', () => { instance.$mount(); expect(instance.$isMounted).toBe(true); - expect(el[INSTANCES]?.get('Tracked')).toBe(instance); + expect(getInstance(el, 'Tracked')).toBe(instance); // Mount and unmount are the whole lifecycle: neither is one-way, and the // instance stays on its element between them, which is what lets a moved // or re-inserted element keep its identity. instance.$unmount(); expect(calls).toEqual(['mounted', 'cleanup', 'unmounted', 'mounted', 'cleanup', 'unmounted']); - expect(el[INSTANCES]?.get('Tracked')).toBe(instance); + expect(getInstance(el, 'Tracked')).toBe(instance); instance.$mount(); expect(instance.$isMounted).toBe(true); diff --git a/packages/v4/src/attribute-namespaces.spec.ts b/packages/v4/src/attribute-namespaces.spec.ts index 40aa214b6..f33a40de0 100644 --- a/packages/v4/src/attribute-namespaces.spec.ts +++ b/packages/v4/src/attribute-namespaces.spec.ts @@ -1,8 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { watchAttributeNamespace } from './attribute-namespaces.js'; -import { EVENTS } from './events.js'; -import { resetDom, settle } from './test/index.js'; -import type { ToolkitDiagnosticDetail } from './diagnostic-contract.js'; +import { captureDiagnostics, resetDom, settle } from './test/index.js'; const cleanups = new Set<() => void>(); @@ -158,11 +156,7 @@ describe('watchAttributeNamespace', () => { describe('a declared vocabulary', () => { it('binds a known head and warns once for an unknown one', async () => { const el = element('
'); - const details: ToolkitDiagnosticDetail[] = []; - document.addEventListener(EVENTS.diagnostic, (event) => { - details.push((event as CustomEvent).detail); - event.preventDefault(); - }); + const log = captureDiagnostics(); const record = recorder(); watched(el, 'data-bind', record.bind, { qualifiers: ['text', 'if', 'prop'], @@ -170,16 +164,17 @@ describe('watchAttributeNamespace', () => { }); expect(record.bound).toEqual(['data-bind:text|text|a']); - expect(details).toHaveLength(1); - expect(details[0].code).toBe('attribute.unknown-qualifier'); - expect(details[0].severity).toBe('warning'); - expect(details[0].component).toBe('DataBind'); - expect(details[0].message).toContain('prpo'); + expect(log.entries).toHaveLength(1); + expect(log.entries[0].code).toBe('attribute.unknown-qualifier'); + expect(log.entries[0].severity).toBe('warning'); + expect(log.entries[0].component).toBe('DataBind'); + expect(log.entries[0].message).toContain('prpo'); // Once per element and per name, whatever the value is rewritten to. el.setAttribute('data-bind:prpo.value', 'c'); await settle(); - expect(details).toHaveLength(1); + expect(log.entries).toHaveLength(1); + log.stop(); }); it('validates the head only, so the name after the dot stays open', () => { @@ -209,22 +204,19 @@ describe('watchAttributeNamespace', () => { // the second separator itself — nothing else stands between this and // `Action` binding a literal `click:s` DOM event. const el = element('
'); - const details: ToolkitDiagnosticDetail[] = []; - document.addEventListener(EVENTS.diagnostic, (event) => { - details.push((event as CustomEvent).detail); - event.preventDefault(); - }); + const log = captureDiagnostics(); const record = recorder(); watched(el, 'data-on', record.bind); expect(record.bound).toEqual([]); - expect(details).toHaveLength(1); - expect(details[0].code).toBe('attribute.unknown-qualifier'); + expect(log.entries).toHaveLength(1); + expect(log.entries[0].code).toBe('attribute.unknown-qualifier'); // Once per element and per name, not re-triggered by a rewrite of the value. el.setAttribute('data-on:click:s', 'b'); await settle(); expect(record.bound).toEqual([]); - expect(details).toHaveLength(1); + expect(log.entries).toHaveLength(1); + log.stop(); }); }); diff --git a/packages/v4/src/autoload.spec.ts b/packages/v4/src/autoload.spec.ts index 9c0f61bb0..2d019dad8 100644 --- a/packages/v4/src/autoload.spec.ts +++ b/packages/v4/src/autoload.spec.ts @@ -3,8 +3,7 @@ import { Base, type BaseConfig, type BaseConstructor } from './Base.js'; import { DIAGNOSTICS, type ToolkitDiagnosticDetail } from './diagnostic-contract.js'; import { whenDOMSettled } from './dom-mutations.js'; import { EVENTS } from './events.js'; -import { getInstances } from './instances.js'; -import { INSTANCES } from './protocol-symbols.js'; +import { getInstance, getInstances } from './instances.js'; import { registerComponent, registerManifest } from './registry.js'; import { captureDiagnostics, resetDom, settle, waitFor } from './test/index.js'; @@ -64,10 +63,6 @@ function render(name: string, attributes: Record = {}, style = O return el; } -function instanceOf(el: Element, name: string): T | undefined { - return el[INSTANCES]?.get(name) as T | undefined; -} - /** * 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 @@ -87,13 +82,13 @@ describe('registerManifest', () => { const el = render(name); await settle(); - expect(el[INSTANCES]).toBeUndefined(); + expect(getInstance(el, name)).toBeUndefined(); registerManifest({ [name]: load }); await settle(); expect(importCount()).toBe(1); - expect(instanceOf(el, name)?.$isMounted).toBe(true); + expect(getInstance(el, name)?.$isMounted).toBe(true); }); it('imports and mounts a component inserted after the manifest', async () => { @@ -104,7 +99,7 @@ describe('registerManifest', () => { const el = render(name); await settle(); - expect(instanceOf(el, name)?.$isMounted).toBe(true); + expect(getInstance(el, name)?.$isMounted).toBe(true); }); it('imports the module once for every element declaring the token', async () => { @@ -115,8 +110,8 @@ describe('registerManifest', () => { await settle(); expect(importCount()).toBe(1); - expect(instanceOf(first, name)?.$isMounted).toBe(true); - expect(instanceOf(second, name)?.$isMounted).toBe(true); + expect(getInstance(first, name)?.$isMounted).toBe(true); + expect(getInstance(second, name)?.$isMounted).toBe(true); }); it('imports nothing when no element declares the token', async () => { @@ -139,8 +134,8 @@ describe('registerManifest', () => { }); await settle(); - expect(instanceOf(namedEl, named.name)?.$isMounted).toBe(true); - expect(instanceOf(defaultEl, fallback.name)?.$isMounted).toBe(true); + expect(getInstance(namedEl, named.name)?.$isMounted).toBe(true); + expect(getInstance(defaultEl, fallback.name)?.$isMounted).toBe(true); }); }); @@ -151,14 +146,14 @@ describe('a lazy declaration before its class arrives', () => { registerManifest({ [name]: { load, mountStrategy: 'visible' } }); await quiet(); - expect(el[INSTANCES]).toBeUndefined(); + expect(getInstance(el, name)).toBeUndefined(); expect(getInstances(name)).toEqual([]); el.setAttribute('style', ONSCREEN); await waitFor(() => getInstances(name).length > 0); expect(getInstances(name)).toHaveLength(1); - expect(instanceOf(el, name)?.$isMounted).toBe(true); + expect(getInstance(el, name)?.$isMounted).toBe(true); }); it('is dropped when its token is removed before the trigger fires', async () => { @@ -191,7 +186,7 @@ describe('a lazy declaration before its class arrives', () => { await waitFor(() => importCount() > 0); expect(importCount()).toBe(1); - expect(instanceOf(el, name)?.$isMounted).toBe(true); + expect(getInstance(el, name)?.$isMounted).toBe(true); }); }); @@ -208,7 +203,7 @@ describe('the strategy that triggers the import', () => { await waitFor(() => importCount() > 0); expect(importCount()).toBe(1); - expect(instanceOf(el, name)?.$isMounted).toBe(true); + expect(getInstance(el, name)?.$isMounted).toBe(true); }); it('lets the element data-mount win over the entry default', async () => { @@ -224,7 +219,7 @@ describe('the strategy that triggers the import', () => { await settle(); expect(importCount()).toBe(1); - expect(instanceOf(el, name)?.$isMounted).toBe(true); + expect(getInstance(el, name)?.$isMounted).toBe(true); }); // An invalid strategy is reported and kept inert by the one scheduling path @@ -232,34 +227,31 @@ describe('the strategy that triggers the import', () => { // is that nothing is downloaded for a trigger which never fires. it('imports nothing for an invalid trigger, and imports when data-mount is corrected', async () => { const { name, load, importCount } = defineLazy(); - const diagnostics: ToolkitDiagnosticDetail[] = []; const el = render(name, { 'data-mount': 'eagre' }); - el.addEventListener(EVENTS.diagnostic, (event) => { - event.preventDefault(); - diagnostics.push((event as CustomEvent).detail); - }); + const log = captureDiagnostics(el); registerManifest({ [name]: load }); - await waitFor(() => diagnostics.length > 0); + await waitFor(() => log.entries.length > 0); expect(importCount()).toBe(0); - expect(el[INSTANCES]?.get(name)).toBeUndefined(); - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].code).toBe(DIAGNOSTICS.component.invalidMountStrategy); + expect(getInstance(el, name)).toBeUndefined(); + expect(log.entries).toHaveLength(1); + expect(log.entries[0].code).toBe(DIAGNOSTICS.component.invalidMountStrategy); + log.stop(); el.setAttribute('data-mount', 'eager'); await settle(); expect(importCount()).toBe(1); - expect(instanceOf(el, name)?.$isMounted).toBe(true); + expect(getInstance(el, name)?.$isMounted).toBe(true); }); it('imports on a media query which matches while its trigger is being applied', async () => { const { name, load, importCount } = defineLazy(); const el = render(name); registerManifest({ [name]: { load, mountStrategy: 'media:(min-width: 1px)' } }); - await waitFor(() => instanceOf(el, name)?.$isMounted); + await waitFor(() => getInstance(el, name)?.$isMounted); expect(importCount()).toBe(1); - expect(instanceOf(el, name)?.$isMounted).toBe(true); + expect(getInstance(el, name)?.$isMounted).toBe(true); }); it('imports nothing while the media query does not match', async () => { @@ -269,7 +261,7 @@ describe('the strategy that triggers the import', () => { await quiet(); expect(importCount()).toBe(0); - expect(el[INSTANCES]).toBeUndefined(); + expect(getInstance(el, name)).toBeUndefined(); }); it('hands a parameterized reversible override to the registry after the import', async () => { @@ -281,7 +273,7 @@ describe('the strategy that triggers the import', () => { expect(importCount()).toBe(0); el.setAttribute('style', ONSCREEN); - const instance = await waitFor(() => instanceOf(el, name)); + const instance = await waitFor(() => getInstance(el, name)); expect(importCount()).toBe(1); expect(instance?.$isMounted).toBe(true); @@ -321,12 +313,12 @@ describe('the strategy that triggers the import', () => { release(); await quiet(); - expect(el[INSTANCES]?.get(name)).toBeUndefined(); + expect(getInstance(el, name)).toBeUndefined(); el.setAttribute('style', ONSCREEN); - await waitFor(() => instanceOf(el, name)?.$isMounted); + await waitFor(() => getInstance(el, name)?.$isMounted); - expect(instanceOf(el, name)?.$isMounted).toBe(true); + expect(getInstance(el, name)?.$isMounted).toBe(true); }); }); @@ -339,7 +331,7 @@ describe('whenDOMSettled and a lazy component', () => { const el = render(name); await whenDOMSettled(); - expect(instanceOf(el, name)?.$isMounted).toBe(true); + expect(getInstance(el, name)?.$isMounted).toBe(true); }); it('does not wait for a conditional trigger', async () => { @@ -378,7 +370,7 @@ describe('registerManifest collisions and failures', () => { message: `"${name}" is already registered; the incoming declaration was ignored.`, }, ]); - expect(instanceOf(el, name)).toBeInstanceOf(Owned); + expect(getInstance(el, name)).toBeInstanceOf(Owned); log.stop(); }); @@ -446,27 +438,22 @@ describe('registerManifest collisions and failures', () => { component: name, }); expect(elementEvents[0].detail.error).toBe(failure); - expect(first[INSTANCES]).toBeUndefined(); - expect(second[INSTANCES]).toBeUndefined(); + expect(getInstance(first, name)).toBeUndefined(); + expect(getInstance(second, name)).toBeUndefined(); }); it('reports a module which resolves to no component class', async () => { counter += 1; const name = `Empty${counter}`; - const diagnostics: ToolkitDiagnosticDetail[] = []; - const onDiagnostic = (event: Event) => { - event.preventDefault(); - diagnostics.push((event as CustomEvent).detail); - }; - document.addEventListener(EVENTS.diagnostic, onDiagnostic); + const log = captureDiagnostics(); registerManifest({ [name]: async () => ({ notAClass: 42 }) }); render(name); await settle(); - document.removeEventListener(EVENTS.diagnostic, onDiagnostic); + log.stop(); - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0]).toMatchObject({ + expect(log.entries).toHaveLength(1); + expect(log.entries[0]).toMatchObject({ severity: 'error', code: DIAGNOSTICS.component.loadFailed, component: name, @@ -490,7 +477,7 @@ describe('registerManifest collisions and failures', () => { message: `"${token}" resolved to a component named "${name}".`, }, ]); - expect(el[INSTANCES]).toBeUndefined(); + expect(getInstance(el, name)).toBeUndefined(); log.stop(); }); }); @@ -509,7 +496,7 @@ describe('a dynamic import declared in config.components', () => { await settle(); expect(child.importCount()).toBe(1); - expect(instanceOf(el, child.name)?.$isMounted).toBe(true); + expect(getInstance(el, child.name)?.$isMounted).toBe(true); }); it('registers the class half of a mixed map right away', async () => { @@ -527,14 +514,14 @@ describe('a dynamic import declared in config.components', () => { const siblingEl = render(eagerName); await settle(); - expect(instanceOf(siblingEl, eagerName)).toBeInstanceOf(Sibling); + expect(getInstance(siblingEl, eagerName)).toBeInstanceOf(Sibling); expect(lazy.importCount()).toBe(0); const lazyEl = render(lazy.name); await settle(); expect(lazy.importCount()).toBe(1); - expect(instanceOf(lazyEl, lazy.name)?.$isMounted).toBe(true); + expect(getInstance(lazyEl, lazy.name)?.$isMounted).toBe(true); }); it('imports the module once for every element declaring the child', async () => { @@ -547,8 +534,8 @@ describe('a dynamic import declared in config.components', () => { await settle(); expect(child.importCount()).toBe(1); - expect(instanceOf(first, child.name)?.$isMounted).toBe(true); - expect(instanceOf(second, child.name)?.$isMounted).toBe(true); + expect(getInstance(first, child.name)?.$isMounted).toBe(true); + expect(getInstance(second, child.name)?.$isMounted).toBe(true); }); it('imports once when two parents declare the same child', async () => { @@ -588,8 +575,8 @@ describe('a dynamic import declared in config.components', () => { expect(parentImports).toBe(1); expect(child.importCount()).toBe(1); - expect(instanceOf(root, parentName)?.$isMounted).toBe(true); - expect(instanceOf(childEl, child.name)?.$isMounted).toBe(true); + expect(getInstance(root, parentName)?.$isMounted).toBe(true); + expect(getInstance(childEl, child.name)?.$isMounted).toBe(true); }); it('honours the element data-mount of a child nobody has imported', async () => { @@ -606,7 +593,7 @@ describe('a dynamic import declared in config.components', () => { await waitFor(() => child.importCount() > 0); expect(child.importCount()).toBe(1); - expect(instanceOf(el, child.name)?.$isMounted).toBe(true); + expect(getInstance(el, child.name)?.$isMounted).toBe(true); }); it('reports a value which is neither a class nor an importer', async () => { @@ -659,7 +646,7 @@ describe('the family a subclass inherits', () => { const el = render(childName); await settle(); - expect(instanceOf(el, childName)).toBeInstanceOf(Child); + expect(getInstance(el, childName)).toBeInstanceOf(Child); }); it('registers a lazy child its base declared', async () => { @@ -672,7 +659,7 @@ describe('the family a subclass inherits', () => { await settle(); expect(child.importCount()).toBe(1); - expect(instanceOf(el, child.name)?.$isMounted).toBe(true); + expect(getInstance(el, child.name)?.$isMounted).toBe(true); }); it('lets a subclass override one key without dropping the rest', async () => { @@ -688,9 +675,9 @@ describe('the family a subclass inherits', () => { await settle(); expect(stale).not.toHaveBeenCalled(); - expect(instanceOf(overriddenEl, overridden.name)).toBeInstanceOf(overridden.Lazy); + expect(getInstance(overriddenEl, overridden.name)).toBeInstanceOf(overridden.Lazy); expect(kept.importCount()).toBe(1); - expect(instanceOf(keptEl, kept.name)?.$isMounted).toBe(true); + expect(getInstance(keptEl, kept.name)?.$isMounted).toBe(true); }); it('registers a shared family once when the base registers too', async () => { @@ -706,7 +693,7 @@ describe('the family a subclass inherits', () => { expect(log.codes).toEqual([]); expect(child.importCount()).toBe(1); - expect(instanceOf(el, child.name)?.$isMounted).toBe(true); + expect(getInstance(el, child.name)?.$isMounted).toBe(true); log.stop(); }); }); diff --git a/packages/v4/src/context-subscription.spec.ts b/packages/v4/src/context-subscription.spec.ts index 0110518a9..60210b702 100644 --- a/packages/v4/src/context-subscription.spec.ts +++ b/packages/v4/src/context-subscription.spec.ts @@ -2,11 +2,10 @@ import { afterEach, describe, expect, it } from 'vitest'; import { Base } from './Base.js'; import { subscribeContext } from './context-subscription.js'; import { createContext, provideContext, provideRootContext, type ContextKey } from './context.js'; -import { DIAGNOSTICS, type ToolkitDiagnosticDetail } from './diagnostic-contract.js'; -import { EVENTS } from './events.js'; +import { DIAGNOSTICS } from './diagnostic-contract.js'; import { getInstance } from './instances.js'; import { registerComponent } from './registry.js'; -import { resetDom, settle } from './test/index.js'; +import { captureDiagnostics, resetDom, settle } from './test/index.js'; afterEach(resetDom); @@ -130,7 +129,7 @@ describe('subscribeContext', () => { `); await settle(); - const member = getInstance(root.querySelector('span')!, 'ReanswerMember')!; + const member = getInstance(root.querySelector('span'), 'ReanswerMember')!; expect(member.seen).toEqual(['page']); root.querySelector('#scope')?.setAttribute('data-component', 'ReanswerScope'); @@ -165,7 +164,7 @@ describe('subscribeContext', () => { `); await settle(); - const member = getInstance(root.querySelector('span')!, 'DistanceMember')!; + const member = getInstance(root.querySelector('span'), 'DistanceMember')!; expect(member.seen).toEqual(['inner']); root.querySelector('#outer')?.setAttribute('data-component', 'DistanceScope'); @@ -235,15 +234,11 @@ describe('subscribeContext', () => { }); it('isolates callback and teardown failures', () => { - const diagnostics: ToolkitDiagnosticDetail[] = []; const key = createContext('failure-isolation'); const root = render(''); const consumer = root.querySelector('span') as Element; provideContext(root, key, { name: 'root' }); - consumer.addEventListener(EVENTS.diagnostic, (event) => { - event.preventDefault(); - diagnostics.push((event as CustomEvent).detail); - }); + const log = captureDiagnostics(consumer); const failed = subscribeContext(consumer, key, () => { throw new Error('callback'); @@ -259,11 +254,12 @@ describe('subscribeContext', () => { failed(); teardownFailed(); expect(seen).toEqual(['root']); - expect(diagnostics.map(({ code }) => code)).toEqual([ + expect(log.codes).toEqual([ DIAGNOSTICS.callback.contextSubscriptionFailed, DIAGNOSTICS.callback.contextTeardownFailed, ]); - expect(diagnostics.every(({ severity }) => severity === 'error')).toBe(true); + expect(log.entries.every(({ severity }) => severity === 'error')).toBe(true); + log.stop(); }); it('does not keep a discarded consumer alive after it was answered', async () => { diff --git a/packages/v4/src/context.spec.ts b/packages/v4/src/context.spec.ts index de1a55d9f..8ffd757f6 100644 --- a/packages/v4/src/context.spec.ts +++ b/packages/v4/src/context.spec.ts @@ -9,12 +9,11 @@ import { signal, type Signal, } from './context.js'; -import { DIAGNOSTICS, type ToolkitDiagnosticDetail } from './diagnostic-contract.js'; -import { EVENTS } from './events.js'; +import { DIAGNOSTICS } from './diagnostic-contract.js'; import { getInstance } from './instances.js'; import { registerComponent } from './registry.js'; import { renderTodoList } from './todo.fixtures.js'; -import { resetDom, settle } from './test/index.js'; +import { captureDiagnostics, resetDom, settle } from './test/index.js'; afterEach(resetDom); @@ -60,15 +59,7 @@ describe('Signal', () => { it('returns a usable unsubscribe after an immediate subscriber failure', () => { const cell = signal(0); const failure = new Error('immediate signal failure'); - const diagnostics: ToolkitDiagnosticDetail[] = []; - document.addEventListener( - EVENTS.diagnostic, - (event) => { - event.preventDefault(); - diagnostics.push((event as CustomEvent).detail); - }, - { once: true }, - ); + const log = captureDiagnostics(); const unsubscribe = cell.subscribe( () => { @@ -82,7 +73,7 @@ describe('Signal', () => { cell.value = 1; expect(seen).toEqual([1]); - expect(diagnostics).toEqual([ + expect(log.entries).toEqual([ { severity: 'error', code: DIAGNOSTICS.callback.signalFailed, @@ -90,17 +81,13 @@ describe('Signal', () => { error: failure, }, ]); + log.stop(); }); it('isolates update failures without staling a reentrant delivery round', () => { const cell = signal(0); const failure = new Error('signal update failure'); - const diagnostics: ToolkitDiagnosticDetail[] = []; - const listener = (event: Event) => { - event.preventDefault(); - diagnostics.push((event as CustomEvent).detail); - }; - document.addEventListener(EVENTS.diagnostic, listener); + const log = captureDiagnostics(); const broken: number[] = []; const later: number[] = []; cell.subscribe((value) => { @@ -113,15 +100,15 @@ describe('Signal', () => { cell.subscribe((value) => later.push(value)); cell.value = 1; - document.removeEventListener(EVENTS.diagnostic, listener); + log.stop(); expect(cell.value).toBe(2); expect(broken).toEqual([1, 2]); expect(later).toEqual([2]); - expect(diagnostics).toHaveLength(2); - expect(diagnostics.every(({ code }) => code === DIAGNOSTICS.callback.signalFailed)).toBe(true); + expect(log.entries).toHaveLength(2); + expect(log.entries.every(({ code }) => code === DIAGNOSTICS.callback.signalFailed)).toBe(true); expect( - diagnostics.every((detail) => detail.severity === 'error' && detail.error === failure), + log.entries.every((detail) => detail.severity === 'error' && detail.error === failure), ).toBe(true); }); @@ -445,7 +432,7 @@ describe('provide/inject', () => { const counter = getInstance(root, 'Counter')!; const button = root.querySelector('button'); - const control = getInstance(button!, 'CounterBtn')!; + const control = getInstance(button, 'CounterBtn')!; button?.click(); button?.click(); diff --git a/packages/v4/src/decorators.spec.ts b/packages/v4/src/decorators.spec.ts index 7eca02cb2..5724f3361 100644 --- a/packages/v4/src/decorators.spec.ts +++ b/packages/v4/src/decorators.spec.ts @@ -644,7 +644,7 @@ describe('@on', () => { const parent = getInstance(root, 'DecoParent')!; const child = getInstance( - root.querySelector('[data-component="DecoChild"]')!, + root.querySelector('[data-component="DecoChild"]'), 'DecoChild', )!; @@ -660,7 +660,7 @@ describe('@on', () => { const parent = getInstance(root, 'DecoParent')!; const child = getInstance( - root.querySelector('[data-component="DecoChild"]')!, + root.querySelector('[data-component="DecoChild"]'), 'DecoChild', )!; @@ -718,7 +718,7 @@ describe('@on', () => { const parent = getInstance(root, 'DecoParent')!; const child = getInstance( - root.querySelector('[data-component="DecoChild"]')!, + root.querySelector('[data-component="DecoChild"]'), 'DecoChild', )!; @@ -849,9 +849,9 @@ describe('@on', () => { await settle(); const parent = getInstance(root, 'SubTargetParent')!; - const sub = getInstance(root.querySelector('[data-component="SubKind"]')!, 'SubKind')!; + const sub = getInstance(root.querySelector('[data-component="SubKind"]'), 'SubKind')!; const base = getInstance( - root.querySelector('[data-component="BaseKind"]')!, + root.querySelector('[data-component="BaseKind"]'), 'BaseKind', )!; @@ -1043,7 +1043,7 @@ describe('@on stacked with @read / @write', () => { const instance = getInstance(root, 'PhasedHandlers')!; const child = getInstance( - root.querySelector('[data-component="PhasedChild"]')!, + root.querySelector('[data-component="PhasedChild"]'), 'PhasedChild', )!; @@ -1101,7 +1101,7 @@ describe('@provide / @inject', () => { const parent = getInstance(root, 'DecoParent')!; const child = getInstance( - root.querySelector('[data-component="DecoChild"]')!, + root.querySelector('[data-component="DecoChild"]'), 'DecoChild', )!; diff --git a/packages/v4/src/diagnostics.spec.ts b/packages/v4/src/diagnostics.spec.ts index c0203e19f..c0632d0c9 100644 --- a/packages/v4/src/diagnostics.spec.ts +++ b/packages/v4/src/diagnostics.spec.ts @@ -8,7 +8,17 @@ import { } from './diagnostic-contract.js'; import { reportDiagnostic, warn, warnOnce } from './diagnostics.js'; import { EVENTS } from './events.js'; +import { captureDiagnostics } from './test/index.js'; +/** + * The listeners below are hand-rolled on purpose: this spec owns the channel + * that `captureDiagnostics()` reads, so the helper cannot instrument it. + * Every remaining raw listener asserts something the helper hides — + * cancellation itself, the order against the default sink, the target the + * dispatch started on, or what a listener mutating the detail does — and + * `captureDiagnostics()` cancels every event and exposes no target, which + * would make each of them vacuous. + */ describe('diagnostics', () => { it('exposes exact deeply frozen stable codes', () => { expect(DIAGNOSTICS).toEqual({ @@ -257,19 +267,15 @@ describe('diagnostics', () => { it('deduplicates by weak owner and misuse key, not by message', () => { const firstOwner = {}; const secondOwner = {}; - const events: ToolkitDiagnosticDetail[] = []; - const listener = (rawEvent: Event) => { - rawEvent.preventDefault(); - events.push((rawEvent as CustomEvent).detail); - }; - document.addEventListener(EVENTS.diagnostic, listener); + // The one test here that only counts dispatches, so the helper fits. + const log = captureDiagnostics(); warnOnce(firstOwner, 'a', DIAGNOSTICS.registry.conflict, 'Same message.'); warnOnce(firstOwner, 'a', DIAGNOSTICS.registry.conflict, 'Same message.'); warnOnce(firstOwner, 'b', DIAGNOSTICS.registry.conflict, 'Same message.'); warnOnce(secondOwner, 'a', DIAGNOSTICS.registry.conflict, 'Same message.'); - document.removeEventListener(EVENTS.diagnostic, listener); - expect(events).toHaveLength(3); + log.stop(); + expect(log.entries).toHaveLength(3); }); }); diff --git a/packages/v4/src/dom-mutations.spec.ts b/packages/v4/src/dom-mutations.spec.ts index 66971a1af..13329853e 100644 --- a/packages/v4/src/dom-mutations.spec.ts +++ b/packages/v4/src/dom-mutations.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { FRAMEWORK_ATTRIBUTES } from './attributes.js'; import { Base, type BaseConfig, type OptionChange } from './Base.js'; -import { DIAGNOSTICS, type ToolkitDiagnosticDetail } from './diagnostic-contract.js'; +import { DIAGNOSTICS } from './diagnostic-contract.js'; import { isObservedDOMAttribute, registerDOMOptionAttributes, @@ -11,12 +11,10 @@ import { whenDOMSettled, type AttributeChange, } from './dom-mutations.js'; -import { EVENTS } from './events.js'; import { getInstance } from './instances.js'; -import { INSTANCES } from './protocol-symbols.js'; import { registerComponent } from './registry.js'; import { SWAP_MODES, swap } from './swap.js'; -import { resetDom } from './test/index.js'; +import { captureDiagnostics, resetDom } from './test/index.js'; let counter = 0; @@ -55,7 +53,7 @@ describe('whenDOMSettled', () => { document.body.append(el); await whenDOMSettled(); - expect(el[INSTANCES]?.get(name)?.$isMounted).toBe(true); + expect(getInstance(el, name)?.$isMounted).toBe(true); }); it('follows mutations created by eager lifecycle work', async () => { @@ -83,7 +81,7 @@ describe('whenDOMSettled', () => { await whenDOMSettled(); const child = parent.firstElementChild; - expect(child?.[INSTANCES]?.get(childName)?.$isMounted).toBe(true); + expect(getInstance(child, childName)?.$isMounted).toBe(true); }); it('does not wait for a conditional mount strategy', async () => { @@ -100,7 +98,7 @@ describe('whenDOMSettled', () => { document.body.append(el); await whenDOMSettled(); - expect(el[INSTANCES]?.get(name)).toBeUndefined(); + expect(getInstance(el, name)).toBeUndefined(); }); it('waits for eager teardown caused by removal', async () => { @@ -118,7 +116,7 @@ describe('whenDOMSettled', () => { el.setAttribute('data-component', name); document.body.append(el); await whenDOMSettled(); - const instance = el[INSTANCES]?.get(name) as Removed; + const instance = getInstance(el, name) as Removed; el.remove(); await whenDOMSettled(); @@ -230,11 +228,7 @@ describe('watchAttributes', () => { const el = document.createElement('div'); document.body.append(el); const failure = new Error('watcher failure'); - const diagnostics: ToolkitDiagnosticDetail[] = []; - el.addEventListener(EVENTS.diagnostic, (event) => { - event.preventDefault(); - diagnostics.push((event as CustomEvent).detail); - }); + const log = captureDiagnostics(el); const changes: AttributeChange[] = []; trackedWatcher(el, () => { throw failure; @@ -244,7 +238,7 @@ describe('watchAttributes', () => { el.setAttribute(VIRTUAL_ATTRIBUTE, 'open()'); await whenDOMSettled(); - expect(diagnostics).toEqual([ + expect(log.entries).toEqual([ { severity: 'error', code: DIAGNOSTICS.callback.attributeWatcherFailed, @@ -253,6 +247,7 @@ describe('watchAttributes', () => { }, ]); expect(changes).toEqual([{ name: VIRTUAL_ATTRIBUTE, value: 'open()', previousValue: null }]); + log.stop(); }); it('delivers after declared options in the same mutation batch', async () => { @@ -317,7 +312,7 @@ describe('watchAttributes', () => { await whenDOMSettled(); expect(instance.$isMounted).toBe(false); - expect(el[INSTANCES]?.has(name)).toBe(false); + expect(getInstance(el, name)).toBeUndefined(); expect(calls).toEqual(['cleanup']); }); @@ -465,7 +460,7 @@ describe('setDOMMutationProcessor', () => { // The registry installed the one processor at import time; a second caller // does not replace it and does not get a second delivery of the batch. - expect(el[INSTANCES]?.get(name)?.$isMounted).toBe(true); + expect(getInstance(el, name)?.$isMounted).toBe(true); expect(later).not.toHaveBeenCalled(); }); }); diff --git a/packages/v4/src/group.spec.ts b/packages/v4/src/group.spec.ts index 467859e33..f0e2ca91a 100644 --- a/packages/v4/src/group.spec.ts +++ b/packages/v4/src/group.spec.ts @@ -211,11 +211,11 @@ function disclosureMarkup(id: string, open = false): string { } function disclosure(root: ParentNode, id: string): Disclosure { - return getInstance(root.querySelector(`#${id}`)!, 'Disclosure')!; + return getInstance(root.querySelector(`#${id}`), 'Disclosure')!; } function group(root: ParentNode, id: string): DisclosureGroup { - return getInstance(root.querySelector(`#${id}`)!, 'DisclosureGroup')!; + return getInstance(root.querySelector(`#${id}`), 'DisclosureGroup')!; } describe('a group of disclosures', () => { diff --git a/packages/v4/src/instances.spec.ts b/packages/v4/src/instances.spec.ts index 363541a9f..46ef1c48a 100644 --- a/packages/v4/src/instances.spec.ts +++ b/packages/v4/src/instances.spec.ts @@ -87,6 +87,9 @@ describe('getInstances by name', () => { await settle(); expect(el.matches('[data-component~="Unregistered"]')).toBe(true); + // The raw read is the premise, not the assertion: it states that no map + // exists at all, so the empty result below cannot be an empty map. A + // lookup cannot say that, and would test these exports with themselves. expect(el[INSTANCES]).toBeUndefined(); expect(getInstances('Unregistered')).toEqual([]); }); diff --git a/packages/v4/src/instances.ts b/packages/v4/src/instances.ts index a7f472fb2..96b4d1d0f 100644 --- a/packages/v4/src/instances.ts +++ b/packages/v4/src/instances.ts @@ -120,10 +120,25 @@ export function getUnmountedInstances( * answer to "is this element's instance there yet", which every plural form * loses by returning a list. * + * `el` accepts `null` so that a `querySelector()` result can be passed + * straight through. The two ways of having no instance — no element, and an + * element without one — are the same answer to the caller, and `undefined` + * says it for both. Narrowing the parameter to `Element` would buy nothing + * back: the body reads an optional map either way, so the only thing a + * stricter type produces is a `!` at every call site that asserts something + * the function never needed. + * + * The *return* stays `T | undefined`, and that asymmetry is deliberate. An + * absent element is a fact the caller may reasonably not know; an absent + * instance is a fact the caller must handle. + * * There is deliberately no `getMountedInstance`. The result is one object, so * a caller who needs the live one reads `.$isMounted` on it — a second export * would only hide that check behind a `undefined` that means two things. */ -export function getInstance(el: Element, name: string): T | undefined { - return el[INSTANCES]?.get(name) as T | undefined; +export function getInstance( + el: Element | null, + name: string, +): T | undefined { + return el?.[INSTANCES]?.get(name) as T | undefined; } diff --git a/packages/v4/src/mount-strategies.spec.ts b/packages/v4/src/mount-strategies.spec.ts index e49cb2d2b..c5dbbe080 100644 --- a/packages/v4/src/mount-strategies.spec.ts +++ b/packages/v4/src/mount-strategies.spec.ts @@ -2,10 +2,10 @@ import { afterEach, describe, expect, it, vi } 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 { getInstance } from './instances.js'; import { registerComponent } from './registry.js'; import { getSharedRuntimeSlot } from './shared-runtime.js'; -import { resetDom, settle, waitFor } from './test/index.js'; +import { captureDiagnostics, resetDom, settle, waitFor } from './test/index.js'; /** * The page-wide interaction signal is a fact about the visit, so it survives a @@ -67,10 +67,6 @@ function render(name: string, attributes: Record = {}, style = O return el; } -function instanceOf(el: Element, name: string): T | undefined { - return el[INSTANCES]?.get(name) as T | undefined; -} - /** * A bounded quiet period, for the assertions that nothing mounted. Every * positive wait below polls for the state it expects; an absence cannot be @@ -93,7 +89,7 @@ describe('eager (default)', () => { const el = render(name); await settle(); - expect(instanceOf(el, name)?.$isMounted).toBe(true); + expect(getInstance(el, name)?.$isMounted).toBe(true); }); }); @@ -103,7 +99,7 @@ describe('data-mount="visible"', () => { const el = render(name, { 'data-mount': 'visible' }, OFFSCREEN); await quiet(); - expect(el[INSTANCES]?.get(name)).toBeUndefined(); + expect(getInstance(el, name)).toBeUndefined(); }); it('mounts once with a root margin and stays mounted afterwards', async () => { @@ -112,7 +108,7 @@ describe('data-mount="visible"', () => { await quiet(); el.setAttribute('style', ONSCREEN); - const instance = await waitFor(() => instanceOf(el, name)); + const instance = await waitFor(() => getInstance(el, name)); expect(instance?.$isMounted).toBe(true); el.setAttribute('style', OFFSCREEN); @@ -126,7 +122,7 @@ describe('data-mount="in-view"', () => { const { name, Tracked } = defineTracked(); type Tracked = InstanceType; const el = render(name, { 'data-mount': 'in-view' }, ONSCREEN); - const instance = await waitFor(() => instanceOf(el, name)); + const instance = await waitFor(() => getInstance(el, name)); expect(instance?.$isMounted).toBe(true); expect(instance?.mounts).toBe(1); @@ -138,7 +134,7 @@ describe('data-mount="in-view"', () => { el.setAttribute('style', ONSCREEN); await waitFor(() => instance?.mounts === 2); - expect(instanceOf(el, name)).toBe(instance); + expect(getInstance(el, name)).toBe(instance); expect(instance?.$isMounted).toBe(true); expect(instance?.mounts).toBe(2); }); @@ -152,11 +148,11 @@ describe('data-mount="interaction"', () => { // cursor — which the top-left corner of the viewport often is. const el = render(name, { 'data-mount': 'interaction' }, OFFSCREEN); await settle(); - expect(el[INSTANCES]?.get(name)).toBeUndefined(); + expect(getInstance(el, name)).toBeUndefined(); el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); await settle(); - expect(instanceOf(el, name)?.$isMounted).toBe(true); + expect(getInstance(el, name)?.$isMounted).toBe(true); }); }); @@ -167,16 +163,16 @@ describe('data-mount="interaction:page"', () => { const { name } = defineTracked(); const el = render(name, { 'data-mount': 'interaction:page' }, OFFSCREEN); await settle(); - expect(el[INSTANCES]?.get(name)).toBeUndefined(); + expect(getInstance(el, name)).toBeUndefined(); // What the element-scoped strategy mounts on says nothing about the page. el.dispatchEvent(new PointerEvent('pointerenter')); await settle(); - expect(el[INSTANCES]?.get(name)).toBeUndefined(); + expect(getInstance(el, name)).toBeUndefined(); document.body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); await settle(); - expect(instanceOf(el, name)?.$isMounted).toBe(true); + expect(getInstance(el, name)?.$isMounted).toBe(true); }); it('mounts every waiting element from one interaction, wherever it lands', async () => { @@ -188,8 +184,8 @@ describe('data-mount="interaction:page"', () => { document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })); await settle(); - expect(instanceOf(first, name)?.$isMounted).toBe(true); - expect(instanceOf(second, name)?.$isMounted).toBe(true); + expect(getInstance(first, name)?.$isMounted).toBe(true); + expect(getInstance(second, name)?.$isMounted).toBe(true); }); it('listens once for the page rather than once per waiting element', async () => { @@ -236,7 +232,7 @@ describe('data-mount="interaction:page"', () => { const late = render(name, { 'data-mount': 'interaction:page' }, OFFSCREEN); await settle(); - expect(instanceOf(late, name)?.$isMounted).toBe(true); + expect(getInstance(late, name)?.$isMounted).toBe(true); }); it('stops waiting for an element that left before the interaction', async () => { @@ -249,7 +245,7 @@ describe('data-mount="interaction:page"', () => { document.body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); await settle(); - expect(el[INSTANCES]?.get(name)).toBeUndefined(); + expect(getInstance(el, name)).toBeUndefined(); }); }); @@ -257,9 +253,9 @@ describe('data-mount="idle"', () => { it('mounts when the main thread goes idle', async () => { const { name } = defineTracked(); const el = render(name, { 'data-mount': 'idle' }); - await waitFor(() => instanceOf(el, name)?.$isMounted); + await waitFor(() => getInstance(el, name)?.$isMounted); - expect(instanceOf(el, name)?.$isMounted).toBe(true); + expect(getInstance(el, name)?.$isMounted).toBe(true); }); }); @@ -273,15 +269,15 @@ describe('data-mount="media:…"', () => { // makes the failing query's absence below mean something. await waitFor( () => - instanceOf(document.querySelector(`[data-component="${matching.name}"]`)!, matching.name) + getInstance(document.querySelector(`[data-component="${matching.name}"]`), matching.name) ?.$isMounted, ); expect( - instanceOf(document.querySelector(`[data-component="${matching.name}"]`)!, matching.name) + getInstance(document.querySelector(`[data-component="${matching.name}"]`), matching.name) ?.$isMounted, ).toBe(true); - expect(narrow[INSTANCES]?.get(failing.name)).toBeUndefined(); + expect(getInstance(narrow, failing.name)).toBeUndefined(); }); }); @@ -290,11 +286,11 @@ describe('config.mountStrategy', () => { const { name } = defineTracked({ mountStrategy: 'visible:200px 0px' }); const el = render(name, {}, OFFSCREEN); await quiet(); - expect(el[INSTANCES]?.get(name)).toBeUndefined(); + expect(getInstance(el, name)).toBeUndefined(); el.setAttribute('style', ONSCREEN); - await waitFor(() => instanceOf(el, name)?.$isMounted); - expect(instanceOf(el, name)?.$isMounted).toBe(true); + await waitFor(() => getInstance(el, name)?.$isMounted); + expect(getInstance(el, name)?.$isMounted).toBe(true); }); it('is overridden by the element attribute', async () => { @@ -302,17 +298,17 @@ describe('config.mountStrategy', () => { const el = render(name, { 'data-mount': 'eager' }, OFFSCREEN); await settle(); - expect(instanceOf(el, name)?.$isMounted).toBe(true); + expect(getInstance(el, name)?.$isMounted).toBe(true); }); 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 quiet(); - expect(el[INSTANCES]?.get(name)).toBeUndefined(); + expect(getInstance(el, name)).toBeUndefined(); el.setAttribute('style', ONSCREEN); - const instance = await waitFor(() => instanceOf(el, name)); + const instance = await waitFor(() => getInstance(el, name)); expect(instance?.$isMounted).toBe(true); el.setAttribute('style', OFFSCREEN); @@ -324,12 +320,12 @@ describe('config.mountStrategy', () => { const { name } = defineTracked({ mountStrategy: 'visible' }); const el = render(name, { 'data-mount': 'interaction' }, OFFSCREEN); await settle(); - expect(el[INSTANCES]?.get(name)).toBeUndefined(); + expect(getInstance(el, name)).toBeUndefined(); el.removeAttribute('data-mount'); el.setAttribute('style', ONSCREEN); - await waitFor(() => instanceOf(el, name)?.$isMounted); - expect(instanceOf(el, name)?.$isMounted).toBe(true); + await waitFor(() => getInstance(el, name)?.$isMounted); + expect(getInstance(el, name)?.$isMounted).toBe(true); }); it('is inherited by a subclass that declares a config of its own', async () => { @@ -344,11 +340,11 @@ describe('config.mountStrategy', () => { const el = render(name, {}, OFFSCREEN); await quiet(); - expect(el[INSTANCES]?.get(name)).toBeUndefined(); + expect(getInstance(el, name)).toBeUndefined(); el.setAttribute('style', ONSCREEN); - await waitFor(() => instanceOf(el, name)?.$isMounted); - expect(instanceOf(el, name)?.$isMounted).toBe(true); + await waitFor(() => getInstance(el, name)?.$isMounted); + expect(getInstance(el, name)?.$isMounted).toBe(true); }); it('is overridden by a subclass declaring its own strategy', async () => { @@ -363,7 +359,7 @@ describe('config.mountStrategy', () => { const el = render(name, {}, OFFSCREEN); await settle(); - expect(instanceOf(el, name)?.$isMounted).toBe(true); + expect(getInstance(el, name)?.$isMounted).toBe(true); }); }); @@ -372,11 +368,11 @@ describe('dynamic data-mount', () => { const { name } = defineTracked(); const el = render(name, { 'data-mount': 'visible' }, OFFSCREEN); await quiet(); - expect(el[INSTANCES]?.get(name)).toBeUndefined(); + expect(getInstance(el, name)).toBeUndefined(); el.setAttribute('data-mount', 'eager'); await settle(); - expect(instanceOf(el, name)?.$isMounted).toBe(true); + expect(getInstance(el, name)?.$isMounted).toBe(true); }); }); @@ -397,7 +393,7 @@ describe('invalid data-mount', () => { await waitFor(() => events.length > 0); - expect(el[INSTANCES]?.get(name)).toBeUndefined(); + expect(getInstance(el, name)).toBeUndefined(); expect(events).toHaveLength(1); const failure = events[0].detail.error; expect(failure).toBeInstanceOf(Error); @@ -422,31 +418,28 @@ describe('invalid data-mount', () => { el.setAttribute('data-mount', 'eager'); await settle(); - expect(instanceOf(el, name)?.$isMounted).toBe(true); + expect(getInstance(el, name)?.$isMounted).toBe(true); }, ); it('does not stop a valid sibling in the same reconciliation batch', async () => { const broken = defineTracked(); const healthy = defineTracked(); - const diagnostics: ToolkitDiagnosticDetail[] = []; const brokenEl = document.createElement('div'); brokenEl.setAttribute('data-component', broken.name); brokenEl.setAttribute('data-mount', 'in-view:not-a-root-margin'); - brokenEl.addEventListener(EVENTS.diagnostic, (event) => { - event.preventDefault(); - diagnostics.push((event as CustomEvent).detail); - }); + const log = captureDiagnostics(brokenEl); const healthyEl = document.createElement('div'); healthyEl.setAttribute('data-component', healthy.name); document.body.append(brokenEl, healthyEl); - await waitFor(() => instanceOf(healthyEl, healthy.name)?.$isMounted); + await waitFor(() => getInstance(healthyEl, healthy.name)?.$isMounted); - expect(brokenEl[INSTANCES]?.get(broken.name)).toBeUndefined(); - expect(diagnostics).toHaveLength(1); - expect(instanceOf(healthyEl, healthy.name)?.$isMounted).toBe(true); + expect(getInstance(brokenEl, broken.name)).toBeUndefined(); + expect(log.entries).toHaveLength(1); + expect(getInstance(healthyEl, healthy.name)?.$isMounted).toBe(true); + log.stop(); }); }); @@ -460,7 +453,7 @@ describe('teardown', () => { await quiet(); el.setAttribute('style', ONSCREEN); await quiet(); - expect(el[INSTANCES]?.get(name)).toBeUndefined(); + expect(getInstance(el, name)).toBeUndefined(); }); it('re-schedules an element moved in a single batch', async () => { @@ -472,7 +465,7 @@ describe('teardown', () => { const el = document.createElement('div'); el.setAttribute('data-component', name); from.append(el); - const instance = await waitFor(() => instanceOf(el, name)); + const instance = await waitFor(() => getInstance(el, name)); expect(instance?.$isMounted).toBe(true); expect(instance?.mounts).toBe(1); @@ -480,7 +473,7 @@ describe('teardown', () => { // mount cycle and starts another without replacing the instance. to.append(el); await waitFor(() => instance?.mounts === 2); - expect(instanceOf(el, name)).toBe(instance); + expect(getInstance(el, name)).toBe(instance); expect(instance?.$isMounted).toBe(true); expect(instance?.unmounts).toBe(1); expect(instance?.mounts).toBe(2); @@ -490,14 +483,14 @@ describe('teardown', () => { it('re-schedules an element that comes back', async () => { const { name } = defineTracked(); const el = render(name, { 'data-mount': 'visible' }, ONSCREEN); - await waitFor(() => instanceOf(el, name)?.$isMounted); - expect(instanceOf(el, name)?.$isMounted).toBe(true); + await waitFor(() => getInstance(el, name)?.$isMounted); + expect(getInstance(el, name)?.$isMounted).toBe(true); el.remove(); - await waitFor(() => instanceOf(el, name)?.$isMounted === false); + await waitFor(() => getInstance(el, name)?.$isMounted === false); document.body.append(el); - await waitFor(() => instanceOf(el, name)?.$isMounted); - expect(instanceOf(el, name)?.$isMounted).toBe(true); + await waitFor(() => getInstance(el, name)?.$isMounted); + expect(getInstance(el, name)?.$isMounted).toBe(true); }); }); @@ -513,12 +506,12 @@ describe('several components on one element', () => { document.body.append(el); await quiet(); - expect(el[INSTANCES]?.get(first.name)).toBeUndefined(); - expect(el[INSTANCES]?.get(second.name)).toBeUndefined(); + expect(getInstance(el, first.name)).toBeUndefined(); + expect(getInstance(el, second.name)).toBeUndefined(); el.setAttribute('style', ONSCREEN); - await waitFor(() => instanceOf(el, first.name)?.$isMounted); - expect(instanceOf(el, first.name)?.$isMounted).toBe(true); - expect(instanceOf(el, second.name)?.$isMounted).toBe(true); + await waitFor(() => getInstance(el, first.name)?.$isMounted); + expect(getInstance(el, first.name)?.$isMounted).toBe(true); + expect(getInstance(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 e9fae220f..845777af1 100644 --- a/packages/v4/src/negotiated-events.spec.ts +++ b/packages/v4/src/negotiated-events.spec.ts @@ -18,19 +18,15 @@ afterEach(async () => { await resetDom(); }); +/** {@link captureDiagnostics} bounded to one awaited call. */ async function catchDiagnostics(run: () => Promise): Promise { - const diagnostics: ToolkitDiagnosticDetail[] = []; - const onDiagnostic = (event: Event) => { - event.preventDefault(); - diagnostics.push((event as CustomEvent).detail); - }; - document.addEventListener(EVENTS.diagnostic, onDiagnostic); + const log = captureDiagnostics(); try { await run(); } finally { - document.removeEventListener(EVENTS.diagnostic, onDiagnostic); + log.stop(); } - return diagnostics; + return log.entries; } function renderTarget() { @@ -163,11 +159,7 @@ describe('domUpdate()', () => { // 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) => { - event.preventDefault(); - diagnostics.push((event as CustomEvent).detail); - }); + const log = captureDiagnostics(target); const runner = () => Promise.resolve(); claim(outer, runner); @@ -175,8 +167,9 @@ describe('domUpdate()', () => { await domUpdate(target, () => target.setAttribute('data-applied-again', 'yes')); expect(target.dataset).toMatchObject({ applied: 'yes', appliedAgain: 'yes' }); - expect(diagnostics.map(({ code }) => code)).toEqual([DIAGNOSTICS.protocol.unappliedDomUpdate]); + expect(log.codes).toEqual([DIAGNOSTICS.protocol.unappliedDomUpdate]); expect(warn).not.toHaveBeenCalled(); + log.stop(); }); it('warns and ignores a wrap() registration made after dispatch', async () => { diff --git a/packages/v4/src/props.spec.ts b/packages/v4/src/props.spec.ts index 7d2a48ef2..ffcc486cd 100644 --- a/packages/v4/src/props.spec.ts +++ b/packages/v4/src/props.spec.ts @@ -174,7 +174,7 @@ describe('a component declared with a props type parameter', () => { `; await settle(); - const instance = getInstance(document.querySelector('form')!, 'Extensible')!; + const instance = getInstance(document.querySelector('form'), 'Extensible')!; expect(instance.$options.target).toBe('here'); expect(instance.$refs.btn).toBeInstanceOf(HTMLButtonElement); expect(instance.$refs.items).toHaveLength(2); diff --git a/packages/v4/src/registry.spec.ts b/packages/v4/src/registry.spec.ts index 6b1f33072..349fb78f5 100644 --- a/packages/v4/src/registry.spec.ts +++ b/packages/v4/src/registry.spec.ts @@ -3,7 +3,6 @@ import { Base, type BaseConfig } from './Base.js'; import { DIAGNOSTICS, type ToolkitDiagnosticDetail } from './diagnostic-contract.js'; import { EVENTS } from './events.js'; import { getInstance } from './instances.js'; -import { INSTANCES } from './protocol-symbols.js'; import { registerComponent } from './registry.js'; import { renderTodoList, TodoItem, TodoList } from './todo.fixtures.js'; import { captureDiagnostics, resetDom, settle } from './test/index.js'; @@ -54,7 +53,7 @@ describe('registry', () => { const el = document.createElement('li'); document.body.append(el); await settle(); - expect(el[INSTANCES]?.get('TodoItem')).toBeUndefined(); + expect(getInstance(el, 'TodoItem')).toBeUndefined(); el.setAttribute('data-component', 'TodoItem'); await settle(); @@ -75,8 +74,8 @@ describe('registry', () => { el.setAttribute('data-component', 'TodoCount'); await settle(); - expect(el[INSTANCES]?.get('TodoItem')).toBeUndefined(); - expect(el[INSTANCES]?.get('TodoCount')).toBe(count); + expect(getInstance(el, 'TodoItem')).toBeUndefined(); + expect(getInstance(el, 'TodoCount')).toBe(count); expect(count.$isMounted).toBe(true); }); @@ -89,7 +88,7 @@ describe('registry', () => { el.removeAttribute('data-component'); await settle(); - expect(el[INSTANCES]?.get('TodoItem')).toBeUndefined(); + expect(getInstance(el, 'TodoItem')).toBeUndefined(); expect(first.$isMounted).toBe(false); el.setAttribute('data-component', 'TodoItem'); @@ -142,7 +141,7 @@ describe('registry', () => { await settle(); expect(attempts).toHaveLength(1); - expect(broken[INSTANCES]?.get('BrokenConstructionErrorEvent')).toBeUndefined(); + expect(getInstance(broken, 'BrokenConstructionErrorEvent')).toBeUndefined(); expect(brokenMounts).toBe(0); expect(healthyMounts).toBe(1); @@ -154,7 +153,7 @@ describe('registry', () => { expect(attempts).toHaveLength(2); expect(attempts[1]).not.toBe(firstAttempt); - expect(broken[INSTANCES]?.get('BrokenConstructionErrorEvent')).toBeUndefined(); + expect(getInstance(broken, 'BrokenConstructionErrorEvent')).toBeUndefined(); expect(brokenMounts).toBe(0); expect(events).toHaveLength(2); expect(events.every((event) => event.target === broken)).toBe(true); @@ -170,7 +169,6 @@ describe('registry', () => { it('preserves a valid pre-existing instance when its registry mount fails', async () => { const failure = new Error('mount failed'); - const diagnostics: ToolkitDiagnosticDetail[] = []; class Existing extends Base { static config = { name: 'ExistingMountFailure' }; @@ -183,22 +181,20 @@ describe('registry', () => { registerComponent(Existing); const el = document.createElement('div'); const instance = new Existing(el); - el.addEventListener(EVENTS.diagnostic, (event) => { - event.preventDefault(); - diagnostics.push((event as CustomEvent).detail); - }); + const log = captureDiagnostics(el); el.setAttribute('data-component', 'ExistingMountFailure'); document.body.append(el); await settle(); - expect(el[INSTANCES]?.get('ExistingMountFailure')).toBe(instance); - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0]).toMatchObject({ + expect(getInstance(el, 'ExistingMountFailure')).toBe(instance); + expect(log.entries).toHaveLength(1); + expect(log.entries[0]).toMatchObject({ severity: 'error', code: DIAGNOSTICS.component.mountFailed, error: failure, component: 'ExistingMountFailure', }); + log.stop(); }); it('processes a pending token replacement before mounting a newly registered class', async () => { @@ -213,9 +209,7 @@ describe('registry', () => { class After extends Base { static config = { name: 'RegistrationAfter' }; mounted(): void { - calls.push( - `after:mounted:before=${Boolean(this.$el[INSTANCES]?.has('RegistrationBefore'))}`, - ); + calls.push(`after:mounted:before=${Boolean(getInstance(this.$el, 'RegistrationBefore'))}`); } } diff --git a/packages/v4/src/responsive-components.spec.ts b/packages/v4/src/responsive-components.spec.ts index f99cd1ba0..ff7a0fe54 100644 --- a/packages/v4/src/responsive-components.spec.ts +++ b/packages/v4/src/responsive-components.spec.ts @@ -2,8 +2,7 @@ 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 { getMountedInstances } from './instances.js'; -import { INSTANCES } from './protocol-symbols.js'; +import { getInstance, getInstances, getMountedInstances } from './instances.js'; import { registerComponent, registerManifest } from './registry.js'; import { BREAKPOINTS, setBreakpoints } from './services/breakpoint.js'; import { captureDiagnostics, resetDom, settle } from './test/index.js'; @@ -53,7 +52,7 @@ function render(attributes: Record): HTMLElement { } function instance(el: Element, name: string): TrackedComponent | undefined { - return el[INSTANCES]?.get(name) as TrackedComponent | undefined; + return getInstance(el, name); } function at(name: 'small' | 'middle' | 'wide'): void { @@ -105,9 +104,11 @@ describe('responsive component declarations', () => { }); await whenDOMSettled(); - expect([...(el[INSTANCES]?.keys() ?? [])].sort()).toEqual( - [action.name, analytics.name, mobileMenu.name, mobileSearch.name].sort(), - ); + expect( + getInstances(el) + .map((found) => found.$config.name) + .sort(), + ).toEqual([action.name, analytics.name, mobileMenu.name, mobileSearch.name].sort()); expect(instance(el, action.name)?.mounts).toBe(1); expect(getMountedInstances(mobileMenu.name)).toEqual([instance(el, mobileMenu.name)]); }); diff --git a/packages/v4/src/responsive-options.spec.ts b/packages/v4/src/responsive-options.spec.ts index 9582e87c9..4caee4a42 100644 --- a/packages/v4/src/responsive-options.spec.ts +++ b/packages/v4/src/responsive-options.spec.ts @@ -138,7 +138,7 @@ describe('responsive options', () => { data-option-label:large="wide">

`, ); await settle(); - const label = getInstance