Files

356 lines
14 KiB
JavaScript

// 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 — 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(' ')
const classesOf = (rendered) => rendered.elements.map((element) => element.props.className ?? '')
/**
* The permission hint the plugin shows on this machine. Node exposes a
* `navigator` too, so the platform branch is exercised here rather than falling
* through to the neutral copy.
*/
const HINT_BY_PLATFORM = {
win32: 'Windows Settings → System → Notifications → DeepSeek Harness',
darwin: 'System Settings → Notifications → DeepSeek Harness',
linux: 'Allow DeepSeek Harness in your desktop notification settings (GNOME / KDE)',
}
const platformHint = HINT_BY_PLATFORM[process.platform] ?? 'Allow DeepSeek Harness in your system notification settings'
test('the plugin page reads as three groups with copy, not raw keys', () => {
const { config } = mount()
const rendered = walk(config.component({ ...config.definition.inject(), view: 'page' }))
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',
'Popup',
'Auto-close',
'6s',
'Never',
'Hovering a card pauses the countdown',
'System notification permission',
'This environment cannot show system notifications',
platformHint,
'Status',
'Window',
'Session watch',
'Last delivery',
'nothing delivered yet',
'Replay',
'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 = classesOf(rendered)
for (const className of [
'dsn-page',
'dsn-lede',
'dsn-group',
'dsn-group-title',
'dsn-group-note',
'dsn-list',
'dsn-row',
'dsn-row-title',
'dsn-state',
'dsn-chips',
'dsn-chip',
'dsn-facts',
'dsn-fact-key',
'dsn-fact-value',
'dsn-button',
]) {
assert.ok(classes.includes(className), `the page is missing .${className}`)
}
// Three switches, five lifetime choices, one status dot, and the readings as
// key/value pairs.
assert.equal(classes.filter((className) => className === 'dsn-switch').length, 3)
assert.equal(classes.filter((className) => className === 'dsn-chip').length, 5)
assert.equal(classes.filter((className) => className === 'dsn-dot is-muted').length, 1)
assert.equal(classes.filter((className) => className === 'dsn-fact-key').length, 4)
assert.equal(classes.filter((className) => className === 'dsn-fact-value').length, 4)
})
test('the popup lifetime is a real choice: the page reads it and writes it back', () => {
const { config } = mount()
const props = config.definition.inject()
const chips = (componentProps) => walk(config.component({ ...componentProps, view: 'page' }))
.elements.filter((element) => element.props.className === 'dsn-chip')
const labelOf = (chip) => walk(chip).text.join('')
const checkedOf = (list) => list.filter((chip) => chip.props['aria-checked'] === true).map(labelOf)
const before = chips(props)
assert.deepEqual(before.map(labelOf), ['3s', '6s', '10s', '30s', 'Never'])
assert.deepEqual(checkedOf(before), ['6s'], 'the shipped default is preselected')
assert.equal(before[0].props.role, 'radio')
assert.equal(walk(config.component({ ...props, view: 'page' })).elements.find((element) => element.props.className === 'dsn-chips').props.role, 'radiogroup')
// "Never" is the zero choice, and choosing it goes through the plugin's own
// action: the store the popup stack reads is updated, not just the button.
before[4].props.onClick()
assert.equal(props.store.getSnapshot().dismissAfterMs, 0)
assert.deepEqual(checkedOf(chips(props)), ['Never'])
before[0].props.onClick()
assert.equal(props.store.getSnapshot().dismissAfterMs, 3000)
assert.deepEqual(checkedOf(chips(props)), ['3s'])
})
test('the page follows the store: switches, permission state and delivery facts', () => {
const { config } = mount()
const props = config.definition.inject()
props.store.set({
...props.store.getSnapshot(),
kinds: { completion: false, approval: true, question: true },
})
const switched = walk(config.component({ ...props, view: 'page' }))
assert.deepEqual(
switched.elements
.filter((element) => element.props.className === 'dsn-switch')
.map((element) => element.props['aria-checked']),
[false, true, true],
)
props.store.set({
...props.store.getSnapshot(),
permission: 'granted',
lastDelivery: { outcome: 'system', shown: true, at: '17:52:30', title: 'Conversation finished' },
})
const granted = walk(config.component({ ...props, view: 'page' }))
const grantedClasses = classesOf(granted)
// A healthy permission has no warning colour and the button becomes the
// channel-specific test.
assert.equal(grantedClasses.includes('dsn-dot is-muted'), false)
assert.equal(grantedClasses.includes('dsn-dot is-error'), false)
assert.ok(grantedClasses.includes('dsn-dot'))
assert.ok(granted.text.join(' ').includes('Test system notification'))
assert.ok(granted.text.join(' ').includes('17:52:30 · system notification (confirmed on screen)'))
assert.ok(grantedClasses.includes('dsn-fact-title'))
props.store.set({
...props.store.getSnapshot(),
permission: 'denied',
lastDelivery: null,
})
const denied = walk(config.component({ ...props, view: 'page' }))
const deniedText = denied.text.join(' ')
assert.ok(classesOf(denied).includes('dsn-dot is-error'))
assert.ok(deniedText.includes('switched off in system settings'))
assert.ok(deniedText.includes(platformHint))
assert.ok(deniedText.includes('nothing delivered yet'))
})
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 = classesOf(rendered)
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'])
})