Files
deepseek-harness/examples/desktop/test/renderer-compact-shadowed.test.js
T
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

198 lines
8.6 KiB
JavaScript

// End-to-end coverage for the compact-card shadowed-events expander
// (task #103 P0-1).
//
// Intent doc (docs/context-fork-intent.md §2.1) calls this out as the demo
// wow point: DSH keeps shadowed events in the log even after they're
// removed from the surface, and the wire tags each event with
// `surface: current|shadowed|log-only`. This UI opens a compact card and
// fans out `session/events(sessionId, {seq})` for each seq the summary
// references. The tests below drive the whole path — compact/summary
// notification → expander DOM → click open → sessionEvents fanout →
// per-seq rows — through the renderer harness.
//
// Verification standards from the intent doc §2.1 P0:
// 1. Card exposes a `View N shadowed events` toggle.
// 2. Opening it renders one row per seq using the wire's readEvent shape.
// 3. If sessionEvents fails or the daemon has no sessionQuery, the
// body shows an honest fallback line — no white silent void.
// 4. Rows are read-only ghosts — no fork button, no click-to-copy.
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const { loadRenderer } = require('./renderer-harness.js')
function makeShadowedEventFixture(seq) {
// Match the readEvent window response shape (packages/ui/jsonrpc/src/server.ts
// 479-492): `{ sessionId, header, target, events, startSeq, endSeq }`.
// We only render `events` so the rest can be minimal.
return {
sessionId: 'sid',
events: [
{
seq,
type: seq % 2 === 0 ? 'user/message' : 'assistant/message',
time: 100 + seq,
surface: 'shadowed',
data: {
content: [{ type: 'text', text: `shadowed body #${seq}` }],
},
},
],
}
}
async function bootWithFakeSessionEvents(sessionEventsImpl) {
return loadRenderer({
sessionEvents: sessionEventsImpl,
})
}
test('compact/summary with shadowedSeqs renders a "View N shadowed events" toggle', async () => {
const { renderer, document } = await bootWithFakeSessionEvents(async () => ({ events: [] }))
renderer.ensureSession('sid', { title: 't', header: {} })
await renderer.selectSession('sid')
renderer.onSessionEvent('sid', {
type: 'compact/summary', seq: 20, time: 1,
data: {
summary: [{ type: 'text', text: 'sum' }],
shadowedTokenCount: 100,
shadowedSeqs: [10, 11, 12],
},
})
const summary = document.querySelector('.shadowed-expander-summary')
assert.ok(summary, 'expander summary must render')
assert.match(summary.textContent, /View 3 shadowed events/)
})
test('opening the expander fans out sessionEvents({seq}) once per shadowed seq', async () => {
const seen = []
const impl = async (sid, opts) => {
seen.push({ sid, opts })
return makeShadowedEventFixture(opts.seq)
}
const { renderer, document } = await bootWithFakeSessionEvents(impl)
renderer.ensureSession('sid', { title: 't', header: {} })
await renderer.selectSession('sid')
renderer.onSessionEvent('sid', {
type: 'compact/summary', seq: 20, time: 1,
data: { summary: [], shadowedTokenCount: 30, shadowedSeqs: [10, 11] },
})
const wrap = document.querySelector('.shadowed-expander')
wrap.open = true
// Fire the toggle event so the renderer's listener runs. Give it a tick
// to complete its Promise.all + DOM writes.
wrap._fire('toggle')
await new Promise((r) => setTimeout(r, 20))
const seqCalls = seen.filter((c) => c.opts && typeof c.opts.seq === 'number').map((c) => c.opts.seq)
assert.deepEqual(seqCalls.sort(), [10, 11], 'one per-seq read per shadowed seq')
// One row per seq, seq label present.
const rows = document.querySelectorAll('.shadowed-event')
assert.equal(rows.length, 2)
const seqLabels = document.querySelectorAll('.shadowed-event-seq')
const labelTexts = Array.from(seqLabels).map((n) => n.textContent).sort()
assert.deepEqual(labelTexts, ['#10', '#11'])
})
test('a per-seq failure downgrades that row without wiping the whole block', async () => {
const impl = async (_sid, opts) => {
if (opts.seq === 11) throw new Error('boom')
return makeShadowedEventFixture(opts.seq)
}
const { renderer, document } = await bootWithFakeSessionEvents(impl)
renderer.ensureSession('sid', { title: 't', header: {} })
await renderer.selectSession('sid')
renderer.onSessionEvent('sid', {
type: 'compact/summary', seq: 20, time: 1,
data: { summary: [], shadowedTokenCount: 30, shadowedSeqs: [10, 11, 12] },
})
const wrap = document.querySelector('.shadowed-expander')
wrap.open = true
wrap._fire('toggle')
await new Promise((r) => setTimeout(r, 20))
// 3 rows: two real, one error placeholder.
const rows = document.querySelectorAll('.shadowed-event')
assert.equal(rows.length, 3)
const errorRow = Array.from(rows).find((r) => r.textContent.includes('error: boom'))
assert.ok(errorRow, 'the failing seq shows an error placeholder, not silent drop')
})
test('empty per-seq responses (no daemon sessionQuery) show the fallback line', async () => {
// A daemon without sessionQuery mounted still answers session/events but
// returns an empty `events` array — no rows to render. Intent doc §2.1
// P0 verification 2 asks the fallback to name this gap explicitly.
const impl = async () => ({ events: [] })
const { renderer, document } = await bootWithFakeSessionEvents(impl)
renderer.ensureSession('sid', { title: 't', header: {} })
await renderer.selectSession('sid')
renderer.onSessionEvent('sid', {
type: 'compact/summary', seq: 20, time: 1,
data: { summary: [], shadowedTokenCount: 30, shadowedSeqs: [10, 11] },
})
const wrap = document.querySelector('.shadowed-expander')
wrap.open = true
wrap._fire('toggle')
await new Promise((r) => setTimeout(r, 20))
const body = document.querySelector('.shadowed-expander-body')
assert.match(body.textContent, /Original events unavailable|sessionQuery/i,
'when no real events came back, the fallback line explains the gap')
// Two "not found" rows should also be present (partial-fetch strategy),
// but the fallback message is what turns a silent void into signal.
const rows = document.querySelectorAll('.shadowed-event')
assert.equal(rows.length, 0, 'no rows when nothing real; only the fallback text')
})
test('no shadowedSeqs → no expander (nothing to expand)', async () => {
const { renderer, document } = await bootWithFakeSessionEvents(async () => ({ events: [] }))
renderer.ensureSession('sid', { title: 't', header: {} })
await renderer.selectSession('sid')
renderer.onSessionEvent('sid', {
type: 'compact/summary', seq: 20, time: 1,
data: { summary: [{ type: 'text', text: 'sum' }], shadowedTokenCount: 0 },
})
assert.equal(document.querySelector('.shadowed-expander'), null,
'no expander when the summary carries no shadowedSeqs')
})
test('read-only ghost — rows carry no fork button and no click affordance', async () => {
const impl = async (_sid, opts) => makeShadowedEventFixture(opts.seq)
const { renderer, document } = await bootWithFakeSessionEvents(impl)
renderer.ensureSession('sid', { title: 't', header: {} })
await renderer.selectSession('sid')
renderer.onSessionEvent('sid', {
type: 'compact/summary', seq: 20, time: 1,
data: { summary: [], shadowedTokenCount: 10, shadowedSeqs: [10] },
})
const wrap = document.querySelector('.shadowed-expander')
wrap.open = true
wrap._fire('toggle')
await new Promise((r) => setTimeout(r, 20))
const row = document.querySelector('.shadowed-event')
assert.ok(row, 'row rendered')
// Intent doc red-line: no fork button, no copy affordance, no click
// handler that could be mistaken for a live action. The renderer never
// adds a `.fork-here` inside a shadowed row.
assert.equal(row.querySelector('.fork-here'), null)
// The row's registered listeners must be empty (nothing bound).
assert.equal((row._listeners.click || []).length, 0, 'no click listener bound')
})
test('bridge missing (window.dsh.sessionEvents undefined) shows an error line', async () => {
// Override the harness stub to delete the bridge before rendering.
const { renderer, dsh, document } = await loadRenderer()
dsh.sessionEvents = undefined
renderer.ensureSession('sid', { title: 't', header: {} })
await renderer.selectSession('sid')
renderer.onSessionEvent('sid', {
type: 'compact/summary', seq: 20, time: 1,
data: { summary: [], shadowedTokenCount: 10, shadowedSeqs: [10] },
})
const wrap = document.querySelector('.shadowed-expander')
wrap.open = true
wrap._fire('toggle')
await new Promise((r) => setTimeout(r, 20))
const body = document.querySelector('.shadowed-expander-body')
assert.match(body.textContent, /runtime bridge missing/)
})