diff --git a/tests/bundle.test.js b/tests/bundle.test.js index b2f58f5..e3fb7fb 100644 --- a/tests/bundle.test.js +++ b/tests/bundle.test.js @@ -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) => { diff --git a/tests/render.test.js b/tests/render.test.js index e07c9ec..4fa288b 100644 --- a/tests/render.test.js +++ b/tests/render.test.js @@ -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}`) } diff --git a/tests/unit/copy.test.js b/tests/unit/copy.test.js index dfc74f8..b196da7 100644 --- a/tests/unit/copy.test.js +++ b/tests/unit/copy.test.js @@ -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 条') }) diff --git a/tests/unit/session.test.js b/tests/unit/session.test.js index 8829818..96b7e5a 100644 --- a/tests/unit/session.test.js +++ b/tests/unit/session.test.js @@ -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) })