diff --git a/packages/v4/DESIGN.md b/packages/v4/DESIGN.md index c30ea141e..98c68c006 100644 --- a/packages/v4/DESIGN.md +++ b/packages/v4/DESIGN.md @@ -1066,6 +1066,22 @@ See [RATIONALE.md — 11. Autoload](./RATIONALE.md#11-autoload). See [RATIONALE.md — 12. Storage](./RATIONALE.md#12-storage). +## 13. Testing — the `/test` subpath + +`@studiometa/js-toolkit-v4/test` ships the eight helpers a test of a component cannot write for itself: `mount(html)`, `settle()`, `frames(count?)`, `waitFor(predicate, options?)`, `resetDom()`, `captureDiagnostics(target?)`, `recordEvents(target, ...types)` and `resetRegistry()`. + +- **The subpath exists because the timing recipe is not derivable.** "Has this component mounted and finished its writes?" is answered by five rounds of a 10ms timer followed by `defaultScheduler.whenIdle()`, and those two numbers encode the mount observer's delivery latency and the scheduler's lane order. Neither half works alone: `whenIdle()` can resolve before the observer has reported the element, and a timer can return between two lanes. `defaultScheduler` and `nextFrame` were already public, so the pieces shipped and the recipe did not. +- **It depends on no test framework.** Nothing in the module imports a runner, an assertion library or a spy; it reads the DOM and the scheduler only, so it runs under Vitest, under Playwright and on a plain browser page. That is also why it is not on the root barrel: a page has no use for it, and the root export count stays what it was. +- **`mount()` returns the wrapper `div` it created**, never the markup's own root, so a fragment of several siblings works and `querySelector()` has a stable handle. `.firstElementChild` is one property away. +- **`waitFor()` returns the predicate's truthy value**, which makes the same call a guard (`() => el.classList.contains('is-open')`) or a query (`() => root.querySelector('.panel')`). It polls on a 10ms cadence and drains the scheduler between attempts, and on timeout it throws — with the caller's `message`, or one naming the timeout. +- **A transition's end state is asserted by polling, never by `settle()`.** A method that starts a transition does not hand it back, and a kept end state lands only after `nextFrame()`, the `from` and `active` states, and either a `transitionend` or one more frame. `settle()` is generous rather than deterministic, which is a flake that passes alone and fails under load. **Polling for an _absence_ is wrong** for the mirror-image reason: `leaveTransition()` clears the other direction's `to` synchronously, so the poll passes before anything has happened. +- **`captureDiagnostics()` reads the channel, and asserting on `console.warn` does not.** A recovered failure is reported on a cancelable event whose _default behaviour_ is the console line; a spy on that line cannot see the code, the severity or the reporting component, and it passes for the wrong diagnostic. The helper cancels each event as it arrives, which is the same act that suppresses the sink — so collecting and silencing are one step, not two, and no spy is involved. `target` defaults to `document`, which sees everything a connected element reported, because diagnostics bubble and compose. +- **`recordEvents(target, ...types)` keeps the order and the payloads.** A component's contract is "`open` came before `opened`, and `opened` carried the height it measured", and a call-counting spy throws both away. Recording several types into one array is the only place their relative order is visible; the richer `{ type, detail }` shape ships because a caller wanting names alone can map, and the reverse is impossible. `$emit` dispatches synchronously but is almost never _called_ synchronously, so the count is awaited with `waitFor`, not read. +- **`resetRegistry()` is the inverse `registerComponent()` deliberately lacks.** A page registers once and keeps it; a test suite is the one caller for which a page-wide registry surviving `resetDom()` is wrong, and the workaround it forces is a counter minting `Widget-1`, `Widget-2`. It is coarse rather than a targeted `unregisterComponent(name)` because the element→controller map is a `WeakMap`: it cannot be enumerated, so nothing can walk it to dispose live triggers, and a `querySelectorAll()` sweep would still miss detached elements. Clearing everything works because the mutation-observer path already disposes a controller as its element leaves the DOM — hence the call belongs _after_ `resetDom()`, and **never in an `afterEach`**, which would unregister the module-top-level registrations for every later test in the file. +- **What is deliberately not in it**: an instance lookup (`getInstances()` already answers it), frame counters, fetch stubs and pointer sequences. Each is either specific to one spec or already served by `vi.fn()` and `@vitest/browser`'s `userEvent`. + +See [RATIONALE.md — 13. Testing](./RATIONALE.md#13-testing). + ## Status for #694 - `LoadService` is removed. `KeyService` is ported, with a target and a fixed repeat counter (§8). Mutation handling is internal to the registry. See §8. diff --git a/packages/v4/RATIONALE.md b/packages/v4/RATIONALE.md index f437e1de1..65fc8e3cb 100644 --- a/packages/v4/RATIONALE.md +++ b/packages/v4/RATIONALE.md @@ -830,3 +830,53 @@ The `createStorage` presets are a different case and stay, because each re ### Why the adapters are tested against the platform `providers.spec.ts` drives each adapter through the six methods for real — the actual storage areas, the actual `location` and `history` — including the paths that only the platform has. To test `createStorage()` over the memory provider proves the storage. It proves nothing about the four adapters that touch the platform, which are the part that can fail. + +## 13. Testing + +### Why the helpers are a shipped module and not a documented snippet + +The recipe encodes two internals — how late a `MutationObserver` delivers, and in what order the scheduler drains its lanes — and a consumer has no way to derive either. A snippet in the documentation makes every consumer copy a number they cannot maintain; when a lane is added, their copy is silently wrong. The subpath makes the copy the framework's own, and the framework's own version is the one the framework's specs prove. + +The evidence is that the copying already happened inside the package: an identical seven-line `mount` in twelve spec files, the same settle loop in eleven, and three hand-copied `waitForClass` bodies. A helper duplicated by the people who wrote the scheduler will be duplicated worse by anyone else. + +### Why it depends on no test framework + +A helper that imports `vitest` is a helper only Vitest users can have, and it drags a runner into the dependency graph of a package that has none. Nothing here needs one: `settle()` awaits a timer and the scheduler, `mount()` writes to `document.body`, and `waitFor()` throws a plain `Error`. The three things a runner is genuinely needed for — spies, assertions and fixtures — are the three things the module refuses to ship. + +### Why it is a barrel and not one subpath per symbol + +Every other subpath in this package exists to keep one imported symbol from dragging a barrel's graph onto a page. A test file is not a page: it loads several of these helpers at once, it is never served to a browser as production code, and the whole module is smaller than the graph any splitting would save. `./utils` is split because a component imports one easing function; `./test` is not, for the same reason read the other way. + +### Why `waitFor()` returns its value + +The two questions a test asks about deferred DOM work are "is it true yet" and "what is it now", and they are the same poll. Returning the truthy value collapses them into one helper instead of a predicate version and a query version, and it removes the second lookup a guard-only version forces on the caller — a lookup which can observe a _different_ element from the one the guard passed on. + +### Why polling replaced `settle()` for transitions, and only in one direction + +This is the record of a defect. Three specs asserted a kept transition class after `settle()`, passed six-for-six in isolation, and failed roughly one run in three under full-suite load. Nothing was racing inside the components: `open()` and `close()` start a transition and do not return it, a kept end state lands several deferred steps later, and `settle()` is generous rather than deterministic. Reading a deferred write at a moment nothing promised is a flake even when everything it reads is correct. + +The asymmetry is what makes it a rule rather than a habit. `leaveTransition()` clears the other direction's `to` class synchronously, before its first await, so "the class is gone" is already true when nothing has happened yet — polling for an absence passes for the wrong reason, and would pass against a component that does nothing at all. An absence is asserted directly, after the awaited call which causes it. + +### Why a diagnostic is asserted on the channel and not on the console + +`vi.spyOn(console, 'warn')` appears forty times across the suite and twenty-six spec files touch diagnostics, several of them twice over: once to silence the console and once, separately, to add a listener that cancels the event. That the second call is what silences the first is the whole point, and it is not guessable from outside — a consumer would have to know the channel name, the detail shape, and that cancelling a cancelable diagnostic is what suppresses its default sink. `captureDiagnostics()` collapses the two into one call whose result is the thing worth asserting on. + +The spy is not merely redundant, it is the weaker assertion. It reads a formatted string, so it cannot tell `registry.conflict` from `registry.lazy-name-mismatch`, cannot see the severity or the reporting component, and breaks when the sink's wording changes — while a diagnostic reported with the wrong code passes it. Most of those forty sites therefore test the sink and believe they are testing the framework. The channel is public and stable precisely so that it, not the console, is what a test reads. + +One trap this uncovered, worth recording because it silently inverts an assertion: `mockRestore()` also clears the call history, so a spy restored before its own `expect` always looks unused. The module's own spec restores in an `afterEach` for that reason. + +### Why `resetRegistry()` is coarse, and why it is not `unregisterComponent(name)` + +The targeted form cannot be written correctly. Disposing one name's live triggers means finding the elements that hold them, and the element→controller map is a `WeakMap` by design: it cannot be enumerated, so nothing can walk it, and a `querySelectorAll()` sweep for the name would still miss every detached element still carrying a controller. A per-name inverse would be an API that looks precise and is not. + +The coarse form is correct for a reason outside itself: the mutation-observer path already disposes a controller as its element leaves the DOM, so once the DOM is empty there are no live triggers left to find. That is why the ordering — `resetDom()`, then `resetRegistry()` — is part of the contract rather than advice, and why the doc comment says outright that the `WeakMap` is not cleared instead of implying a clean slate. + +Its footgun is stated in the same place because the shape of a spec file guarantees someone will hit it: registrations happen at module top level, so `resetRegistry()` in an `afterEach` unregisters them for every later test in the file, and the failure appears in a test that looks unrelated. `resetDom()` deliberately does not call it. + +### What was refused + +`getInstance(el, name)` — the public `getInstances()` answers it, and a second spelling of a lookup is a second thing to keep true. A frame counter that patches `requestAnimationFrame` — one caller, and a global patch is a poor thing to hand out. Fetch stubs and pointer-event sequences — `vi.fn()` and `@vitest/browser`'s `userEvent` do both better, and each was shaped by the one spec that grew it. + +An event recorder was refused on that same list and then shipped as `recordEvents()`, which is worth recording as a reversal rather than quietly correcting. The refusal was right about `vi.fn()` replacing a _counter_ and wrong about what the seven spec files hand-rolling one were actually doing: they were recording `{ type, detail }` across several event names into one array, because the assertion is the sequence and its payloads, and that is the one thing a per-listener spy cannot express. Two shapes existed in the wild — `type` only, and `{ type, detail }` — and the richer one ships because mapping down is free and mapping up is impossible. + +A re-export shim in `test-utils.ts` was refused too. The old file keeps the fixtures and the two helpers that stay source-only, and the specs move onto the new module in one pass; a shim would be both a compatibility layer and a temporary solution meant to be replaced. diff --git a/packages/v4/package.json b/packages/v4/package.json index 18de96ec2..e8898a55a 100644 --- a/packages/v4/package.json +++ b/packages/v4/package.json @@ -19,6 +19,10 @@ "types": "./dist/utils/index.d.ts", "import": "./dist/utils/index.js" }, + "./test": { + "types": "./dist/test/index.d.ts", + "import": "./dist/test/index.js" + }, "./package.json": "./package.json", "./watchAttributeNamespace": { "types": "./dist/subpaths/watchAttributeNamespace.d.ts", diff --git a/packages/v4/scripts/check-package.js b/packages/v4/scripts/check-package.js index 9d05ee29e..25caa9a9b 100644 --- a/packages/v4/scripts/check-package.js +++ b/packages/v4/scripts/check-package.js @@ -285,7 +285,11 @@ try { const packOutput = await run('npm', ['pack', '--json', '--pack-destination', packRoot], { cwd: packageRoot, }); - const [metadata] = JSON.parse(packOutput); + // `npm pack --json` answers with an array of packed packages up to npm 11, + // and with an object keyed by package name from npm 12 on. One package is + // packed either way: read its metadata out of whichever shape arrived. + const packed = JSON.parse(packOutput); + const [metadata] = Array.isArray(packed) ? packed : Object.values(packed); assert(metadata, 'npm pack returned no package metadata.'); const packedFiles = assertPackageContent(metadata); diff --git a/packages/v4/src/registry.ts b/packages/v4/src/registry.ts index 88e9841d7..e3d8c2669 100644 --- a/packages/v4/src/registry.ts +++ b/packages/v4/src/registry.ts @@ -292,6 +292,56 @@ export function registerManifest(entries: ComponentManifest): void { } } +/** + * Drop every registration, so the names a test used are free again. + * + * `registerComponent()` has no inverse by design: a page registers once and + * keeps the registration for as long as it lives. A test suite is the one + * caller for which that is wrong — the registry is page-wide and it survives + * `resetDom()`, so a name registered by one spec is still taken in the next + * one, and the workaround is a counter that mints `Widget-1`, `Widget-2` and + * tells the reader nothing. This is the escape hatch, re-exported from + * `@studiometa/js-toolkit-v4/test`; it is not part of a page's vocabulary. + * + * **It is coarse on purpose.** A targeted `unregisterComponent(name)` would + * have to find the elements holding that name's live triggers, and the map + * from element to controller is a `WeakMap`: it cannot be enumerated, so + * nothing can walk it to dispose them, and a `querySelectorAll()` sweep would + * still miss every detached element. Clearing the whole registry works because + * the mutation-observer path already disposes a controller as its element + * leaves the DOM — which is why the call belongs *after* `resetDom()`. + * + * Two things it therefore does not do. It cannot clear that `WeakMap`, so a + * controller whose element is still connected keeps its trigger; empty the DOM + * first. And it does not narrow the attribute filter the shared observer built + * from the options of everything ever registered — an extra watched attribute + * costs a reconciliation pass that finds no owner, and nothing more. + * + * **Where the call belongs.** Spec files register at module top level, so + * this in an `afterEach` silently unregisters everything for every later test + * in the file. Put it in an `afterAll`, or call it and register again straight + * away: + * + * ```ts + * afterEach(async () => { + * await resetDom(); + * resetRegistry(); + * registerComponents(Subject, Emitter); + * }); + * ``` + */ +export function resetRegistry(): void { + registry.clear(); + manifest.clear(); + imports.clear(); + responsiveElements.clear(); + registryState.pendingResponsiveElements.clear(); + registryState.responsiveTask?.cancel(); + registryState.responsiveTask = null; + registryState.unwatchBreakpoints?.(); + registryState.unwatchBreakpoints = null; +} + /** Import and register a lazy entry once per name. Failed imports are not retried. */ function importComponent(name: string, target?: Element): Promise { const pending = imports.get(name); diff --git a/packages/v4/src/test/index.spec.ts b/packages/v4/src/test/index.spec.ts new file mode 100644 index 000000000..93f9086bc --- /dev/null +++ b/packages/v4/src/test/index.spec.ts @@ -0,0 +1,402 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as subpath from '@studiometa/js-toolkit-v4/test'; +import { Base } from '../Base.js'; +import { warn } from '../diagnostics.js'; +import { getInstances } from '../instances.js'; +import { registerComponent, registerComponents, registerManifest } from '../registry.js'; +import { defaultScheduler } from '../scheduler.js'; +import { + captureDiagnostics, + frames, + mount, + recordEvents, + resetDom, + resetRegistry, + settle, + waitFor, +} from './index.js'; + +/** + * The subject writes from a scheduled task rather than from `mounted()` + * directly: what `mount()` has to guarantee is not "the hook ran" but "the + * writes the hook queued have landed". + */ +class Subject extends Base { + static config = { name: 'TestHelpersSubject' }; + + mountedCalls = 0; + + mounted(): void { + this.mountedCalls += 1; + defaultScheduler.write(() => { + this.$el.textContent = 'written'; + }); + } +} + +/** Emits on demand and on a scheduled write, to cover both delivery timings. */ +class Emitter extends Base<{ $emits: { ping: { count: number }; pong: void } }> { + static config = { name: 'TestHelpersEmitter' }; + + ping(count: number): void { + this.$emit('ping', { count }); + } + + pingLater(count: number): void { + defaultScheduler.write(() => { + this.$emit('ping', { count }); + this.$emit('pong'); + }); + } +} + +registerComponents(Subject, Emitter); + +afterEach(resetDom); +// Restored here rather than inline: `mockRestore()` also clears the call +// history, so a spy restored before its own assertion always looks unused. +afterEach(() => { + vi.restoreAllMocks(); +}); + +/** Silence the default console sink, to prove the channel replaces it. */ +function silenceSink() { + return vi.spyOn(console, 'warn').mockImplementation(() => {}); +} + +describe('the /test subpath', () => { + it('serves these eight helpers under the package name, and nothing else', () => { + expect(Object.keys(subpath).sort()).toEqual([ + 'captureDiagnostics', + 'frames', + 'mount', + 'recordEvents', + 'resetDom', + 'resetRegistry', + 'settle', + 'waitFor', + ]); + expect(subpath.mount).toBe(mount); + expect(subpath.settle).toBe(settle); + expect(subpath.frames).toBe(frames); + expect(subpath.waitFor).toBe(waitFor); + expect(subpath.resetDom).toBe(resetDom); + expect(subpath.captureDiagnostics).toBe(captureDiagnostics); + expect(subpath.recordEvents).toBe(recordEvents); + expect(subpath.resetRegistry).toBe(resetRegistry); + }); +}); + +describe('mount()', () => { + it('returns a wrapper whose components are mounted and done writing', async () => { + const root = await mount('
'); + + expect(root.parentElement).toBe(document.body); + expect(root.tagName).toBe('DIV'); + + const [subject] = getInstances('TestHelpersSubject', root); + expect(subject.mountedCalls).toBe(1); + expect(subject.$el).toBe(root.firstElementChild); + expect(subject.$el.textContent).toBe('written'); + }); + + it('keeps several siblings under the one wrapper', async () => { + const root = await mount( + '
', + ); + + expect(root.children).toHaveLength(2); + expect(getInstances('TestHelpersSubject', root)).toHaveLength(2); + }); +}); + +describe('settle()', () => { + it('drains work queued after the DOM was touched', async () => { + const el = document.createElement('div'); + document.body.append(el); + defaultScheduler.write(() => { + el.textContent = 'late'; + }); + + await settle(); + + expect(el.textContent).toBe('late'); + expect(defaultScheduler.phase).toBe('idle'); + }); +}); + +describe('frames()', () => { + it('awaits the requested number of animation frames', async () => { + let seen = 0; + let running = true; + const count = () => { + if (!running) return; + seen += 1; + requestAnimationFrame(count); + }; + requestAnimationFrame(count); + + await frames(3); + running = false; + + expect(seen).toBeGreaterThanOrEqual(3); + }); +}); + +describe('waitFor()', () => { + it('returns the truthy value the predicate produced', async () => { + const root = await mount('
'); + const host = root.querySelector('#host') as HTMLElement; + setTimeout(() => { + host.innerHTML = '

late

'; + }, 30); + + const panel = await waitFor(() => root.querySelector('.panel')); + + expect(panel).toBeInstanceOf(HTMLParagraphElement); + expect(panel.textContent).toBe('late'); + }); + + it('resolves immediately when the predicate is already true', async () => { + const el = document.createElement('div'); + el.classList.add('is-open'); + + await expect(waitFor(() => el.classList.contains('is-open'))).resolves.toBe(true); + }); + + it('waits for a class added a few frames later', async () => { + const el = document.createElement('div'); + document.body.append(el); + void frames(3).then(() => el.classList.add('is-open')); + + await expect(waitFor(() => el.classList.contains('is-open'))).resolves.toBe(true); + }); + + it('throws a message naming the timeout when the predicate never turns true', async () => { + await expect(waitFor(() => false, { timeout: 50 })).rejects.toThrow( + 'waitFor: the predicate never returned a truthy value within 50ms.', + ); + }); + + it('throws the given message instead of the default', async () => { + await expect( + waitFor(() => null, { timeout: 50, message: '"is-open" never landed on the panel' }), + ).rejects.toThrow('"is-open" never landed on the panel'); + }); + + it('polls for at least the whole timeout before giving up', async () => { + const start = Date.now(); + await expect(waitFor(() => undefined, { timeout: 100 })).rejects.toThrow(); + + expect(Date.now() - start).toBeGreaterThanOrEqual(100); + }); + + it('treats every falsy value as "not yet"', async () => { + const values: Array = [ + false, + null, + undefined, + 0, + '', + 'done', + ]; + let index = 0; + + await expect(waitFor(() => values[index++])).resolves.toBe('done'); + expect(index).toBe(values.length); + }); +}); + +describe('resetDom()', () => { + it('empties the body and unmounts what was in it', async () => { + const root = await mount('
'); + expect(getInstances('TestHelpersSubject', root)).toHaveLength(1); + + await resetDom(); + + expect(document.body.innerHTML).toBe(''); + expect(getInstances('TestHelpersSubject')).toEqual([]); + }); +}); + +describe('captureDiagnostics()', () => { + it('collects what the framework reported, and keeps the console quiet', () => { + // Spied only to prove the sink never ran; the assertion is on the channel. + const sink = silenceSink(); + const log = captureDiagnostics(); + + // A second class under a name already taken is a reported conflict. + registerComponent( + class extends Base { + static config = { name: 'TestHelpersSubject' }; + }, + ); + + log.stop(); + + expect(log.codes).toEqual(['registry.conflict']); + expect(log.entries[0]).toMatchObject({ + severity: 'warning', + code: 'registry.conflict', + component: 'TestHelpersSubject', + }); + expect(log.entries[0].message).toContain('TestHelpersSubject'); + expect(sink).not.toHaveBeenCalled(); + }); + + it('scopes to the target it was given', async () => { + const sink = silenceSink(); + const root = await mount('
'); + const inside = root.querySelector('#inside') as HTMLElement; + const outside = root.querySelector('#outside') as HTMLElement; + const log = captureDiagnostics(inside); + + warn('ref.mismatch', 'reported on the watched element', { target: inside }); + warn('ref.mismatch', 'reported elsewhere', { target: outside }); + + log.stop(); + + expect(log.entries.map((entry) => entry.message)).toEqual(['reported on the watched element']); + // The one it did not watch reached its own default sink. + expect(sink).toHaveBeenCalledTimes(1); + }); + + it('stops collecting, and lets the sink run again, once stopped', () => { + const sink = silenceSink(); + const log = captureDiagnostics(); + log.stop(); + + warn('ref.mismatch', 'after stop'); + + expect(log.codes).toEqual([]); + expect(sink).toHaveBeenCalledTimes(1); + }); +}); + +describe('recordEvents()', () => { + it("captures a component's emit, with its detail", async () => { + const root = await mount('
'); + const [emitter] = getInstances('TestHelpersEmitter', root); + const log = recordEvents(root, 'ping'); + + emitter.ping(2); + emitter.ping(3); + log.stop(); + + expect(log.events).toEqual([ + { type: 'ping', detail: { count: 2 } }, + { type: 'ping', detail: { count: 3 } }, + ]); + }); + + it('keeps several types in one array, in delivery order', async () => { + const root = await mount('
'); + const [emitter] = getInstances('TestHelpersEmitter', root); + const log = recordEvents(root, 'ping', 'pong'); + + emitter.pingLater(1); + await waitFor(() => log.events.length === 2); + log.stop(); + + expect(log.events).toEqual([ + { type: 'ping', detail: { count: 1 } }, + { type: 'pong', detail: null }, + ]); + }); + + it('ignores a type it was not asked for, and stops when stopped', async () => { + const root = await mount('
'); + const [emitter] = getInstances('TestHelpersEmitter', root); + const log = recordEvents(root, 'ping'); + + emitter.$emit('pong'); + emitter.ping(1); + log.stop(); + emitter.ping(2); + + expect(log.events).toEqual([{ type: 'ping', detail: { count: 1 } }]); + }); +}); + +describe('resetRegistry()', () => { + /** + * These cases empty the page-wide registry, which the two components at the + * top of this file live in. Restoring them is the documented pattern: the + * reset, then the registrations still needed — never a bare reset in an + * `afterEach`, which would unregister them for every later test. + */ + afterEach(async () => { + await resetDom(); + resetRegistry(); + registerComponents(Subject, Emitter); + }); + + it('frees a name that would otherwise report a conflict', async () => { + const first = class extends Base { + static config = { name: 'TestHelpersRecycled' }; + }; + const second = class extends Base { + static config = { name: 'TestHelpersRecycled' }; + }; + const log = captureDiagnostics(); + + registerComponent(first); + await resetDom(); + resetRegistry(); + registerComponent(second); + + log.stop(); + expect(log.codes).toEqual([]); + + const root = await mount('
'); + const [instance] = getInstances('TestHelpersRecycled', root); + expect(instance).toBeInstanceOf(second); + }); + + it('is what avoids that conflict — without it the second name is taken', async () => { + silenceSink(); + const log = captureDiagnostics(); + + registerComponent( + class extends Base { + static config = { name: 'TestHelpersContested' }; + }, + ); + await resetDom(); + registerComponent( + class extends Base { + static config = { name: 'TestHelpersContested' }; + }, + ); + + log.stop(); + + expect(log.codes).toEqual(['registry.conflict']); + }); + + it('stops a component registered earlier from mounting again', async () => { + registerComponent( + class extends Base { + static config = { name: 'TestHelpersRetired' }; + }, + ); + const before = await mount('
'); + expect(getInstances('TestHelpersRetired', before)).toHaveLength(1); + + await resetDom(); + resetRegistry(); + + const after = await mount('
'); + expect(getInstances('TestHelpersRetired', after)).toEqual([]); + }); + + it('drops a lazy manifest entry too, without importing it', async () => { + const load = vi.fn(() => Promise.resolve(Subject)); + registerManifest({ TestHelpersLazy: load }); + + resetRegistry(); + await mount('
'); + + expect(load).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/v4/src/test/index.ts b/packages/v4/src/test/index.ts new file mode 100644 index 000000000..005ddc673 --- /dev/null +++ b/packages/v4/src/test/index.ts @@ -0,0 +1,300 @@ +/** + * Test helpers for components built on `@studiometa/js-toolkit-v4`. + * + * Published as `@studiometa/js-toolkit-v4/test`, and deliberately free of any + * test framework: nothing here imports a runner, an assertion library or a + * spy. The module reads the DOM, the framework's own scheduler and the + * framework's own channels, so the same helpers work under Vitest, under + * Playwright and on a plain browser page. + * + * The first question every test of a component asks is one no consumer can + * derive: *has this component mounted, and has it finished its writes?* + * Mounting is observer-driven and writes are scheduled in lanes, so the answer + * is a specific interleaving of timers and scheduler idles rather than a single + * `await`. {@link settle} is that interleaving, and {@link mount}, + * {@link waitFor} and {@link resetDom} are built on it. + * + * The rest answer the questions whose right answer is not the obvious one. + * {@link captureDiagnostics} reads the diagnostic channel instead of the + * console sink it happens to write to; {@link recordEvents} keeps the order and + * the payloads a call-counting spy throws away; and `resetRegistry()` undoes a + * registration the framework has no other way to undo. + */ + +import { type ToolkitDiagnosticDetail } from '../diagnostic-contract.js'; +import { EVENTS } from '../events.js'; +import { defaultScheduler, nextFrame } from '../scheduler.js'; + +// `resetRegistry()` lives next to the state it clears, which is module-private +// to the registry. It is re-exported here because a test suite is its only +// caller — see the doc comment there for where the call belongs. +export { resetRegistry } from '../registry.js'; + +/** Options for {@link waitFor}. */ +export interface WaitForOptions { + /** How long to poll before throwing, in milliseconds. Defaults to `1000`. */ + timeout?: number; + /** The error message thrown on timeout. Replaces the default entirely. */ + message?: string; +} + +/** + * Wait until every pending mount and every scheduled write has landed. + * + * The numbers are not arbitrary and are not guessable from the outside. A + * component mounts from a `MutationObserver` callback, which the browser + * delivers on its own schedule rather than on the microtask that appended the + * element; the work the component then queues is drained by the scheduler in + * lane order, and a lane may queue into the next one. So neither half is + * enough alone: a bare `whenIdle()` can resolve before the observer has even + * reported the element, and a bare timer can return between two lanes. Five + * rounds of "let the event loop turn, then drain the scheduler" cover a mount + * that cascades into children, injections and DOM writes. + * + * This is the right tool for "the component is up and its writes are done". It + * is the wrong tool for a transition's end state — see {@link waitFor}. + */ +export async function settle(): Promise { + for (let i = 0; i < 5; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + await defaultScheduler.whenIdle(); + } +} + +/** + * Await a number of animation frames. + * + * Use it when the assertion is about frame-driven work — a `useRaf` service, a + * `scheduler.tick()` subscriber, a transition staged one frame at a time — and + * the number of frames is part of what is being asserted. When the number is + * merely a guess, poll with {@link waitFor} instead. + */ +export async function frames(count = 3): Promise { + for (let i = 0; i < count; i += 1) { + await nextFrame(); + } +} + +/** + * Render markup into a wrapper `div`, append it to the document and wait for + * the components inside it to be mounted and settled. + * + * The returned element is the wrapper, never the markup's own root: it is a + * stable handle for `querySelector()` calls and it keeps a fragment of several + * siblings working. Reach for `.firstElementChild` when the component's own + * element is what the test needs. + * + * ```ts + * const root = await mount('
'); + * const [counter] = getInstances('Counter', root); + * ``` + */ +export async function mount(html: string): Promise { + const root = document.createElement('div'); + root.innerHTML = html; + document.body.append(root); + await settle(); + return root; +} + +/** + * Poll a predicate until it returns something truthy, and return that value. + * + * It composes as both a guard and a query, because the value comes back: + * + * ```ts + * await waitFor(() => button.classList.contains('is-open')); + * const panel = await waitFor(() => root.querySelector('.panel')); + * ``` + * + * Any falsy result — `false`, `null`, `undefined`, and also `0` or `''` — + * counts as "not yet". Between two attempts the helper both lets a 10ms timer + * elapse and drains the scheduler, so it advances the framework's own work + * instead of spinning on timers alone. On timeout it throws, with `message` if + * one was given. + * + * **This is how a transition's end state is asserted.** `open()`, `close()` + * and the `$watchChildren` callbacks *start* a transition and do not hand it + * back, and a kept end state only lands after `nextFrame()`, the `from` and + * `active` states, and either a `transitionend` or one more frame. A single + * {@link settle} is generous, not deterministic: it passes on a spec run + * alone and fails under full-suite load. So assert such a state by polling for + * it here, or by awaiting `enter()`/`leave()` where the component returns the + * promise. + * + * **The rule is asymmetric, and the asymmetry is the point.** Never poll for + * an *absence*: `leaveTransition()` clears the other direction's `to` class + * synchronously, before its first await, so "the class is gone" is already + * true when nothing has happened yet and the poll passes for the wrong + * reason. Assert a removal directly, after the awaited call that causes it. + */ +export async function waitFor( + predicate: () => T | false | null | undefined, + { timeout = 1000, message }: WaitForOptions = {}, +): Promise { + const deadline = Date.now() + timeout; + + for (;;) { + const value = predicate(); + if (value) { + return value; + } + if (Date.now() > deadline) { + throw new Error( + message ?? `waitFor: the predicate never returned a truthy value within ${timeout}ms.`, + ); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + await defaultScheduler.whenIdle(); + } +} + +/** What {@link captureDiagnostics} hands back. */ +export interface DiagnosticCapture { + /** The `code` of every diagnostic seen, in order. */ + codes: string[]; + /** The full detail of every diagnostic seen, in the same order. */ + entries: ToolkitDiagnosticDetail[]; + /** Detach the listener and let the default sink run again. */ + stop(): void; +} + +/** + * Collect the diagnostics reported while a test runs, and keep them off the + * console. + * + * **Assert on this, not on the console.** A recovered failure is reported on a + * cancelable event, and writing to the console is only that event's default + * behaviour — one sink among the several a consumer may install. A test that + * spies on `console.warn` asserts on the sink: it passes for a diagnostic + * carrying the wrong code, it cannot see the component or the severity, and it + * breaks when the sink's wording changes. This helper reads the channel + * instead, so `codes` is what the framework actually reported. + * + * Silencing is not a separate step. Each event is cancelled as it arrives, + * which is exactly what suppresses the default sink, so the console stays + * clean for as long as the capture is open and no spy is needed: + * + * ```ts + * const log = captureDiagnostics(); + * registerComponent(Duplicate); + * expect(log.codes).toContain('registry.conflict'); + * log.stop(); + * ``` + * + * `target` defaults to `document`, which sees everything: a diagnostic is + * dispatched on the element it concerns when there is one, and it bubbles. + * Pass an element to scope the capture to one subtree — but a diagnostic + * reported about a detached element is dispatched on `document` instead, so a + * scoped capture will not see it. + * + * `entries` carries the whole detail — `severity`, `code`, `message`, the + * `component` name when one reported it, and `error` on an error-severity + * entry — for the cases where the code alone is not the assertion. + */ +export function captureDiagnostics(target: EventTarget = document): DiagnosticCapture { + const codes: string[] = []; + const entries: ToolkitDiagnosticDetail[] = []; + const listener = (event: Event) => { + const { detail } = event as CustomEvent; + codes.push(detail.code); + entries.push(detail); + // Cancelling is what suppresses the default console sink. + event.preventDefault(); + }; + + target.addEventListener(EVENTS.diagnostic, listener); + + return { + codes, + entries, + stop() { + target.removeEventListener(EVENTS.diagnostic, listener); + }, + }; +} + +/** One event {@link recordEvents} saw. */ +export interface RecordedEvent { + type: string; + /** The `detail` of a `CustomEvent`; `undefined` for a platform event. */ + detail: unknown; +} + +/** What {@link recordEvents} hands back. */ +export interface EventRecording { + /** Every matching event seen, in delivery order. */ + events: RecordedEvent[]; + /** Detach every listener. */ + stop(): void; +} + +/** + * Record the named events reaching a target, with the payload each carried. + * + * The order and the payloads are the assertion a component's contract is made + * of — that `open` came before `opened`, and that the second one carried the + * height it measured — and neither survives a spy that only counts calls: + * + * ```ts + * const log = recordEvents(root, 'open', 'opened'); + * await instance.open(); + * expect(log.events).toEqual([ + * { type: 'open', detail: null }, + * { type: 'opened', detail: { height: 120 } }, + * ]); + * log.stop(); + * ``` + * + * Recording types rather than one type keeps a sequence spanning several names + * in one array, which is the only place their relative order is visible. A + * caller that wants the names alone maps over the result; the reverse is not + * possible, so the richer shape is the one that ships. + * + * `$emit` itself dispatches synchronously, but almost nothing calls it + * synchronously: the emit follows a mount, a scheduled write or a transition, + * and the call that started that chain has already returned. So **wait for the + * count rather than reading it**, with {@link waitFor}: + * + * ```ts + * const log = recordEvents(root, 'ping'); + * instance.start(); + * await waitFor(() => log.events.length === 2); + * ``` + * + * Because component events bubble, the target is usually the wrapper + * {@link mount} returned rather than the component's own element — which is + * also how a test sees the events of a child it never looked up. + */ +export function recordEvents(target: EventTarget, ...types: string[]): EventRecording { + const events: RecordedEvent[] = []; + const listener = (event: Event) => { + events.push({ type: event.type, detail: (event as CustomEvent).detail }); + }; + + for (const type of types) { + target.addEventListener(type, listener); + } + + return { + events, + stop() { + for (const type of types) { + target.removeEventListener(type, listener); + } + }, + }; +} + +/** + * Empty the document body and wait for the unmounts to land. + * + * Removing an element is observed like adding one, so the teardown a component + * registered runs after the same delivery latency. Call it from an `afterEach` + * hook: a component left mounted keeps its service subscriptions and leaks + * into the next test. + */ +export async function resetDom(): Promise { + document.body.innerHTML = ''; + await settle(); +} diff --git a/packages/v4/test/package-node-consumer.js b/packages/v4/test/package-node-consumer.js index b3d05b7b4..198d67f4d 100644 --- a/packages/v4/test/package-node-consumer.js +++ b/packages/v4/test/package-node-consumer.js @@ -24,6 +24,7 @@ import transformDefault, { transform } from '@studiometa/js-toolkit-v4/utils/tra import easeOutQuadDefault, { easeOutQuad } from '@studiometa/js-toolkit-v4/utils/easeOutQuad'; import randomIntDefault, { randomInt } from '@studiometa/js-toolkit-v4/utils/randomInt'; import deepmergeDefault, { deepmerge } from '@studiometa/js-toolkit-v4/utils/deepmerge'; +import * as testHelpers from '@studiometa/js-toolkit-v4/test'; assert.equal(Base, toolkit.Base); assert.equal(BaseDefault, Base); @@ -116,4 +117,29 @@ assert.deepEqual(utils.createRange(0, 2, 1), [0, 1, 2]); assert.equal(typeof utils.debounce(() => {}), 'function'); await utils.wait(1); +// The `/test` subpath ships built and links to the packed scheduler and +// registry. It stays out of the root barrel, and it loads outside a browser: +// nothing reaches for the DOM until a helper that needs one is called. +assert.deepEqual(Object.keys(testHelpers).sort(), [ + 'captureDiagnostics', + 'frames', + 'mount', + 'recordEvents', + 'resetDom', + 'resetRegistry', + 'settle', + 'waitFor', +]); +assert.equal(toolkit.waitFor, undefined); +assert.equal(toolkit.captureDiagnostics, undefined); +assert.equal(toolkit.recordEvents, undefined); +assert.equal(toolkit.resetRegistry, undefined); +assert.equal(await testHelpers.waitFor(() => 'now'), 'now'); +await assert.rejects( + testHelpers.waitFor(() => false, { timeout: 0, message: 'never landed' }), + { + message: 'never landed', + }, +); + console.log('Node packed consumer: root and public subpaths passed.'); diff --git a/packages/v4/tsconfig.json b/packages/v4/tsconfig.json index 22e01f091..31b828a28 100644 --- a/packages/v4/tsconfig.json +++ b/packages/v4/tsconfig.json @@ -6,6 +6,7 @@ "paths": { "@studiometa/js-toolkit-v4": ["./src/index.ts"], "@studiometa/js-toolkit-v4/utils": ["./src/utils/index.ts"], + "@studiometa/js-toolkit-v4/test": ["./src/test/index.ts"], "@studiometa/js-toolkit-v4/utils/*": ["./src/subpaths/utils/*.ts"], "@studiometa/js-toolkit-v4/*": ["./src/subpaths/*.ts"] }, diff --git a/packages/v4/vitest.config.js b/packages/v4/vitest.config.js index a5513b45e..1a1a7fd9c 100644 --- a/packages/v4/vitest.config.js +++ b/packages/v4/vitest.config.js @@ -19,6 +19,10 @@ export default defineConfig({ find: /^@studiometa\/js-toolkit-v4\/utils$/, replacement: `${srcRoot}/utils/index.ts`, }, + { + find: /^@studiometa\/js-toolkit-v4\/test$/, + replacement: `${srcRoot}/test/index.ts`, + }, { find: /^@studiometa\/js-toolkit-v4\/(.+)$/, replacement: `${srcRoot}/subpaths/$1.ts`, diff --git a/scripts/generate-subpaths.js b/scripts/generate-subpaths.js index 2e46cf0fb..4c2f71028 100644 --- a/scripts/generate-subpaths.js +++ b/scripts/generate-subpaths.js @@ -22,15 +22,24 @@ function paths(packageDir) { /** * The `exports` entries which are not one per symbol: the root barrel, the utils - * barrel and the manifest itself. `./package.json` stays a plain string — it is - * the same file under every condition. + * barrel, the test barrel and the manifest itself. `./package.json` stays a plain + * string — it is the same file under every condition. * + * `./test` is conditional on the barrel existing, because only v4 has one. It is + * a barrel rather than a set of per-symbol subpaths: a test file loads several of + * these helpers at once and none of them is on a page's critical path, so the + * tree-shaking argument that splits the other two does not apply. + * + * @param {string} srcRoot The absolute path of the package's `src/` directory. * @returns {Record} */ -function groupedExports() { +function groupedExports(srcRoot) { return { '.': conditions('index'), './utils': conditions('utils/index'), + ...(existsSync(resolve(srcRoot, 'test/index.ts')) + ? { './test': conditions('test/index') } + : {}), './package.json': './package.json', }; } @@ -57,7 +66,7 @@ function expectedStubs(srcRoot) { * @returns {Record} */ function expectedExports(packageDir, srcRoot) { - const map = { ...groupedExports(), ...buildSubpathExports(srcRoot) }; + const map = { ...groupedExports(srcRoot), ...buildSubpathExports(srcRoot) }; // v4 publishes built artifacts only. Its export map must not point at omitted sources. if (packageDir === 'packages/v4') { for (const target of Object.values(map)) {