✅ test: 补上待处理请求与页面结构的回归用例

This commit is contained in:
pyh
2026-10-04 19:24:03 +08:00
parent ec8661b317
commit 302e0a8bf5
4 changed files with 153 additions and 69 deletions
+2 -1
View File
@@ -140,7 +140,8 @@ test('applying the plugin registers its three seats without touching the DOM', (
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')
assert.equal(configProps.copy.deliveryFacts(null, configProps.fallbackT), null)
assert.equal(configProps.copy.replayValue(0, configProps.fallbackT), 'None')
})
test('the committed artifact is exactly what src/client builds', async (t) => {
+82 -22
View File
@@ -1,10 +1,10 @@
// 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.
// 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'
@@ -98,11 +98,23 @@ function walk(node, collected = { text: [], types: [], elements: [] }) {
}
const flatten = (node) => walk(node).text.join(' ')
const classesOf = (rendered) => rendered.elements.map((element) => element.props.className ?? '')
test('the plugin page renders every section with copy, not raw keys', () => {
/**
* 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 props = { ...config.definition.inject(), view: 'page' }
const rendered = walk(config.component(props))
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.
@@ -111,10 +123,14 @@ test('the plugin page renders every section with copy, not raw keys', () => {
'What to notify about',
'The switches are stored in this browser',
'System notification permission',
'The buttons here go straight to the system channel',
'This environment cannot show system notifications',
platformHint,
'Status',
'Window',
'Session watch',
'Last delivery',
'nothing delivered yet',
'nothing waiting to be replayed',
'Replay',
'Test alert',
]) {
assert.ok(text.includes(copy), `the page is missing: ${copy}`)
@@ -122,30 +138,74 @@ test('the plugin page renders every section with copy, not raw keys', () => {
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']) {
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)
// The live window fact is rendered, not remembered.
assert.match(text, /Window: (in front \(popup is used\)|not in front \(system notification is used\))/)
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 reads the store: switches, permission and diagnostics follow it', () => {
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 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/)
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', () => {
@@ -169,7 +229,7 @@ test('the popup stack renders nothing while empty, and one card per alert', () =
assert.ok(text.includes('Bash is waiting for your approval'))
assert.ok(text.includes('View'))
const classes = rendered.elements.map((element) => element.props.className ?? '')
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}`)
}
+30 -35
View File
@@ -8,7 +8,7 @@ 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)
const { copyFor, deliveryFacts, replayValue } = createCopy(t)
/** One trigger candidate with every field a caller may leave empty. */
function candidate(overrides) {
@@ -41,47 +41,42 @@ test('an approval without a tool name still says what is happening', () => {
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: '标题 · 要继续吗?' })
test('a question carries the question text', () => {
assert.deepEqual(
copyFor(candidate({ kind: 'question', pendingKind: 'question', detail: '要继续吗?' })),
{ 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: '' }))
test('a plan review says what it is instead of repeating the plan text', () => {
// A plan review arrives as `kind: 'question'` with its own discriminator, so the
// discriminator has to be read first — otherwise the plan-review copy is unreachable.
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: '标题' }))
assert.equal(review.body, '标题:计划正在等待你确认')
assert.equal(review.title, '需要回答')
})
test('a delivery that never happened is described as such', () => {
assert.equal(describeDelivery(null, t), '还没有投递过')
assert.equal(describeDelivery(undefined, t), '还没有投递过')
test('a delivery that never happened has no facts to show', () => {
assert.equal(deliveryFacts(null, t), null)
assert.equal(deliveryFacts(undefined, t), null)
})
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 newest delivery is split into time, channel and what it was', () => {
assert.deepEqual(
deliveryFacts({ outcome: 'system', shown: true, at: '17:52:30', title: '会话已完成' }, t),
{ at: '17:52:30', outcome: '系统通知(系统已确认弹出)', title: '会话已完成' },
)
assert.equal(deliveryFacts({ outcome: 'system', shown: undefined, at: '1:00:00', title: 'X' }, t).outcome, '系统通知(系统没有回报,回到窗口时补发轻弹窗)')
assert.equal(deliveryFacts({ outcome: 'system', shown: false, at: '1:00:00', title: 'X' }, t).outcome, '系统通知没弹出来,回到窗口时补发轻弹窗')
assert.equal(deliveryFacts({ outcome: 'system-refused-threw', at: '1:00:00', title: 'X' }, t).outcome, '系统通知被拒绝,改用了轻弹窗')
assert.equal(deliveryFacts({ outcome: 'replay', at: '1:00:00', title: 'X' }, t).outcome, '应用内轻弹窗(回到窗口时补发)')
assert.equal(deliveryFacts({ outcome: 'popup', at: '1:00:00', title: 'X' }, t).outcome, '应用内轻弹窗')
assert.equal(deliveryFacts({ outcome: 'popup', at: '1:00:00', title: undefined }, t).title, '')
})
test('the replay line counts what is still waiting', () => {
assert.equal(describeReplay(0, t), '没有待补发的提醒')
assert.equal(describeReplay(3, t), '等你回到窗口时补发:3 条')
test('the replay value is short enough for one status line', () => {
assert.equal(replayValue(0, t), '没有')
assert.equal(replayValue(3, t), '3 条')
})
+39 -11
View File
@@ -9,11 +9,14 @@ import {
preview,
questionText,
servedPendingKind,
stillWorth,
stillOwed,
titleOf,
} from '../../src/client/core/session.js'
test('only the three interaction kinds the Harness renders are served', () => {
test('only the three interaction kinds the domains publish are served', () => {
// The literals the shipped domains publish: dsh-client-ui-approval sets
// `kind = "approval"`, dsh-client-ui-user-questions picks between `"question"`
// and `"plan-review"` for one batch.
assert.equal(servedPendingKind({ kind: 'approval' }), 'approval')
assert.equal(servedPendingKind({ kind: 'question' }), 'question')
assert.equal(servedPendingKind({ kind: 'plan-review' }), 'plan-review')
@@ -58,17 +61,42 @@ test('the conversation on screen is the one the main view retains', () => {
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 }
test('a pending interaction stays owed while it waits, even though the run is still running', () => {
// The session is `running` whenever the agent loop is active, and a pending
// approval or question is exactly that: the loop is waiting for the human. The
// run state must therefore not gate these alerts — it used to drop every one
// of them, which is why only completions ever reached the user.
const candidate = { kind: 'approval', sessionId: 'a', pendingKind: 'approval', test: false }
const status = (pendingInteraction, running) => new Map([['a', { pendingInteraction, running }]])
assert.equal(stillOwed(candidate, { list: { byId: { a: { running: true } } }, status: status({ kind: 'approval' }, true) }), true)
assert.equal(stillOwed(candidate, { list: { byId: { a: { running: false } } }, status: status({ kind: 'approval' }, false) }), true)
// Answered inside the settle window: the request is gone, so nothing is owed.
assert.equal(stillOwed(candidate, { list: { byId: { a: { running: true } } }, status: status(undefined, true) }), false)
// Replaced by a different request of the same kind: a new request is a new alert.
assert.equal(stillOwed({ ...candidate, kind: 'question', pendingKind: 'question' }, { list: { byId: {} }, status: status({ kind: 'approval' }, true) }), false)
// A plan review is owed on its own discriminator.
assert.equal(stillOwed({ ...candidate, kind: 'question', pendingKind: 'plan-review' }, { list: { byId: {} }, status: status({ kind: 'plan-review' }, true) }), true)
// Nothing contradicts the request, so it is still the user's move.
assert.equal(stillOwed(candidate, { list: { byId: {} }, status: new Map() }), true)
assert.equal(stillOwed(candidate, { list: undefined, status: undefined }), true)
})
test('a completion is owed only while the conversation stayed idle', () => {
const candidate = { kind: 'completion', sessionId: 'a', pendingKind: '', 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)
assert.equal(stillOwed(candidate, { list: undefined, status: undefined }), true)
assert.equal(stillOwed(candidate, live({ a: { running: false } })), true)
assert.equal(stillOwed(candidate, live({})), true)
assert.equal(stillOwed(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)
assert.equal(stillOwed(candidate, { list: { byId: { a: { running: true } } }, status: new Map([['a', { running: false }]]) }), true)
})
test('a test alert is always owed', () => {
const live = { list: { byId: { a: { running: true } } }, status: new Map([['a', { running: true }]]) }
assert.equal(stillOwed({ kind: 'completion', sessionId: 'a', pendingKind: '', test: true }, live), true)
assert.equal(stillOwed({ kind: 'approval', sessionId: 'a', pendingKind: 'approval', test: true }, live), true)
})