module-rust, id 'rust', built from the Integration Kit's template. Phase 1's job
is the kit's own argument: get every seam working at once with almost nothing in
them, so that afterwards you break exactly one at a time.
What is here:
* /rust on all three tiers, because the loader holds module.json's mounts against
what is registered in BOTH directions -- so the declaration and the
registration land together or not at all. The player tier is honestly thin: it
answers the server list on the authenticated tier, delegating to the same model
the public tier uses so the two cannot drift while they are meant to be the
same. It is the address the app will call, registered now rather than moved
later.
* Two tables. rust_servers is configuration an operator writes; rust_server_state
is what a sidecar reported. Separate tables because they have different
writers, lifetimes and audiences -- and because purging observed state while
keeping the configuration is a thing an operator will want.
* Per-server sidecar tokens through ctx.secretBox, write-only in the API. The
admin list reports hasToken and never the credential, and an empty token on a
save leaves the stored one alone -- a form that posts its own blank field would
otherwise erase a credential every time somebody renamed a server.
* A real sidecar client. It never throws: every call answers {ok, status, data},
and the status is what tells a wrong URL from a wrong token from a mismatched
protocol -- all three present as 'the site says my server is offline' and each
has a different fix.
* The five guards, green: check:imports, check:swagger, check:externals, and both
suites.
What is deliberately NOT registered: the Team provider, triggers, audiences,
engagement seeds, notification streams, the four event catalogues, and the two
extension slots. Each arrives with the phase that has something real to put in
it, and a test asserts their absence so that removing it is deliberate. A
declared trigger nothing emits and a declared slot nothing fills are both
surfaces an operator can configure and then wait on, which is worse than an
absent one because the absence is visible.
Two corrections to the kit's template, both feedback for a later phase:
* registration.test.js read one page BY NAME to check declared slots are
rendered, so a module declaring none dies on ENOENT before reaching the loop
that would have been empty. It now scans every file under src/routes.
* test/_fakes.js supplied validator: {}. An admin router that builds validation
chains at file scope cannot be required with that, so the fake holds the real
express-validator -- for the same reason it holds a real express Router.
The kit was right about noGameConnection.test.js: its header predicts that a
module adding a sidecar client will see the check go red, names sidecarClient.js
as the file to allow, and says narrow it rather than delete it. That is exactly
what happened on the first run, and the fix was the one line the header names.
Installed into a real core and verified: the module reaches 'started', publishes
its capability, serves its chunk, and renders a server whose server.hello
originated in a live Rust server.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
155 lines
7.8 KiB
JavaScript
155 lines
7.8 KiB
JavaScript
// What can be checked about the client half without a browser.
|
|
//
|
|
// Not much, and being honest about that is the point: the client half's real
|
|
// failures are timing and resolution, and neither has a shape a DOM-less test
|
|
// runner can see. MODULE_API.md §7.7's four-step browser smoke is what actually
|
|
// proves this half works, and it is re-run whenever this seam changes.
|
|
//
|
|
// What IS testable here is the configuration that decides resolution — and one
|
|
// of these tests exists because the trap it guards cost this project real time: Vite's object-form `resolve.alias` does PREFIX matching, so a `react`
|
|
// key silently also rewrites `react/jsx-runtime`. An anchored regexp in the
|
|
// array form cannot. That is a property of the config, and a test can hold it.
|
|
|
|
import test from 'node:test'
|
|
import assert from 'node:assert'
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
const CLIENT = path.resolve(HERE, '..')
|
|
|
|
const { bareImports, problemsWith } = await import('../scripts/checkExternals.js')
|
|
const configModule = await import('../vite.config.js')
|
|
const config = configModule.default
|
|
const { SHARED, SHARED_PACKAGES: guardedPackages } = configModule
|
|
|
|
test('every alias is an anchored regexp, never a bare prefix string', () => {
|
|
const aliases = config.resolve.alias
|
|
assert.ok(Array.isArray(aliases), 'alias must use the ARRAY form — the object form prefix-matches')
|
|
for (const { find } of aliases) {
|
|
assert.ok(find instanceof RegExp, `alias "${find}" is a string; a string prefix-matches`)
|
|
assert.ok(find.source.startsWith('^') && find.source.endsWith('$'), `alias ${find} is not anchored`)
|
|
}
|
|
})
|
|
|
|
test('react and react/jsx-runtime resolve to different shims', () => {
|
|
// The exact collision the object form causes. Asserted on the outcome rather
|
|
// than on the config's shape, so it keeps holding however the config is
|
|
// rewritten.
|
|
const resolve = (specifier) =>
|
|
config.resolve.alias.find(({ find }) => find.test(specifier))?.replacement
|
|
assert.ok(resolve('react'))
|
|
assert.ok(resolve('react/jsx-runtime'))
|
|
assert.notStrictEqual(resolve('react'), resolve('react/jsx-runtime'))
|
|
})
|
|
|
|
test('every shared dependency is aliased', () => {
|
|
for (const specifier of ['react', 'react/jsx-runtime', 'react-dom', 'react-dom/client', 'react-router-dom']) {
|
|
assert.ok(
|
|
config.resolve.alias.some(({ find }) => find.test(specifier)),
|
|
`${specifier} is not aliased — it would be bundled, giving the page a second copy`,
|
|
)
|
|
}
|
|
})
|
|
|
|
test('rollup external stays empty — it preempts the aliases rather than backing them up', () => {
|
|
// Rollup asks `external` BEFORE Vite's alias resolver runs, so a specifier
|
|
// listed in both is marked external and never aliased. The chunk then ships
|
|
// bare `import 'react'`, which no browser can resolve without an import map
|
|
// and CSP forbids one. §3.6 shows both; they do not compose.
|
|
assert.deepStrictEqual(config.build.rollupOptions.external, [])
|
|
})
|
|
|
|
test('the not-bundled guard covers every shared specifier and is not derived from them', () => {
|
|
// The direction of this dependency is the finding. Deriving the forbidden
|
|
// package list FROM the alias list means deleting an alias also deletes the
|
|
// guard against what that alias prevented — which is precisely when the guard
|
|
// is needed. So the guard states the contract, and this asserts the aliases
|
|
// stay inside it.
|
|
const packages = new Set(guardedPackages)
|
|
for (const { specifier } of SHARED) {
|
|
const pkg = specifier.startsWith('@') ? specifier.split('/').slice(0, 2).join('/') : specifier.split('/')[0]
|
|
assert.ok(packages.has(pkg), `${pkg} is aliased but not guarded against being bundled`)
|
|
}
|
|
})
|
|
|
|
test('every alias points at a shim file that exists', () => {
|
|
for (const { find, replacement } of config.resolve.alias) {
|
|
assert.ok(fs.existsSync(replacement), `alias ${find} points at a missing file: ${replacement}`)
|
|
}
|
|
})
|
|
|
|
test('the build emits one unhashed entry.js, which is what module.json names', () => {
|
|
assert.deepStrictEqual(config.build.lib.formats, ['es'])
|
|
assert.strictEqual(config.build.lib.fileName(), 'entry.js')
|
|
const manifest = JSON.parse(fs.readFileSync(path.resolve(CLIENT, '..', 'module.json'), 'utf8'))
|
|
assert.strictEqual(manifest.client.entry, 'client/dist/entry.js')
|
|
assert.strictEqual(config.build.outDir, 'dist')
|
|
})
|
|
|
|
test('modulePreload polyfilling stays off — an inline bootstrap is refused under CSP', () => {
|
|
assert.strictEqual(config.build.modulePreload.polyfill, false)
|
|
})
|
|
|
|
test('exactly one file reads window.__rg, and every shim goes through it', () => {
|
|
// `shim/rg.js` is the single reader, and that is not tidiness: it is what
|
|
// makes the "core did not publish its dependencies" message reachable. The
|
|
// shims touch the global before anything else in the chunk does, so a check
|
|
// placed in the first-imported file is a guarantee that lasts until someone
|
|
// sorts the imports.
|
|
const dir = path.join(CLIENT, 'src', 'shim')
|
|
const shims = fs.readdirSync(dir)
|
|
assert.ok(shims.length >= 5)
|
|
for (const file of shims) {
|
|
const source = fs.readFileSync(path.join(dir, file), 'utf8')
|
|
const code = source.replace(/^\s*\/\/.*$/gm, '') // the comments discuss the global
|
|
if (file === 'rg.js') {
|
|
assert.match(code, /window\.__rg/, 'rg.js must be the one that reads the global')
|
|
assert.doesNotMatch(code, /^\s*import\s/m, 'rg.js imports something')
|
|
continue
|
|
}
|
|
assert.doesNotMatch(code, /window\.__rg/, `${file} reads the global directly instead of via rg()`)
|
|
assert.match(code, /rg\(\)/, `${file} does not resolve through rg()`)
|
|
// A shim may import its sibling helper and nothing else — anything further
|
|
// would be a shim with a dependency to resolve, the problem it exists to remove.
|
|
for (const [, spec] of code.matchAll(/^\s*import\s[^'"]*['"]([^'"]+)['"]/gm)) {
|
|
assert.strictEqual(spec, './rg.js', `${file} imports ${spec}`)
|
|
}
|
|
}
|
|
})
|
|
|
|
test('the built chunk has no bare imports and bundles no shared dependency', () => {
|
|
// The artifact check itself, over the artifact that ships. Skipped rather than
|
|
// failed when there is no build: `npm test` must be runnable before `npm run
|
|
// build`, and CI runs them in order.
|
|
const chunk = path.join(CLIENT, 'dist', 'entry.js')
|
|
if (!fs.existsSync(chunk)) return
|
|
assert.deepStrictEqual(problemsWith(fs.readFileSync(chunk, 'utf8')), [])
|
|
})
|
|
|
|
test('an import inside a string is not an import — the check reads code, not text', () => {
|
|
// The regression that made this necessary: the first chunk with real content
|
|
// in it had a button labelled "Approve and import" put the token
|
|
// immediately before a quote. The check rejected the whole build, naming a
|
|
// fragment of minified JSX as the offending specifier.
|
|
const uiCopy = 'const a=n("button",{children:"Approve and import"}),b=1;'
|
|
assert.deepStrictEqual(bareImports(uiCopy), [])
|
|
|
|
// Neither is one in a comment, or in a template literal.
|
|
assert.deepStrictEqual(bareImports('// import "react" would be wrong here\nconst a=1'), [])
|
|
assert.deepStrictEqual(bareImports('/* import "react" */ const a=1'), [])
|
|
assert.deepStrictEqual(bareImports('const s=`import "react"`'), [])
|
|
|
|
// And a real one still is, in each form the build could emit.
|
|
assert.deepStrictEqual(bareImports('import"react";'), ['react'])
|
|
assert.deepStrictEqual(bareImports('import{useState}from"react";'), ['react'])
|
|
assert.deepStrictEqual(bareImports('const m=await import("react-dom/client")'), ['react-dom/client'])
|
|
// A relative specifier is a split chunk, not a shared dependency: not our concern.
|
|
assert.deepStrictEqual(bareImports('import"./other.js";'), [])
|
|
|
|
// The case that proves the mask tracks escapes: a quote escaped INSIDE a
|
|
// string must not end it early and leave the tail looking like code.
|
|
assert.deepStrictEqual(bareImports('const s="he said \\"import\\" loudly";'), [])
|
|
})
|