Files
deepseek-harness/examples/desktop/test/expand-affordance.test.js
ZiyaZhang e8f5c0b51b feat(desktop): DSH Electron desktop shell — harness internals visualized
Minimal Electron shell over the DSH JSON-RPC runtime — a first-look at
what a ChatGPT.app-style host on top of the DeepSeek Harness looks
like, with the harness's normally-invisible internals (trace timeline,
context surface, subagent tree, compaction, plugin registry, rubrics)
brought forward as first-class UI surfaces so plugin authors and
researchers can see what the agent is actually doing.

Runs against three keyless-to-live profiles (stdio-echo works on
master out of the box; daemon-echo / daemon-vibe-echo activate once
the daemon-demo lands; stdio-deepseek and daemon-vibe hit the real
DeepSeek API when you supply a key). HARNESS_DEV auto-resolves to the
in-repo runtime when this shell ships under examples/desktop/, so a
fresh clone launches without config; env DSH_DEV_ROOT overrides for
custom layouts, and a sibling deepseek-harness-dev/ checkout is the
original dev workflow.

Cold-clone gate (P0 fixes for first-time-clone usability):
- HARNESS_DEV: 3-candidate resolver (env → walk-up in-repo marker →
  sibling), unit-tested via mock fs so ordering is locked without
  needing either real layout on disk.
- config yml leaves rewritten at assemble time so the sibling-clone
  paths (../../deepseek-harness-dev/examples/echo-agent/…) become
  the in-repo paths (../../echo-agent/…) in the released tree —
  source yml stays usable for local dev, released tree ships a
  working shape.
- pnpm-workspace.yaml allowBuilds.electron = true (was placeholder).
- missing-key card in stdio-deepseek offers a one-click switch to
  stdio-echo (the keyless profile that works on master) rather than
  daemon-echo (blocked on the not-yet-shipped daemon-demo).
- assemble-oss-release.sh rewrites the source-side breadcrumb name
  'dsh-desktop-demo' → 'dsh-desktop' for the released package.json.

FOUC guard on the onboarding gate (41fc5df carried) keeps the
first-launch splash from flashing before the runtime probe finishes.

Test suite (1634 tests in source, 3990 in the runtime repo) covers
resolver ordering, renderer classifiers, trace timeline shape,
compaction diff rendering, rubric parity, and the missing-key
onboarding paths.
2026-07-18 12:59:34 -07:00

216 lines
9.1 KiB
JavaScript

// expand-affordance.test.js — lock the disclosure-marker + a11y
// contract for fix/expand-affordance (2026-07-18).
//
// User report: "没有展开时候,看上去让人不是很知道它点击是可以展开的
// ……哪怕加一个那种折叠小箭头". This test locks:
//
// 1. details-aria.js's wireDetailsAria helper reflects [open] state
// onto the summary's aria-expanded attribute (initial + after
// toggle). Plugin authors copy this pattern.
// 2. CSS tail block in style.css adds a ▸ marker on every 缺失
// surface enumerated in docs/expand-affordance-audit.md. We
// static-grep the stylesheet for the required selectors + the
// ▸ (\25B8) glyph — jsdom-less environment can't render
// pseudo-elements, so the grep is the enforceable lock.
// 3. assistant-turn.js's trace drawer builder sets a user-facing
// tooltip + wires aria-expanded through the toggle event.
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const { wireDetailsAria } = require(path.join(__dirname, '..', 'src', 'renderer', 'details-aria.js'))
function makeStubDetails (initialOpen = false) {
const listeners = new Map()
const attrs = new Map()
const summary = {
tagName: 'SUMMARY',
setAttribute (name, value) { attrs.set(name, String(value)) },
getAttribute (name) { return attrs.has(name) ? attrs.get(name) : null },
}
const details = {
tagName: 'DETAILS',
open: initialOpen,
addEventListener (event, fn) {
if (!listeners.has(event)) listeners.set(event, [])
listeners.get(event).push(fn)
},
dispatch (event) {
const fns = listeners.get(event) || []
for (const fn of fns) fn.call(this)
},
}
return { details, summary, attrs }
}
test('wireDetailsAria: reflects initial open state', () => {
const { details, summary, attrs } = makeStubDetails(false)
wireDetailsAria(details, summary)
assert.equal(attrs.get('aria-expanded'), 'false', 'closed details → aria-expanded=false')
const opened = makeStubDetails(true)
wireDetailsAria(opened.details, opened.summary)
assert.equal(opened.attrs.get('aria-expanded'), 'true', 'open details → aria-expanded=true')
})
test('wireDetailsAria: reflects toggle event', () => {
const { details, summary, attrs } = makeStubDetails(false)
wireDetailsAria(details, summary)
assert.equal(attrs.get('aria-expanded'), 'false')
// Simulate user click opening the drawer — the native <details>
// element flips `.open` then dispatches `toggle`. We mimic both.
details.open = true
details.dispatch('toggle')
assert.equal(attrs.get('aria-expanded'), 'true', 'after toggle open → true')
details.open = false
details.dispatch('toggle')
assert.equal(attrs.get('aria-expanded'), 'false', 'after toggle close → false')
})
test('wireDetailsAria: no-ops on missing args', () => {
// Guard against half-built rows (a builder that forgot to attach
// the summary). Should not throw, and neither the missing summary
// nor the missing details should raise.
assert.doesNotThrow(() => wireDetailsAria(null, {}))
assert.doesNotThrow(() => wireDetailsAria({}, null))
assert.doesNotThrow(() => wireDetailsAria(null, null))
})
// -- CSS marker lock --------------------------------------------------------
const CSS_PATH = path.join(__dirname, '..', 'src', 'renderer', 'style.css')
const cssText = fs.readFileSync(CSS_PATH, 'utf8')
// The 14 缺失 selectors that gained a ▸ marker in the fix/expand-affordance
// tail block. If a surface later moves to a different fold pattern, the
// entry must be moved out of this list into the audit doc's 达标 column.
const AFFORDANCE_SELECTORS = [
'.turn-trace-drawer > .turn-trace-drawer-summary::before',
'details.prompt-blocked-row > summary.pb-row-head::before',
'.devtools-row-summary::before',
'.recall-card summary::after', // right-tail placement (semantic ⌕ occupies row head)
'.inject-card summary::before',
'.subagent-trace > .subagent-trace-summary::after', // right-tail placement (status glyph occupies row head)
'.raw-inject-card > .raw-inject-summary::before',
'.raw-inject-l2 > summary::before',
'.runtime-row-head::before',
'.context-page-row-summary::before',
'.trace-detail-row-fields-summary::before',
'.trace-detail-section > summary::before',
'.trace-detail-attr-group > .trace-detail-attr-group-head::before',
'.trace-detail-field-block > .trace-detail-field-block-head::before',
]
test('style.css: every 缺失 disclosure surface declares a fold marker', () => {
for (const sel of AFFORDANCE_SELECTORS) {
const found = cssText.indexOf(sel) !== -1
assert.ok(found, `expected style.css to declare ${sel} for fold-affordance`)
}
})
test('style.css: fold-affordance block uses the ▸ (\\25B8) glyph', () => {
// Locate the batch block by its header comment (added in the
// fix/expand-affordance CSS tail — one authoritative site).
const header = 'Expand-affordance batch (fix/expand-affordance, 2026-07-18)'
const idx = cssText.indexOf(header)
assert.notEqual(idx, -1, 'fix/expand-affordance CSS block must exist')
const block = cssText.slice(idx)
// Every marker rule in the block uses `content: '\25B8'` (▸). Count
// must equal the number of ::before/::after selectors above — same
// one triangle glyph, uniform rotation on [open].
const markerCount = (block.match(/content:\s*'\\25B8'/g) || []).length
assert.ok(
markerCount >= AFFORDANCE_SELECTORS.length,
`expected ≥${AFFORDANCE_SELECTORS.length} ▸ markers in the batch; got ${markerCount}`
)
// Every marker rotates 90deg when the enclosing details is [open].
const rotateCount = (block.match(/transform:\s*rotate\(90deg\)/g) || []).length
assert.ok(
rotateCount >= AFFORDANCE_SELECTORS.length,
`expected ≥${AFFORDANCE_SELECTORS.length} rotate(90deg) rules; got ${rotateCount}`
)
})
test('style.css: no emoji sneaked in via the fold-affordance batch', () => {
const header = 'Expand-affordance batch (fix/expand-affordance, 2026-07-18)'
const idx = cssText.indexOf(header)
const block = cssText.slice(idx)
// Range check: no code-point in the emoji planes.
// U+1F300..U+1FAFF pictographs + U+2600..U+27BF misc symbols/dingbats.
// (We deliberately allow U+25B8 ▸ which is in the Geometric Shapes block,
// U+2500-U+257F — outside these ranges.)
const emojiRe = /[\u{1F300}-\u{1FAFF}\u{1F000}-\u{1F2FF}]/u
assert.equal(emojiRe.test(block), false, 'fold-affordance batch must contain no emoji')
})
// -- assistant-turn.js trace-drawer wiring lock -----------------------------
const AT_PATH = path.join(__dirname, '..', 'src', 'renderer', 'assistant-turn.js')
const atText = fs.readFileSync(AT_PATH, 'utf8')
test('assistant-turn: trace drawer summary carries user-facing tooltip + aria-expanded wiring', () => {
// Locate the traceDrawerEl builder block (skip the guard-clause
// occurrence at the top of the function).
const anchor = atText.indexOf("drawer.className = 'turn-trace-drawer'")
assert.notEqual(anchor, -1)
const block = atText.slice(anchor, anchor + 2000)
assert.ok(
/summary\.title\s*=\s*'Click to expand Tree \/ Timeline \/ Graph views'/.test(block),
'trace drawer summary must have the discoverability tooltip'
)
assert.ok(
/summary\.setAttribute\('aria-expanded',\s*'false'\)/.test(block),
'trace drawer summary must set aria-expanded=false initially'
)
assert.ok(
/drawer\.addEventListener\('toggle'/.test(block),
'trace drawer must reflect open state via toggle event'
)
})
// -- renderer.js parallel drawer builder lock -------------------------------
const RJ_PATH = path.join(__dirname, '..', 'src', 'renderer', 'renderer.js')
const rjText = fs.readFileSync(RJ_PATH, 'utf8')
test('renderer.js: finishTurnContainer trace drawer wires aria-expanded + tooltip', () => {
// There are two drawer builders — assistant-turn.js and this
// renderer.js path used before the TurnBuilder migration completes.
// Both must set the tooltip so a fresh session's first turn is
// still discoverable.
const anchor = rjText.indexOf("drawer.className = 'turn-trace-drawer'")
assert.notEqual(anchor, -1)
const block = rjText.slice(anchor, anchor + 2000)
assert.ok(
/summary\.title\s*=\s*'Click to expand Tree \/ Timeline \/ Graph views'/.test(block),
'renderer trace drawer summary must have the discoverability tooltip'
)
assert.ok(
/summary\.setAttribute\('aria-expanded',\s*'false'\)/.test(block),
'renderer trace drawer must set aria-expanded=false initially'
)
assert.ok(
/drawer\.addEventListener\('toggle'/.test(block),
'renderer trace drawer must reflect open state via toggle event'
)
})
test('renderer.js: init hook installs a document-wide details aria observer', () => {
assert.ok(
/initDetailsAriaObserver/.test(rjText),
'renderer.js must install initDetailsAriaObserver'
)
assert.ok(
/new MutationObserver/.test(rjText),
'observer must use MutationObserver'
)
assert.ok(
/dataset\.ariaWired/.test(rjText),
'observer must be idempotent via dataset.ariaWired'
)
})