Files

160 lines
6.6 KiB
JavaScript

// The built artifact, exercised the way the page exercises it: the module table
// calls `window.__ModuleLoader__.load`, materializes the factory, and hands the
// result to Cordis. This runs under Node with stubs, which is exactly the point:
// the bundle may not need a DOM before its components render, and `require` may
// only ever ask for modules the module table actually holds.
//
// The freshness case is the guard against the one real maintenance hazard of
// this layout: editing src/client/** and committing without rebuilding.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const artifactPath = resolve(repoRoot, 'client.js')
const source = await readFile(artifactPath, 'utf8')
/** The smallest React the bundle can be handed: property access only. */
const reactStub = {
createElement: () => null,
Fragment: Symbol('Fragment'),
useCallback: (callback) => callback,
useEffect: () => {},
useSyncExternalStore: () => undefined,
}
/** Load the artifact against a stub module table and return its registration. */
function materialize(moduleTable = { react: reactStub }) {
let registration
const previous = globalThis.window
globalThis.window = { __ModuleLoader__: { load: (value) => { registration = value } } }
try {
// eslint-disable-next-line no-new-func -- executing the artifact IS the test
new Function(source)()
} finally {
globalThis.window = previous
}
assert.ok(registration, 'the bundle never registered a factory')
const requested = []
const exports = registration.factory((specifier) => {
requested.push(specifier)
if (!Object.hasOwn(moduleTable, specifier)) {
throw new Error(`the bundle asked the module table for "${specifier}"`)
}
return moduleTable[specifier]
})
return { registration, exports, requested }
}
/** The smallest client root context that lets `apply` run without a page. */
function fakeContext() {
const effects = []
const slots = []
const registrations = []
return {
effects,
slots,
registrations,
ctx: {
get: () => undefined,
effect: (callback, label) => {
effects.push(label)
return () => {}
},
slots: {
inject: (name, setup) => {
slots.push(name)
setup()
},
register: (definition, component) => {
registrations.push({ definition, component })
return { dispose() {} }
},
},
},
}
}
test('the artifact is a generated, self-contained script', () => {
assert.match(source.split('\n')[0], /^\/\/ Generated by scripts\/build-client\.mjs/)
assert.ok(!source.includes('\r'), 'the artifact must be LF-only (see .gitattributes)')
assert.ok(source.includes('window.__ModuleLoader__.load({'), 'missing the registration wrapper')
assert.ok(source.includes("id: '@dsh-plugin/session-notify',"), 'the registration id must be the package name')
assert.ok(source.trimEnd().endsWith('})'), 'the registration wrapper is not closed')
const factoryAt = source.indexOf('factory(require) {')
assert.ok(factoryAt > 0, 'the factory is missing')
const requires = [...source.matchAll(/\brequire\(\s*"([^"]+)"\s*\)/g)]
assert.ok(requires.length > 0, 'the bundle never requires react')
for (const [whole, specifier] of requires) {
assert.equal(specifier, 'react', 'only platform seeds may be requested synchronously')
assert.ok(source.indexOf(whole) > factoryAt, 'require must sit inside the factory, or it would load eagerly')
}
assert.ok(!/^\s*(?:import|export)\s/m.test(source), 'a self-contained bundle has no top-level import/export')
})
test('materializing the factory yields the plugin the module table expects', () => {
const { registration, exports, requested } = materialize()
assert.equal(registration.id, '@dsh-plugin/session-notify')
assert.deepEqual(exports.inject, ['slots', 'locale'])
assert.equal(typeof exports.apply, 'function')
assert.deepEqual([...new Set(requested)], ['react'])
})
test('applying the plugin registers its three seats without touching the DOM', () => {
const { exports } = materialize()
const { ctx, effects, slots, registrations } = fakeContext()
exports.apply(ctx)
assert.deepEqual(effects, [
'session-notify: dictionaries',
'session-notify: window focus',
'session-notify: disposal',
])
assert.deepEqual(slots, ['shell.overlay', 'plugins.bundle.config', 'shell.overlay'])
assert.deepEqual(registrations.map(({ definition }) => definition.id ?? definition.key), [
'session-notify-toasts',
'@dsh-plugin/session-notify',
'session-notify-watch',
])
assert.deepEqual(registrations.map(({ definition }) => definition.order), [100, undefined, 101])
for (const { component } of registrations) assert.equal(typeof component, 'function')
const [toasts, config] = registrations
const toastProps = toasts.definition.inject()
assert.deepEqual(toastProps.store.getSnapshot().kinds, { completion: true, approval: true, question: true })
assert.equal(toastProps.store.getSnapshot().permission, 'unsupported')
assert.equal(toastProps.platform, undefined)
for (const operation of ['dismiss', 'arm', 'hold', 'open']) {
assert.equal(typeof toastProps[operation], 'function', `${operation} is not a function`)
}
assert.equal(typeof toastProps.fallbackT, 'function')
const configProps = config.definition.inject()
assert.equal(typeof configProps.ask, 'function')
assert.equal(typeof configProps.testAny, 'function')
assert.equal(typeof configProps.testSystem, 'function')
assert.equal(typeof configProps.setKind, 'function')
assert.equal(typeof configProps.setDismiss, 'function')
assert.equal(configProps.store.getSnapshot().dismissAfterMs, 6000, 'a browser with no stored choice gets the shipped lifetime')
assert.equal(configProps.replayCount(), 0)
// No locale service in this fake context, so the plugin's own translator falls
// back to its English dictionary.
assert.equal(configProps.copy.deliveryFacts(null, configProps.fallbackT), null)
assert.equal(configProps.copy.replayValue(0, configProps.fallbackT), 'None')
})
test('the committed artifact is exactly what src/client builds', async (t) => {
const { buildClient } = await import('../scripts/build-client.mjs')
let built
try {
built = await buildClient({ write: false })
} catch (error) {
t.skip(`cannot rebuild without devDependencies: ${error.message}`)
return
}
assert.equal(built, source, 'client.js is stale — run `pnpm run build` and commit the result')
})