✅ test: 补上纯函数、产物冒烟与组件渲染测试,守护产物新鲜度

This commit is contained in:
pyh
2026-10-04 19:01:23 +08:00
parent 68ab60273e
commit 99bbf0cc1e
6 changed files with 731 additions and 0 deletions
+156
View File
@@ -0,0 +1,156 @@
// 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')
})
+260
View File
@@ -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'])
})
+87
View File
@@ -0,0 +1,87 @@
// The copy table: what a candidate becomes on screen, and how the settings page
// describes the newest delivery. Assertions use the real Chinese dictionary, so
// a key rename in the dictionaries and a rename here cannot drift apart.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createCopy } from '../../src/client/core/copy.js'
import { createTranslator } from '../../src/client/i18n/index.js'
const t = createTranslator({ getSnapshot: () => ({ active: 'zh' }) })
const { copyFor, describeDelivery, describeReplay } = createCopy(t)
/** One trigger candidate with every field a caller may leave empty. */
function candidate(overrides) {
return { kind: 'completion', sessionId: 'session-1', title: '标题', detail: '', pendingKind: '', ...overrides }
}
test('a finished conversation names the trigger as the title and the conversation as the body', () => {
assert.deepEqual(copyFor(candidate()), {
kind: 'completion',
title: '会话已完成',
body: '标题 已完成这一轮回答',
})
})
test('an empty title falls back to the untitled copy', () => {
assert.equal(copyFor(candidate({ title: '' })).body, '未命名会话 已完成这一轮回答')
})
test('an approval names the waiting tool when the client publishes one', () => {
const notice = copyFor(candidate({ kind: 'approval', pendingKind: 'approval', detail: 'Bash' }))
assert.deepEqual(notice, {
kind: 'approval',
title: '需要授权',
body: '标题:工具 Bash 正在等待你的授权',
})
})
test('an approval without a tool name still says what is happening', () => {
const notice = copyFor(candidate({ kind: 'approval', pendingKind: 'approval', detail: '' }))
assert.equal(notice.body, '标题:有工具正在等待你的授权')
})
test('a question carries the question text, and a plan review the plan\'s own text', () => {
const question = copyFor(candidate({ kind: 'question', pendingKind: 'question', detail: '要继续吗?' }))
assert.deepEqual(question, { kind: 'question', title: '需要回答', body: '标题 · 要继续吗?' })
assert.equal(copyFor(candidate({ kind: 'question', pendingKind: 'question', detail: '' })).body, '标题:正在等待你的回答')
// v1.0.6 behavior, pinned on purpose: a plan review is queued with
// `kind: 'question'`, so the question branch above answers it and the
// `body.planReview` copy is unreachable. See the note in core/copy.js — fixing
// it changes what a plan review says, so it is not part of this refactor.
const review = copyFor(candidate({ kind: 'question', pendingKind: 'plan-review', detail: '' }))
assert.equal(review.kind, 'question')
assert.equal(review.body, '标题:正在等待你的回答')
assert.notEqual(review.body, t('body.planReview', { title: '标题' }))
})
test('a delivery that never happened is described as such', () => {
assert.equal(describeDelivery(null, t), '还没有投递过')
assert.equal(describeDelivery(undefined, t), '还没有投递过')
})
test('the newest delivery is described by channel, confirmation and local time', () => {
const system = describeDelivery({ outcome: 'system', shown: true, at: '17:52:30', title: '会话已完成' }, t)
assert.equal(system, '最近一次投递: 17:52:30 · 系统通知(系统已确认弹出) · 会话已完成')
const unconfirmed = describeDelivery({ outcome: 'system', shown: undefined, at: '17:52:30', title: 'X' }, t)
assert.match(unconfirmed, /系统没有回报/)
const refused = describeDelivery({ outcome: 'system', shown: false, at: '17:52:30', title: 'X' }, t)
assert.match(refused, /没弹出来/)
const fellBack = describeDelivery({ outcome: 'system-refused-threw', shown: undefined, at: '17:52:30', title: 'X' }, t)
assert.match(fellBack, /系统通知被拒绝,改用了轻弹窗/)
const replayed = describeDelivery({ outcome: 'replay', at: '17:52:30', title: 'X' }, t)
assert.match(replayed, /应用内轻弹窗(回到窗口时补发)/)
const popup = describeDelivery({ outcome: 'popup', at: '17:52:30', title: 'X' }, t)
assert.match(popup, /· 应用内轻弹窗 ·/)
})
test('the replay line counts what is still waiting', () => {
assert.equal(describeReplay(0, t), '没有待补发的提醒')
assert.equal(describeReplay(3, t), '等你回到窗口时补发:3 条')
})
+93
View File
@@ -0,0 +1,93 @@
// The dictionaries and the translator. The key sets are asserted equal because
// the translator falls back to whichever dictionary has a key: an asymmetric key
// would silently switch language instead of failing, and a key nobody reads is a
// defect this repository has removed once already.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createTranslator, dictionaries, registerDictionaries } from '../../src/client/i18n/index.js'
import { zh } from '../../src/client/i18n/zh.js'
import { en } from '../../src/client/i18n/en.js'
test('both dictionaries carry exactly the same keys', () => {
assert.deepEqual(Object.keys(zh).sort(), Object.keys(en).sort())
assert.deepEqual(Object.keys(dictionaries).sort(), ['en', 'zh'])
})
test('every key carries copy in both languages', () => {
for (const [key, value] of Object.entries(zh)) {
assert.equal(typeof value, 'string', `${key} is not a string in zh`)
assert.notEqual(value, '', `${key} is empty in zh`)
}
for (const [key, value] of Object.entries(en)) {
assert.equal(typeof value, 'string', `${key} is not a string in en`)
assert.notEqual(value, '', `${key} is empty in en`)
}
})
test('the seat translation wins, and a key it does not know falls back to the dictionary', () => {
const locale = {
bind: (namespace) => {
assert.equal(namespace, 'session-notify')
return (key, params) => (key === 'notification.completion' ? `seat:${params?.title ?? ''}` : key)
},
getSnapshot: () => ({ active: 'zh' }),
}
const t = createTranslator(locale)
assert.equal(t('notification.completion', { title: 'X' }), 'seat:X')
assert.equal(t('body.untitled'), '未命名会话')
})
test('the active language selects the dictionary, and an unknown key renders itself', () => {
const t = createTranslator({ bind: () => undefined, getSnapshot: () => ({ active: 'zh-CN' }) })
assert.equal(t('body.untitled'), '未命名会话')
const english = createTranslator(undefined)
assert.equal(english('body.untitled'), 'Untitled conversation')
assert.equal(english('nope.not.a.key'), 'nope.not.a.key')
})
test('interpolation replaces every known placeholder and leaves unknown ones alone', () => {
const t = createTranslator({ getSnapshot: () => ({ active: 'en' }) })
assert.equal(t('body.approval', { title: 'A', tool: 'Bash' }), 'A: Bash is waiting for your approval')
assert.equal(t('body.approval', { title: 'A' }), 'A: {tool} is waiting for your approval')
assert.equal(t('body.untitled', { unused: 'x' }), 'Untitled conversation')
})
test('a locale service that throws on every call keeps the copy renderable', () => {
const t = createTranslator({
bind: () => {
throw new Error('no such namespace')
},
getSnapshot: () => {
throw new Error('no snapshot')
},
})
assert.equal(t('settings.title'), 'Session notifications')
})
test('registration prefers the typed form and falls back to one call per locale', () => {
const typed = []
const disposeTyped = registerDictionaries({ register: (ns, dict) => { typed.push([ns, dict]); return () => {} } })
assert.deepEqual(typed, [['session-notify', dictionaries]])
assert.equal(typeof disposeTyped, 'function')
const single = []
let disposed = 0
const disposeSingle = registerDictionaries({
register: (ns, id, dict) => {
if (typeof id === 'object') throw new Error('typed form unsupported')
single.push([ns, id, dict])
return () => { disposed += 1 }
},
})
assert.deepEqual(single.map(([ns, id]) => [ns, id]), [['session-notify', 'zh'], ['session-notify', 'en']])
disposeSingle()
assert.equal(disposed, 2)
})
test('a profile without a locale service is not an error', () => {
assert.equal(typeof registerDictionaries(undefined), 'function')
assert.doesNotThrow(() => registerDictionaries(undefined)())
assert.equal(createTranslator(undefined)('body.untitled'), 'Untitled conversation')
})
+74
View File
@@ -0,0 +1,74 @@
// The pure derivations over the client's own session state: what counts as a
// served pending interaction, what a title or a preview becomes, which
// conversation is on screen, and whether a queued alert is still owed.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
isOnScreen,
preview,
questionText,
servedPendingKind,
stillWorth,
titleOf,
} from '../../src/client/core/session.js'
test('only the three interaction kinds the Harness renders are served', () => {
assert.equal(servedPendingKind({ kind: 'approval' }), 'approval')
assert.equal(servedPendingKind({ kind: 'question' }), 'question')
assert.equal(servedPendingKind({ kind: 'plan-review' }), 'plan-review')
assert.equal(servedPendingKind({ kind: 'something-else' }), undefined)
assert.equal(servedPendingKind({ kind: 42 }), undefined)
assert.equal(servedPendingKind(undefined), undefined)
assert.equal(servedPendingKind(null), undefined)
assert.equal(servedPendingKind('approval'), undefined)
})
test('a preview collapses whitespace and never exceeds one popup line', () => {
assert.equal(preview(' a\n\t b '), 'a b')
assert.equal(preview(undefined), '')
assert.equal(preview('x'.repeat(80)), 'x'.repeat(80))
assert.equal(preview('x'.repeat(81)), `${'x'.repeat(79)}…`)
})
test('the first question text is read across every shape the client publishes', () => {
assert.equal(questionText({ questions: [{ question: '继续吗?' }] }), '继续吗?')
assert.equal(questionText({ questions: [{ text: '继续吗?' }] }), '继续吗?')
assert.equal(questionText({ question: '继续吗?' }), '继续吗?')
assert.equal(questionText({ prompt: '继续吗?' }), '继续吗?')
assert.equal(questionText({ displayReason: { text: '继续吗?' } }), '继续吗?')
assert.equal(questionText({ reason: { text: '继续吗?' } }), '继续吗?')
assert.equal(questionText({}), '')
assert.equal(questionText(null), '')
assert.equal(questionText('继续吗?'), '')
})
test('a session title falls back to its identity', () => {
assert.equal(titleOf({ title: ' 会话 A ' }, 'abcdef123456'), '会话 A')
assert.equal(titleOf({ title: '' }, 'abcdef123456'), 'abcdef12')
assert.equal(titleOf(undefined, 'abcdef123456'), 'abcdef12')
assert.equal(titleOf({ title: 42 }, 'abcdef123456'), 'abcdef12')
})
test('the conversation on screen is the one the main view retains', () => {
const list = { byId: { a: { retainedBy: { mainView: 1 } }, b: { retainedBy: { mainView: 0 } } } }
assert.equal(isOnScreen('a', list), true)
assert.equal(isOnScreen('b', list), false)
assert.equal(isOnScreen('c', list), false)
assert.equal(isOnScreen('a', undefined), false)
})
test('a queued alert is dropped when its conversation resumed inside the settle window', () => {
const candidate = { kind: 'completion', sessionId: 'a', test: false }
const live = (byId, status) => ({ list: { byId }, status })
assert.equal(stillWorth(candidate, { list: undefined, status: undefined }), true)
assert.equal(stillWorth(candidate, live({ a: { running: false } })), true)
assert.equal(stillWorth(candidate, live({})), true)
assert.equal(stillWorth(candidate, live({ a: { running: true } })), false)
// The per-session selector wins over the list summary, which still carries the
// pre-transition flag.
assert.equal(stillWorth(candidate, { list: { byId: { a: { running: true } } }, status: new Map([['a', { running: false }]]) }), true)
// A test alert is owed no matter what the live state says.
assert.equal(stillWorth({ ...candidate, test: true }, live({ a: { running: true } })), true)
})
+61
View File
@@ -0,0 +1,61 @@
// The browser-local trigger switches. Storage is foreign input: it can be
// absent, hold another version's shape, or refuse to write, and none of those
// may stop the plugin from working with its defaults.
import { afterEach, test } from 'node:test'
import assert from 'node:assert/strict'
import { STORAGE_KEY, readStoredKinds, writeStoredKinds } from '../../src/client/core/storage.js'
/** Install a storage double, optionally one that throws. */
function useStorage(behaviour) {
const data = new Map()
globalThis.localStorage = {
getItem: (key) => {
if (behaviour === 'read-throws') throw new Error('denied')
return data.has(key) ? data.get(key) : null
},
setItem: (key, value) => {
if (behaviour === 'write-throws') throw new Error('quota')
data.set(key, String(value))
},
}
return data
}
afterEach(() => {
delete globalThis.localStorage
})
test('everything is on when this browser has no stored choice', () => {
useStorage()
assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true })
})
test('without storage at all the defaults still apply', () => {
assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true })
assert.doesNotThrow(() => writeStoredKinds({ completion: false, approval: true, question: true }))
})
test('a stored switch survives a round trip, and unknown or broken values do not', () => {
const data = useStorage()
writeStoredKinds({ completion: false, approval: true, question: false })
assert.equal(data.get(STORAGE_KEY), JSON.stringify({ completion: false, approval: true, question: false }))
assert.deepEqual(readStoredKinds(), { completion: false, approval: true, question: false })
data.set(STORAGE_KEY, '{ not json')
assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true })
data.set(STORAGE_KEY, JSON.stringify({ completion: 'yes', approval: false }))
assert.deepEqual(readStoredKinds(), { completion: true, approval: false, question: true })
data.set(STORAGE_KEY, JSON.stringify({ somethingElse: false }))
assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true })
})
test('storage that refuses to be read or written never throws', () => {
useStorage('read-throws')
assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true })
useStorage('write-throws')
assert.doesNotThrow(() => writeStoredKinds({ completion: false, approval: false, question: false }))
})