diff --git a/src/testSupport/domAssert.mjs b/src/testSupport/domAssert.mjs new file mode 100644 index 000000000..0c8de120f --- /dev/null +++ b/src/testSupport/domAssert.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; + +const describe = (node) => + node?.nodeType === 1 + ? `<${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}>` + : String(node); + +/** + * Assert that two DOM nodes (or a node and null) are the same. Never hand + * happy-dom nodes to assert.equal or deepEqual: a failure prints both, and a + * node reaches the whole page and window, which runs the process out of + * memory. + */ +export function assertSameNode(actual, expected, message = '') { + assert.ok( + actual === expected, + `${message ? `${message}: ` : ''}${describe(actual)} is not ${describe(expected)}`, + ); +} diff --git a/src/ui/panelNavigation.test.mjs b/src/ui/panelNavigation.test.mjs index ec6cc8882..c253bb03d 100644 --- a/src/ui/panelNavigation.test.mjs +++ b/src/ui/panelNavigation.test.mjs @@ -1,5 +1,6 @@ import { after, before, test } from 'node:test'; import assert from 'node:assert/strict'; +import { assertSameNode } from '../testSupport/domAssert.mjs'; import { installVueDom } from '../testSupport/vueDom.mjs'; let PanelNavigation; @@ -132,8 +133,8 @@ test('destroy removes the rails and stops following the stacks', async () => { const { left, right, calls, ui, navigation } = setup(); navigation.destroy(); navigation.destroy(); - assert.equal(left.querySelector('.gev-panel-rail'), null); - assert.equal(right.querySelector('.gev-panel-rail'), null); + assertSameNode(left.querySelector('.gev-panel-rail'), null); + assertSameNode(right.querySelector('.gev-panel-rail'), null); navigation.select('data-panel'); assert.deepEqual(calls, []); ui.destroy(); diff --git a/src/ui/railCards.test.mjs b/src/ui/railCards.test.mjs index 7107fd093..d53b03831 100644 --- a/src/ui/railCards.test.mjs +++ b/src/ui/railCards.test.mjs @@ -1,6 +1,7 @@ import '../testSupport/registerDom.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; +import { assertSameNode } from '../testSupport/domAssert.mjs'; import { nextTick } from 'vue'; import { createRailCards } from './railCards.js'; import { railDocumentFixture as railFixture } from './railTestFixture.mjs'; @@ -94,7 +95,7 @@ test('footer buttons retain focus and current callbacks; links use safe new tabs }, ]), ]); - assert.equal(f.document.activeElement, button); + assertSameNode(f.document.activeElement, button); assert.equal(button.parentElement.tagName, 'FOOTER'); assert.equal( f.find((n) => n.className === 'rail-card-action-hint').textContent, @@ -200,7 +201,7 @@ test('setting ranges stay below presets and commit expensive filters on release' assert.equal(slider.value, '53', 'release keeps the handle until the owner'); view.update([model(53)]); assert.equal(slider.value, '53'); - assert.equal(f.document.activeElement, slider); + assertSameNode(f.document.activeElement, slider); slider.value = '41'; slider.dispatchEvent(new Event('input')); await nextTick(); @@ -334,7 +335,7 @@ test('labelled rows, lists and results retain focus and dispatch current params' const chip = f.find((n) => n.dataset.chipId === 'motion'); chip.focus(); view.update([model(true)]); - assert.equal(f.document.activeElement, chip); + assertSameNode(f.document.activeElement, chip); chip.click(); assert.deepEqual(calls.at(-1), ['a', { paused: false }]); const storm = f.find( diff --git a/src/ui/railTimeline.test.mjs b/src/ui/railTimeline.test.mjs index fd9ced861..edbdbc7fe 100644 --- a/src/ui/railTimeline.test.mjs +++ b/src/ui/railTimeline.test.mjs @@ -1,6 +1,7 @@ import '../testSupport/registerDom.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; +import { assertSameNode } from '../testSupport/domAssert.mjs'; import { nextTick } from 'vue'; import { createRailTimeline } from './railTimeline.js'; import { railDocumentFixture as railFixture } from './railTestFixture.mjs'; @@ -46,8 +47,8 @@ test('preview is immediate, commits are coalesced and the drag DOM and tick mapp index: 1, readout: 'Refresh', }); - assert.equal(slider.parentElement, row); - assert.equal(row.children[1], slider); + assertSameNode(slider.parentElement, row); + assertSameNode(row.children[1], slider); assert.equal(slider.value, '0'); assert.equal(slider.max, '2'); assert.equal( diff --git a/src/ui/recentImagery.test.mjs b/src/ui/recentImagery.test.mjs index 81d910e3a..5ed0b6bba 100644 --- a/src/ui/recentImagery.test.mjs +++ b/src/ui/recentImagery.test.mjs @@ -1,6 +1,7 @@ import '../testSupport/registerDom.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; +import { assertSameNode } from '../testSupport/domAssert.mjs'; import { readFileSync } from 'node:fs'; import { createRecentImageryPanel } from './recentImagery.js'; import { createRecentImageryLayer } from '../layers/recentImagery/index.js'; @@ -246,7 +247,7 @@ function part(f, id, name) { // ---- tests --------------------------------------------------------------------- test('the readout mounts in the rail body, hides while the layer is off and opens the panel once on first appearance', async () => { const f = fixture(); - assert.equal(f.root.parentNode, f.body); + assertSameNode(f.root.parentNode, f.body); assert.equal(f.panel.hidden, true); assert.equal(f.count.textContent, ''); await f.ready(); @@ -864,7 +865,7 @@ test('the divider exists only while a swipe is live, labelled A / B or IMAGE / B }; assert.equal(swap.tagName, 'BUTTON'); assert.equal(swap.textContent, 'SWAP'); - assert.equal(swap.parentNode, f.byId('ri-controls')); + assertSameNode(swap.parentNode, f.byId('ri-controls')); await f.ready(); await f.settleLease(); assert.equal(f.splits.length, 0, 'one image, no divider'); @@ -993,7 +994,7 @@ test('opacity drives both images; EXPORT downloads the pin or the preview and EX f.readout.destroy(); assert.equal(await f.readout.exportImage('a'), false); assert.equal(f.fetches.length, fetched); - assert.equal(container.querySelector('.recent-imagery-readout'), null); + assertSameNode(container.querySelector('.recent-imagery-readout'), null); }); test('DETAILS is a collapsed rail card holding every note, and the empty-days toggle', async () => { @@ -1077,7 +1078,7 @@ test('destroy returns every listener, the divider and the panel state', async () assert.equal(split.destroyed, true); assert.equal(f.root.listenerCount(), 0); assert.equal(f.strip.listenerCount(), 0); - assert.equal(f.root.parentNode, null); + assertSameNode(f.root.parentNode, null); assert.equal(f.document.getElementById('recent-imagery-panel').hidden, true); f.layer.setMode('ab'); assert.equal(f.splits.length, 1, 'no renders after destroy'); diff --git a/src/ui/sceneControls.test.mjs b/src/ui/sceneControls.test.mjs index fdb9226ef..9ab9e6bd5 100644 --- a/src/ui/sceneControls.test.mjs +++ b/src/ui/sceneControls.test.mjs @@ -1,5 +1,6 @@ import test, { after, before } from 'node:test'; import assert from 'node:assert/strict'; +import { assertSameNode } from '../testSupport/domAssert.mjs'; import { createStateChannel } from '../app/stateChannel.js'; import { sceneElements } from './scenePresentation.js'; import { mountSceneSharing } from './sceneSharing.js'; @@ -192,7 +193,7 @@ test('Scene controls dispatch panel and shot-row actions, and rows outlive repub ]); f.owner.renderShotList(); await nextTick(); - assert.equal(f.elements.shots.children[0], row); + assertSameNode(f.elements.shots.children[0], row); // An owner destroyed after a newer one attached leaves the newer list. const newer = new SceneControls({ read: () => f.state, @@ -391,7 +392,7 @@ test('inline shot names save on Enter, cancel on Escape, and reject blank names' label.dispatchEvent(new Event('dblclick')); await nextTick(); const input = label.children[0]; - assert.equal(document.activeElement, input); + assertSameNode(document.activeElement, input); input.value = value; input.dispatchEvent(key(commitKey)); input.dispatchEvent(new Event('blur')); @@ -482,7 +483,7 @@ test('Scene controls consume current state, preserve rows on progress, and unsub f.state.progress = 0.5; channel.publish({ type: 'progress-changed' }); await nextTick(); - assert.equal(f.elements.shots.children[0], row); + assertSameNode(f.elements.shots.children[0], row); f.state.status = 'Project exported'; channel.publish({ type: 'project-exported' }); assert.equal(f.elements.status.textContent, 'Project exported'); @@ -619,7 +620,7 @@ test('The sharing row sits under SAVE & SHARE, after the file row', () => { const dispose = mountSceneSharing({ edit() {}, share() {} }, panel); const bar = inner.children[1]; assert.equal(bar.className, 'scene-controls'); - assert.equal(inner.children[2], status); + assertSameNode(inner.children[2], status); assert.ok(bar.children.every((button) => button.title)); dispose(); assert.deepEqual(inner.children, [fileRow, status]); diff --git a/src/ui/statusBadge.test.mjs b/src/ui/statusBadge.test.mjs index 1ac67a385..c1c1b782b 100644 --- a/src/ui/statusBadge.test.mjs +++ b/src/ui/statusBadge.test.mjs @@ -1,6 +1,7 @@ import '../testSupport/registerDom.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; +import { assertSameNode } from '../testSupport/domAssert.mjs'; import { createStatusBadge, updateStatusBadge } from './statusBadge.js'; import { createRailCards } from './railCards.js'; import { railDocumentFixture as railFixture } from './railTestFixture.mjs'; @@ -51,7 +52,7 @@ test('painting preserves feature state, live regions and focus with zero identic assert.equal(badge.getAttribute('role'), 'status'); assert.equal(badge.getAttribute('aria-live'), 'polite'); assert.equal(badge.dataset.frameState, 'ready'); - assert.equal(document.activeElement, focusOwner); + assertSameNode(document.activeElement, focusOwner); }); test('coverage badges keep keyed identity, hide empty coverage and never claim feed health', () => { @@ -74,7 +75,7 @@ test('coverage badges keep keyed identity, hide empty coverage and never claim f badge, ); assert.deepEqual({ ...badge.dataset }, {}); - assert.equal(f.document.activeElement, header); + assertSameNode(f.document.activeElement, header); view.destroy(); view.update([card]); assert.equal(f.container.children.length, 0); diff --git a/src/ui/weatherPanel.test.mjs b/src/ui/weatherPanel.test.mjs index 921121fcc..60aa507ad 100644 --- a/src/ui/weatherPanel.test.mjs +++ b/src/ui/weatherPanel.test.mjs @@ -1,6 +1,7 @@ import '../testSupport/registerDom.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; +import { assertSameNode } from '../testSupport/domAssert.mjs'; import { setLocale } from '../i18n/index.js'; import { createWeatherPanel } from './weatherPanel.js'; import { railDocumentFixture as railFixture } from './railTestFixture.mjs'; @@ -115,7 +116,7 @@ test('ordered articles omit Controls and retain history labels, forecast dates a view.update([radar, wind]); const windCard = card(f, 'wind'); assert.equal(windCard.tagName, 'ARTICLE'); - assert.equal(windCard.parentElement.children[0], windCard); + assertSameNode(windCard.parentElement.children[0], windCard); assert.equal( f.find((n) => n.dataset.weatherOpen), null, @@ -403,13 +404,13 @@ test('cyclones lead, and observed history owns one bordered group with only acti ['weather-cyclones', 'wind'], ); const group = f.find((n) => n.className === 'weather-observed-group'); - assert.equal(root.children[1], group); + assertSameNode(root.children[1], group); assert.equal(group.children[0].textContent, 'Observed history'); assert.equal( group.children[1].textContent, 'Rain radar · Satellite clouds · Lightning density', ); - assert.equal(group.children[2], timelineHost(f)); + assertSameNode(group.children[2], timelineHost(f)); assert.deepEqual( [...group.children[3].children].map((n) => n.dataset.cardId), ['weather-radar', 'weather-satellite', 'weather-lightning'],