|
| 1 | +// Two files, one top-level name — fine in the bundle, broken in the prototype. |
| 2 | +// |
| 3 | +// The build imports each source as an ES module, so a top-level `function X` |
| 4 | +// is module-scoped and two files may each have their own. The raw prototype |
| 5 | +// loads the same files as Babel <script> tags, where every top-level |
| 6 | +// declaration is a GLOBAL: the last file loaded wins, and the earlier file's |
| 7 | +// own call sites silently get the other one. |
| 8 | +// |
| 9 | +// That is how `AuditView` behaved. `qh-panels.jsx` had one for a single run's |
| 10 | +// trail and `qh-admin-audit.jsx` one for the admin screen; in the prototype the |
| 11 | +// developer view's Audit tab rendered the ADMIN component with no `st` prop and |
| 12 | +// threw `Cannot read properties of undefined (reading 'pushToast')`. The build |
| 13 | +// showed nothing, which is exactly why this is a test and not a habit. |
| 14 | +// |
| 15 | +// The mirror image of portal-globals.test.mjs: there the prototype was right |
| 16 | +// and the build broken; here the build is right and the prototype broken. Both |
| 17 | +// come from the same asymmetry, so both need a check nobody has to remember. |
| 18 | +import test from 'node:test'; |
| 19 | +import assert from 'node:assert/strict'; |
| 20 | +import { readFileSync, readdirSync } from 'node:fs'; |
| 21 | +import { fileURLToPath } from 'node:url'; |
| 22 | +import { dirname, resolve } from 'node:path'; |
| 23 | + |
| 24 | +const HERE = dirname(fileURLToPath(import.meta.url)); |
| 25 | +const SRC = resolve(HERE, '..', 'src'); |
| 26 | + |
| 27 | +// `qh-api.jsx` and `qh-api-mock.jsx` are ALTERNATIVES — the prototype loads one |
| 28 | +// or the other, never both — so they are meant to define the same names. |
| 29 | +const ALTERNATIVES = new Set(['qh-api.jsx', 'qh-api-mock.jsx']); |
| 30 | + |
| 31 | +// Known and reported to design, not blessed: `qhAgo` exists twice with |
| 32 | +// DIFFERENT signatures (qh-panels takes an epoch number, qh-admin-data an ISO |
| 33 | +// string). It is their file and their call, so it is named here rather than |
| 34 | +// renamed unilaterally — and naming it keeps this test able to fail for |
| 35 | +// anything new. See CODE_TO_DESIGN_BRIEF.md, 2026-09-15. |
| 36 | +const KNOWN = new Set(['qhAgo']); |
| 37 | + |
| 38 | +function topLevelNames(src) { |
| 39 | + const out = new Set(); |
| 40 | + for (const m of src.matchAll(/^(?:function|class|const)\s+([A-Za-z_$][\w$]*)/gm)) { |
| 41 | + out.add(m[1]); |
| 42 | + } |
| 43 | + return out; |
| 44 | +} |
| 45 | + |
| 46 | +test('no top-level name is declared by two files that load together', () => { |
| 47 | + const owners = new Map(); |
| 48 | + const clashes = []; |
| 49 | + for (const f of readdirSync(SRC).filter(f => f.endsWith('.jsx')).sort()) { |
| 50 | + for (const name of topLevelNames(readFileSync(resolve(SRC, f), 'utf8'))) { |
| 51 | + const prev = owners.get(name); |
| 52 | + if (prev === undefined) { owners.set(name, f); continue; } |
| 53 | + if (ALTERNATIVES.has(prev) && ALTERNATIVES.has(f)) continue; |
| 54 | + if (KNOWN.has(name)) continue; |
| 55 | + clashes.push(`${name}: ${prev} and ${f}`); |
| 56 | + } |
| 57 | + } |
| 58 | + assert.deepEqual(clashes, [], |
| 59 | + 'in the prototype the later file wins and the earlier one gets a stranger'); |
| 60 | +}); |
| 61 | + |
| 62 | +test('the run trail and the admin screen are not both AuditView', () => { |
| 63 | + const panels = readFileSync(resolve(SRC, 'qh-panels.jsx'), 'utf8'); |
| 64 | + assert.match(panels, /function RunAuditView\b/, |
| 65 | + 'a run\'s trail is RunAuditView; AuditView is the admin screen'); |
| 66 | + assert.doesNotMatch(panels, /function AuditView\b/); |
| 67 | + assert.match(panels, /<RunAuditView\b/, 'and it is what the tab renders'); |
| 68 | +}); |
0 commit comments