157 lines
6.4 KiB
JavaScript
157 lines
6.4 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(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.describeDelivery(null, configProps.fallbackT), 'nothing delivered yet')
|
|
})
|
|
|
|
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')
|
|
})
|