✅ test: 补上纯函数、产物冒烟与组件渲染测试,守护产物新鲜度
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
// The components, rendered through a tiny React double.
|
||||
//
|
||||
// Why a double instead of React: this repository ships no runtime dependency at
|
||||
// all, and the whole point of these tests is the wiring the refactor introduced —
|
||||
// which component reads which prop through which seat. The double implements the
|
||||
// four React entry points this package uses, so a component render is exercised
|
||||
// end to end (hooks included) without a DOM or a real React.
|
||||
//
|
||||
// It runs against the BUILT artifact, because that is what the page loads.
|
||||
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 source = await readFile(resolve(repoRoot, 'client.js'), 'utf8')
|
||||
|
||||
/** The smallest React this package can run on, plus a way to run effects on demand. */
|
||||
function createReactDouble({ runEffects = false } = {}) {
|
||||
const pending = []
|
||||
return {
|
||||
pending,
|
||||
React: {
|
||||
Fragment: Symbol('Fragment'),
|
||||
createElement: (type, props, ...children) => ({ type, props: props ?? {}, children }),
|
||||
useCallback: (callback) => callback,
|
||||
useEffect: (effect) => {
|
||||
pending.push(effect)
|
||||
// Components here may only touch the DOM from their own effects, so this
|
||||
// is opt-in: ToastLayer's escape-key effect needs a document.
|
||||
if (runEffects) effect()
|
||||
},
|
||||
useSyncExternalStore: (subscribe, getSnapshot) => getSnapshot(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Materialize the artifact, then install it, and hand back its three seats. */
|
||||
function mount({ runEffects = false } = {}) {
|
||||
const { React, pending } = createReactDouble({ runEffects })
|
||||
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 exports = registration.factory((specifier) => {
|
||||
assert.equal(specifier, 'react', 'only platform seeds may be requested')
|
||||
return React
|
||||
})
|
||||
|
||||
const effects = []
|
||||
const registrations = []
|
||||
exports.apply({
|
||||
get: () => undefined,
|
||||
effect: (callback, label) => {
|
||||
effects.push(label)
|
||||
return () => {}
|
||||
},
|
||||
slots: {
|
||||
inject: (name, setup) => setup(),
|
||||
register: (definition, component) => {
|
||||
registrations.push({ definition, component })
|
||||
return { dispose() {} }
|
||||
},
|
||||
},
|
||||
})
|
||||
const [toasts, config, observer] = registrations
|
||||
return { toasts, config, observer, pending, effects }
|
||||
}
|
||||
|
||||
/** Walk a rendered element tree, collecting text, host types and every element. */
|
||||
function walk(node, collected = { text: [], types: [], elements: [] }) {
|
||||
if (node === null || node === undefined || typeof node === 'boolean') return collected
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) walk(child, collected)
|
||||
return collected
|
||||
}
|
||||
if (typeof node === 'string' || typeof node === 'number') {
|
||||
collected.text.push(String(node))
|
||||
return collected
|
||||
}
|
||||
if (typeof node.type === 'function') {
|
||||
walk(node.type(node.props), collected)
|
||||
return collected
|
||||
}
|
||||
collected.types.push(node.type)
|
||||
collected.elements.push(node)
|
||||
// The double stores a `createElement` call's children on the element itself,
|
||||
// exactly where React's own elements keep them.
|
||||
walk(node.children, collected)
|
||||
return collected
|
||||
}
|
||||
|
||||
const flatten = (node) => walk(node).text.join(' ')
|
||||
|
||||
test('the plugin page renders every section with copy, not raw keys', () => {
|
||||
const { config } = mount()
|
||||
const props = { ...config.definition.inject(), view: 'page' }
|
||||
const rendered = walk(config.component(props))
|
||||
const text = rendered.text.join(' ')
|
||||
|
||||
// No locale service in this context, so the seat falls back to English.
|
||||
for (const copy of [
|
||||
'Every alert comes from this page',
|
||||
'What to notify about',
|
||||
'The switches are stored in this browser',
|
||||
'System notification permission',
|
||||
'The buttons here go straight to the system channel',
|
||||
'Status',
|
||||
'nothing delivered yet',
|
||||
'nothing waiting to be replayed',
|
||||
'Test alert',
|
||||
]) {
|
||||
assert.ok(text.includes(copy), `the page is missing: ${copy}`)
|
||||
}
|
||||
assert.ok(!text.includes('config.'), 'a raw translation key reached the page')
|
||||
assert.ok(!text.includes('settings.'), 'a raw translation key reached the page')
|
||||
|
||||
const classes = rendered.elements.map((element) => element.props.className ?? '')
|
||||
for (const className of ['dsn-sections', 'dsn-section', 'dsn-section-title', 'dsn-row', 'dsn-button', 'dsn-status']) {
|
||||
assert.ok(classes.includes(className), `the page is missing .${className}`)
|
||||
}
|
||||
assert.equal(classes.filter((className) => className === 'dsn-switch').length, 3)
|
||||
|
||||
// The live window fact is rendered, not remembered.
|
||||
assert.match(text, /Window: (in front \(popup is used\)|not in front \(system notification is used\))/)
|
||||
})
|
||||
|
||||
test('the page reads the store: switches, permission and diagnostics follow it', () => {
|
||||
const { config } = mount()
|
||||
const props = config.definition.inject()
|
||||
props.store.set({
|
||||
...props.store.getSnapshot(),
|
||||
kinds: { completion: false, approval: true, question: true },
|
||||
lastDelivery: { outcome: 'system', shown: true, at: '17:52:30', title: 'Conversation finished' },
|
||||
})
|
||||
const rendered = walk(config.component({ ...props, view: 'page' }))
|
||||
const switches = rendered.elements
|
||||
.filter((element) => element.props.className === 'dsn-switch')
|
||||
.map((element) => element.props['aria-checked'])
|
||||
assert.deepEqual(switches, [false, true, true])
|
||||
assert.match(rendered.text.join(' '), /Last delivery: 17:52:30 · system notification \(confirmed on screen\) · Conversation finished/)
|
||||
})
|
||||
|
||||
test('the summary view is the one-line description the plugin row shows', () => {
|
||||
const { config } = mount()
|
||||
const text = flatten(config.component({ ...config.definition.inject(), view: 'summary' }))
|
||||
assert.equal(text.trim(), 'A system notification while the window is in the background, a light in-app popup while it is in the foreground; the conversation on screen is never interrupted.')
|
||||
})
|
||||
|
||||
test('the popup stack renders nothing while empty, and one card per alert', () => {
|
||||
const { toasts } = mount()
|
||||
const props = toasts.definition.inject()
|
||||
assert.equal(toasts.component(props), null)
|
||||
|
||||
props.store.set({
|
||||
...props.store.getSnapshot(),
|
||||
toasts: [{ id: 'dsn-1', kind: 'approval', title: 'Approval required', body: 'Conversation: Bash is waiting for your approval', sessionId: 'session-1' }],
|
||||
})
|
||||
const rendered = walk(toasts.component(props))
|
||||
const text = rendered.text.join(' ')
|
||||
assert.ok(text.includes('Approval required'))
|
||||
assert.ok(text.includes('Bash is waiting for your approval'))
|
||||
assert.ok(text.includes('View'))
|
||||
|
||||
const classes = rendered.elements.map((element) => element.props.className ?? '')
|
||||
for (const className of ['dsn-stack', 'dsn-toast', 'dsn-toast-icon is-approval', 'dsn-toast-title', 'dsn-toast-desc', 'dsn-toast-action', 'dsn-toast-close']) {
|
||||
assert.ok(classes.includes(className), `the popup is missing .${className}`)
|
||||
}
|
||||
// The close control is a square hit area with an icon only, so its label is
|
||||
// the only accessible name it has.
|
||||
assert.equal(
|
||||
rendered.elements.find((element) => element.props.className === 'dsn-toast-close').props['aria-label'],
|
||||
'Dismiss',
|
||||
)
|
||||
// The stack announces itself politely, and an approval is an alert, not a status.
|
||||
assert.equal(rendered.elements.find((element) => element.props.className === 'dsn-stack').props['aria-live'], 'polite')
|
||||
assert.equal(rendered.elements.find((element) => element.props.className === 'dsn-toast').props.role, 'alert')
|
||||
})
|
||||
|
||||
test('the popup buttons call back into the plugin, and the card pauses its own lifetime', () => {
|
||||
const { toasts } = mount()
|
||||
const called = []
|
||||
const props = {
|
||||
...toasts.definition.inject(),
|
||||
dismiss: (id) => called.push(['dismiss', id]),
|
||||
open: (sessionId) => called.push(['open', sessionId]),
|
||||
arm: (id) => called.push(['arm', id]),
|
||||
hold: (id) => called.push(['hold', id]),
|
||||
}
|
||||
props.store.set({
|
||||
...props.store.getSnapshot(),
|
||||
toasts: [{ id: 'dsn-7', kind: 'question', title: 'Answer needed', body: 'A · continue?', sessionId: 'session-9' }],
|
||||
})
|
||||
const rendered = walk(toasts.component(props))
|
||||
const card = rendered.elements.find((element) => element.props.className === 'dsn-toast')
|
||||
const [view, close] = rendered.elements.filter((element) => typeof element.props.onClick === 'function')
|
||||
|
||||
card.props.onMouseEnter()
|
||||
card.props.onMouseLeave()
|
||||
view.props.onClick()
|
||||
close.props.onClick()
|
||||
|
||||
assert.deepEqual(called, [
|
||||
['hold', 'dsn-7'],
|
||||
['arm', 'dsn-7'],
|
||||
['dismiss', 'dsn-7'],
|
||||
['open', 'session-9'],
|
||||
['dismiss', 'dsn-7'],
|
||||
])
|
||||
})
|
||||
|
||||
test('the observer reports its own health once the session hooks arrive', () => {
|
||||
const { observer, toasts, effects } = mount({ runEffects: true })
|
||||
const store = toasts.definition.inject().store
|
||||
const observation = observer.definition.inject().observation
|
||||
|
||||
// Health changes that are not `watching` also reach the tagged console, so the
|
||||
// diagnostic is captured and asserted instead of polluting the test output.
|
||||
// Note which way round it fires: the observer logs when it recovers from a
|
||||
// degraded state, and it logs the initial transition, but a healthy observer
|
||||
// turning silent reaches the console only as the settings page's own error line.
|
||||
const logged = []
|
||||
const originalError = console.error
|
||||
console.error = (message) => logged.push(String(message))
|
||||
try {
|
||||
observer.component({
|
||||
observation,
|
||||
useSessions: (select) => select({ byId: {} }),
|
||||
useSessionStatus: (select) => select(new Map()),
|
||||
})
|
||||
assert.equal(store.getSnapshot().health.state, 'watching')
|
||||
|
||||
observer.component({ observation, useSessions: undefined, useSessionStatus: undefined })
|
||||
assert.equal(store.getSnapshot().health.state, 'noHooks')
|
||||
|
||||
observer.component({
|
||||
observation,
|
||||
useSessions: (select) => select({ byId: {} }),
|
||||
useSessionStatus: (select) => select(new Map()),
|
||||
})
|
||||
assert.equal(store.getSnapshot().health.state, 'watching')
|
||||
} finally {
|
||||
console.error = originalError
|
||||
}
|
||||
assert.equal(logged.length, 2, `expected two health diagnostics, got ${JSON.stringify(logged)}`)
|
||||
for (const line of logged) assert.match(line, /session-notify: observer health: watching\b/)
|
||||
|
||||
// The observer owns no visible UI, and it is registered without a seat locale.
|
||||
assert.equal(observer.definition.id, 'session-notify-watch')
|
||||
assert.equal(observer.definition.order, 101)
|
||||
assert.equal(observer.component({ observation }), null)
|
||||
assert.deepEqual(effects, ['session-notify: dictionaries', 'session-notify: window focus', 'session-notify: disposal'])
|
||||
})
|
||||
Reference in New Issue
Block a user