Files
Module-uo/server/test/manifest.test.js
wtclaude 6b99d7e220
All checks were successful
PR Checks / client-build (pull_request) Successful in 17s
PR Checks / server-tests (pull_request) Successful in 8m47s
test(server): port core's UO suite onto the ctx harness
22 test files moved from core, plus the two that were split out of files core
keeps. 351 tests pass.

One change runs through every moved test, and it is the boundary rather than a
chore: core internals can no longer be stubbed by requiring them, because there
are none to require. `../utils/db` and `../model/settings` do not exist here.
What a test controls instead is the ctx core would have handed over, installed
once by test/_setup.js -- which is a better seam anyway, since it is exactly the
surface the contract promises and nothing wider.

The ctx _setup installs is deliberately unfrozen. Core freezes what it hands a
module and entry.test.js still asserts against a frozen one; but a test that
needs settings.get to return a path has to be able to say so.

Two tests changed SHAPE, and that is the boundary too. fromShardEvent used to
assert through publish() into pushDevices and a captured fetch -- which
endpoints were hit, how many requests went out. None of that is this module's
any more: publish is ctx.push.publish, and the device registry and the relay are
behind it. Reaching for them from here would be reaching past ctx. What remains
is what the module owns and is the part worth guarding: a game account resolves
to a website user, a personal target that resolves to nobody is dropped rather
than published, and a sensitive kind never reaches publish at all.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 12:07:15 -05:00

88 lines
3.9 KiB
JavaScript

// `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('the manifest claims a coherent bundle', () => {
// `capabilities` is published by GET /api/v1/public/modules and is what a
// client feature-detects against — the SPA and the Android app both read it —
// so it must not claim something the module does not serve. Checked as a shape
// rather than a list: which capabilities exist is a product decision, that
// they are non-empty opaque strings is the contract.
for (const c of manifest.capabilities || []) {
assert.match(c, /^[a-z][a-z0-9-]*$/, `capability "${c}" is not an opaque lowercase id`)
}
// Declaring a mount is what makes the prefix this module's; the loader checks
// the declaration against what register() actually registers (entry.test.js).
assert.ok(Object.keys(manifest.mounts).length > 0, 'a module that mounts nothing serves nothing')
assert.deepStrictEqual(manifest.extensions, ['admin.users.detail'])
})