Files
apes-sidebar/tests/subagent-jobs.spec.ts
T
Menghuan1918 6586e3e2d3 feat: adapt to DSH 0.1.7-alpha.1 and yield the read-only previews
DSH 0.1.7-alpha.1 is 1299 commits past 0.1.6-alpha.2 and rewrites four
contracts this plugin was built on. The peer floor moves to
^0.1.7-alpha.1 (semver's prerelease rule means ^0.1.6-alpha.2 can never
match a 0.1.7 prerelease); 0.1.6-alpha.2 and earlier stay on v0.19.1.
Released as 0.21.0-alpha.1 (dist-tag alpha).

Hard breaks:
- dsh-settings was rewritten: register(ns, schema) is gone, a form is
  addressed by the plugin row's Loader entry id and read from the
  module's exported Config. The preferences are merged into Config and
  marked volatile — which requires DSH's own schemastery, because only
  that build wraps a volatile field in the cosmokit reference the
  loader's volatile commit walks. With the public package a settings
  write reports success and revision 1 while the effective value never
  changes.
- ui-primitives renamed its whole icon family (IconXxx14/16 ->
  IconXxxRegular/Medium): 26 imported symbols, 17 files. The failing
  mode is render-time, not load-time. Five call sites that relied on the
  old 14px default now pass size={14} explicitly.
- Session format v4 rejects source.kind === 'plugin'; the side-chat
  boundary injection now writes plugin:dsh-better-sidebar, the same kind
  DSH's own v3->v4 migration produces, so historical threads survive.
  Tool results moved to role 'tool' with top-level content/isError; all
  four parsers accept both shapes.
- The client session snapshot dropped subagentsByParent/jobsBySession
  and the on-demand subagent-catalog observation API. The Tasks page
  reads projectionsBySession and a new jobs.list route.

Yields and convergence:
- The read-only previews go to DSH's documentpreview (Office->PDF, a
  spreadsheet worker, zoom, per-directory refresh); editor.canOpen
  refuses those 26 extensions. Markdown, HTML (with its sandbox
  switches) and the editable code buffer stay.
- The host browser kind is desktop-profile-only now, so the web e2e
  lanes expect no browser entry and assert its absence.
- Link takeover claims only urlTarget links and passes the rest to the
  host, fixing a regression from the 0.1.6 round: the fallback pointed
  at a 'browser' type the plugin no longer registers, so http links in
  its own markdown did nothing.

Also fixed, found along the way:
- The file tree refreshes live (new directory watch over the plugin's
  own socket), since the plugin owns the `files` kind.
- ctx.sessions.list has no `current` field at either tag; the three
  readers were dead. They now use ctx.sidebarRight.mounted.
- package.json.files was missing lib/client-locale.js.
- Two genuinely dead call sites: sessions.openSubagent/open/
  refreshSubagents do not exist on the current baseline, so the
  topology click, the jump-back and the retry were all no-ops.
- The test harness window shim lacked addEventListener, which the 0.1.7
  primitives call at module-evaluation time; and the e2e token exchange
  needs Playwright's request stack, not Node's fetch.
2026-09-22 17:09:08 +08:00

164 lines
6.8 KiB
TypeScript

/**
* Pure-helper tests for the Subagent page's background-job section:
* tree-membership collection, ordering, and status presentation mapping.
*/
import { describe, expect, it } from 'vitest'
import {
collectTreeJobs,
detectNewJob,
formatJobDuration,
isJobLive,
orderJobs,
jobDotState,
jobStatusLabel,
treeSessionIds,
} from '../src/client/subagent-jobs.ts'
import type { SidebarSessionSummary, SidebarJobStatus, SidebarJobView } from '../src/context-types.ts'
/** The translator stub: renders duration templates like the real locale copy. */
const templates: Record<string, string> = {
jobDurationSeconds: '{seconds}秒',
jobDurationMinutes: '{minutes}分{seconds}秒',
jobDurationHours: '{hours}小时{minutes}分',
}
const t = (key: string, params?: Record<string, string | number>): string => {
let text = templates[key] ?? key
if (params !== undefined) {
for (const [name, value] of Object.entries(params)) text = text.replaceAll(`{${name}}`, String(value))
}
return text
}
function summary(id: string, over: Partial<SidebarSessionSummary> = {}): SidebarSessionSummary {
return { id, displayTitle: `title-${id}`, ...over }
}
function job(id: string, over: Partial<SidebarJobView> = {}): SidebarJobView {
return { id, kind: 'bash', label: `cmd ${id}`, status: 'running', startedAt: 1_000, ...over }
}
describe('treeSessionIds', () => {
it('includes the root and every subagent-origin session whose chain reaches it', () => {
const byId = {
root: summary('root'),
child: summary('child', { origin: 'subagent', parentId: 'root' }),
grand: summary('grand', { origin: 'subagent', parentId: 'child' }),
orphan: summary('orphan', { origin: 'subagent', parentId: 'gone' }),
other: summary('other', { origin: 'subagent', parentId: 'other-root' }),
}
const ids = treeSessionIds(byId, 'root')
expect([...ids].sort()).toEqual(['child', 'grand', 'root'])
})
it('fails soft on parent cycles and yields nothing without a root', () => {
const byId = {
root: summary('root'),
a: summary('a', { origin: 'subagent', parentId: 'b' }),
b: summary('b', { origin: 'subagent', parentId: 'a' }),
}
expect(treeSessionIds(byId, 'root').size).toBe(1)
expect(treeSessionIds(byId, undefined).size).toBe(0)
})
})
describe('collectTreeJobs', () => {
it('collects jobs of the whole tree with owner titles, ignoring outside sessions', () => {
const byId = {
root: summary('root'),
child: summary('child', { origin: 'subagent', parentId: 'root' }),
}
// The map is the client's per-owner read of `jobs.list` (the registry's
// fence admits a job to its owner session only), keyed by that owner.
const jobsByOwner = {
root: [job('bash-1')],
child: [job('bash-2', { status: 'completed', finishedAt: 2_000 })],
stranger: [job('bash-9')],
}
const rows = collectTreeJobs(byId, jobsByOwner, 'root')
expect(rows.map(row => [row.ownerSessionId, row.ownerTitle, row.job.id]))
.toEqual([['root', 'title-root', 'bash-1'], ['child', 'title-child', 'bash-2']])
})
it('returns an empty list for an absent read or empty sets', () => {
const byId = { root: summary('root') }
expect(collectTreeJobs(byId, undefined, 'root')).toEqual([])
expect(collectTreeJobs(byId, {}, 'root')).toEqual([])
})
})
describe('orderJobs', () => {
it('puts live rows first in start order, then settled rows newest-first', () => {
const row = (id: string, status: SidebarJobStatus, startedAt: number, finishedAt?: number) => ({
ownerSessionId: 'root',
ownerTitle: 'root',
job: job(id, { status, startedAt, ...(finishedAt !== undefined ? { finishedAt } : {}) }),
})
const rows = [
row('old-settled', 'completed', 1_000, 2_000),
row('live-2', 'running', 4_000),
row('new-settled', 'killed', 1_500, 1_800),
row('live-1', 'stopping', 3_000),
]
expect(orderJobs(rows).map(r => r.job.id)).toEqual(['live-1', 'live-2', 'old-settled', 'new-settled'])
})
})
describe('status presentation helpers', () => {
it('treats running and stopping as live', () => {
expect(isJobLive(job('a', { status: 'running' }))).toBe(true)
expect(isJobLive(job('b', { status: 'stopping' }))).toBe(true)
expect(isJobLive(job('c', { status: 'completed' }))).toBe(false)
expect(isJobLive(job('d', { status: 'killed' }))).toBe(false)
expect(isJobLive(job('e', { status: 'failed' }))).toBe(false)
})
it('maps the five wire statuses to dot states and localized labels', () => {
expect(jobDotState('running')).toBe('ongoing')
expect(jobDotState('stopping')).toBe('warning')
expect(jobDotState('completed')).toBe('done')
expect(jobDotState('killed')).toBe('warning')
expect(jobDotState('failed')).toBe('error')
expect(jobStatusLabel('running', t)).toBe('jobStatusRunning')
expect(jobStatusLabel('stopping', t)).toBe('jobStatusStopping')
expect(jobStatusLabel('completed', t)).toBe('jobStatusCompleted')
expect(jobStatusLabel('killed', t)).toBe('jobStatusKilled')
expect(jobStatusLabel('failed', t)).toBe('jobStatusFailed')
})
it('formats durations in at most two adjacent units', () => {
expect(formatJobDuration(0, t)).toBe('0秒')
expect(formatJobDuration(45_000, t)).toBe('45秒')
expect(formatJobDuration(90_000, t)).toBe('1分30秒')
expect(formatJobDuration(3_661_000, t)).toBe('1小时1分')
// Negative or fractional input clamps to zero seconds.
expect(formatJobDuration(-5, t)).toBe('0秒')
})
})
describe('detectNewJob', () => {
/**
* The baseline rule the auto-open trigger adds on top of this helper: the
* FIRST list a page reads only arms the baseline (the poller keeps
* `prev === undefined` until a read succeeds), so a conversation that is
* already running jobs when the page loads never pops the Tasks page. The
* helper itself is pure over two lists.
*/
it('fires on EVERY job id the previous list lacked, and on nothing else', () => {
// A new id appears (the first job, then another one alongside it).
expect(detectNewJob([], [job('bash-1')])).toBe(true)
expect(detectNewJob([job('bash-1')], [job('bash-1'), job('bash-2')])).toBe(true)
// Settling only mutates status: same ids, no trigger.
expect(detectNewJob(
[job('bash-1')],
[job('bash-1', { status: 'completed', finishedAt: 2_000 })],
)).toBe(false)
// Identical lists, and lists that only LOST a job (settled and dropped),
// are not new work.
expect(detectNewJob([job('bash-1')], [job('bash-1')])).toBe(false)
expect(detectNewJob([job('bash-1'), job('bash-2')], [job('bash-2')])).toBe(false)
// The first list of a fresh page is compared against itself by the caller
// (prev stays undefined until a read succeeds) — the helper's quiet case.
expect(detectNewJob([], [])).toBe(false)
})
})