test: compare DOM nodes by identity, never through assert.equal

A failed assert.equal or deepEqual prints both operands, and a happy-dom
node reaches the whole page and window: printing one exhausted memory and
took the machine down. assertSameNode() compares identity and fails with
a short description of each node; every node comparison in the happy-dom
tests uses it.
This commit is contained in:
Sameh Khamis committed 2026-10-30 20:38:29 -07:00
1 parent e949584c0a
commit 3a2acf9b0f
8 files changed
+46 -20

No files matched your search

+19
View File
@@ -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)}`,
);
}
+3 -2
View File
@@ -1,5 +1,6 @@
import { after, before, test } from 'node:test'; import { after, before, test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { assertSameNode } from '../testSupport/domAssert.mjs';
import { installVueDom } from '../testSupport/vueDom.mjs'; import { installVueDom } from '../testSupport/vueDom.mjs';
let PanelNavigation; let PanelNavigation;
@@ -132,8 +133,8 @@ test('destroy removes the rails and stops following the stacks', async () => {
const { left, right, calls, ui, navigation } = setup(); const { left, right, calls, ui, navigation } = setup();
navigation.destroy(); navigation.destroy();
navigation.destroy(); navigation.destroy();
assert.equal(left.querySelector('.gev-panel-rail'), null); assertSameNode(left.querySelector('.gev-panel-rail'), null);
assert.equal(right.querySelector('.gev-panel-rail'), null); assertSameNode(right.querySelector('.gev-panel-rail'), null);
navigation.select('data-panel'); navigation.select('data-panel');
assert.deepEqual(calls, []); assert.deepEqual(calls, []);
ui.destroy(); ui.destroy();
+4 -3
View File
@@ -1,6 +1,7 @@
import '../testSupport/registerDom.mjs'; import '../testSupport/registerDom.mjs';
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { assertSameNode } from '../testSupport/domAssert.mjs';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import { createRailCards } from './railCards.js'; import { createRailCards } from './railCards.js';
import { railDocumentFixture as railFixture } from './railTestFixture.mjs'; 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(button.parentElement.tagName, 'FOOTER');
assert.equal( assert.equal(
f.find((n) => n.className === 'rail-card-action-hint').textContent, 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'); assert.equal(slider.value, '53', 'release keeps the handle until the owner');
view.update([model(53)]); view.update([model(53)]);
assert.equal(slider.value, '53'); assert.equal(slider.value, '53');
assert.equal(f.document.activeElement, slider); assertSameNode(f.document.activeElement, slider);
slider.value = '41'; slider.value = '41';
slider.dispatchEvent(new Event('input')); slider.dispatchEvent(new Event('input'));
await nextTick(); 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'); const chip = f.find((n) => n.dataset.chipId === 'motion');
chip.focus(); chip.focus();
view.update([model(true)]); view.update([model(true)]);
assert.equal(f.document.activeElement, chip); assertSameNode(f.document.activeElement, chip);
chip.click(); chip.click();
assert.deepEqual(calls.at(-1), ['a', { paused: false }]); assert.deepEqual(calls.at(-1), ['a', { paused: false }]);
const storm = f.find( const storm = f.find(
+3 -2
View File
@@ -1,6 +1,7 @@
import '../testSupport/registerDom.mjs'; import '../testSupport/registerDom.mjs';
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { assertSameNode } from '../testSupport/domAssert.mjs';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import { createRailTimeline } from './railTimeline.js'; import { createRailTimeline } from './railTimeline.js';
import { railDocumentFixture as railFixture } from './railTestFixture.mjs'; 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, index: 1,
readout: 'Refresh', readout: 'Refresh',
}); });
assert.equal(slider.parentElement, row); assertSameNode(slider.parentElement, row);
assert.equal(row.children[1], slider); assertSameNode(row.children[1], slider);
assert.equal(slider.value, '0'); assert.equal(slider.value, '0');
assert.equal(slider.max, '2'); assert.equal(slider.max, '2');
assert.equal( assert.equal(
+5 -4
View File
@@ -1,6 +1,7 @@
import '../testSupport/registerDom.mjs'; import '../testSupport/registerDom.mjs';
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { assertSameNode } from '../testSupport/domAssert.mjs';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { createRecentImageryPanel } from './recentImagery.js'; import { createRecentImageryPanel } from './recentImagery.js';
import { createRecentImageryLayer } from '../layers/recentImagery/index.js'; import { createRecentImageryLayer } from '../layers/recentImagery/index.js';
@@ -246,7 +247,7 @@ function part(f, id, name) {
// ---- tests --------------------------------------------------------------------- // ---- tests ---------------------------------------------------------------------
test('the readout mounts in the rail body, hides while the layer is off and opens the panel once on first appearance', async () => { 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(); 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.panel.hidden, true);
assert.equal(f.count.textContent, ''); assert.equal(f.count.textContent, '');
await f.ready(); 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.tagName, 'BUTTON');
assert.equal(swap.textContent, 'SWAP'); 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.ready();
await f.settleLease(); await f.settleLease();
assert.equal(f.splits.length, 0, 'one image, no divider'); 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(); f.readout.destroy();
assert.equal(await f.readout.exportImage('a'), false); assert.equal(await f.readout.exportImage('a'), false);
assert.equal(f.fetches.length, fetched); 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 () => { 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(split.destroyed, true);
assert.equal(f.root.listenerCount(), 0); assert.equal(f.root.listenerCount(), 0);
assert.equal(f.strip.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); assert.equal(f.document.getElementById('recent-imagery-panel').hidden, true);
f.layer.setMode('ab'); f.layer.setMode('ab');
assert.equal(f.splits.length, 1, 'no renders after destroy'); assert.equal(f.splits.length, 1, 'no renders after destroy');
+5 -4
View File
@@ -1,5 +1,6 @@
import test, { after, before } from 'node:test'; import test, { after, before } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { assertSameNode } from '../testSupport/domAssert.mjs';
import { createStateChannel } from '../app/stateChannel.js'; import { createStateChannel } from '../app/stateChannel.js';
import { sceneElements } from './scenePresentation.js'; import { sceneElements } from './scenePresentation.js';
import { mountSceneSharing } from './sceneSharing.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(); f.owner.renderShotList();
await nextTick(); 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. // An owner destroyed after a newer one attached leaves the newer list.
const newer = new SceneControls({ const newer = new SceneControls({
read: () => f.state, 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')); label.dispatchEvent(new Event('dblclick'));
await nextTick(); await nextTick();
const input = label.children[0]; const input = label.children[0];
assert.equal(document.activeElement, input); assertSameNode(document.activeElement, input);
input.value = value; input.value = value;
input.dispatchEvent(key(commitKey)); input.dispatchEvent(key(commitKey));
input.dispatchEvent(new Event('blur')); 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; f.state.progress = 0.5;
channel.publish({ type: 'progress-changed' }); channel.publish({ type: 'progress-changed' });
await nextTick(); await nextTick();
assert.equal(f.elements.shots.children[0], row); assertSameNode(f.elements.shots.children[0], row);
f.state.status = 'Project exported'; f.state.status = 'Project exported';
channel.publish({ type: 'project-exported' }); channel.publish({ type: 'project-exported' });
assert.equal(f.elements.status.textContent, '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 dispose = mountSceneSharing({ edit() {}, share() {} }, panel);
const bar = inner.children[1]; const bar = inner.children[1];
assert.equal(bar.className, 'scene-controls'); 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)); assert.ok(bar.children.every((button) => button.title));
dispose(); dispose();
assert.deepEqual(inner.children, [fileRow, status]); assert.deepEqual(inner.children, [fileRow, status]);
+3 -2
View File
@@ -1,6 +1,7 @@
import '../testSupport/registerDom.mjs'; import '../testSupport/registerDom.mjs';
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { assertSameNode } from '../testSupport/domAssert.mjs';
import { createStatusBadge, updateStatusBadge } from './statusBadge.js'; import { createStatusBadge, updateStatusBadge } from './statusBadge.js';
import { createRailCards } from './railCards.js'; import { createRailCards } from './railCards.js';
import { railDocumentFixture as railFixture } from './railTestFixture.mjs'; 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('role'), 'status');
assert.equal(badge.getAttribute('aria-live'), 'polite'); assert.equal(badge.getAttribute('aria-live'), 'polite');
assert.equal(badge.dataset.frameState, 'ready'); 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', () => { 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, badge,
); );
assert.deepEqual({ ...badge.dataset }, {}); assert.deepEqual({ ...badge.dataset }, {});
assert.equal(f.document.activeElement, header); assertSameNode(f.document.activeElement, header);
view.destroy(); view.destroy();
view.update([card]); view.update([card]);
assert.equal(f.container.children.length, 0); assert.equal(f.container.children.length, 0);
+4 -3
View File
@@ -1,6 +1,7 @@
import '../testSupport/registerDom.mjs'; import '../testSupport/registerDom.mjs';
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { assertSameNode } from '../testSupport/domAssert.mjs';
import { setLocale } from '../i18n/index.js'; import { setLocale } from '../i18n/index.js';
import { createWeatherPanel } from './weatherPanel.js'; import { createWeatherPanel } from './weatherPanel.js';
import { railDocumentFixture as railFixture } from './railTestFixture.mjs'; 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]); view.update([radar, wind]);
const windCard = card(f, 'wind'); const windCard = card(f, 'wind');
assert.equal(windCard.tagName, 'ARTICLE'); assert.equal(windCard.tagName, 'ARTICLE');
assert.equal(windCard.parentElement.children[0], windCard); assertSameNode(windCard.parentElement.children[0], windCard);
assert.equal( assert.equal(
f.find((n) => n.dataset.weatherOpen), f.find((n) => n.dataset.weatherOpen),
null, null,
@@ -403,13 +404,13 @@ test('cyclones lead, and observed history owns one bordered group with only acti
['weather-cyclones', 'wind'], ['weather-cyclones', 'wind'],
); );
const group = f.find((n) => n.className === 'weather-observed-group'); 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[0].textContent, 'Observed history');
assert.equal( assert.equal(
group.children[1].textContent, group.children[1].textContent,
'Rain radar · Satellite clouds · Lightning density', 'Rain radar · Satellite clouds · Lightning density',
); );
assert.equal(group.children[2], timelineHost(f)); assertSameNode(group.children[2], timelineHost(f));
assert.deepEqual( assert.deepEqual(
[...group.children[3].children].map((n) => n.dataset.cardId), [...group.children[3].children].map((n) => n.dataset.cardId),
['weather-radar', 'weather-satellite', 'weather-lightning'], ['weather-radar', 'weather-satellite', 'weather-lightning'],