test(client): check what the chunk registers, and fix two defects in the checks
Three things, all about checks that had never met a real chunk.
`checkExternals.js` rejected slice 3's build outright, naming a fragment of
minified JSX as an imported specifier: a button reading "Approve and import"
puts the token immediately before a quote, and no regexp can tell that from a
statement. Same wall the server's `checkImports.js` hit, answered the same way —
a character walk. A mask rather than a rewrite, because a real import has its
keyword outside a string and its specifier inside one.
Writing the test for that false positive found the false NEGATIVE underneath
it: the pattern required whitespace after `import`, so it could not see
`import{useState}from"react"` — the one shape a minified build actually emits,
and the most likely way for a missed alias to reach production. It has never
been able to see it.
`registration.test.js` is new: stand up a fake `window.__rg` with a recording
registry and the real React, import the BUILT chunk, and read back what it
asked for. No DOM, because nothing renders. It holds the agreement that rots
quietly — every nav row points at a route this module actually registered —
rather than restating both lists.
CI now builds before it tests, because both of those read `dist/entry.js` and
skip without it. Run the other way round they are green and asking nothing.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -27,53 +27,146 @@ import { fileURLToPath } from 'node:url'
|
||||
|
||||
const CHUNK = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'entry.js')
|
||||
|
||||
if (!fs.existsSync(CHUNK)) {
|
||||
console.error(`No chunk at ${CHUNK} — run \`npm run build\` first.`)
|
||||
process.exit(1)
|
||||
/**
|
||||
* Which characters of the chunk are inside a string, template or comment.
|
||||
*
|
||||
* **A check that reads code with a regexp fails on code that talks about
|
||||
* itself.** The first real chunk this script ever saw — slice 3's, the first
|
||||
* with any content in it — was rejected for importing `" }),\n !l && …`,
|
||||
* because a button reading "Approve and import" put the token `import`
|
||||
* immediately before a quote and the pattern could not tell that from a
|
||||
* statement. Slice 0's chunk was 0.2 kB and this branch had never run against
|
||||
* anything.
|
||||
*
|
||||
* The server half hit the same wall from the other side and answered it the same
|
||||
* way (`server/scripts/checkImports.js`): a character walk, not a cleverer
|
||||
* regexp. There is no regexp that distinguishes a keyword from the same letters
|
||||
* inside a string, because that distinction is a property of the parse.
|
||||
*
|
||||
* A mask rather than a rewrite, because the two halves of a real import — the
|
||||
* keyword and the specifier — sit on opposite sides of the boundary: the keyword
|
||||
* must be OUTSIDE a string and the specifier must be a string. Blanking strings
|
||||
* would take the answer with the noise.
|
||||
*/
|
||||
export function stringMask(src) {
|
||||
const inString = new Uint8Array(src.length)
|
||||
let i = 0
|
||||
while (i < src.length) {
|
||||
const c = src[i]
|
||||
const two = src.slice(i, i + 2)
|
||||
if (two === '//') {
|
||||
const nl = src.indexOf('\n', i)
|
||||
const end = nl === -1 ? src.length : nl
|
||||
inString.fill(1, i, end)
|
||||
i = end
|
||||
} else if (two === '/*') {
|
||||
const close = src.indexOf('*/', i + 2)
|
||||
const end = close === -1 ? src.length : close + 2
|
||||
inString.fill(1, i, end)
|
||||
i = end
|
||||
} else if (c === '"' || c === "'" || c === '`') {
|
||||
// The opening quote itself stays unmasked: a specifier is read starting
|
||||
// at its quote, and the regexp below anchors on that.
|
||||
i += 1
|
||||
while (i < src.length && src[i] !== c) {
|
||||
// A backslash escapes the next character, including the closing quote.
|
||||
const step = src[i] === '\\' ? 2 : 1
|
||||
inString.fill(1, i, Math.min(i + step, src.length))
|
||||
i += step
|
||||
}
|
||||
i += 1
|
||||
} else {
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
return inString
|
||||
}
|
||||
|
||||
const chunk = fs.readFileSync(CHUNK, 'utf8')
|
||||
const problems = []
|
||||
|
||||
// Static and dynamic imports that survived into the output. A relative or
|
||||
// absolute specifier is a chunk that was split, which this build does not do —
|
||||
// `lib` mode with one entry emits one file — so anything here is a bare name.
|
||||
const IMPORTS = /(?:^|[\s;}])(?:import\s+[^'"]*?from\s*|import\s*|import\()\s*['"]([^'"]+)['"]/g
|
||||
const bare = new Set()
|
||||
for (const [, specifier] of chunk.matchAll(IMPORTS)) {
|
||||
if (!specifier.startsWith('.') && !specifier.startsWith('/')) bare.add(specifier)
|
||||
}
|
||||
if (bare.size) {
|
||||
problems.push(
|
||||
`the chunk still imports ${[...bare].map((s) => `"${s}"`).join(', ')} — ` +
|
||||
'nothing can resolve a bare specifier in the browser without an import map, ' +
|
||||
'and CSP forbids one. Alias it to a shim in vite.config.js (MODULE_API.md §3.6).',
|
||||
)
|
||||
//
|
||||
// **This pattern used to require whitespace after `import`, and so could not see
|
||||
// the one shape the build actually emits.** Minified Rollup output is
|
||||
// `import{useState}from"react"`, with no space anywhere in it; the old
|
||||
// `import\s+[^'"]*?from` needed at least one, fell through to the bare-specifier
|
||||
// alternative, met `{` instead of a quote and matched nothing. A bare named
|
||||
// import — the most likely way for an alias to miss — would have passed this
|
||||
// check silently. It was found by writing the test for the false POSITIVE above
|
||||
// it, which is the argument for testing a check against both answers.
|
||||
//
|
||||
// `(?:^|[^\w$.])` rather than a whitespace class, so `a.import(x)` and
|
||||
// `myimport"x"` are excluded for the right reason: `import` must not be preceded
|
||||
// by an identifier character or a dot. `[^'"()]*?` cannot swallow a dynamic
|
||||
// import's parenthesis.
|
||||
const IMPORTS = /(?:^|[^\w$.])import\s*(?:\(\s*|[^'"()]*?from\s*)?['"]([^'"]+)['"]/g
|
||||
|
||||
/** Every bare specifier the chunk still imports at runtime. */
|
||||
export function bareImports(chunk) {
|
||||
const masked = stringMask(chunk)
|
||||
const bare = new Set()
|
||||
for (const match of chunk.matchAll(IMPORTS)) {
|
||||
// Where the `import` keyword itself starts — one past the leading delimiter,
|
||||
// unless the match began at position 0.
|
||||
const keywordAt = match.index + (match[0].startsWith('import') ? 0 : 1)
|
||||
if (masked[keywordAt]) continue // the letters, inside a string. Not a statement.
|
||||
const specifier = match[1]
|
||||
if (!specifier.startsWith('.') && !specifier.startsWith('/')) bare.add(specifier)
|
||||
}
|
||||
return [...bare]
|
||||
}
|
||||
|
||||
// Fingerprints from the shared libraries' own source. Each is a string those
|
||||
// packages ship and this module has no other reason to contain.
|
||||
//
|
||||
// These are matched against the RAW chunk, deliberately unmasked: a bundled
|
||||
// library's source arrives as code AND as its own error-message strings, and
|
||||
// masking would discard half the evidence. The direction of the risk is opposite
|
||||
// to the import check's — here a false positive is a fingerprint too generic,
|
||||
// which is a fixable choice of probe, not a property of the parse.
|
||||
const BUNDLED = [
|
||||
{ what: 'react', probe: 'react.development.js' },
|
||||
{ what: 'react', probe: 'Invalid hook call' },
|
||||
{ what: 'react-dom', probe: 'react-dom.development.js' },
|
||||
{ what: 'react-router-dom', probe: 'useRoutes() may be used only in the context of a <Router> component' },
|
||||
]
|
||||
for (const { what, probe } of BUNDLED) {
|
||||
if (chunk.includes(probe)) {
|
||||
|
||||
/** Every problem with this chunk, as sentences. Empty means it ships. */
|
||||
export function problemsWith(chunk) {
|
||||
const problems = []
|
||||
const bare = bareImports(chunk)
|
||||
if (bare.length) {
|
||||
problems.push(
|
||||
`the chunk appears to BUNDLE ${what} (found ${JSON.stringify(probe)}). ` +
|
||||
'There is exactly one React in the page and core owns it — a second copy ' +
|
||||
'loads fine and then fails at the first hook (MODULE_API.md §3.2).',
|
||||
`the chunk still imports ${bare.map((s) => `"${s}"`).join(', ')} — ` +
|
||||
'nothing can resolve a bare specifier in the browser without an import map, ' +
|
||||
'and CSP forbids one. Alias it to a shim in vite.config.js (MODULE_API.md §3.6).',
|
||||
)
|
||||
}
|
||||
for (const { what, probe } of BUNDLED) {
|
||||
if (chunk.includes(probe)) {
|
||||
problems.push(
|
||||
`the chunk appears to BUNDLE ${what} (found ${JSON.stringify(probe)}). ` +
|
||||
'There is exactly one React in the page and core owns it — a second copy ' +
|
||||
'loads fine and then fails at the first hook (MODULE_API.md §3.2).',
|
||||
)
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
if (problems.length) {
|
||||
console.error('\nThe built chunk breaks the shared-dependency rule:\n')
|
||||
for (const p of problems) console.error(` - ${p}\n`)
|
||||
process.exit(1)
|
||||
// Only when run as a script. Importing this from a test must not read a chunk
|
||||
// that may not have been built, and must not call process.exit.
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
if (!fs.existsSync(CHUNK)) {
|
||||
console.error(`No chunk at ${CHUNK} — run \`npm run build\` first.`)
|
||||
process.exit(1)
|
||||
}
|
||||
const problems = problemsWith(fs.readFileSync(CHUNK, 'utf8'))
|
||||
if (problems.length) {
|
||||
console.error('\nThe built chunk breaks the shared-dependency rule:\n')
|
||||
for (const p of problems) console.error(` - ${p}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
const kb = (fs.statSync(CHUNK).size / 1024).toFixed(1)
|
||||
console.log(`OK — dist/entry.js (${kb} kB) has no bare imports and bundles no shared dependency.`)
|
||||
}
|
||||
|
||||
const kb = (fs.statSync(CHUNK).size / 1024).toFixed(1)
|
||||
console.log(`OK — dist/entry.js (${kb} kB) has no bare imports and bundles no shared dependency.`)
|
||||
|
||||
Reference in New Issue
Block a user