feat(ci): packaging, release and the frozen manifest
Phase 2 of docs/modules/rust/PLAN.md. Phase 1 built five guards and ran them by hand; this repo had no workflows at all, so nothing gated the branch that gets released and there was no way to release it. Three pieces: - **release.yml** — the derived-version engine link, installer and Module-uo already run (conventional-commit subjects since the newest tag; module.json's version survives as a floor; workflow_dispatch as the backdoor), assembling the bundle from an include list and publishing the tarball, the install manifest carrying its sha256, and SHA256SUMS. The tag is the number that ships and CI stamps it into the bundle's own module.json. - **pr-checks.yml** — server tests, check:imports, check:bundle, check:swagger, the client build, client tests and check:externals, plus frozen-manifest. - **frozen-manifest** — clones core at the sha pinned in ci/core-ref.json, generates its route table without this module and with it, and takes the difference. It ran locally against that exact ref: six routes, all documented, no core route moved. That is the first proof by a running core that /rust collides with nothing — phase 1 could only check it by reading, because core mounts /status and /version at a tier root where the loader's own collision probe cannot see them. The bundle carries no node_modules, because the shipped half declares no runtime dependencies (org lead, phase 2). checkBundle.js holds both halves of that: the include list still covers everything server/index.js reaches, and no dependency has appeared without the release learning to pack it. Verified by breaking it — dropping "model" from the list names the exact edit and exits 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
"scripts": {
|
||||
"test": "node --test",
|
||||
"check:imports": "node scripts/checkImports.js",
|
||||
"check:bundle": "node scripts/checkBundle.js",
|
||||
"swagger": "node scripts/swaggerFragment.js",
|
||||
"check:swagger": "node scripts/swaggerFragment.js --check"
|
||||
},
|
||||
|
||||
301
server/scripts/checkBundle.js
Normal file
301
server/scripts/checkBundle.js
Normal file
@@ -0,0 +1,301 @@
|
||||
#!/usr/bin/env node
|
||||
// ── Does the release actually ship everything the module needs? ────────────
|
||||
//
|
||||
// `ci/bundle.json` says what a release copies. `server/index.js` says what the
|
||||
// module requires. Nothing keeps two lists in agreement on its own, and the first
|
||||
// module this project shipped proved it: `Module-uo` added `server/commands/` in a
|
||||
// cutover, its include list did not learn about it, and v1.0.0 installed cleanly
|
||||
// and then died on the operator's box with
|
||||
//
|
||||
// module "uo" failed to load — {"stage":"register","reason":"Cannot find
|
||||
// module './commands/guild.command'"}
|
||||
//
|
||||
// Nothing caught it, because the PR checks install the module by copying the
|
||||
// WHOLE repo into core — they only ever exercised a tree that had the file. **The
|
||||
// subset only exists in the release**, and the release had no check that the
|
||||
// subset was complete. This module has that check from its first release rather
|
||||
// than after its first outage.
|
||||
//
|
||||
// It asks the question in the two places it can be asked:
|
||||
//
|
||||
// --check (PR checks) Every file reachable from the entry point by a
|
||||
// relative require lives under something ci/bundle.json
|
||||
// lists. Source-tree only, so it is fast and needs no
|
||||
// assembled bundle — it fails on the PR that adds the
|
||||
// directory, which is where the fix is cheapest.
|
||||
//
|
||||
// --bundle <dir> (release) Every relative specifier inside an ASSEMBLED bundle
|
||||
// resolves to a file that is in it. Asked of the
|
||||
// artifact rather than of the source, so it also
|
||||
// catches a copy that half-failed, a list that names a
|
||||
// path that has moved, and anything else between the
|
||||
// declaration and the tarball.
|
||||
//
|
||||
// The two are deliberately not the same question. The first is about the list
|
||||
// being right; the second is about the tarball being right. A release runs both.
|
||||
//
|
||||
// ── The third question, which is this module's own ─────────────────────────
|
||||
//
|
||||
// `server/package.json` declares **no runtime dependencies**, and the release
|
||||
// therefore runs no `npm ci` and packs no `node_modules`. That is a decision, not
|
||||
// an accident (org lead, phase 2), and the whole value of it is that the day it
|
||||
// stops being true is a loud day. So both modes also assert the declaration is
|
||||
// still empty: add a `dependencies` entry without teaching release.yml to install
|
||||
// and pack it, and the bundle ships an import of something that is not there —
|
||||
// the missing-directory failure again, wearing a different hat.
|
||||
//
|
||||
// ── Why reachability, and not "require the entry point" ────────────────────
|
||||
//
|
||||
// The obvious check — require the bundle's entry and see if it throws — does not
|
||||
// work here, and the reason is in index.js's own header: its requires are inside
|
||||
// `register()` because require order is load-bearing (`core.init(ctx)` has to run
|
||||
// before anything under `router/` is required). So requiring the entry evaluates
|
||||
// exactly one line, `require('./core')`, and reports success on a bundle missing
|
||||
// every router it has. Calling `register()` for real would need a fake `ctx`
|
||||
// complete enough to satisfy the whole module — which is what `test/` is for, and
|
||||
// `test/` does not ship. Walking the requires statically asks the same question
|
||||
// without needing either.
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { stripCommentsAndTemplates } = require('./checkImports')
|
||||
|
||||
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
|
||||
|
||||
// Only relative specifiers. A bare one is checkImports.js's question, not this
|
||||
// one, and the two failures want different advice.
|
||||
const RELATIVE = /(?:require\(|from\s+|import\()\s*['"](\.[^'"]+)['"]/g
|
||||
|
||||
/**
|
||||
* Resolve a relative specifier the way Node would, for the file cases that can
|
||||
* appear here: an exact path, `+.js`/`+.json`, or a directory's `index.js`.
|
||||
*
|
||||
* Returns null when nothing exists — which is the finding, not an error.
|
||||
*/
|
||||
function resolveFile(fromDir, specifier) {
|
||||
const base = path.resolve(fromDir, specifier)
|
||||
const candidates = [base, `${base}.js`, `${base}.json`, path.join(base, 'index.js')]
|
||||
for (const c of candidates) {
|
||||
if (fs.existsSync(c) && fs.statSync(c).isFile()) return c
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Every file reachable from `entry` by following relative requires, plus every
|
||||
* specifier that resolved to nothing.
|
||||
*
|
||||
* Exported so the test can point it at fixtures — the same reason checkImports.js
|
||||
* exports `scan`. A check that has never been shown to fail is a check nobody
|
||||
* knows the state of, and this one is load-bearing for every release.
|
||||
*/
|
||||
function reachable(entry) {
|
||||
const seen = new Set()
|
||||
const missing = []
|
||||
const queue = [entry]
|
||||
|
||||
while (queue.length) {
|
||||
const file = queue.shift()
|
||||
if (seen.has(file)) continue
|
||||
seen.add(file)
|
||||
|
||||
// A .json dependency is a leaf: it is reached, it ships, and it has no
|
||||
// requires of its own to follow.
|
||||
if (file.endsWith('.json')) continue
|
||||
|
||||
const source = stripCommentsAndTemplates(fs.readFileSync(file, 'utf8'))
|
||||
for (const [, specifier] of source.matchAll(RELATIVE)) {
|
||||
const target = resolveFile(path.dirname(file), specifier)
|
||||
if (target) queue.push(target)
|
||||
else missing.push({ file, specifier })
|
||||
}
|
||||
}
|
||||
|
||||
return { files: [...seen], missing }
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything ci/bundle.json says ends up in the bundle, as absolute paths:
|
||||
* `server[]` relative to server/, `root[]` and `generated[]` relative to the
|
||||
* module root. All three are equally "in the tarball" as far as a require is
|
||||
* concerned — the only difference is how they get there.
|
||||
*/
|
||||
function declaredServerPaths(moduleRoot = MODULE_ROOT) {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(moduleRoot, 'ci', 'bundle.json'), 'utf8'))
|
||||
return [
|
||||
...manifest.server.map((p) => path.join(moduleRoot, 'server', p)),
|
||||
...(manifest.root || []).map((p) => path.join(moduleRoot, p)),
|
||||
...(manifest.generated || []).map((p) => path.join(moduleRoot, p)),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* The runtime dependencies the shipped half declares.
|
||||
*
|
||||
* Empty is the shape this module is built around, and the release packs no
|
||||
* `node_modules` because of it. Returned rather than asserted so both modes can
|
||||
* report it with their own advice.
|
||||
*/
|
||||
function runtimeDependencies(moduleRoot = MODULE_ROOT) {
|
||||
const pkgPath = path.join(moduleRoot, 'server', 'package.json')
|
||||
if (!fs.existsSync(pkgPath)) return []
|
||||
return Object.keys(JSON.parse(fs.readFileSync(pkgPath, 'utf8')).dependencies || {})
|
||||
}
|
||||
|
||||
const DEPENDENCY_ADVICE =
|
||||
'The release packs no node_modules, because this module declared none. A dependency\n' +
|
||||
'listed here but not installed and copied by .gitea/workflows/release.yml ships as an\n' +
|
||||
'import of something that is not in the tarball — the module installs and then dies at\n' +
|
||||
'the register stage on the operator\'s box.\n\n' +
|
||||
'Either drop the dependency (everything the shipped half needs arrives on ctx — §2.3),\n' +
|
||||
'or add the `npm ci --omit=dev` + copy steps to release.yml and list "node_modules" in\n' +
|
||||
'ci/bundle.json\'s server[], then update this check.\n'
|
||||
|
||||
const covers = (declared, file) => declared.some((d) => file === d || file.startsWith(d + path.sep))
|
||||
|
||||
/**
|
||||
* --check: is ci/bundle.json's list sufficient for what the entry point reaches?
|
||||
*
|
||||
* Reports the top-level entry to ADD rather than the individual files, because
|
||||
* that is the edit: the list is stated in top-level paths, and a new directory
|
||||
* arrives with a dozen files in it.
|
||||
*/
|
||||
function checkDeclaration(moduleRoot = MODULE_ROOT) {
|
||||
const serverRoot = path.join(moduleRoot, 'server')
|
||||
const entry = path.join(serverRoot, 'index.js')
|
||||
const { files, missing } = reachable(entry)
|
||||
const declared = declaredServerPaths(moduleRoot)
|
||||
|
||||
// Grouped by the entry that would have to be added, which is the top-level
|
||||
// path under server/ — or, for the rare reachable file outside it, the path
|
||||
// itself, since that one belongs in root[] instead.
|
||||
const uncovered = new Map()
|
||||
for (const file of files) {
|
||||
if (covers(declared, file)) continue
|
||||
const inServer = file.startsWith(serverRoot + path.sep)
|
||||
const key = inServer
|
||||
? `server/${path.relative(serverRoot, file).split(path.sep)[0]}`
|
||||
: path.relative(moduleRoot, file).split(path.sep).join('/')
|
||||
if (!uncovered.has(key)) uncovered.set(key, [])
|
||||
uncovered.get(key).push(file)
|
||||
}
|
||||
|
||||
return { uncovered, missing, reached: files.length, dependencies: runtimeDependencies(moduleRoot) }
|
||||
}
|
||||
|
||||
/**
|
||||
* --bundle: does every relative specifier inside an assembled bundle resolve?
|
||||
*
|
||||
* Walks the bundle's own server tree rather than starting from the entry point,
|
||||
* so a file that ships but is broken is caught too.
|
||||
*/
|
||||
function checkBundle(bundleRoot) {
|
||||
const serverRoot = path.join(bundleRoot, 'server')
|
||||
const missing = []
|
||||
const files = []
|
||||
|
||||
const walk = (dir) => {
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, e.name)
|
||||
if (e.isDirectory()) {
|
||||
// An installed dependency tree would be npm's business, not this
|
||||
// check's. This module ships none; the skip stays so that the day one
|
||||
// arrives, this is not also the thing that breaks.
|
||||
if (e.name !== 'node_modules') walk(p)
|
||||
} else if (/\.(js|mjs|cjs)$/.test(e.name)) {
|
||||
files.push(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(serverRoot)
|
||||
|
||||
for (const file of files) {
|
||||
const source = stripCommentsAndTemplates(fs.readFileSync(file, 'utf8'))
|
||||
for (const [, specifier] of source.matchAll(RELATIVE)) {
|
||||
if (!resolveFile(path.dirname(file), specifier)) missing.push({ file, specifier })
|
||||
}
|
||||
}
|
||||
|
||||
return { missing, scanned: files.length, dependencies: runtimeDependencies(bundleRoot) }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
reachable,
|
||||
resolveFile,
|
||||
checkDeclaration,
|
||||
checkBundle,
|
||||
declaredServerPaths,
|
||||
runtimeDependencies,
|
||||
}
|
||||
|
||||
// Required by a test, or run as the check? Only the second one exits.
|
||||
if (require.main !== module) return
|
||||
|
||||
const bundleFlag = process.argv.indexOf('--bundle')
|
||||
|
||||
if (bundleFlag !== -1) {
|
||||
const root = process.argv[bundleFlag + 1]
|
||||
if (!root) {
|
||||
console.error('--bundle needs the path to an assembled bundle')
|
||||
process.exit(2)
|
||||
}
|
||||
const { missing, scanned, dependencies } = checkBundle(path.resolve(root))
|
||||
if (missing.length) {
|
||||
console.error(`\nThe assembled bundle is incomplete — ${missing.length} require(s) resolve to nothing:\n`)
|
||||
for (const m of missing) {
|
||||
console.error(` ${path.relative(root, m.file)}\n requires "${m.specifier}" — not in the bundle`)
|
||||
}
|
||||
console.error('\nAdd the missing path to ci/bundle.json.\n')
|
||||
process.exit(1)
|
||||
}
|
||||
if (dependencies.length) {
|
||||
console.error(`\nThe assembled bundle declares ${dependencies.length} runtime dependency(ies) it does not carry:\n`)
|
||||
for (const d of dependencies) console.error(` ${d}`)
|
||||
console.error(`\n${DEPENDENCY_ADVICE}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`OK — every relative require in the bundle resolves (${scanned} files scanned), and it needs no node_modules.`)
|
||||
} else {
|
||||
const { uncovered, missing, reached, dependencies } = checkDeclaration()
|
||||
|
||||
if (missing.length) {
|
||||
console.error(`\n${missing.length} require(s) resolve to nothing in the source tree:\n`)
|
||||
for (const m of missing) {
|
||||
console.error(` ${path.relative(MODULE_ROOT, m.file)}\n requires "${m.specifier}"`)
|
||||
}
|
||||
console.error('')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (uncovered.size) {
|
||||
console.error('\nci/bundle.json does not ship everything server/index.js reaches.\n')
|
||||
console.error('A release built from this list would install and then fail at the')
|
||||
console.error("register stage with \"Cannot find module\", on the operator's box.\n")
|
||||
for (const [key, files] of uncovered) {
|
||||
console.error(` ${key} (${files.length} file${files.length === 1 ? '' : 's'} reachable)`)
|
||||
for (const f of files.slice(0, 5)) console.error(` ${path.relative(MODULE_ROOT, f)}`)
|
||||
if (files.length > 5) console.error(` … and ${files.length - 5} more`)
|
||||
}
|
||||
// server[] is written relative to server/, so name the entry to add rather
|
||||
// than the path just displayed — they differ by exactly that prefix.
|
||||
const toServer = [...uncovered.keys()].filter((k) => k.startsWith('server/'))
|
||||
const toRoot = [...uncovered.keys()].filter((k) => !k.startsWith('server/'))
|
||||
if (toServer.length) {
|
||||
console.error(`\nAdd ${toServer.map((k) => `"${k.slice('server/'.length)}"`).join(', ')} to ci/bundle.json's server[].`)
|
||||
}
|
||||
if (toRoot.length) {
|
||||
console.error(`\nAdd ${toRoot.map((k) => `"${k}"`).join(', ')} to ci/bundle.json's root[].`)
|
||||
}
|
||||
console.error('')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (dependencies.length) {
|
||||
console.error(`\nserver/package.json declares ${dependencies.length} runtime dependency(ies):\n`)
|
||||
for (const d of dependencies) console.error(` ${d}`)
|
||||
console.error(`\n${DEPENDENCY_ADVICE}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`OK — ci/bundle.json ships every file server/index.js reaches (${reached} files), and no runtime dependency is declared.`)
|
||||
}
|
||||
196
server/scripts/frozenManifest.js
Normal file
196
server/scripts/frozenManifest.js
Normal file
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env node
|
||||
// ── §5.3 — this module's frozen route manifest ─────────────────────────────
|
||||
//
|
||||
// Core freezes its URL surface in `server/routes.manifest.json` by walking the
|
||||
// live Express stack and committing the result; a PR that moves a URL has to
|
||||
// commit the new manifest, which puts the change in front of a reviewer. The URLs
|
||||
// this module serves are not in that file. They are here, frozen the same way and
|
||||
// by the same generator.
|
||||
//
|
||||
// **The module's routes are DERIVED, never listed.** This script is handed two
|
||||
// manifests generated from the SAME core at the pinned ref — one without this
|
||||
// module on the volume, one with — and the difference is what this module serves.
|
||||
// Nothing here says "/api/v1/public/rust/*"; a mount prefix appears in exactly one
|
||||
// place, `server/index.js`'s `registerRoutes` call, which is where an operator's
|
||||
// core reads it from too.
|
||||
//
|
||||
// Taking the difference rather than filtering by prefix buys the other half of
|
||||
// §5.3 for free, and it is the half that matters most: **no core URL may move.**
|
||||
// A module that shadowed a core route, or whose mount displaced one, shows up here
|
||||
// as a removal or a change, not merely as an addition somewhere else. That is the
|
||||
// promise §1.2 makes to the shipped Android app and the Discord bot.
|
||||
//
|
||||
// It is also the only check that can see the blind spot §13's own registration
|
||||
// comment names: core answers several public routes mounted at the TIER ROOT
|
||||
// rather than under a prefix — `/status` and `/version` among them — and the
|
||||
// loader's collision probe cannot find those. `/rust` was checked against core's
|
||||
// mount tables by hand when phase 1 chose it. From here it is checked by a core.
|
||||
//
|
||||
// The third thing it checks is the OpenAPI fragment (§2.8). `swagger-fragment.json`
|
||||
// is generated from the module's own registrations against §2.4's stated tier
|
||||
// bases — the one place a constant could be wrong. Here there is ground truth: a
|
||||
// real core with this module loaded, reporting the URLs it actually serves. Every
|
||||
// route must have a documented operation and every documented operation must be a
|
||||
// route. That is the per-module form of core's standing rule, never ship a route
|
||||
// that isn't in the spec — and it is what stops a wrong constant in the generator
|
||||
// from producing a fragment that is internally consistent and describes nothing
|
||||
// core will ever serve.
|
||||
//
|
||||
// Usage (the workflow does the cloning; see .gitea/workflows/pr-checks.yml):
|
||||
// node scripts/frozenManifest.js --before core-only.json --after core-plus-rust.json
|
||||
// node scripts/frozenManifest.js --before … --after … --check
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
|
||||
const MANIFEST = path.join(MODULE_ROOT, 'routes.manifest.json')
|
||||
const FRAGMENT = path.join(MODULE_ROOT, 'swagger-fragment.json')
|
||||
|
||||
const COMMENT =
|
||||
'Generated inventory of the URLs module-rust serves - the module half of the freeze ' +
|
||||
'core keeps in server/routes.manifest.json. DERIVED as the difference between a core ' +
|
||||
'without this module and the same core with it, both at the pinned ref in ci/core-ref.json. ' +
|
||||
'Regenerate with the frozen-manifest job in .gitea/workflows/pr-checks.yml; see ' +
|
||||
'server/scripts/frozenManifest.js.'
|
||||
|
||||
const key = (r) => `${r.method} ${r.path}`
|
||||
|
||||
/**
|
||||
* The module's routes, plus proof that core's own surface did not move.
|
||||
*
|
||||
* @param {object} before routes.manifest.json from core alone
|
||||
* @param {object} after routes.manifest.json from the same core with this module
|
||||
* @returns {{ added: object[], removed: string[] }}
|
||||
*/
|
||||
function diffManifests(before, after) {
|
||||
const added = []
|
||||
const removed = []
|
||||
|
||||
for (const tier of ['public', 'internal']) {
|
||||
const was = new Set((before[tier] || []).map(key))
|
||||
for (const route of after[tier] || []) {
|
||||
if (!was.has(key(route))) added.push({ ...route, tier })
|
||||
was.delete(key(route))
|
||||
}
|
||||
for (const gone of was) removed.push(`${tier} ${gone}`)
|
||||
}
|
||||
|
||||
added.sort((a, b) => (key(a) < key(b) ? -1 : 1))
|
||||
return { added, removed }
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of the module's routes the fragment fails to document, and vice versa.
|
||||
*
|
||||
* Express `:id` is OpenAPI `{id}`; the fragment is already in OpenAPI's spelling
|
||||
* because that is what core merges, so the manifest's paths are converted here
|
||||
* rather than the other way round.
|
||||
*/
|
||||
function coverage(added, fragment) {
|
||||
const documented = new Set()
|
||||
for (const [p, item] of Object.entries(fragment.paths || {})) {
|
||||
for (const method of Object.keys(item)) documented.add(`${method.toUpperCase()} ${p}`)
|
||||
}
|
||||
|
||||
const undocumented = []
|
||||
for (const route of added) {
|
||||
const oas = `${route.method} ${route.path.replace(/:([A-Za-z0-9_]+)/g, '{$1}')}`
|
||||
if (documented.has(oas)) documented.delete(oas)
|
||||
else undocumented.push(oas)
|
||||
}
|
||||
|
||||
// Whatever is left is documented and not served: a route that moved or was
|
||||
// deleted while its annotation stayed behind. Core's own spec has no equivalent
|
||||
// check and grew four orphan tags and thirty-three orphan schemas because of it.
|
||||
return { undocumented, unserved: [...documented].sort() }
|
||||
}
|
||||
|
||||
function serialize(routes) {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
$comment: COMMENT,
|
||||
routes: routes.map(({ method, path: p, tier }) => ({ method, path: p, tier })),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`
|
||||
}
|
||||
|
||||
function main() {
|
||||
const arg = (name) => {
|
||||
const i = process.argv.indexOf(name)
|
||||
return i === -1 ? null : process.argv[i + 1]
|
||||
}
|
||||
const beforePath = arg('--before')
|
||||
const afterPath = arg('--after')
|
||||
if (!beforePath || !afterPath) {
|
||||
process.stderr.write('usage: frozenManifest.js --before <manifest> --after <manifest> [--check]\n')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const before = JSON.parse(fs.readFileSync(beforePath, 'utf8'))
|
||||
const after = JSON.parse(fs.readFileSync(afterPath, 'utf8'))
|
||||
const { added, removed } = diffManifests(before, after)
|
||||
|
||||
let failed = false
|
||||
|
||||
if (removed.length > 0) {
|
||||
process.stderr.write(
|
||||
`\nLoading this module REMOVED or CHANGED ${removed.length} of core's own route(s):\n` +
|
||||
`${removed.map((r) => ` - ${r}`).join('\n')}\n` +
|
||||
'A module may only add. This is the frozen-URL promise (MODULE_SYSTEM.md §1.2) breaking.\n',
|
||||
)
|
||||
failed = true
|
||||
}
|
||||
|
||||
if (added.length === 0) {
|
||||
process.stderr.write(
|
||||
'\nLoading this module added NO routes. Either it failed to load in the core checkout\n' +
|
||||
'(check the boot log for a startup_failed line) or the two manifests are the same file.\n',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const fragment = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8'))
|
||||
const { undocumented, unserved } = coverage(added, fragment)
|
||||
if (undocumented.length > 0) {
|
||||
process.stderr.write(
|
||||
`\n${undocumented.length} route(s) this module serves have no operation in swagger-fragment.json:\n` +
|
||||
`${undocumented.map((r) => ` - ${r}`).join('\n')}\n` +
|
||||
'Run `npm run swagger --prefix server` and commit the result (MODULE_API.md §2.8).\n',
|
||||
)
|
||||
failed = true
|
||||
}
|
||||
if (unserved.length > 0) {
|
||||
process.stderr.write(
|
||||
`\n${unserved.length} operation(s) in swagger-fragment.json are not routes this module serves:\n` +
|
||||
`${unserved.map((r) => ` - ${r}`).join('\n')}\n` +
|
||||
'A documented URL nobody serves is a client following the docs into a 404.\n',
|
||||
)
|
||||
failed = true
|
||||
}
|
||||
|
||||
if (failed) process.exit(1)
|
||||
|
||||
const contents = serialize(added)
|
||||
if (process.argv.includes('--check')) {
|
||||
const current = fs.existsSync(MANIFEST) ? fs.readFileSync(MANIFEST, 'utf8').replace(/\r\n/g, '\n') : null
|
||||
if (current !== contents) {
|
||||
process.stderr.write(
|
||||
'\nroutes.manifest.json is stale. The URLs this module serves changed — regenerate it and\n' +
|
||||
'commit the result so the move is reviewed rather than merged as mechanical.\n',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
process.stdout.write(`routes.manifest.json is current — ${added.length} routes, all documented\n`)
|
||||
return
|
||||
}
|
||||
|
||||
fs.writeFileSync(MANIFEST, contents)
|
||||
process.stdout.write(`wrote routes.manifest.json — ${added.length} routes, all documented\n`)
|
||||
}
|
||||
|
||||
if (require.main === module) main()
|
||||
|
||||
module.exports = { diffManifests, coverage, serialize, MANIFEST, FRAGMENT }
|
||||
273
server/test/checkBundle.test.js
Normal file
273
server/test/checkBundle.test.js
Normal file
@@ -0,0 +1,273 @@
|
||||
// The bundle check, checked.
|
||||
//
|
||||
// `scripts/checkBundle.js` exists because of a failure this module has not had
|
||||
// and does not intend to: Module-uo's v1.0.0 shipped without `server/commands/`
|
||||
// and died at the register stage on the operator's box. A check written in
|
||||
// response to one bug is worth exactly as much as its coverage of that bug, so
|
||||
// the first two tests below are that bug, in both modes — a list that has stopped
|
||||
// covering what the entry point reaches, and a tarball with the file missing from
|
||||
// it — and the third pair is this module's own version of it, a runtime
|
||||
// dependency declared and not packed.
|
||||
//
|
||||
// **Every fixture is a template literal, and that is load-bearing** — the same
|
||||
// reason checkImports.test.js gives. `scripts/checkImports.js` scans this
|
||||
// directory too, so an ordinary quoted string holding a relative require would
|
||||
// make this file fail that check. Templates are blanked by the stripper.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
|
||||
const {
|
||||
reachable,
|
||||
resolveFile,
|
||||
checkDeclaration,
|
||||
checkBundle,
|
||||
declaredServerPaths,
|
||||
runtimeDependencies,
|
||||
} = require('../scripts/checkBundle')
|
||||
|
||||
/**
|
||||
* Write a throwaway module tree: `files` under server/, `bundle` as its
|
||||
* ci/bundle.json, `pkg` as its server/package.json. Returns the module root.
|
||||
*/
|
||||
function fixture(files, bundle = { server: ['index.js'] }, pkg = null) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'module-rust-bundle-'))
|
||||
for (const [name, source] of Object.entries(files)) {
|
||||
const file = path.join(root, 'server', name)
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true })
|
||||
fs.writeFileSync(file, source)
|
||||
}
|
||||
fs.mkdirSync(path.join(root, 'ci'), { recursive: true })
|
||||
fs.writeFileSync(path.join(root, 'ci', 'bundle.json'), JSON.stringify(bundle))
|
||||
if (pkg) {
|
||||
fs.mkdirSync(path.join(root, 'server'), { recursive: true })
|
||||
fs.writeFileSync(path.join(root, 'server', 'package.json'), JSON.stringify(pkg))
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
const cleanup = (root) => fs.rmSync(root, { recursive: true, force: true })
|
||||
|
||||
// ── The regression this script was written for ─────────────────────────────
|
||||
|
||||
test('--check catches a directory the include list has stopped covering', () => {
|
||||
const root = fixture(
|
||||
{
|
||||
'index.js': `const r = require('./router/public/rust.router')`,
|
||||
'router/public/rust.router.js': `module.exports = {}`,
|
||||
},
|
||||
{ server: ['index.js'] }, // `router` missing — exactly Module-uo's v1.0.0
|
||||
)
|
||||
try {
|
||||
const { uncovered } = checkDeclaration(root)
|
||||
assert.strictEqual(uncovered.size, 1)
|
||||
assert.ok(uncovered.has('server/router'))
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('--bundle catches the file missing from an assembled tarball', () => {
|
||||
const root = fixture({ 'index.js': `require('./router/public/rust.router')` })
|
||||
try {
|
||||
const { missing } = checkBundle(root)
|
||||
assert.strictEqual(missing.length, 1)
|
||||
assert.strictEqual(missing[0].specifier, './router/public/rust.router')
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
// ── This module's own version of that failure ──────────────────────────────
|
||||
//
|
||||
// The release packs no node_modules because the shipped half declares no
|
||||
// dependencies (org lead, phase 2). The value of that decision is entirely in
|
||||
// the day it stops being true being a LOUD day, so both modes ask.
|
||||
|
||||
test('--check reports a runtime dependency the release would not pack', () => {
|
||||
const root = fixture({ 'index.js': `module.exports = 1` }, { server: ['index.js'] }, {
|
||||
name: 'x',
|
||||
dependencies: { ws: '^8.21.0' },
|
||||
})
|
||||
try {
|
||||
assert.deepStrictEqual(checkDeclaration(root).dependencies, ['ws'])
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('--bundle reports a dependency the assembled bundle declares and does not carry', () => {
|
||||
const root = fixture({ 'index.js': `module.exports = 1` }, { server: ['index.js'] }, {
|
||||
name: 'x',
|
||||
dependencies: { ws: '^8.21.0' },
|
||||
})
|
||||
try {
|
||||
assert.deepStrictEqual(checkBundle(root).dependencies, ['ws'])
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('devDependencies are not runtime dependencies', () => {
|
||||
// express, express-validator and swagger-autogen are all here and none of them
|
||||
// ships: the shipped half is handed express on `ctx` (§2.3). A check that
|
||||
// confused the two would fail on a correct repo, which is the one way to make
|
||||
// everyone stop reading it.
|
||||
const root = fixture({ 'index.js': `module.exports = 1` }, { server: ['index.js'] }, {
|
||||
name: 'x',
|
||||
devDependencies: { express: '^4.19.2' },
|
||||
})
|
||||
try {
|
||||
assert.deepStrictEqual(runtimeDependencies(root), [])
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
// ── It has to reach requires that are not at the top level ─────────────────
|
||||
|
||||
test('follows requires written inside a function', () => {
|
||||
// index.js requires inside `register()` because require order is load-bearing:
|
||||
// `core.init(ctx)` has to run before anything under router/ is required. A
|
||||
// check that only saw file-scope requires would miss every router this module
|
||||
// has.
|
||||
const root = fixture(
|
||||
{
|
||||
'index.js': `module.exports = function register(ctx) { const r = require('./router/a') }`,
|
||||
'router/a.js': `module.exports = {}`,
|
||||
},
|
||||
{ server: ['index.js', 'router'] },
|
||||
)
|
||||
try {
|
||||
assert.strictEqual(checkDeclaration(root).uncovered.size, 0)
|
||||
assert.strictEqual(checkBundle(root).missing.length, 0)
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('follows requires transitively, not just one hop', () => {
|
||||
const root = fixture(
|
||||
{
|
||||
'index.js': `require('./a')`,
|
||||
'a.js': `require('./b')`,
|
||||
'b.js': `require('./deep/c')`,
|
||||
'deep/c.js': `module.exports = {}`,
|
||||
},
|
||||
{ server: ['index.js', 'a.js', 'b.js'] }, // `deep` missing
|
||||
)
|
||||
try {
|
||||
assert.ok(checkDeclaration(root).uncovered.has('server/deep'))
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
// ── Resolution has to match Node's, or it invents failures ─────────────────
|
||||
|
||||
test('resolves a directory to its index.js', () => {
|
||||
const root = fixture(
|
||||
{ 'index.js': `require('./boot')`, 'boot/index.js': `module.exports = {}` },
|
||||
{ server: ['index.js', 'boot'] },
|
||||
)
|
||||
try {
|
||||
assert.strictEqual(checkDeclaration(root).uncovered.size, 0)
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('resolves a .json dependency, and does not try to parse it for requires', () => {
|
||||
// server/index.js's last line requires ../module.json, which is why this case
|
||||
// is not hypothetical and why `generated` is in the declared list at all.
|
||||
const root = fixture(
|
||||
{ 'index.js': `require('./data/atlas.json')`, 'data/atlas.json': `{"a":1}` },
|
||||
{ server: ['index.js', 'data'] },
|
||||
)
|
||||
try {
|
||||
const { uncovered, missing } = checkDeclaration(root)
|
||||
assert.strictEqual(missing.length, 0)
|
||||
assert.strictEqual(uncovered.size, 0)
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('survives a require cycle', () => {
|
||||
const root = fixture(
|
||||
{ 'index.js': `require('./a')`, 'a.js': `require('./index')` },
|
||||
{ server: ['index.js', 'a.js'] },
|
||||
)
|
||||
try {
|
||||
assert.strictEqual(checkDeclaration(root).uncovered.size, 0)
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('a specifier that resolves to nothing is reported, not thrown', () => {
|
||||
const root = fixture({ 'index.js': `require('./gone')` })
|
||||
try {
|
||||
const { missing } = checkDeclaration(root)
|
||||
assert.strictEqual(missing.length, 1)
|
||||
assert.strictEqual(missing[0].specifier, './gone')
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('prose describing a require is not a require', () => {
|
||||
// The failure mode checkImports.js hit the first time it ran: index.js's own
|
||||
// header explains why it must never require express, and comments in this repo
|
||||
// name module paths constantly.
|
||||
const root = fixture(
|
||||
{ 'index.js': `// this file used to require('./router/gone')\nmodule.exports = 1` },
|
||||
{ server: ['index.js'] },
|
||||
)
|
||||
try {
|
||||
assert.strictEqual(checkDeclaration(root).missing.length, 0)
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
test("node_modules inside a bundle is npm's business, not this check's", () => {
|
||||
// Nothing ships one today. The skip stays so that the day a dependency does
|
||||
// arrive, this is not also the thing that breaks.
|
||||
const root = fixture({
|
||||
'index.js': `module.exports = 1`,
|
||||
'node_modules/ws/index.js': `require('./lib/that-npm-owns')`,
|
||||
})
|
||||
try {
|
||||
assert.strictEqual(checkBundle(root).missing.length, 0)
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
// ── And the real repo, which is the check that actually gates a release ────
|
||||
|
||||
test('the real ci/bundle.json covers everything the real entry point reaches', () => {
|
||||
const { uncovered, missing, reached, dependencies } = checkDeclaration()
|
||||
assert.deepStrictEqual([...uncovered.keys()], [])
|
||||
assert.deepStrictEqual(missing, [])
|
||||
assert.deepStrictEqual(dependencies, [])
|
||||
assert.ok(reached > 1, 'the walk should reach more than the entry point itself')
|
||||
})
|
||||
|
||||
test('every path ci/bundle.json declares exists', () => {
|
||||
// A list naming a path that has moved packs nothing and says nothing — `cp` in
|
||||
// the release would fail, but only after the tag had been pushed.
|
||||
for (const p of declaredServerPaths()) {
|
||||
assert.ok(fs.existsSync(p), `ci/bundle.json names ${p}, which does not exist`)
|
||||
}
|
||||
})
|
||||
|
||||
test('the entry point is reachable from the declared list', () => {
|
||||
const entry = path.resolve(__dirname, '..', 'index.js')
|
||||
assert.ok(reachable(entry).files.includes(entry))
|
||||
assert.ok(resolveFile(path.dirname(entry), './core'))
|
||||
})
|
||||
177
server/test/frozenManifest.test.js
Normal file
177
server/test/frozenManifest.test.js
Normal file
@@ -0,0 +1,177 @@
|
||||
// The frozen manifest's derivation, checked.
|
||||
//
|
||||
// `scripts/frozenManifest.js` runs in one place — a CI job with a whole core
|
||||
// checked out beside it — so it is the least-exercised piece of machinery in this
|
||||
// repo, and it is the piece that decides whether the URLs this module claims are
|
||||
// the URLs it serves (MODULE_API.md §5.3). Its three answers are pure functions of
|
||||
// two manifests and a fragment, so all three are asked here, with fixtures rather
|
||||
// than a clone.
|
||||
//
|
||||
// What is deliberately NOT asserted here: the numbers. `routes.manifest.json`'s
|
||||
// routes are proved by the job that generates them from a real core, and a copy of
|
||||
// that count in this file would only ever be a second thing to update.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const { diffManifests, coverage, MANIFEST, FRAGMENT } = require('../scripts/frozenManifest')
|
||||
|
||||
const manifest = (public_ = [], internal = []) => ({ public: public_, internal })
|
||||
const get = (p) => ({ method: 'GET', path: p })
|
||||
|
||||
test("the module's routes are the ones a core gains by loading it", () => {
|
||||
const before = manifest([get('/api/v1/public/settings')])
|
||||
const after = manifest([get('/api/v1/public/settings'), get('/api/v1/public/rust/servers')])
|
||||
|
||||
const { added, removed } = diffManifests(before, after)
|
||||
assert.deepStrictEqual(removed, [])
|
||||
assert.deepStrictEqual(added, [{ method: 'GET', path: '/api/v1/public/rust/servers', tier: 'public' }])
|
||||
})
|
||||
|
||||
test('a route core loses to the module is reported, not quietly absorbed', () => {
|
||||
// The failure this exists for, and the one phase 1 could only check by reading:
|
||||
// core mounts several routes at the TIER ROOT (/status, /version) that the
|
||||
// loader's collision probe cannot see, so a module whose mount displaced one
|
||||
// would not show up as an addition — the URL is unchanged — and a check that
|
||||
// only looked at what appeared would call it clean.
|
||||
const before = manifest([get('/api/v1/public/settings'), get('/api/v1/public/status')])
|
||||
const after = manifest([get('/api/v1/public/settings')])
|
||||
|
||||
const { removed } = diffManifests(before, after)
|
||||
assert.deepStrictEqual(removed, ['public GET /api/v1/public/status'])
|
||||
})
|
||||
|
||||
test('a route whose METHOD changed counts as removed and added', () => {
|
||||
const { added, removed } = diffManifests(
|
||||
manifest([{ method: 'POST', path: '/api/v1/admin/thing' }]),
|
||||
manifest([{ method: 'PUT', path: '/api/v1/admin/thing' }]),
|
||||
)
|
||||
assert.deepStrictEqual(removed, ['public POST /api/v1/admin/thing'])
|
||||
assert.strictEqual(added.length, 1)
|
||||
})
|
||||
|
||||
test('the internal app is diffed too, and keeps its own tier', () => {
|
||||
const { added } = diffManifests(
|
||||
manifest([], [get('/internal/health')]),
|
||||
manifest([], [get('/internal/health'), get('/internal/rust/thing')]),
|
||||
)
|
||||
assert.deepStrictEqual(added, [{ method: 'GET', path: '/internal/rust/thing', tier: 'internal' }])
|
||||
})
|
||||
|
||||
test('added routes are sorted, so the committed file does not churn on traversal order', () => {
|
||||
const { added } = diffManifests(
|
||||
manifest([]),
|
||||
manifest([get('/b'), get('/a'), { method: 'POST', path: '/a' }]),
|
||||
)
|
||||
assert.deepStrictEqual(
|
||||
added.map((r) => `${r.method} ${r.path}`),
|
||||
['GET /a', 'GET /b', 'POST /a'],
|
||||
)
|
||||
})
|
||||
|
||||
// ── coverage: the route ⇄ fragment agreement ────────────────────────────────
|
||||
|
||||
const fragment = (paths) => ({ paths })
|
||||
|
||||
test('a served route with no documented operation is named', () => {
|
||||
const { undocumented, unserved } = coverage([get('/api/v1/public/rust/servers')], fragment({}))
|
||||
assert.deepStrictEqual(undocumented, ['GET /api/v1/public/rust/servers'])
|
||||
assert.deepStrictEqual(unserved, [])
|
||||
})
|
||||
|
||||
test('a documented operation nobody serves is named too', () => {
|
||||
// The direction core's own spec has no check for, which is how it accumulated
|
||||
// orphan tags and schemas describing routes that had moved out of it. A
|
||||
// documented URL nobody serves is a client following the docs into a 404.
|
||||
const { undocumented, unserved } = coverage([], fragment({ '/api/v1/public/rust/gone': { get: {} } }))
|
||||
assert.deepStrictEqual(undocumented, [])
|
||||
assert.deepStrictEqual(unserved, ['GET /api/v1/public/rust/gone'])
|
||||
})
|
||||
|
||||
test('express :params and OpenAPI {params} are the same route', () => {
|
||||
const { undocumented, unserved } = coverage(
|
||||
[{ method: 'DELETE', path: '/api/v1/admin/rust/servers/:id' }],
|
||||
fragment({ '/api/v1/admin/rust/servers/{id}': { delete: {} } }),
|
||||
)
|
||||
assert.deepStrictEqual(undocumented, [])
|
||||
assert.deepStrictEqual(unserved, [])
|
||||
})
|
||||
|
||||
test('methods are matched, not just paths', () => {
|
||||
const { undocumented, unserved } = coverage(
|
||||
[{ method: 'POST', path: '/api/v1/admin/rust/servers/:id/test' }],
|
||||
fragment({ '/api/v1/admin/rust/servers/{id}/test': { get: {} } }),
|
||||
)
|
||||
assert.deepStrictEqual(undocumented, ['POST /api/v1/admin/rust/servers/{id}/test'])
|
||||
assert.deepStrictEqual(unserved, ['GET /api/v1/admin/rust/servers/{id}/test'])
|
||||
})
|
||||
|
||||
// ── the committed artifacts, against each other ─────────────────────────────
|
||||
//
|
||||
// These two files are generated together by a job that has a real core; here
|
||||
// there is no core, so what can still be asked is whether they agree with each
|
||||
// other. If they do not, one of them was committed without the other.
|
||||
|
||||
test('every route in the committed manifest has a committed operation', () => {
|
||||
const { routes } = JSON.parse(fs.readFileSync(MANIFEST, 'utf8'))
|
||||
const spec = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8'))
|
||||
const { undocumented, unserved } = coverage(routes, spec)
|
||||
assert.deepStrictEqual(undocumented, [], 'routes.manifest.json lists routes swagger-fragment.json does not document')
|
||||
assert.deepStrictEqual(unserved, [], 'swagger-fragment.json documents operations routes.manifest.json does not list')
|
||||
})
|
||||
|
||||
test('the fragment carries only the three sections §6.1a allows', () => {
|
||||
const spec = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8'))
|
||||
assert.deepStrictEqual(Object.keys(spec).sort(), ['components', 'paths', 'tags'])
|
||||
assert.deepStrictEqual(Object.keys(spec.components), ['schemas'])
|
||||
})
|
||||
|
||||
test("the fragment defines only namespaced schemas, and redefines none of core's", () => {
|
||||
const spec = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8'))
|
||||
for (const name of Object.keys(spec.components.schemas)) {
|
||||
assert.match(name, /^Rust[A-Z]/, `${name} is not namespaced — core wins the collision and drops it (§6.1a)`)
|
||||
}
|
||||
// Anything this fragment REFERENCES and does not define has to be one of
|
||||
// core's shared schemas, which resolve in the merged document — that is the
|
||||
// whole point of a fragment. A typo'd $ref is otherwise invisible until a
|
||||
// reader opens /api/docs.json and finds a dangling pointer.
|
||||
const refs = JSON.stringify(spec.paths).match(/#[/]components[/]schemas[/]([A-Za-z0-9_]+)/g) || []
|
||||
const shared = ['Error', 'ValidationError']
|
||||
for (const name of new Set(refs.map((r) => r.split('/').pop()))) {
|
||||
const resolvable = Object.hasOwn(spec.components.schemas, name) || shared.includes(name)
|
||||
assert.ok(resolvable, `$ref to ${name} resolves to nothing — not defined here, not one of core's shared schemas`)
|
||||
}
|
||||
})
|
||||
|
||||
test('every path in the fragment is fully qualified', () => {
|
||||
const spec = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8'))
|
||||
for (const p of Object.keys(spec.paths)) {
|
||||
// §6.1a: core merges the fragment verbatim and never re-derives a prefix, so
|
||||
// a router-relative path here is a path nothing serves.
|
||||
assert.match(p, /^\/api\/v1\/(public|admin|player)\//, `${p} is not a fully-qualified URL`)
|
||||
assert.doesNotMatch(p, /\/$/, `${p} has a trailing slash — no client calls that URL`)
|
||||
}
|
||||
})
|
||||
|
||||
test("the manifest and the module's declared mounts agree", () => {
|
||||
const { routes } = JSON.parse(fs.readFileSync(MANIFEST, 'utf8'))
|
||||
const { mounts } = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'module.json'), 'utf8'))
|
||||
|
||||
const declared = []
|
||||
for (const [tier, prefixes] of Object.entries(mounts)) {
|
||||
for (const prefix of prefixes) declared.push(`/api/v1/${tier}${prefix}/`)
|
||||
}
|
||||
|
||||
// Every route this module serves is under a prefix it declared. There is no
|
||||
// exception here yet, and that is the point of asserting it now: phase 6 adds
|
||||
// the `admin.users.detail` extension slot, whose routes live under core's
|
||||
// `/api/v1/admin/users/` rather than under any mount of ours (§2.4). When that
|
||||
// arrives this test must grow the exception deliberately, rather than a route
|
||||
// outside every declared mount arriving unnoticed.
|
||||
for (const route of routes) {
|
||||
const under = declared.some((d) => route.path.startsWith(d))
|
||||
assert.ok(under, `${route.method} ${route.path} is served from outside every mount module.json declares`)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user