feat(module): the bundle skeleton (phase 3, slice 0)
The first real module. It registers nothing, deliberately: what slice 0 proves
is the delivery path itself, end to end, before a single UO file moves into it.
Server half: module.json, an entry point that takes (ctx, api) and registers
nothing, a test suite built on a fake ctx, and scripts/checkImports.js -- the
MODULE_API.md §5.1 boundary check. Client half: the Vite library build, four
shims re-exporting react / react-dom/client / react-router-dom / jsx-runtime
from window.__rg, an entry that verifies each is identity-equal to core's copy,
and scripts/checkExternals.js. 29 server tests, 9 client tests, both new.
Verified against a real core: the module loads, mounts its zero routes, runs to
`started`, and is published by /api/v1/public/modules. Its chunk serves from
the entry's directory with `Cache-Control: no-cache` while the module's server
source, module.json and package.json all 404. In Chrome, under the enforced
`script-src 'self'`, the chunk evaluates and reports all four shared
dependencies OK, with zero CSP reports and no console errors.
Three findings, each of which had produced a green build that was wrong.
MODULE_API.md §3.6 shows `external` alongside the aliases and they do not
compose. Rollup asks `external` BEFORE Vite's alias resolver runs, so a
specifier 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. Built cleanly and emitted exactly that; checkExternals caught
it. So: alias only, `external` empty, and vite.config.js grows a resolution-time
guard that fails the build if a shared dependency resolves into node_modules.
That guard was wrong twice before it worked. Written against Rollup's `load`
hook it never ran -- `load` is first-wins and an earlier plugin had already
claimed the module -- so a deliberately-broken alias produced a 24 kB chunk with
react-router welded in, and a green build. And its forbidden-package list was
derived from the alias list "so the two cannot disagree", which meant deleting
an alias also deleted the guard against what that alias prevented. It states the
contract now, and a test asserts the aliases stay inside it.
checkImports failed on its own documentation the first time it ran: the comment
naming require("../../etc/passwd") as an example of what to catch, and index.js
explaining why the module must never require("express"). A boundary check that
cannot survive being described is one people stop writing comments around. It
strips comments and template literals with a character walk rather than a
regexp, because a URL in a string contains a comment opener and a comment
contains quotes -- and it has its own test suite, since a check never shown to
fail is a check nobody knows the state of.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
103
server/test/_fakes.js
Normal file
103
server/test/_fakes.js
Normal file
@@ -0,0 +1,103 @@
|
||||
// Test doubles for what core hands the module.
|
||||
//
|
||||
// The module's server half is testable WITHOUT core, and that is not a
|
||||
// convenience — it is the contract holding. Everything the module may touch
|
||||
// arrives on `ctx` (MODULE_API.md §2.3), so a `ctx` this file can build is a
|
||||
// complete statement of the module's dependencies. If a test ever needs
|
||||
// something that is not here, either the module reached past the boundary or
|
||||
// §2.3 needs a member; both are worth stopping for.
|
||||
//
|
||||
// `fakeCtx` mirrors §2.3 member for member, including the freezing, so a module
|
||||
// that assigns to `ctx.something` fails here the way it would in core.
|
||||
|
||||
const express = require('express')
|
||||
|
||||
/** Records every call, so a test can assert what a module asked for. */
|
||||
function spy(returns) {
|
||||
const fn = (...args) => {
|
||||
fn.calls.push(args)
|
||||
return typeof returns === 'function' ? returns(...args) : returns
|
||||
}
|
||||
fn.calls = []
|
||||
return fn
|
||||
}
|
||||
|
||||
function fakeLog() {
|
||||
const log = { error: spy(), warn: spy(), info: spy(), debug: spy() }
|
||||
return log
|
||||
}
|
||||
|
||||
function fakeCtx(overrides = {}) {
|
||||
const logs = []
|
||||
const ctx = {
|
||||
moduleId: 'uo',
|
||||
paths: { moduleRoot: require('path').resolve(__dirname, '..', '..') },
|
||||
express,
|
||||
validator: require('express-validator'),
|
||||
db: { query: spy(Promise.resolve([])), pool: {} },
|
||||
log: (namespace) => {
|
||||
const log = fakeLog()
|
||||
logs.push({ namespace, log })
|
||||
return log
|
||||
},
|
||||
settings: { get: spy(Promise.resolve(null)), set: spy(Promise.resolve()), getInstanceName: spy(Promise.resolve('Test')) },
|
||||
auth: { getUserFromRequest: spy(null) },
|
||||
push: { publish: spy(Promise.resolve()) },
|
||||
secretBox: { encrypt: spy('enc'), decrypt: spy('dec') },
|
||||
middleware: {
|
||||
requireAuth: (req, res, next) => next(),
|
||||
requireRole: () => (req, res, next) => next(),
|
||||
siteMode: (req, res, next) => next(),
|
||||
validate: (req, res, next) => next(),
|
||||
noindex: (req, res, next) => next(),
|
||||
},
|
||||
uploads: { upload: {}, UPLOAD_DIR: '/tmp', MIME_EXT: {} },
|
||||
posts: { listAll: spy(Promise.resolve([])), getById: spy(Promise.resolve(null)), linkAnnounceJob: spy(Promise.resolve()), markAnnounced: spy(Promise.resolve()) },
|
||||
...overrides,
|
||||
}
|
||||
// Non-enumerable, and that is not tidiness. Core freezes every object value on
|
||||
// `ctx` one level deep, so an enumerable recorder hung off it would be frozen
|
||||
// by the loop below and every `log.info` call would throw on push — which is
|
||||
// how this was found. Keeping it off the enumeration also makes the fake more
|
||||
// faithful: a module iterating `ctx` sees exactly §2.3's members and nothing
|
||||
// a test put there.
|
||||
Object.defineProperty(ctx, 'logs', { value: logs, enumerable: false })
|
||||
for (const value of Object.values(ctx)) {
|
||||
if (value && typeof value === 'object') Object.freeze(value)
|
||||
}
|
||||
return Object.freeze(ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* The registration api, recording rather than mounting.
|
||||
*
|
||||
* Copies core's `once()` rule (§2.4: "calling twice is an error") because a
|
||||
* module that registers the same thing twice must fail in its own test suite
|
||||
* and not first on an operator's install.
|
||||
*/
|
||||
function fakeApi() {
|
||||
const record = {
|
||||
routes: null,
|
||||
extensions: [],
|
||||
streams: null,
|
||||
legs: [],
|
||||
hooks: {},
|
||||
}
|
||||
const called = new Set()
|
||||
const once = (name) => {
|
||||
if (called.has(name)) throw new Error(`${name}() called twice`)
|
||||
called.add(name)
|
||||
}
|
||||
const api = {
|
||||
registerRoutes(mounts) { once('registerRoutes'); record.routes = mounts },
|
||||
registerExtension(slot, router) { record.extensions.push({ slot, router }) },
|
||||
registerNotificationStreams(streams) { once('registerNotificationStreams'); record.streams = streams },
|
||||
registerAnnounceLeg(leg) { record.legs.push(leg) },
|
||||
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
|
||||
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
|
||||
}
|
||||
api.record = record
|
||||
return api
|
||||
}
|
||||
|
||||
module.exports = { fakeCtx, fakeApi, spy }
|
||||
136
server/test/checkImports.test.js
Normal file
136
server/test/checkImports.test.js
Normal file
@@ -0,0 +1,136 @@
|
||||
// The boundary check, checked.
|
||||
//
|
||||
// `scripts/checkImports.js` is the acceptance test for the whole module contract
|
||||
// (MODULE_API.md §5.1), and a check that has never been shown to fail is a check
|
||||
// nobody knows the state of. These point it at fixtures that break each rule and
|
||||
// assert it says so — and at prose that merely *describes* breaking them, which
|
||||
// is what it got wrong the first time it was run.
|
||||
//
|
||||
// **Every fixture is a template literal, and that is load-bearing.** The scanner
|
||||
// reads the files in this directory too, so an ordinary quoted string holding
|
||||
// `require('../../x')` would make this file fail the very check it is testing.
|
||||
// Templates are blanked by the stripper for exactly this class of text: source
|
||||
// being composed as data is not source being imported.
|
||||
|
||||
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 { scan, stripCommentsAndTemplates, SERVER_ROOT, MODULE_ROOT } = require('../scripts/checkImports')
|
||||
|
||||
/** Write `files` into a throwaway module tree and scan it. */
|
||||
function scanFixture(files, { dev = new Set() } = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'module-uo-'))
|
||||
const src = path.join(root, 'server')
|
||||
for (const [name, source] of Object.entries(files)) {
|
||||
const file = path.join(src, name)
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true })
|
||||
fs.writeFileSync(file, source)
|
||||
}
|
||||
try {
|
||||
return scan(src, root, { shipped: (f) => !f.startsWith(path.join(src, 'test') + path.sep), dev })
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
test('the real server half is clean', () => {
|
||||
assert.deepStrictEqual(scan(SERVER_ROOT, MODULE_ROOT), [])
|
||||
})
|
||||
|
||||
test('catches a relative path that escapes the module root', () => {
|
||||
const found = scanFixture({ 'a.js': `require('../../server/src/utils/db')` })
|
||||
assert.strictEqual(found.length, 1)
|
||||
assert.strictEqual(found[0].why, 'escapes the module root')
|
||||
})
|
||||
|
||||
test('allows a relative path that stays inside it, however deep', () => {
|
||||
assert.deepStrictEqual(
|
||||
scanFixture({ 'deep/nested/a.js': `require('../../../module.json')` }),
|
||||
[],
|
||||
)
|
||||
})
|
||||
|
||||
test('catches an absolute path', () => {
|
||||
const found = scanFixture({ 'a.js': `require('/etc/passwd')` })
|
||||
assert.strictEqual(found[0].why, 'absolute path')
|
||||
})
|
||||
|
||||
test('catches a bare specifier in shipped code, even a devDependency', () => {
|
||||
// The rule that makes the boundary real: express arrives on ctx. A shipped
|
||||
// file requiring it would fail on a real install, because a module lives
|
||||
// outside core's server/ and never reaches core's node_modules.
|
||||
const found = scanFixture({ 'a.js': `const express = require('express')` }, { dev: new Set(['express']) })
|
||||
assert.strictEqual(found.length, 1)
|
||||
assert.match(found[0].why, /should this come from ctx/)
|
||||
})
|
||||
|
||||
test('allows a devDependency in test code, which never runs inside core', () => {
|
||||
assert.deepStrictEqual(
|
||||
scanFixture({ 'test/a.js': `const express = require('express')` }, { dev: new Set(['express']) }),
|
||||
[],
|
||||
)
|
||||
})
|
||||
|
||||
test('allows node builtins anywhere, with or without the node: prefix', () => {
|
||||
assert.deepStrictEqual(
|
||||
scanFixture({ 'a.js': `require('path'); require('node:fs'); import crypto from 'node:crypto'` }),
|
||||
[],
|
||||
)
|
||||
})
|
||||
|
||||
test('catches ESM and dynamic forms, not only require()', () => {
|
||||
const found = scanFixture({
|
||||
'a.js': [`import db from '../../core/db.js'`, `const x = await import('../../core/other.js')`].join('\n'),
|
||||
})
|
||||
assert.strictEqual(found.length, 2)
|
||||
})
|
||||
|
||||
test('ignores a violation that is only DESCRIBED in a comment', () => {
|
||||
// The first run of this check failed on its own documentation, and on
|
||||
// index.js's comment explaining why the module must never require('express').
|
||||
// Prose about the rule must not trip the rule.
|
||||
assert.deepStrictEqual(
|
||||
scanFixture({
|
||||
'a.js': [
|
||||
`// Never write require("../../server/src/utils/db") - it escapes the module root.`,
|
||||
`/* Nor import express from "express": core hands it over on ctx. */`,
|
||||
`const path = require('path')`,
|
||||
].join('\n'),
|
||||
}),
|
||||
[],
|
||||
)
|
||||
})
|
||||
|
||||
test('ignores a specifier-shaped string inside a template literal', () => {
|
||||
assert.deepStrictEqual(
|
||||
scanFixture({ 'a.js': ['const sql = ', '`SELECT 1 -- require("../../x")`'].join('') }),
|
||||
[],
|
||||
)
|
||||
})
|
||||
|
||||
test('a comment opener inside a string does not swallow the rest of the file', () => {
|
||||
// The reason this is a character walk and not a regexp: a URL in a string
|
||||
// contains `//`, and treating that as a comment would blank everything after
|
||||
// it — turning the check into one that silently passes.
|
||||
const found = scanFixture({
|
||||
'a.js': [`const url = 'https://example.com/x'`, `require('../../escaped')`].join('\n'),
|
||||
})
|
||||
assert.strictEqual(found.length, 1, 'the specifier after a URL string was missed')
|
||||
})
|
||||
|
||||
test('a quote inside a comment does not swallow the rest of the file', () => {
|
||||
const found = scanFixture({
|
||||
'a.js': [`// don't do this`, `require('../../escaped')`].join('\n'),
|
||||
})
|
||||
assert.strictEqual(found.length, 1)
|
||||
})
|
||||
|
||||
test('stripping preserves line numbers', () => {
|
||||
// Blanked rather than removed, so anything that later reports a line still
|
||||
// reports the right one.
|
||||
const src = ['/* a', 'b', 'c */', `require("x")`, ''].join('\n')
|
||||
assert.strictEqual(stripCommentsAndTemplates(src).split('\n').length, src.split('\n').length)
|
||||
})
|
||||
71
server/test/entry.test.js
Normal file
71
server/test/entry.test.js
Normal file
@@ -0,0 +1,71 @@
|
||||
// The entry point's contract with core (MODULE_API.md §2.2).
|
||||
//
|
||||
// Slice 0 registers nothing, so there is very little behaviour to assert — and
|
||||
// the rules that DO apply are the ones that would otherwise be discovered on an
|
||||
// operator's install: registering synchronously, never awaiting, never touching
|
||||
// a database, never mutating what it was handed. Those hold for every slice
|
||||
// after this one too, which is why they are tested against the entry point
|
||||
// rather than against whatever it happens to register today.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const register = require('../index')
|
||||
const { fakeCtx, fakeApi } = require('./_fakes')
|
||||
|
||||
test('exports a single register function', () => {
|
||||
assert.strictEqual(typeof register, 'function')
|
||||
})
|
||||
|
||||
test('registers synchronously and returns nothing to await', () => {
|
||||
const result = register(fakeCtx(), fakeApi())
|
||||
// Not `assert.strictEqual(result, undefined)` alone: a module that returned a
|
||||
// promise would be a module whose registration core silently never waits for.
|
||||
assert.ok(!result || typeof result.then !== 'function', 'register() must not return a thenable')
|
||||
})
|
||||
|
||||
test('touches no database at registration time', () => {
|
||||
const ctx = fakeCtx()
|
||||
register(ctx, fakeApi())
|
||||
assert.deepStrictEqual(ctx.db.query.calls, [], 'register() queried the database')
|
||||
})
|
||||
|
||||
test('registers nothing in slice 0', () => {
|
||||
const api = fakeApi()
|
||||
register(fakeCtx(), api)
|
||||
assert.strictEqual(api.record.routes, null)
|
||||
assert.strictEqual(api.record.streams, null)
|
||||
assert.deepStrictEqual(api.record.extensions, [])
|
||||
assert.deepStrictEqual(api.record.legs, [])
|
||||
assert.deepStrictEqual(api.record.hooks, {})
|
||||
})
|
||||
|
||||
test('takes a frozen ctx and does not try to write to it', () => {
|
||||
const ctx = fakeCtx()
|
||||
assert.ok(Object.isFrozen(ctx))
|
||||
// Core freezes one level deep; a module that assigned to ctx would throw here
|
||||
// in strict mode and fail silently outside it. Either way it must not.
|
||||
assert.doesNotThrow(() => register(ctx, fakeApi()))
|
||||
})
|
||||
|
||||
test('logs through ctx.log, never through console', () => {
|
||||
const ctx = fakeCtx()
|
||||
register(ctx, fakeApi())
|
||||
assert.strictEqual(ctx.logs.length, 1, 'expected exactly one logger to be taken')
|
||||
const { log } = ctx.logs[0]
|
||||
assert.strictEqual(log.info.calls.length, 1)
|
||||
assert.strictEqual(log.info.calls[0][0], 'registered')
|
||||
})
|
||||
|
||||
test('carries no hidden state between calls', () => {
|
||||
// Core calls register() exactly once, and the `once()` guard that enforces
|
||||
// that lives in core's `api` — not here. What this asserts is the module's
|
||||
// own half of it: registering into a second `api` produces the same result as
|
||||
// the first, so nothing is memoised at file scope where a re-register would
|
||||
// silently do less than it appears to.
|
||||
const first = fakeApi()
|
||||
const second = fakeApi()
|
||||
register(fakeCtx(), first)
|
||||
register(fakeCtx(), second)
|
||||
assert.deepStrictEqual(second.record, first.record)
|
||||
})
|
||||
81
server/test/manifest.test.js
Normal file
81
server/test/manifest.test.js
Normal file
@@ -0,0 +1,81 @@
|
||||
// `module.json` is what core validates before it will load anything, and every
|
||||
// rule it is checked against lives in core's loader (MODULE_API.md §2.1, §4.3).
|
||||
// Restating those rules here means a manifest mistake fails in this repo's CI,
|
||||
// which can say what is wrong, rather than on an install, where the symptom is a
|
||||
// module that is simply absent.
|
||||
//
|
||||
// These are the loader's own patterns, copied deliberately rather than imported:
|
||||
// this repo has no dependency on core's source, and a copy that drifts is
|
||||
// exactly what the coreApi range exists to catch.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..', '..')
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'module.json'), 'utf8'))
|
||||
|
||||
const KEYS = new Set([
|
||||
'id', 'name', 'version', 'coreApi', 'server', 'client',
|
||||
'schema', 'purge', 'mounts', 'extensions', 'capabilities',
|
||||
])
|
||||
const ID = /^[a-z][a-z0-9-]{1,31}$/
|
||||
const PREFIX = /^\/[a-z0-9][a-z0-9-]*$/
|
||||
const TIERS = ['public', 'admin', 'player']
|
||||
|
||||
test('declares no key core would reject', () => {
|
||||
for (const key of Object.keys(manifest)) {
|
||||
assert.ok(KEYS.has(key), `unknown key "${key}" — core rejects rather than ignores it`)
|
||||
}
|
||||
})
|
||||
|
||||
test('id is "uo" and matches the directory it installs as', () => {
|
||||
assert.ok(ID.test(manifest.id))
|
||||
assert.strictEqual(manifest.id, 'uo')
|
||||
})
|
||||
|
||||
test('version and coreApi are present and semver-shaped', () => {
|
||||
assert.match(manifest.version, /^\d+\.\d+\.\d+/)
|
||||
assert.match(manifest.coreApi, /^[\^~]?\d+\.\d+\.\d+/)
|
||||
})
|
||||
|
||||
test('every declared file exists', () => {
|
||||
for (const key of ['server', 'schema', 'purge']) {
|
||||
if (manifest[key]) {
|
||||
assert.ok(fs.existsSync(path.join(ROOT, manifest[key])), `${key}: ${manifest[key]} is missing`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('a schema fragment always comes with a purge', () => {
|
||||
// Core enforces this too. A module that can create tables and cannot drop them
|
||||
// leaves an operator with orphaned data and no supported way to remove it.
|
||||
if (manifest.schema) assert.ok(manifest.purge, 'declares schema but no purge')
|
||||
})
|
||||
|
||||
test('client.entry is in a subdirectory, because its directory is what gets served', () => {
|
||||
assert.ok(manifest.client, 'module-uo ships a client half')
|
||||
const entry = manifest.client.entry
|
||||
assert.match(path.basename(entry), /^[A-Za-z0-9][A-Za-z0-9._-]*\.js$/)
|
||||
// The rule worth a test of its own: core serves `dirname(entry)`, so an entry
|
||||
// in the module root would publish the server source and module.json.
|
||||
assert.notStrictEqual(path.dirname(path.resolve(ROOT, entry)), ROOT)
|
||||
assert.ok(path.resolve(ROOT, entry).startsWith(ROOT + path.sep), 'entry escapes the module root')
|
||||
})
|
||||
|
||||
test('declared mounts are single lowercase segments in known tiers', () => {
|
||||
for (const [tier, prefixes] of Object.entries(manifest.mounts || {})) {
|
||||
assert.ok(TIERS.includes(tier), `unknown tier "${tier}"`)
|
||||
for (const prefix of prefixes) assert.match(prefix, PREFIX)
|
||||
}
|
||||
})
|
||||
|
||||
test('notification stream ids and announce legs stay namespaced or grandfathered', () => {
|
||||
// Nothing to check yet — slice 0 registers neither. The assertion that matters
|
||||
// is that the manifest does not quietly claim capabilities the module does not
|
||||
// serve, since `GET /api/v1/public/modules` publishes them to clients.
|
||||
assert.deepStrictEqual(manifest.capabilities || [], [])
|
||||
assert.deepStrictEqual(manifest.mounts || {}, {})
|
||||
assert.deepStrictEqual(manifest.extensions || [], [])
|
||||
})
|
||||
Reference in New Issue
Block a user