diff --git a/internal/check-poster-type-registry.mjs b/internal/check-poster-type-registry.mjs new file mode 100644 index 000000000..fde8823eb --- /dev/null +++ b/internal/check-poster-type-registry.mjs @@ -0,0 +1,81 @@ +#!/usr/bin/env node +/** + * check-poster-type-registry.mjs — the poster type registry is ONE per page, whichever bundle's copy asks. + * + * Every bundle inlines its own copy of library/components/poster/posterTypes.js, and is + * defined by whichever bundle loads first. A module-level registry made that element see only its own bundle's + * types: a type another bundle registered rendered "none". So the registry is page-global (globalThis). + * + * ASSERTS, with TWO independent copies of the module loaded side by side (as two bundles are on one page): + * A. a type registered through copy B is found through copy A (the copy that defined the element); + * B. the built-in 'country' is listed by both copies, once; + * C. a name registered twice keeps the FIRST registration (the second returns false); + * D. every registration is announced on window ('dxm-poster-type-registered', { name }), so a section that asked + * before its type arrived can render when it does; + * E. PosterSectionView.vue re-resolves its type on that event. + * `--self-test`: the per-bundle (module-level Map) registry must FAIL arm A. + */ +import { readFileSync, writeFileSync, mkdtempSync, rmSync, mkdirSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { tmpdir } from 'node:os' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') +const SRC = join(ROOT, 'library/components/poster/posterTypes.js') +const VIEW = join(ROOT, 'library/components/poster/PosterSectionView.vue') + +// A stand-in window that records the announcements. +const events = [] +globalThis.window = { dispatchEvent: (e) => { events.push(e); return true } } +globalThis.CustomEvent = class { constructor(type, init) { this.type = type; this.detail = init && init.detail } } + +async function copies(source) { + // Two copies = two module instances (as two IIFE bundles each inline one). Written to a scratch dir with a stub + // for the lazily imported country adapter, so nothing else loads. + const dir = mkdtempSync(join(tmpdir(), 'dxm-pt-')) + mkdirSync(join(dir, 'types')) + writeFileSync(join(dir, 'types', 'countryPosterType.js'), 'export default { items: () => [], title: () => "", actions: () => ({}) }\n') + const code = source.replace(/import\.meta\.env\s*&&\s*import\.meta\.env\.DEV/g, 'false') + writeFileSync(join(dir, 'a.mjs'), code); writeFileSync(join(dir, 'b.mjs'), code) + const a = await import(pathToFileURL(join(dir, 'a.mjs')).href) + const b = await import(pathToFileURL(join(dir, 'b.mjs')).href) + return { a, b, done: () => rmSync(dir, { recursive: true, force: true }) } +} + +async function audit(source, viewSrc) { + const fails = [] + for (const k of Object.getOwnPropertyNames(globalThis)) if (k === '__DXM_POSTER_TYPES__') delete globalThis[k] + events.length = 0 + const { a, b, done } = await copies(source) + try { + const fixture = { items: () => [{ id: 'x' }], title: () => 'X', actions: () => ({}) } + b.registerPosterType('gate-b-type', fixture) + if (!(await a.posterType('gate-b-type'))) fails.push('A: a type registered through one bundle\'s copy is NOT found through another\'s (a per-bundle registry)') + const na = a.posterTypeNames(), nb = b.posterTypeNames() + if (na.filter((n) => n === 'country').length !== 1 || nb.filter((n) => n === 'country').length !== 1) fails.push(`B: 'country' is not listed exactly once by each copy (${na.join(',')} | ${nb.join(',')})`) + const second = a.registerPosterType('gate-b-type', { items: () => [], title: () => 'Y', actions: () => ({}) }) + const kept = await b.posterType('gate-b-type') + if (second !== false || !kept || kept.title() !== 'X') fails.push('C: a name registered twice did not keep the FIRST registration') + if (!events.some((e) => e.type === 'dxm-poster-type-registered' && e.detail && e.detail.name === 'gate-b-type')) fails.push('D: a registration is not announced on window (dxm-poster-type-registered)') + } finally { done() } + if (!/dxm-poster-type-registered|TYPE_REGISTERED_EVENT/.test(viewSrc)) fails.push('E: PosterSectionView.vue does not re-resolve its type when one is registered later') + return fails +} + +if (process.argv.includes('--self-test')) { + // The per-bundle registry this gate exists to stop: a module-level Map. + const perBundle = `const LOADERS = new Map([['country', () => import('./types/countryPosterType.js')]]) +const CACHE = new Map() +export function registerPosterType(name, a) { LOADERS.set(name, typeof a === 'function' ? a : () => Promise.resolve({ default: a })); CACHE.delete(name); return true } +export function posterType(name) { if (!LOADERS.has(name)) return Promise.resolve(null); if (!CACHE.has(name)) CACHE.set(name, LOADERS.get(name)().then((m) => m.default || m)); return CACHE.get(name) } +export const posterTypeNames = () => [...LOADERS.keys()] +` + const f = await audit(perBundle, readFileSync(VIEW, 'utf8')) + const ok = f.some((x) => x.startsWith('A:')) + console.log(ok ? `✓ caught: a per-bundle registry -> ${f.find((x) => x.startsWith('A:'))}` : '✗ MISSED: the per-bundle registry passed arm A') + process.exitCode = ok ? 0 : 1 +} else { + const f = await audit(readFileSync(SRC, 'utf8'), readFileSync(VIEW, 'utf8')) + if (f.length) { for (const x of f) console.log('✗ ' + x); console.log(`FAIL check:poster-type-registry (${f.length})`); process.exitCode = 1 } + else console.log('PASS check:poster-type-registry — one registry per page: a type registered through one bundle\'s copy is found through another\'s; the first registration wins; registrations are announced and the section re-resolves') +} diff --git a/internal/check-preview-flag-dev-only.mjs b/internal/check-preview-flag-dev-only.mjs index 1363c9f5d..5e5b4dc84 100644 --- a/internal/check-preview-flag-dev-only.mjs +++ b/internal/check-preview-flag-dev-only.mjs @@ -8,18 +8,19 @@ * landing and to DX Life's /dx-maps, so one assignment there would print India publicly. * * ASSERTS: - * A. no file under public/ ASSIGNS the flag (any spelling a minifier writes: = true, =!0, =1, …); reading it - * (=== true) is fine; - * B. every SOURCE line that assigns it (library/, plugin-designs/, outside scripts/ and tests) is behind - * import.meta.env.DEV on the same line. - * `--self-test` plants each defect in memory and exits 0 only if both are caught. + * A. NO file under public/ MENTIONS the flag at all. An assignment there would turn the preview on, and a READ + * there means the withheld path itself shipped, behind a global any embedding page or console can set (that + * IS shipping it). Every read is compiled away by its DEV guard, so the built tree never names the flag; + * B. every SOURCE line that mentions it, read OR write (library/, plugin-designs/, outside scripts/ and tests; + * comments aside), carries import.meta.env.DEV on the same line. + * `--self-test` plants each defect in memory and exits 0 only if all are caught. */ import { readFileSync, readdirSync, statSync } from 'node:fs' import { join, dirname, relative } from 'node:path' import { fileURLToPath } from 'node:url' const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') -const ASSIGN = /DXM_PREVIEW_UNRELEASED\s*=(?!=)/ +const MENTION = /DXM_PREVIEW_UNRELEASED/ const TEXT = /\.(m?js|html|json|css|txt)$/ function* walk(dir) { @@ -37,9 +38,9 @@ function* walk(dir) { function auditBuilt(files) { const fails = [] for (const [rel, text] of files) { - if (!ASSIGN.test(text)) continue - const line = text.split('\n').findIndex((l) => ASSIGN.test(l)) + 1 - fails.push(`A ${rel}:${line} ASSIGNS DXM_PREVIEW_UNRELEASED in the built tree (it would show withheld content publicly)`) + if (!MENTION.test(text)) continue + const line = text.split('\n').findIndex((l) => MENTION.test(l)) + 1 + fails.push(`A ${rel}:${line} names DXM_PREVIEW_UNRELEASED in the built tree (a setter would show withheld content; a read means the withheld path shipped)`) } return fails } @@ -47,7 +48,7 @@ function auditSource(files) { const fails = [] for (const [rel, text] of files) { text.split('\n').forEach((l, i) => { - if (ASSIGN.test(l) && !/import\.meta\.env\.DEV/.test(l) && !/^\s*(\/\/|\*)/.test(l)) fails.push(`B ${rel}:${i + 1} assigns DXM_PREVIEW_UNRELEASED outside an import.meta.env.DEV guard`) + if (MENTION.test(l) && !/import\.meta\.env\.DEV/.test(l) && !/^\s*(\/\/|\*|\/\*)/.test(l)) fails.push(`B ${rel}:${i + 1} reads or writes DXM_PREVIEW_UNRELEASED outside an import.meta.env.DEV guard`) }) } return fails @@ -59,14 +60,16 @@ const source = () => [...walk(join(ROOT, 'library')), ...walk(join(ROOT, 'plugin .map((p) => [relative(ROOT, p), readFileSync(p, 'utf8')]) if (process.argv.includes('--self-test')) { - const a = auditBuilt([['public/dx-maps/x/x.js', 'var a=1;globalThis.DXM_PREVIEW_UNRELEASED=!0;'], ['public/dx-maps/x/ok.js', 'if(globalThis.DXM_PREVIEW_UNRELEASED===true)f()']]) - const b = auditSource([['library/x.js', 'globalThis.DXM_PREVIEW_UNRELEASED = true'], ['library/ok.js', 'if (import.meta.env.DEV) globalThis.DXM_PREVIEW_UNRELEASED = true']]) - const okA = a.length === 1 && /x\/x\.js/.test(a[0]), okB = b.length === 1 && /library\/x\.js/.test(b[0]) - console.log(okA ? '✓ caught: a built bundle that assigns the flag (minified =!0); a read (===) is not flagged' : '✗ MISSED: the built-tree assignment') - console.log(okB ? '✓ caught: a source assignment outside import.meta.env.DEV; the guarded one passes' : '✗ MISSED: the unguarded source assignment') + const a = auditBuilt([['public/dx-maps/x/set.js', 'var a=1;globalThis.DXM_PREVIEW_UNRELEASED=!0;'], ['public/dx-maps/x/read.js', 'if(globalThis.DXM_PREVIEW_UNRELEASED===!0)showIndia()'], ['public/dx-maps/x/ok.js', 'showOthers()']]) + const b = auditSource([['library/set.js', 'globalThis.DXM_PREVIEW_UNRELEASED = true'], ['library/read.js', "export const previewUnreleased = () => typeof globalThis !== 'undefined' && globalThis.DXM_PREVIEW_UNRELEASED === true"], + ['library/ok.js', 'if (import.meta.env.DEV) globalThis.DXM_PREVIEW_UNRELEASED = true'], ['library/okread.js', 'export const p = () => import.meta.env.DEV && globalThis.DXM_PREVIEW_UNRELEASED === true'], ['library/c.js', '// DXM_PREVIEW_UNRELEASED is named in a comment']]) + const okA = a.length === 2 && a.some((x) => /set\.js/.test(x)) && a.some((x) => /read\.js/.test(x)) + const okB = b.length === 2 && b.some((x) => /library\/set\.js/.test(x)) && b.some((x) => /library\/read\.js/.test(x)) + console.log(okA ? '✓ caught: a built bundle that SETS the flag (=!0) and one that READS it (the withheld path shipped); a clean one passes' : '✗ MISSED: a built-tree setter or reader') + console.log(okB ? '✓ caught: an unguarded source SETTER and an unguarded source READ (the plain previewUnreleased()); the DEV-guarded ones and comments pass' : '✗ MISSED: an unguarded source setter or read') process.exitCode = okA && okB ? 0 : 1 } else { const fails = [...auditBuilt(built()), ...auditSource(source())] if (fails.length) { for (const f of fails) console.log('✗ ' + f); console.log(`FAIL check:preview-flag-dev-only (${fails.length})`); process.exitCode = 1 } - else console.log('PASS check:preview-flag-dev-only — no built file assigns DXM_PREVIEW_UNRELEASED; every source assignment is behind import.meta.env.DEV') + else console.log('PASS check:preview-flag-dev-only — no built file names DXM_PREVIEW_UNRELEASED; every source read and write is behind import.meta.env.DEV') } diff --git a/library/components/poster/PosterSectionView.vue b/library/components/poster/PosterSectionView.vue index 605dc54e6..50dccfd5a 100644 --- a/library/components/poster/PosterSectionView.vue +++ b/library/components/poster/PosterSectionView.vue @@ -1,13 +1,13 @@ - + - + - + - + \n\n\n", - "scriptMapbox": "\n\n\n\n\n\n" + "script": "\n\n\n\n\n\n", + "scriptMapbox": "\n\n\n\n\n\n" } }, { @@ -272,7 +272,7 @@ "scriptUrl": "./country-posters/country-posters.js", "embed": { "iframe": "", - "script": "\n\n\n" + "script": "\n\n\n" } }, { @@ -288,8 +288,8 @@ "scriptUrl": "./country-posters/country-posters.js", "embed": { "iframe": "", - "script": "\n\n\n\n\n\n", - "scriptMapbox": "\n\n\n\n\n\n" + "script": "\n\n\n\n\n\n", + "scriptMapbox": "\n\n\n\n\n\n" } }, { @@ -305,7 +305,7 @@ "scriptUrl": "./country-posters/country-posters.js", "embed": { "iframe": "", - "script": "\n\n\n" + "script": "\n\n\n" } } ] diff --git a/public/dx-maps/manifest.json b/public/dx-maps/manifest.json index 2cf273254..8be3f77b0 100644 --- a/public/dx-maps/manifest.json +++ b/public/dx-maps/manifest.json @@ -232,7 +232,7 @@ "bundle": "country-posters", "scriptUrl": "./country-posters/country-posters.js", "output": "dx-maps/country-posters/country-posters.js", - "integrity": "sha384-VJGval7ZtjxCT7u6crut+uOIvHluTvmvMs7cJiHOeuTcudewlJfCC6y5kwI/XAdp", + "integrity": "sha384-wo556zVsMm0BC5dqD9Grf/T03snHhZwLb7V61TjczWpRiGBE7tSEyCXkfrIs+zC+", "elements": [ "country-posters-map" ], @@ -255,8 +255,8 @@ "scriptUrl": "./country-posters/country-posters.js", "embed": { "iframe": "", - "script": "\n\n\n\n\n\n", - "scriptMapbox": "\n\n\n\n\n\n" + "script": "\n\n\n\n\n\n", + "scriptMapbox": "\n\n\n\n\n\n" } }, { @@ -272,7 +272,7 @@ "scriptUrl": "./country-posters/country-posters.js", "embed": { "iframe": "", - "script": "\n\n\n" + "script": "\n\n\n" } }, { @@ -288,8 +288,8 @@ "scriptUrl": "./country-posters/country-posters.js", "embed": { "iframe": "", - "script": "\n\n\n\n\n\n", - "scriptMapbox": "\n\n\n\n\n\n" + "script": "\n\n\n\n\n\n", + "scriptMapbox": "\n\n\n\n\n\n" } }, { @@ -305,7 +305,7 @@ "scriptUrl": "./country-posters/country-posters.js", "embed": { "iframe": "", - "script": "\n\n\n" + "script": "\n\n\n" } } ]