356 lines
14 KiB
JavaScript
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'])
|
|
})
|