Files
deepseek-harness/examples/desktop/test/context-side-drawer.test.js
ZiyaZhang 95b8b2a1af feat(desktop): context page right-top details drawer (peek summary)
Context page top-right Details toggle + 320px peek drawer with mini
occupancy bars, intervention counts, and Jump-to-ledger buttons.
Full ledger below remains unchanged (方案 B conservative).
Escape/× to close.

Files:
- src/renderer/index.html: header toggle + aside scaffold + script tag
- src/renderer/style.css: drawer styles (append-only)
- src/renderer/context-side-drawer.js: mount/render/toggle logic (new)
- test/context-side-drawer.test.js: 7 unit tests (new)
- scripts/qa-cdp-shoot-context-topright.mjs: CDP visual gate (new)
- docs/qa-context-topright/*.png: 3 QA screenshots

Test counts: 1813 -> 1820 (+7).
Mirrors merge f7324b0 on internal test-real branch.
2026-07-19 08:19:48 -07:00

172 lines
6.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Tests for fix/context-topright-panel — src/renderer/context-side-drawer.js.
//
// Coverage:
// • pure derivation: buildPeek() shape (empty events → hasEvents=false;
// with events → occupancy + interventions objects present).
// • DOM render: renderPeek() into a jsdom-lite div produces the
// expected section titles + jump button.
// • wiring: after install(), clicking the toggle flips `.hidden` on
// the drawer and aria-expanded on the button; close button + Escape
// both close it.
//
// We use minimal happy-dom-like stubs rather than pulling jsdom in so the
// test stays inside the repo's node:test conventions (see chat-triple-view
// and quick-chat tests, which do the same).
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const drawer = require('../src/renderer/context-side-drawer.js')
// --- Static gates (index.html + style.css) --------------------------------
test('index.html: Context page header carries the top-right Details toggle', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'src', 'renderer', 'index.html'), 'utf8')
assert.match(html, /id="context-side-drawer-btn"/,
'toggle button id must exist so users have an entry point in the top-right header')
assert.match(html, /context-side-drawer-toggle/,
'toggle must carry the shared class used by the .toggle[aria-expanded="true"] rule')
assert.match(html, /aria-label="Toggle context detail drawer"/,
'toggle button must have an aria-label for AT users')
})
test('index.html: right-side #context-side-drawer aside exists, hidden by default', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'src', 'renderer', 'index.html'), 'utf8')
assert.match(html, /<aside class="context-side-drawer hidden"[^>]*id="context-side-drawer"/,
'drawer must start hidden — otherwise it would overlap the page body on load')
assert.match(html, /id="context-side-drawer-close"/,
'close button id must exist so Escape/× both have a bound handler target')
})
test('style.css: .context-side-drawer geometry mirrors the chat-side-drawer family', () => {
const css = fs.readFileSync(path.join(__dirname, '..', 'src', 'renderer', 'style.css'), 'utf8')
const m = css.match(/\.context-side-drawer\s*\{[\s\S]+?\}/)
assert.ok(m, '.context-side-drawer rule missing')
assert.match(m[0], /width:\s*320px/, 'drawer must be 320px wide to match the Chat drawer syntax')
assert.match(m[0], /right:\s*0/, 'drawer must anchor to the right edge')
assert.match(css, /\.context-side-drawer\.hidden\s*\{\s*display:\s*none/,
'.hidden must collapse the drawer — used by the toggle')
})
// --- Pure derivation: buildPeek ------------------------------------------
test('buildPeek: empty events → hasEvents=false, no occupancy/interventions', () => {
const peek = drawer.buildPeek([])
assert.equal(peek.hasEvents, false)
assert.equal(peek.occupancy, null)
assert.equal(peek.interventions, null)
})
test('buildPeek: with fake windowApi/interventionApi returns projected sections', () => {
const windowApi = {
computeWindowBreakdown: () => ({
totalTokens: 1234, budget: 128000, budgetPct: 1, mode: 'approx',
slices: [
{ family: 'system_prompt', label: 'System prompt', tokens: 100, pct: 8 },
{ family: 'history', label: 'History', tokens: 1000, pct: 78 },
],
}),
}
const interventionApi = {
collectInterventions: () => [
{ kind: 'inject', label: 'plugin: skill loaded' },
{ kind: 'compact', label: 'compact @ turn 3' },
{ kind: 'recall', label: 'recall from memory' },
{ kind: 'steer', label: 'user steered' },
],
}
const peek = drawer.buildPeek([{ type: 'user/message', data: {} }], {
windowApi, interventionApi,
})
assert.equal(peek.hasEvents, true)
assert.equal(peek.occupancy.totalTokens, 1234)
assert.equal(peek.occupancy.slices.length, 2)
assert.equal(peek.interventions.count, 4)
assert.equal(peek.interventions.tail.length, 3, 'peek only shows last 3 marker labels')
assert.equal(peek.interventions.tail[2].kind, 'steer')
})
// --- DOM render: renderPeek ----------------------------------------------
function makeElement(tag) {
const el = {
tagName: tag.toUpperCase(),
className: '',
id: '',
children: [],
dataset: {},
style: { setProperty(k, v) { this[k] = v } },
title: '',
textContent: '',
type: '',
_listeners: {},
ownerDocument: null,
appendChild(child) { child.parentNode = this; this.children.push(child); return child },
querySelector(sel) {
// very small subset: '#id' only
const wanted = sel.startsWith('#') ? sel.slice(1) : null
if (!wanted) return null
const stack = [...this.children]
while (stack.length) {
const n = stack.shift()
if (n && n.id === wanted) return n
if (n && n.children) stack.push(...n.children)
}
return null
},
setAttribute(k, v) { this[k] = v },
addEventListener(k, fn) { (this._listeners[k] = this._listeners[k] || []).push(fn) },
}
return el
}
function makeDoc() {
const doc = {
createElement(tag) { const e = makeElement(tag); e.ownerDocument = doc; return e },
}
return doc
}
test('renderPeek: empty peek writes the "no active session" placeholder', () => {
const doc = makeDoc()
const container = doc.createElement('div')
container.ownerDocument = doc
drawer.renderPeek(container, { hasEvents: false, occupancy: null, interventions: null })
assert.equal(container.className, 'context-side-drawer-body')
assert.equal(container.children.length, 1)
assert.match(container.children[0].textContent, /No active session/)
})
test('renderPeek: full peek writes occupancy + interventions + jump button', () => {
const doc = makeDoc()
const container = doc.createElement('div')
container.ownerDocument = doc
drawer.renderPeek(container, {
hasEvents: true,
occupancy: {
totalTokens: 1234, budget: 128000, budgetPct: 1, mode: 'approx',
slices: [{ family: 'history', label: 'History', tokens: 1000, pct: 78 }],
},
interventions: {
count: 2,
tail: [
{ kind: 'inject', label: 'plugin note' },
{ kind: 'compact', label: 'compact @ 3' },
],
},
})
const titles = []
for (const c of container.children) {
// section > title is the first child
const t = c.children && c.children[0]
if (t && t.className === 'context-side-drawer-section-title') titles.push(t.textContent)
}
assert.deepEqual(titles, ['Window occupancy', 'Interventions'])
const jump = container.querySelector('#context-side-drawer-jump')
assert.ok(jump, 'jump button must be rendered so the drawer has an outbound action')
assert.equal(jump.textContent, 'Jump to full context page')
})