Files
session-notify/tests/render.test.js
T

321 lines
13 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',
'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-facts',
'dsn-fact-key',
'dsn-fact-value',
'dsn-button',
]) {
assert.ok(classes.includes(className), `the page is missing .${className}`)
}
// Three switches, 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-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 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'])
})