library/composables/useViewAddress.js is the page's address for the view a reader is on: ?tab= &pane= &place=, with a level rule, an id whitelist, and ONE history write (commitUrl). It keeps history.state and sets its `current` to the new URL, so a link survives the next in-app navigation on a Vue Router host. The OUTERMOST owner writes, named by profile-config urlOwner: - 'app' writes history itself; - 'host' hands its router one dxm-address; - absent, nothing is written and links are still read. Nested elements announce dxm-view. Back/Forward is read and never written. useShareableView's filter write used to call replaceState(null, ...) itself, which wiped the router's state. It now goes through commitUrl (writeFilterView). A filter write keeps tab/pane/place, and a tab/pane/place write keeps the filters. Level 2 is `pane`, not `view`: ?view= is already published by four of our pages. check:view-address (gates:pre) runs on a simulated browser history. It covers: - the whitelist and the level rule; - one push per tab change, with state + current kept; - no write when unchanged, on a partner page, or by a host owner; - the filters and the view keys keeping each other through writes, Back and Forward; - a census of every history writer in library/ and plugin-designs/ (6 today). A new writer fails until it is listed. The self-test has 14 poisons, each caught. Real-source mutations are caught too: drop current, drop the unchanged guard, drop the level rule, an uncomposed event, no length cap, an aliased pushState in a plugin.
237 lines
20 KiB
JavaScript
237 lines
20 KiB
JavaScript
#!/usr/bin/env node
|
||
// check:view-address — the page's address (library/composables/useViewAddress.js) and its ONE history write.
|
||
// A. the pure helpers: the id whitelist + 64 cap; an unknown or malformed value reads as absent (the default opens);
|
||
// the level rule (a tab change drops pane and place, a pane change drops place, unless the change sets them);
|
||
// foreign params keep their exact text.
|
||
// B. the writes, on a simulated browser history: an 'app' owner pushes once and keeps history.state with `current`
|
||
// and the hash; an unchanged address writes nothing; a partner page (no owner) writes nothing; a 'host' owner
|
||
// writes nothing and hands the host one dxm-address event; nested elements announce dxm-view; Back/Forward is
|
||
// only read, never written.
|
||
// C. COEXISTENCE with the filters (useShareableView, which writes through the same commitUrl): a filter write keeps
|
||
// tab/pane/place, the hash or the query it does not own, and the router's history.state; a tab/pane/place write
|
||
// keeps the filters (query AND hash form); both survive Back and Forward, and re-applying the old tab after Back
|
||
// writes nothing, so Forward still exists.
|
||
// D. THE CENSUS of history writers, enumerated from the tree: every history.pushState/replaceState call in library/
|
||
// and plugin-designs/ is the helper's or a listed writer. A new one fails until it is listed here with its reason.
|
||
// --self-test: each poison below must FAIL its arm. Pure node: no Vite, no build, no browser.
|
||
import { execFileSync } from 'node:child_process'
|
||
import { readFileSync } from 'node:fs'
|
||
import { join, dirname } from 'node:path'
|
||
import { fileURLToPath } from 'node:url'
|
||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||
|
||
// The history writers that exist today besides the helper, by call count. Adding one means a row here with a reason.
|
||
const KNOWN = {
|
||
'library/composables/useViewAddress.js': [1, 'THE write (commitUrl)'],
|
||
'library/utils/languageRequest.js': [1, '?lang= on a generated page only (MAP_OWNS_PAGE); keeps state and every other param'],
|
||
'library/layouts/app-wrapper-material/index.html': [1, "the layout page's own #/<map> hash; a fragment-only URL keeps the query"],
|
||
'plugin-designs/gospel-internet/src/useBoard.js': [1, 'its own keys on its own leaves (writeUrl); keeps state, foreign params and the hash'],
|
||
'plugin-designs/gospel-resources/data-tables.vue': [1, "clearFilter: replaceState({}, '', pathname) wipes the whole query and hash; reported to its owner"],
|
||
'plugin-designs/regions-dashboard/region-dashboards.vue': [2, '#<slug> on its own leaf (URL_SYNC); P2 moves it to `place`'],
|
||
}
|
||
// SOURCE files only: gates and node scripts may simulate history. A write is `.pushState(` / `.replaceState(` (an aliased
|
||
// history included) or a computed `history[… 'pushState' …]`.
|
||
const WRITE = /\.(?:push|replace)State\s*\(|\[[^\]\n]*['"](?:push|replace)State['"]/g
|
||
function census() {
|
||
const out = execFileSync('git', ['grep', '--untracked', '-cE', '(push|replace)State', '--', 'library', 'plugin-designs',
|
||
':!*.md', ':!*.mjs', ':!**/scripts/**', ':!**/test/**', ':!**/*.test.*', ':!**/*.spec.*'], { cwd: ROOT, encoding: 'utf8' }).trim()
|
||
const counts = {}
|
||
for (const line of out.split('\n').filter(Boolean)) {
|
||
const i = line.lastIndexOf(':')
|
||
const file = line.slice(0, i)
|
||
// -c counts LINES; count the calls themselves (a ternary writes twice on one line).
|
||
const n = (readFileSync(join(ROOT, file), 'utf8').match(WRITE) || []).length
|
||
if (n) counts[file] = n
|
||
}
|
||
return counts
|
||
}
|
||
|
||
// ── a browser's history, simulated: entries + index, popstate on back/forward, write counters ──────────────────────
|
||
function browser(href, state = null) {
|
||
const entries = [{ url: new URL(href), state }]
|
||
let i = 0
|
||
const writes = { push: 0, replace: 0 }
|
||
const listeners = new Set()
|
||
const loc = {
|
||
get href() { return entries[i].url.href },
|
||
get pathname() { return entries[i].url.pathname },
|
||
get search() { return entries[i].url.search },
|
||
get hash() { return entries[i].url.hash },
|
||
}
|
||
const copy = (s) => (s == null ? s : JSON.parse(JSON.stringify(s)))
|
||
const hist = {
|
||
get state() { return entries[i].state },
|
||
get length() { return entries.length },
|
||
pushState(s, _, url) { writes.push++; entries.splice(i + 1); entries.push({ url: new URL(url, entries[i].url), state: copy(s) }); i++ },
|
||
replaceState(s, _, url) { writes.replace++; entries[i] = { url: new URL(url, entries[i].url), state: copy(s) } },
|
||
back() { if (i > 0) { i--; listeners.forEach((h) => h()) } },
|
||
forward() { if (i < entries.length - 1) { i++; listeners.forEach((h) => h()) } },
|
||
}
|
||
globalThis.location = loc; globalThis.history = hist
|
||
globalThis.addEventListener = (t, h) => t === 'popstate' && listeners.add(h)
|
||
globalThis.removeEventListener = (t, h) => t === 'popstate' && listeners.delete(h)
|
||
const at = () => loc.pathname + loc.search + loc.hash
|
||
const q = () => new URLSearchParams(loc.search)
|
||
return { loc, hist, writes, at, q, listeners }
|
||
}
|
||
const unset = () => { for (const k of ['location', 'history', 'addEventListener', 'removeEventListener']) delete globalThis[k] }
|
||
|
||
async function arms({ va, sv, counts }) {
|
||
const fails = []
|
||
const eq = (tag, got, want) => { if (JSON.stringify(got) !== JSON.stringify(want)) fails.push(`${tag}: got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`) }
|
||
try {
|
||
// ── A. pure helpers ──
|
||
eq('A: a plain id is kept', va.cleanKey('regions-dashboard'), 'regions-dashboard')
|
||
eq('A: a 64-char id is kept', va.cleanKey('a'.repeat(64)), 'a'.repeat(64))
|
||
for (const bad of ['a'.repeat(65), '<b>', 'a b', '-lead', '../x', 'x?y', 'x#y', '']) {
|
||
if (va.cleanKey(bad) !== '') fails.push(`A: the whitelist lets ${JSON.stringify(bad.slice(0, 20))} through`)
|
||
}
|
||
eq('A: an unknown or malformed value reads as absent', va.readViewKeys('?tab=a%20b&pane=%E0%A4%A&place=angola&tab=second'), { tab: '', pane: '', place: 'angola' })
|
||
eq('A: a tab change drops pane and place', va.viewSearch('?tab=a&pane=b&place=c&lang=ar', { tab: 'd' }), '?tab=d&lang=ar')
|
||
eq('A: a pane change drops place, keeps tab', va.viewSearch('?tab=a&pane=b&place=c', { pane: 'e' }), '?tab=a&pane=e')
|
||
eq('A: a place change keeps tab and pane', va.viewSearch('?tab=a&pane=b&place=c', { place: 'f' }), '?tab=a&pane=b&place=f')
|
||
eq('A: a change that sets the deeper level keeps it', va.viewSearch('?tab=a&pane=b&place=c', { tab: 'd', pane: 'p' }), '?tab=d&pane=p')
|
||
eq('A: the same tab changes nothing', va.viewSearch('?tab=a&pane=b&place=c', { tab: 'a' }), '?tab=a&pane=b&place=c')
|
||
eq('A: foreign params keep their exact text', va.viewSearch('?lang=ar&access=yes%7Cpartial&q=rice+%26+beans&tab=a', { tab: 'b' }), '?tab=b&lang=ar&access=yes%7Cpartial&q=rice+%26+beans')
|
||
eq('A: removing the last key leaves no "?"', va.viewSearch('?tab=a', { tab: '' }), '')
|
||
eq('A: urlOwner', ['app', 'host', 'App', true, undefined, 'partner'].map((o) => va.urlOwnerOf({ urlOwner: o })), ['app', 'host', '', '', '', ''])
|
||
|
||
// ── B. writes ──
|
||
{
|
||
const b = browser('https://x.test/deep-research?lang=ar#top', { back: '/', current: '/deep-research?lang=ar#top', position: 3 })
|
||
const r = va.writeViewKeys({ tab: 'regions' }, { owner: 'app', push: true })
|
||
if (r !== 'written' || b.writes.push !== 1) fails.push(`B: an app owner's tab change is not ONE push (${r}, ${b.writes.push} pushes)`)
|
||
if (b.at() !== '/deep-research?tab=regions&lang=ar#top') fails.push(`B: the write lost the hash or a foreign param: ${b.at()}`)
|
||
const s = b.hist.state || {}
|
||
if (s.position !== 3 || s.back !== '/') fails.push(`B: history.state was not kept: ${JSON.stringify(s)}`)
|
||
if (s.current !== b.at()) fails.push(`B: history.state.current is not the new URL (${s.current}): a Vue Router host loses the link`)
|
||
const r2 = va.writeViewKeys({ tab: 'regions' }, { owner: 'app', push: true })
|
||
if (r2 !== 'unchanged' || b.writes.push + b.writes.replace !== 1) fails.push(`B: an unchanged address wrote (${r2})`)
|
||
if (va.commitUrl({}) !== false || b.writes.push + b.writes.replace !== 1) fails.push('B: commitUrl wrote an identical URL')
|
||
}
|
||
{
|
||
const b = browser('https://partner.test/page?tab=theirs')
|
||
for (const owner of ['', undefined, 'partner']) va.writeViewKeys({ tab: 'regions' }, { owner, push: true })
|
||
if (b.writes.push + b.writes.replace) fails.push(`B: a partner page (no owner) was written ${b.writes.push + b.writes.replace}×`)
|
||
}
|
||
{
|
||
const b = browser('https://dx.test/deep-research?lang=ar')
|
||
const el = new EventTarget(); const got = []
|
||
el.addEventListener(va.ADDRESS_EVENT, (e) => got.push(e))
|
||
const r = va.writeViewKeys({ tab: 'posters' }, { owner: 'host', push: true, el })
|
||
if (b.writes.push + b.writes.replace) fails.push("B: a 'host' owner wrote history itself (the host's router must)")
|
||
if (r !== 'announced' || got.length !== 1) fails.push(`B: a 'host' owner did not hand the host ONE ${va.ADDRESS_EVENT} (${r}, ${got.length})`)
|
||
else {
|
||
const e = got[0]
|
||
if (!e.bubbles || !e.composed) fails.push(`B: ${va.ADDRESS_EVENT} cannot leave a shadow root (bubbles ${e.bubbles}, composed ${e.composed})`)
|
||
if (e.detail?.keys?.tab !== 'posters' || e.detail?.push !== true || e.detail?.href !== '/deep-research?tab=posters&lang=ar') fails.push(`B: ${va.ADDRESS_EVENT} detail is wrong: ${JSON.stringify(e.detail)}`)
|
||
}
|
||
// the host has not navigated yet; once it has, the same choice again must not ask it to navigate twice
|
||
b.hist.replaceState(b.hist.state, '', '/deep-research?tab=posters&lang=ar'); b.writes.replace--
|
||
const r2 = va.writeViewKeys({ tab: 'posters' }, { owner: 'host', push: true, el })
|
||
if (r2 !== 'unchanged' || got.length !== 1) fails.push(`B: a 'host' owner announced an unchanged address (${r2}, ${got.length} events)`)
|
||
}
|
||
{
|
||
browser('https://x.test/p')
|
||
const el = new EventTarget(); const got = []
|
||
el.addEventListener(va.VIEW_EVENT, (e) => got.push(e))
|
||
va.announceView(el, 'pane', 'dashboard')
|
||
if (va.announceView(el, 'filters', 'x') !== false) fails.push('B: announceView accepts a key that is not tab/pane/place')
|
||
if (got.length !== 1 || !got[0].bubbles || !got[0].composed || got[0].detail?.key !== 'pane' || got[0].detail?.value !== 'dashboard') fails.push(`B: a nested element's ${va.VIEW_EVENT} is wrong or cannot leave its shadow root`)
|
||
}
|
||
|
||
// ── C. coexistence with the filters, and Back/Forward ──
|
||
{
|
||
const b = browser('https://x.test/board?tab=a&pane=b&place=c#top', { current: '/board?tab=a&pane=b&place=c#top', position: 2 })
|
||
sv.writeFilterView({ access_level: 'yes' }, 'rice', { mode: 'query', facetKeys: { access: 'access_level' } })
|
||
const q = b.q()
|
||
if (q.get('tab') !== 'a' || q.get('pane') !== 'b' || q.get('place') !== 'c') fails.push(`C: a filter write (query) dropped tab/pane/place: ${b.at()}`)
|
||
if (q.get('access') !== 'yes' || q.get('q') !== 'rice' || b.loc.hash !== '#top') fails.push(`C: a filter write (query) lost its own params or the hash: ${b.at()}`)
|
||
if (b.hist.state?.position !== 2 || b.hist.state?.current !== b.at()) fails.push(`C: a filter write dropped the router's history.state or its current: ${JSON.stringify(b.hist.state)}`)
|
||
if (b.writes.push) fails.push('C: a filter write pushed (filters replace)')
|
||
}
|
||
{
|
||
const b = browser('https://x.test/research?tab=a&pane=b')
|
||
sv.writeFilterView({ region: 'asia' }, '', {})
|
||
if (b.loc.search !== '?tab=a&pane=b' || !b.loc.hash.startsWith('#f=region:asia')) fails.push(`C: a filter write (hash) dropped tab/pane/place: ${b.at()}`)
|
||
}
|
||
{
|
||
const b = browser('https://x.test/board?access=yes&q=rice#f=region:asia')
|
||
va.writeViewKeys({ tab: 'map' }, { owner: 'app', push: true })
|
||
const q = b.q()
|
||
if (q.get('access') !== 'yes' || q.get('q') !== 'rice' || b.loc.hash !== '#f=region:asia') fails.push(`C: a tab write dropped the filters: ${b.at()}`)
|
||
va.writeViewKeys({ place: 'angola' }, { owner: 'app', push: true })
|
||
if (b.q().get('access') !== 'yes' || b.loc.hash !== '#f=region:asia') fails.push(`C: a place write dropped the filters: ${b.at()}`)
|
||
}
|
||
{
|
||
// A shell with the filters inside: first paint, a filter, a tab change, another filter, then Back and Forward.
|
||
// The app re-applies the tab on popstate the way an owner does: it sets its tab, and its tab watcher writes.
|
||
const b = browser('https://x.test/deep-research')
|
||
const app = { tab: '' }
|
||
const setTab = (t, push) => { app.tab = t; va.writeViewKeys({ tab: t }, { owner: 'app', push }) }
|
||
const off = va.onAddressChange((keys) => setTab(keys.tab || 'home', true))
|
||
setTab('a', false)
|
||
sv.writeFilterView({ access_level: 'yes' }, '', { mode: 'query', facetKeys: { access: 'access_level' } })
|
||
setTab('b', true)
|
||
sv.writeFilterView({ access_level: 'no' }, '', { mode: 'query', facetKeys: { access: 'access_level' } })
|
||
const len = b.hist.length; const w = b.writes.push + b.writes.replace
|
||
b.hist.back()
|
||
if (b.q().get('tab') !== 'a' || b.q().get('access') !== 'yes') fails.push(`C: after Back the entry lost its tab or its filters: ${b.at()}`)
|
||
if (app.tab !== 'a') fails.push('C: Back did not re-apply the tab')
|
||
if (b.writes.push + b.writes.replace !== w) fails.push('C: re-applying the tab after Back WROTE history (Forward is lost)')
|
||
b.hist.forward()
|
||
if (b.hist.length !== len || b.q().get('tab') !== 'b' || b.q().get('access') !== 'no') fails.push(`C: after Forward the entry lost its tab or its filters, or Forward was gone: ${b.at()} (${b.hist.length}/${len})`)
|
||
off()
|
||
if (b.listeners.size) fails.push('C: onAddressChange off() left its popstate listener')
|
||
}
|
||
} finally { unset() }
|
||
|
||
// ── D. the census ──
|
||
for (const [file, n] of Object.entries(counts)) {
|
||
if (!KNOWN[file]) fails.push(`D: a new history writer: ${file} (${n}). Route it through useViewAddress (commitUrl), or list it in KNOWN with its reason`)
|
||
else if (KNOWN[file][0] !== n) fails.push(`D: ${file} now has ${n} history writes, the census says ${KNOWN[file][0]}`)
|
||
}
|
||
for (const file of Object.keys(KNOWN)) if (!counts[file]) fails.push(`D: ${file} is listed as a writer but no longer writes: drop its row`)
|
||
return fails
|
||
}
|
||
|
||
const va = await import(join(ROOT, 'library/composables/useViewAddress.js'))
|
||
const sv = await import(join(ROOT, 'library/composables/useShareableView.js'))
|
||
const counts = census()
|
||
const real = { va, sv, counts }
|
||
let code = 0
|
||
if (process.argv.includes('--self-test')) {
|
||
// The old filter write, as it was: its own replaceState(null, …) of the whole URL.
|
||
const oldFilterWrite = (fs, term, o = {}) => {
|
||
const url = o.mode === 'query' ? sv.serializeQuery(fs, term, location.search, o) + location.hash : location.search + sv.serializeView(fs, term, o)
|
||
history.replaceState(null, '', location.pathname + url); return true
|
||
}
|
||
const poisons = [
|
||
['a write that drops history.state.current', { ...real, va: { ...va, writeViewKeys: (c, o) => { const s = va.viewSearch(location.search, c); history[o.push ? 'pushState' : 'replaceState']({ ...history.state }, '', location.pathname + s + location.hash); return 'written' } } }, /^B: history\.state\.current/],
|
||
['a write when not the owner', { ...real, va: { ...va, writeViewKeys: (c, o) => va.writeViewKeys(c, { ...o, owner: 'app' }) } }, /^B: a partner page/],
|
||
['a tab change that keeps a stale place', { ...real, va: { ...va, viewSearch: (s, c) => { const k = { ...va.readViewKeys(s), ...Object.fromEntries(Object.entries(c).map(([a, b]) => [a, va.cleanKey(b)])) }; const f = s.replace(/^\?/, '').split('&').filter((p) => p && !va.VIEW_KEYS.includes(p.split('=')[0])); const all = [...va.VIEW_KEYS.filter((x) => k[x]).map((x) => `${x}=${k[x]}`), ...f]; return all.length ? '?' + all.join('&') : '' } } }, /^A: a tab change drops pane and place/],
|
||
['a whitelist with no length cap', { ...real, va: { ...va, cleanKey: (v) => (/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(String(v ?? '')) ? String(v) : '') } }, /^A: the whitelist lets/],
|
||
['a write when the address is unchanged', { ...real, va: { ...va, writeViewKeys: (c, o) => { const s = va.viewSearch(location.search, c); history[o.push ? 'pushState' : 'replaceState']({ ...(history.state || {}), current: location.pathname + s + location.hash }, '', location.pathname + s + location.hash); return 'written' } } }, /^B: an unchanged address wrote|^C: re-applying the tab after Back WROTE/],
|
||
["a 'host' owner that writes history itself", { ...real, va: { ...va, writeViewKeys: (c, o) => va.writeViewKeys(c, { ...o, owner: o.owner === 'host' ? 'app' : o.owner }) } }, /^B: a 'host' owner wrote/],
|
||
['a dxm-address that cannot leave a shadow root', { ...real, va: { ...va, writeViewKeys: (c, o) => { if (o.owner !== 'host') return va.writeViewKeys(c, o); const s = va.viewSearch(location.search, c); o.el.dispatchEvent(new CustomEvent(va.ADDRESS_EVENT, { bubbles: true, detail: { keys: va.readViewKeys(s), href: location.pathname + s, push: o.push } })); return 'announced' } } }, /^B: dxm-address cannot leave/],
|
||
// the three coexistence poisons
|
||
['a FILTER write that drops tab/pane/place (no foreign params kept)', { ...real, sv: { ...sv, writeFilterView: (fs, term, o = {}) => va.commitUrl(o.mode === 'query' ? { search: sv.serializeQuery(fs, term, '', o) } : { search: '', hash: sv.serializeView(fs, term, o) }) } }, /^C: a filter write \((query|hash)\) dropped tab\/pane\/place/],
|
||
['the old filter write: its own replaceState(null) (the router state is wiped)', { ...real, sv: { ...sv, writeFilterView: oldFilterWrite } }, /^C: a filter write dropped the router's history\.state/],
|
||
['a TAB write that drops the filters (foreign params and hash)', { ...real, va: { ...va, writeViewKeys: (c, o) => { if (o.owner !== 'app') return va.writeViewKeys(c, o); const k = va.readViewKeys(va.viewSearch(location.search, c)); const s = va.VIEW_KEYS.filter((x) => k[x]).map((x) => `${x}=${k[x]}`).join('&'); return va.commitUrl({ search: s ? '?' + s : '', hash: '' }, { push: o.push }) ? 'written' : 'unchanged' } } }, /^C: a (tab|place) write dropped the filters/],
|
||
['a tab change written as a REPLACE (Back leaves the page)', { ...real, va: { ...va, writeViewKeys: (c, o) => va.writeViewKeys(c, { ...o, push: false }) } }, /^B: an app owner's tab change is not ONE push|^C: after Back/],
|
||
['a popstate handler that writes the address', { ...real, va: { ...va, onAddressChange: (cb) => va.onAddressChange((k) => { cb(k); history.pushState(history.state, '', location.href) }) } }, /^C: re-applying the tab after Back WROTE|^C: after Forward/],
|
||
// the census
|
||
['a new history writer in the tree', { ...real, counts: { ...counts, 'plugin-designs/some-plugin/page.vue': 1 } }, /^D: a new history writer/],
|
||
['useShareableView writing history itself again', { ...real, counts: { ...counts, 'library/composables/useShareableView.js': 1 } }, /^D: a new history writer: library\/composables\/useShareableView\.js/],
|
||
]
|
||
for (const [name, inp, rx] of poisons) {
|
||
const f = await arms(inp); const ok = f.some((x) => rx.test(x))
|
||
console.log(ok ? `✓ caught: ${name} -> ${f.find((x) => rx.test(x))}` : `✗ MISSED: ${name}${f.length ? ' (failed instead: ' + f[0] + ')' : ''}`)
|
||
if (!ok) code = 1
|
||
}
|
||
} else {
|
||
const f = await arms(real)
|
||
if (f.length) { f.forEach((x) => console.log('FAIL ' + x)); code = 1 }
|
||
else console.log(`PASS check:view-address — the id whitelist and the level rule; one push per tab change with history.state + current kept; nothing written when unchanged, on a partner page, or by a 'host' owner (one dxm-address instead); filters and tab/pane/place keep each other through writes, Back and Forward; ${Object.keys(counts).length} history writers in the tree, all accounted for`)
|
||
}
|
||
process.exit(code)
|