✅ test: 补上待处理请求与页面结构的回归用例
This commit is contained in:
@@ -140,7 +140,8 @@ test('applying the plugin registers its three seats without touching the DOM', (
|
|||||||
assert.equal(configProps.replayCount(), 0)
|
assert.equal(configProps.replayCount(), 0)
|
||||||
// No locale service in this fake context, so the plugin's own translator falls
|
// No locale service in this fake context, so the plugin's own translator falls
|
||||||
// back to its English dictionary.
|
// 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) => {
|
test('the committed artifact is exactly what src/client builds', async (t) => {
|
||||||
|
|||||||
+82
-22
@@ -1,10 +1,10 @@
|
|||||||
// The components, rendered through a tiny React double.
|
// The components, rendered through a tiny React double.
|
||||||
//
|
//
|
||||||
// Why a double instead of React: this repository ships no runtime dependency at
|
// 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 —
|
// all, and the whole point of these tests is the wiring — which component reads
|
||||||
// which component reads which prop through which seat. The double implements the
|
// which prop through which seat. The double implements the four React entry
|
||||||
// four React entry points this package uses, so a component render is exercised
|
// points this package uses, so a component render is exercised end to end (hooks
|
||||||
// end to end (hooks included) without a DOM or a real React.
|
// included) without a DOM or a real React.
|
||||||
//
|
//
|
||||||
// It runs against the BUILT artifact, because that is what the page loads.
|
// It runs against the BUILT artifact, because that is what the page loads.
|
||||||
import { test } from 'node:test'
|
import { test } from 'node:test'
|
||||||
@@ -98,11 +98,23 @@ function walk(node, collected = { text: [], types: [], elements: [] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const flatten = (node) => walk(node).text.join(' ')
|
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 { config } = mount()
|
||||||
const props = { ...config.definition.inject(), view: 'page' }
|
const rendered = walk(config.component({ ...config.definition.inject(), view: 'page' }))
|
||||||
const rendered = walk(config.component(props))
|
|
||||||
const text = rendered.text.join(' ')
|
const text = rendered.text.join(' ')
|
||||||
|
|
||||||
// No locale service in this context, so the seat falls back to English.
|
// 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',
|
'What to notify about',
|
||||||
'The switches are stored in this browser',
|
'The switches are stored in this browser',
|
||||||
'System notification permission',
|
'System notification permission',
|
||||||
'The buttons here go straight to the system channel',
|
'This environment cannot show system notifications',
|
||||||
|
platformHint,
|
||||||
'Status',
|
'Status',
|
||||||
|
'Window',
|
||||||
|
'Session watch',
|
||||||
|
'Last delivery',
|
||||||
'nothing delivered yet',
|
'nothing delivered yet',
|
||||||
'nothing waiting to be replayed',
|
'Replay',
|
||||||
'Test alert',
|
'Test alert',
|
||||||
]) {
|
]) {
|
||||||
assert.ok(text.includes(copy), `the page is missing: ${copy}`)
|
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('config.'), 'a raw translation key reached the page')
|
||||||
assert.ok(!text.includes('settings.'), '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 ?? '')
|
const classes = classesOf(rendered)
|
||||||
for (const className of ['dsn-sections', 'dsn-section', 'dsn-section-title', 'dsn-row', 'dsn-button', 'dsn-status']) {
|
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}`)
|
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-switch').length, 3)
|
||||||
|
assert.equal(classes.filter((className) => className === 'dsn-dot is-muted').length, 1)
|
||||||
// The live window fact is rendered, not remembered.
|
assert.equal(classes.filter((className) => className === 'dsn-fact-key').length, 4)
|
||||||
assert.match(text, /Window: (in front \(popup is used\)|not in front \(system notification is used\))/)
|
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 { config } = mount()
|
||||||
const props = config.definition.inject()
|
const props = config.definition.inject()
|
||||||
|
|
||||||
props.store.set({
|
props.store.set({
|
||||||
...props.store.getSnapshot(),
|
...props.store.getSnapshot(),
|
||||||
kinds: { completion: false, approval: true, question: true },
|
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' },
|
lastDelivery: { outcome: 'system', shown: true, at: '17:52:30', title: 'Conversation finished' },
|
||||||
})
|
})
|
||||||
const rendered = walk(config.component({ ...props, view: 'page' }))
|
const granted = walk(config.component({ ...props, view: 'page' }))
|
||||||
const switches = rendered.elements
|
const grantedClasses = classesOf(granted)
|
||||||
.filter((element) => element.props.className === 'dsn-switch')
|
// A healthy permission has no warning colour and the button becomes the
|
||||||
.map((element) => element.props['aria-checked'])
|
// channel-specific test.
|
||||||
assert.deepEqual(switches, [false, true, true])
|
assert.equal(grantedClasses.includes('dsn-dot is-muted'), false)
|
||||||
assert.match(rendered.text.join(' '), /Last delivery: 17:52:30 · system notification \(confirmed on screen\) · Conversation finished/)
|
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', () => {
|
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('Bash is waiting for your approval'))
|
||||||
assert.ok(text.includes('View'))
|
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']) {
|
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}`)
|
assert.ok(classes.includes(className), `the popup is missing .${className}`)
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-35
@@ -8,7 +8,7 @@ import { createCopy } from '../../src/client/core/copy.js'
|
|||||||
import { createTranslator } from '../../src/client/i18n/index.js'
|
import { createTranslator } from '../../src/client/i18n/index.js'
|
||||||
|
|
||||||
const t = createTranslator({ getSnapshot: () => ({ active: 'zh' }) })
|
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. */
|
/** One trigger candidate with every field a caller may leave empty. */
|
||||||
function candidate(overrides) {
|
function candidate(overrides) {
|
||||||
@@ -41,47 +41,42 @@ test('an approval without a tool name still says what is happening', () => {
|
|||||||
assert.equal(notice.body, '标题:有工具正在等待你的授权')
|
assert.equal(notice.body, '标题:有工具正在等待你的授权')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('a question carries the question text, and a plan review the plan\'s own text', () => {
|
test('a question carries the question text', () => {
|
||||||
const question = copyFor(candidate({ kind: 'question', pendingKind: 'question', detail: '要继续吗?' }))
|
assert.deepEqual(
|
||||||
assert.deepEqual(question, { kind: 'question', title: '需要回答', body: '标题 · 要继续吗?' })
|
copyFor(candidate({ kind: 'question', pendingKind: 'question', detail: '要继续吗?' })),
|
||||||
|
{ kind: 'question', title: '需要回答', body: '标题 · 要继续吗?' },
|
||||||
|
)
|
||||||
assert.equal(copyFor(candidate({ kind: 'question', pendingKind: 'question', detail: '' })).body, '标题:正在等待你的回答')
|
assert.equal(copyFor(candidate({ kind: 'question', pendingKind: 'question', detail: '' })).body, '标题:正在等待你的回答')
|
||||||
|
})
|
||||||
|
|
||||||
// v1.0.6 behavior, pinned on purpose: a plan review is queued with
|
test('a plan review says what it is instead of repeating the plan text', () => {
|
||||||
// `kind: 'question'`, so the question branch above answers it and the
|
// A plan review arrives as `kind: 'question'` with its own discriminator, so the
|
||||||
// `body.planReview` copy is unreachable. See the note in core/copy.js — fixing
|
// discriminator has to be read first — otherwise the plan-review copy is unreachable.
|
||||||
// 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: '计划正文…' }))
|
||||||
const review = copyFor(candidate({ kind: 'question', pendingKind: 'plan-review', detail: '' }))
|
|
||||||
assert.equal(review.kind, 'question')
|
assert.equal(review.kind, 'question')
|
||||||
assert.equal(review.body, '标题:正在等待你的回答')
|
assert.equal(review.body, '标题:计划正在等待你确认')
|
||||||
assert.notEqual(review.body, t('body.planReview', { title: '标题' }))
|
assert.equal(review.title, '需要回答')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('a delivery that never happened is described as such', () => {
|
test('a delivery that never happened has no facts to show', () => {
|
||||||
assert.equal(describeDelivery(null, t), '还没有投递过')
|
assert.equal(deliveryFacts(null, t), null)
|
||||||
assert.equal(describeDelivery(undefined, t), '还没有投递过')
|
assert.equal(deliveryFacts(undefined, t), null)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('the newest delivery is described by channel, confirmation and local time', () => {
|
test('the newest delivery is split into time, channel and what it was', () => {
|
||||||
const system = describeDelivery({ outcome: 'system', shown: true, at: '17:52:30', title: '会话已完成' }, t)
|
assert.deepEqual(
|
||||||
assert.equal(system, '最近一次投递: 17:52:30 · 系统通知(系统已确认弹出) · 会话已完成')
|
deliveryFacts({ outcome: 'system', shown: true, at: '17:52:30', title: '会话已完成' }, t),
|
||||||
|
{ at: '17:52:30', outcome: '系统通知(系统已确认弹出)', title: '会话已完成' },
|
||||||
const unconfirmed = describeDelivery({ outcome: 'system', shown: undefined, at: '17:52:30', title: 'X' }, t)
|
)
|
||||||
assert.match(unconfirmed, /系统没有回报/)
|
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, '系统通知没弹出来,回到窗口时补发轻弹窗')
|
||||||
const refused = describeDelivery({ outcome: 'system', shown: false, at: '17:52:30', title: 'X' }, t)
|
assert.equal(deliveryFacts({ outcome: 'system-refused-threw', at: '1:00:00', title: 'X' }, t).outcome, '系统通知被拒绝,改用了轻弹窗')
|
||||||
assert.match(refused, /没弹出来/)
|
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, '应用内轻弹窗')
|
||||||
const fellBack = describeDelivery({ outcome: 'system-refused-threw', shown: undefined, at: '17:52:30', title: 'X' }, t)
|
assert.equal(deliveryFacts({ outcome: 'popup', at: '1:00:00', title: undefined }, t).title, '')
|
||||||
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', () => {
|
test('the replay value is short enough for one status line', () => {
|
||||||
assert.equal(describeReplay(0, t), '没有待补发的提醒')
|
assert.equal(replayValue(0, t), '没有')
|
||||||
assert.equal(describeReplay(3, t), '等你回到窗口时补发:3 条')
|
assert.equal(replayValue(3, t), '3 条')
|
||||||
})
|
})
|
||||||
|
|||||||
+39
-11
@@ -9,11 +9,14 @@ import {
|
|||||||
preview,
|
preview,
|
||||||
questionText,
|
questionText,
|
||||||
servedPendingKind,
|
servedPendingKind,
|
||||||
stillWorth,
|
stillOwed,
|
||||||
titleOf,
|
titleOf,
|
||||||
} from '../../src/client/core/session.js'
|
} 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: 'approval' }), 'approval')
|
||||||
assert.equal(servedPendingKind({ kind: 'question' }), 'question')
|
assert.equal(servedPendingKind({ kind: 'question' }), 'question')
|
||||||
assert.equal(servedPendingKind({ kind: 'plan-review' }), 'plan-review')
|
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)
|
assert.equal(isOnScreen('a', undefined), false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('a queued alert is dropped when its conversation resumed inside the settle window', () => {
|
test('a pending interaction stays owed while it waits, even though the run is still running', () => {
|
||||||
const candidate = { kind: 'completion', sessionId: 'a', test: false }
|
// 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 })
|
const live = (byId, status) => ({ list: { byId }, status })
|
||||||
|
|
||||||
assert.equal(stillWorth(candidate, { list: undefined, status: undefined }), true)
|
assert.equal(stillOwed(candidate, { list: undefined, status: undefined }), true)
|
||||||
assert.equal(stillWorth(candidate, live({ a: { running: false } })), true)
|
assert.equal(stillOwed(candidate, live({ a: { running: false } })), true)
|
||||||
assert.equal(stillWorth(candidate, live({})), true)
|
assert.equal(stillOwed(candidate, live({})), true)
|
||||||
assert.equal(stillWorth(candidate, live({ a: { running: true } })), false)
|
assert.equal(stillOwed(candidate, live({ a: { running: true } })), false)
|
||||||
// The per-session selector wins over the list summary, which still carries the
|
// The per-session selector wins over the list summary, which still carries the
|
||||||
// pre-transition flag.
|
// pre-transition flag.
|
||||||
assert.equal(stillWorth(candidate, { list: { byId: { a: { running: true } } }, status: new Map([['a', { running: false }]]) }), true)
|
assert.equal(stillOwed(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)
|
|
||||||
|
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)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user