chore: adapt the tasks/workflow/teams work to DSH 0.1.7-rc.1 (rebase onto v0.21.1)
Rebases the whole branch onto main's new 0.1.7-rc.1 baseline and lands the adaptation the new host contract requires: the ui-primitives icon family rename (Icon<Name><14|16> -> Icon<Name>Regular/Medium, with the 14px defaults pinned back explicitly) and the StateDot redraw, the rewritten dsh-settings surface (entry-id namespace, DSH schemastery with collected volatile returns, configure auto:false), session format v4 (producer-owned source kind plus the rewritten tool-result shape, parsers accepting both), the client snapshot move to projectionsBySession.subagentCatalog with the jobs.list route behind it, the ^0.1.7-rc.1 peer floor with vendor and bare-import devDeps, the reduced chunk set mirrored in all eight places, and the capability hand-backs (read-only previews, terminal, browser). Verified: typecheck and lint clean; 119 files / 1200 tests; build with no old contract residue in the bundles; mount e2e 7/7; the tasks page still passes its 70-assertion real-browser contract.
This commit is contained in:
@@ -14,10 +14,17 @@ import { describe, expect, it } from 'vitest'
|
||||
// (CodeMirror's UA probe).
|
||||
import './browser-globals.ts'
|
||||
import { CHUNK_EXTERNALS } from '../src/client/chunk-loader.ts'
|
||||
import { CHUNK_NAMES } from '../src/bundle-route.ts'
|
||||
|
||||
const g = globalThis as Record<string, unknown>
|
||||
|
||||
const CHUNKS = ['editor', 'mermaid']
|
||||
/**
|
||||
* The chunk set, DERIVED from the host route's allowlist — the same registry
|
||||
* `/sidebar/bundle` serves from (src/bundle-route.ts). Never hand-mirrored:
|
||||
* the hand-written list that used to live here drifted (`locale` was
|
||||
* missing), so the locale artifact went unguarded.
|
||||
*/
|
||||
const CHUNKS = CHUNK_NAMES
|
||||
|
||||
/** All chunk artifacts present (tsdown emits the whole lib/ in one run). */
|
||||
const chunksBuilt = CHUNKS.every(name => existsSync(`lib/client-${name}.js`))
|
||||
|
||||
@@ -146,6 +146,21 @@ describe.skipIf(!libBuilt)('registry manifest consistency (dsh.plugin.json)', ()
|
||||
}
|
||||
})
|
||||
|
||||
it('every chunk is staged by scripts/package-registry.mjs (the registry channel ships its own file list)', () => {
|
||||
// The community-registry channel installs the staged `registry/` tree
|
||||
// wholesale, and /sidebar/bundle reads the chunk from THAT copy — a chunk
|
||||
// missing from the staging list 404s there exactly like one missing from
|
||||
// package.json#files does on npm. The list is read as source text because
|
||||
// the script performs its copies at module scope (it cannot be imported).
|
||||
const script = readFileSync(resolve(ROOT, 'scripts/package-registry.mjs'), 'utf8')
|
||||
const start = script.indexOf('const files = [')
|
||||
expect(start, 'scripts/package-registry.mjs declares no `const files = [` list').toBeGreaterThan(-1)
|
||||
const staged = script.slice(start, script.indexOf(']', start))
|
||||
for (const file of CHUNK_FILES) {
|
||||
expect(staged.includes(`'${file}'`), `${file} is not staged by scripts/package-registry.mjs`).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('client bundles require only frozen module-table entries', () => {
|
||||
for (const file of ['lib/client.js', manifest.client!.main!, ...CHUNK_FILES]) {
|
||||
const source = readFileSync(resolve(ROOT, file), 'utf8')
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
/**
|
||||
* Tasks page tests for the background-jobs DRAWER: rows render from the
|
||||
* `jobsBySession` mirror, clicking a row peeks its output in an anchored
|
||||
* popover (portaled to document.body) through `jobs.output` with the OWNER
|
||||
* session scope, the kill button needs a two-click confirm, settled rows
|
||||
* offer no kill, and the output popover never polls while the page is hidden.
|
||||
* Tasks page tests: the projection-backed topology (branch expansion inferred
|
||||
* from the child's OWN catalog, the `mode: 'unknown'` row, navigation through
|
||||
* the workspace face, catalog retry through the 0.1.7 refresh name) and the
|
||||
* background-jobs DRAWER — rows read through the plugin's `jobs.list` route
|
||||
* one session at a time (the registry's access fence admits a job to its OWNER
|
||||
* only), clicking a row peeks its output in an anchored popover (portaled to
|
||||
* document.body) through `jobs.output` with the owner scope, the kill button
|
||||
* needs a two-click confirm, settled rows offer no kill, and nothing polls
|
||||
* while the page is hidden.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -11,7 +15,13 @@ import { createElement } from 'react'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { renderRoot } from './test-utils.ts'
|
||||
import { SubagentView } from '../src/client/SubagentView.tsx'
|
||||
import type { Context, SidebarSessionList } from '../src/context-types.ts'
|
||||
import type {
|
||||
Context,
|
||||
SidebarJobView,
|
||||
SidebarSessionList,
|
||||
SidebarSubagentAddress,
|
||||
SidebarSubagentCatalogEntry,
|
||||
} from '../src/context-types.ts'
|
||||
|
||||
/** A subscribable sessions-list snapshot (mirror of the runtime list feed). */
|
||||
function makeStore(initial: SidebarSessionList) {
|
||||
@@ -32,48 +42,59 @@ function makeStore(initial: SidebarSessionList) {
|
||||
|
||||
type Store = ReturnType<typeof makeStore>
|
||||
|
||||
/** The client context face SubagentView touches (history stub; everything else inert). */
|
||||
function makeCtx(store: Store): Context {
|
||||
/** The navigation spy the page must reach (DSH 0.1.7 `ctx.uiWorkspace`). */
|
||||
interface NavigationSpy {
|
||||
opened: Array<SidebarSubagentAddress | string>
|
||||
}
|
||||
|
||||
/**
|
||||
* The client context face SubagentView touches. Navigation rides the
|
||||
* workspace face: 0.1.6 moved main-view selection off `ISessions`, so a spy
|
||||
* left on `sessions.openSubagent` would never fire.
|
||||
*/
|
||||
function makeCtx(store: Store, navigation?: NavigationSpy): Context {
|
||||
return {
|
||||
sessions: {
|
||||
list: store,
|
||||
setSubagentCatalogOpen: () => {},
|
||||
openSubagent: () => {},
|
||||
open: () => {},
|
||||
refreshSubagents: async () => {},
|
||||
},
|
||||
connection: {
|
||||
api: {
|
||||
subagents: {
|
||||
history: async () => ({ result: { ok: true, value: { events: [], hasMore: false } } }),
|
||||
},
|
||||
},
|
||||
},
|
||||
sessions: { list: store },
|
||||
get: (name: string) => (name === 'uiWorkspace' && navigation !== undefined
|
||||
? { openSession: (target: SidebarSubagentAddress | string) => { navigation.opened.push(target) } }
|
||||
: undefined),
|
||||
} as unknown as Context
|
||||
}
|
||||
|
||||
const outputCalls: Array<{ sessionId: string; id: string }> = []
|
||||
const killCalls: Array<{ sessionId: string; id: string }> = []
|
||||
/** Job lists the stubbed `jobs.list` route answers with, keyed by OWNER session. */
|
||||
let jobsByOwner: Record<string, SidebarJobView[]> = {}
|
||||
/** The owner sessions the page actually read (the fence's unit of access). */
|
||||
let jobListCalls: string[] = []
|
||||
|
||||
function jsonResponse(value: unknown): Response {
|
||||
return { ok: true, status: 200, json: async () => value } as unknown as Response
|
||||
}
|
||||
|
||||
/** One `subagentCatalog` projection row (DSH 0.1.7 shape). */
|
||||
function entry(
|
||||
id: string,
|
||||
mode: SidebarSubagentCatalogEntry['mode'],
|
||||
label?: string,
|
||||
): SidebarSubagentCatalogEntry {
|
||||
return { id, createdAt: 1_000, mode, ...(label === undefined ? {} : { label }) }
|
||||
}
|
||||
|
||||
/** One session's loaded projection snapshot. */
|
||||
function ready(entries: SidebarSubagentCatalogEntry[]) {
|
||||
return { values: { subagentCatalog: entries }, state: 'ready' as const, error: null }
|
||||
}
|
||||
|
||||
function baseSnapshot(): SidebarSessionList {
|
||||
return {
|
||||
current: 'root',
|
||||
byId: {
|
||||
root: { id: 'root', displayTitle: '主会话', running: true },
|
||||
child: { id: 'child', displayTitle: '子代理', origin: 'subagent', parentId: 'root', running: true },
|
||||
},
|
||||
subagentsByParent: {},
|
||||
jobsBySession: {
|
||||
root: [
|
||||
{ id: 'bash-1', kind: 'bash', label: 'sleep 300', status: 'running', startedAt: 1_000 },
|
||||
],
|
||||
child: [
|
||||
{ id: 'bash-2', kind: 'bash', label: 'echo hi', status: 'completed', startedAt: 2_000, finishedAt: 3_000 },
|
||||
],
|
||||
projectionsBySession: {
|
||||
root: ready([entry('child', 'continuable')]),
|
||||
child: ready([]),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -81,12 +102,22 @@ function baseSnapshot(): SidebarSessionList {
|
||||
beforeEach(() => {
|
||||
outputCalls.length = 0
|
||||
killCalls.length = 0
|
||||
jobListCalls = []
|
||||
jobsByOwner = {
|
||||
root: [{ id: 'bash-1', kind: 'bash', label: 'sleep 300', status: 'running', startedAt: 1_000 }],
|
||||
child: [{ id: 'bash-2', kind: 'bash', label: 'echo hi', status: 'completed', startedAt: 2_000, finishedAt: 3_000 }],
|
||||
}
|
||||
vi.stubGlobal('fetch', async (url: string | URL | Request, init?: RequestInit) => {
|
||||
const method = String(url).split('/').pop()
|
||||
const body = JSON.parse(String(init?.body)) as { sessionId?: string; id?: string; rootSessionId?: string }
|
||||
if (method === 'subagents.live') {
|
||||
return jsonResponse({ ok: true, value: { live: {} } })
|
||||
}
|
||||
if (method === 'jobs.list') {
|
||||
const owner = body.sessionId ?? ''
|
||||
jobListCalls.push(owner)
|
||||
return jsonResponse({ ok: true, value: { jobs: jobsByOwner[owner] ?? [] } })
|
||||
}
|
||||
if (method === 'jobs.output') {
|
||||
outputCalls.push({ sessionId: body.sessionId ?? '', id: body.id ?? '' })
|
||||
// bash-9 stands for a job the model never read (read:false).
|
||||
@@ -116,15 +147,127 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.useRealTimers()
|
||||
for (const el of document.querySelectorAll('body > div')) el.remove()
|
||||
})
|
||||
|
||||
describe('SubagentView background jobs', () => {
|
||||
it('renders the tree jobs with status, durations, and owner labels', () => {
|
||||
/**
|
||||
* Flush the mount-time job reads: the poll fires immediately, and the request
|
||||
* → envelope → json → Promise.all chain settles over several microtask ticks.
|
||||
*/
|
||||
async function flushJobs(): Promise<void> {
|
||||
for (let tick = 0; tick < 4; tick++) {
|
||||
await act(async () => { await Promise.resolve() })
|
||||
}
|
||||
}
|
||||
|
||||
/** Render the page and wait for its first job read to land. */
|
||||
async function renderPage(store: Store, active = true, navigation?: NavigationSpy) {
|
||||
const rendered = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active, ctx: makeCtx(store, navigation) }),
|
||||
)
|
||||
await flushJobs()
|
||||
return rendered
|
||||
}
|
||||
|
||||
describe('SubagentView topology (projectionsBySession)', () => {
|
||||
it("expands a branch inferred from the child's own catalog", async () => {
|
||||
const store = makeStore({
|
||||
byId: {
|
||||
root: { id: 'root', displayTitle: '主会话' },
|
||||
child: { id: 'child', displayTitle: 'Worker', origin: 'subagent', parentId: 'root' },
|
||||
grand: { id: 'grand', displayTitle: 'Grandchild', origin: 'subagent', parentId: 'child' },
|
||||
great: { id: 'great', displayTitle: 'Great', origin: 'subagent', parentId: 'grand' },
|
||||
},
|
||||
projectionsBySession: {
|
||||
root: ready([entry('child', 'continuable', 'Worker')]),
|
||||
// The child carries a child of its own: 0.1.7 rows have no
|
||||
// `hasChildren`, it is the child's own catalog that says so.
|
||||
child: ready([entry('grand', 'one-shot', 'Grandchild')]),
|
||||
grand: ready([entry('great', 'one-shot', 'Great')]),
|
||||
great: ready([]),
|
||||
},
|
||||
})
|
||||
const { container, unmount } = await renderPage(store)
|
||||
expect(container.textContent).toContain('Worker')
|
||||
expect(container.textContent).toContain('Grandchild')
|
||||
expect(container.textContent).toContain('可续接')
|
||||
expect(container.textContent).toContain('一次性')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('never claims a mode for an unknown row', async () => {
|
||||
const store = makeStore({
|
||||
byId: { root: { id: 'root', displayTitle: '主会话' } },
|
||||
projectionsBySession: {
|
||||
// 0.1.7's third mode: a child the host kept without a readable
|
||||
// descriptor. It claims neither mode, so the card states neither.
|
||||
root: ready([entry('opaque', 'unknown', 'Opaque child')]),
|
||||
opaque: ready([]),
|
||||
},
|
||||
})
|
||||
const { container, unmount } = await renderPage(store)
|
||||
expect(container.textContent).toContain('Opaque child')
|
||||
expect(container.textContent).not.toContain('一次性')
|
||||
expect(container.textContent).not.toContain('可续接')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('opens a child through the workspace face (the sessions face is gone since 0.1.6)', async () => {
|
||||
const navigation: NavigationSpy = { opened: [] }
|
||||
const store = makeStore(baseSnapshot())
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
const { container, unmount } = await renderPage(store, true, navigation)
|
||||
// Unfold so the settled child is reachable, open its detail window and
|
||||
// use the jump button (the card itself is the detail affordance).
|
||||
const foldToggle = container.querySelector('button[aria-label="展开已完成的节点"]') as HTMLButtonElement
|
||||
await act(async () => { foldToggle.click() })
|
||||
const node = container.querySelector('[data-graph-node="child"]') as HTMLElement
|
||||
await act(async () => { node.click() })
|
||||
const jump = [...document.querySelectorAll('[role="dialog"] button')]
|
||||
.find(button => button.textContent?.includes('查看转录')) as HTMLButtonElement | undefined
|
||||
await act(async () => { jump?.click() })
|
||||
expect(navigation.opened).toEqual([
|
||||
{ parentSessionId: 'root', childSessionId: 'child', mode: 'continuable' },
|
||||
])
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('retries a failed catalog through the 0.1.7 refresh name', async () => {
|
||||
const refreshed: string[] = []
|
||||
const store = makeStore({
|
||||
byId: { root: { id: 'root', displayTitle: '主会话' } },
|
||||
projectionsBySession: {
|
||||
root: { values: {}, state: 'error', error: { code: 'boom', message: '目录读取失败' } },
|
||||
},
|
||||
})
|
||||
const rendered = renderRoot(createElement(SubagentView, {
|
||||
sessionId: 'root',
|
||||
active: true,
|
||||
ctx: {
|
||||
sessions: {
|
||||
list: store,
|
||||
refreshProjections: async (sessionId: string) => { refreshed.push(sessionId) },
|
||||
},
|
||||
get: () => undefined,
|
||||
} as unknown as Context,
|
||||
}))
|
||||
await flushJobs()
|
||||
// The banner names the failed branch count; the per-row message lives in
|
||||
// the catalog view the page folds (see `subagent-catalog.ts`).
|
||||
expect(rendered.container.textContent).toContain('1 个分支加载失败')
|
||||
const retry = [...rendered.container.querySelectorAll('button')]
|
||||
.find(button => button.textContent?.includes('重试'))
|
||||
expect(retry).toBeDefined()
|
||||
await act(async () => { retry?.click() })
|
||||
expect(refreshed).toEqual(['root'])
|
||||
rendered.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubagentView background jobs', () => {
|
||||
it('renders the tree jobs with status, durations, and owner labels', async () => {
|
||||
const store = makeStore(baseSnapshot())
|
||||
const { container, unmount } = await renderPage(store)
|
||||
const text = container.textContent ?? ''
|
||||
expect(text).toContain('后台任务')
|
||||
expect(text).toContain('2 个后台任务 · 1 运行中')
|
||||
@@ -133,43 +276,50 @@ describe('SubagentView background jobs', () => {
|
||||
expect(text).toContain('echo hi')
|
||||
expect(text).toContain('主会话')
|
||||
expect(text).toContain('子代理')
|
||||
// The fence admits a job to its owner only, so the tree costs one read per
|
||||
// tree session (and never a read of a session outside the tree).
|
||||
expect([...jobListCalls].sort()).toEqual(['child', 'root'])
|
||||
// Only the running row offers a kill button.
|
||||
expect(container.querySelectorAll('button[aria-label="终止"]')).toHaveLength(1)
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('renders nothing job-related when the mirror is empty', () => {
|
||||
const store = makeStore({ current: 'root', byId: { root: { id: 'root', displayTitle: '主会话' } }, subagentsByParent: {}, jobsBySession: {} })
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
it('renders nothing job-related when the tree has no jobs', async () => {
|
||||
jobsByOwner = {}
|
||||
const store = makeStore({ byId: { root: { id: 'root', displayTitle: '主会话' } } })
|
||||
const { container, unmount } = await renderPage(store)
|
||||
expect(container.textContent).not.toContain('后台任务')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('survives the mirror emptying while mounted (hook-order regression #300)', async () => {
|
||||
const store = makeStore(baseSnapshot())
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
expect(container.textContent).toContain('sleep 300')
|
||||
// The mirror empties (all jobs settled and dropped): the section must
|
||||
// vanish WITHOUT reordering hooks — a hook below the empty-state return
|
||||
// would crash React with "Rendered fewer hooks than expected" (the
|
||||
// minified #300 the sidebar boundary surfaces with a retry button).
|
||||
await act(async () => { store.set({ ...baseSnapshot(), jobsBySession: {} }) })
|
||||
expect(container.textContent).not.toContain('后台任务')
|
||||
// And returning jobs must work too, with the same hook order.
|
||||
await act(async () => { store.set(baseSnapshot()) })
|
||||
expect(container.textContent).toContain('sleep 300')
|
||||
unmount()
|
||||
it('survives the job list emptying while mounted (hook-order regression #300)', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const store = makeStore(baseSnapshot())
|
||||
const { container, unmount } = await renderPage(store)
|
||||
expect(container.textContent).toContain('sleep 300')
|
||||
// Every job settles and drops: the section must vanish WITHOUT reordering
|
||||
// hooks — a hook below the empty-state return would crash React with
|
||||
// "Rendered fewer hooks than expected" (the minified #300 the sidebar
|
||||
// boundary surfaces with a retry button).
|
||||
jobsByOwner = {}
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(5_000) })
|
||||
expect(container.textContent).not.toContain('后台任务')
|
||||
// And returning jobs must work too, with the same hook order.
|
||||
jobsByOwner = {
|
||||
root: [{ id: 'bash-1', kind: 'bash', label: 'sleep 300', status: 'running', startedAt: 1_000 }],
|
||||
}
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(5_000) })
|
||||
expect(container.textContent).toContain('sleep 300')
|
||||
unmount()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('shows the selected job output in an anchored popover, dismissable', async () => {
|
||||
const store = makeStore(baseSnapshot())
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
const { container, unmount } = await renderPage(store)
|
||||
const row = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement
|
||||
await act(async () => { row.click() })
|
||||
// The peek request carries the OWNER session (the fence compares it).
|
||||
@@ -188,9 +338,7 @@ describe('SubagentView background jobs', () => {
|
||||
|
||||
it('switches the single output popover between rows', async () => {
|
||||
const store = makeStore(baseSnapshot())
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
const { container, unmount } = await renderPage(store)
|
||||
const first = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement
|
||||
await act(async () => { first.click() })
|
||||
expect(document.body.textContent).toContain('output-of-bash-1')
|
||||
@@ -208,17 +356,14 @@ describe('SubagentView background jobs', () => {
|
||||
})
|
||||
|
||||
it('explains when the model has not read the job yet', async () => {
|
||||
const snapshot = baseSnapshot()
|
||||
snapshot.jobsBySession = {
|
||||
jobsByOwner = {
|
||||
root: [
|
||||
...(snapshot.jobsBySession?.root ?? []),
|
||||
...jobsByOwner.root ?? [],
|
||||
{ id: 'bash-9', kind: 'bash', label: 'unread cmd', status: 'running', startedAt: 9_000 },
|
||||
],
|
||||
}
|
||||
const store = makeStore(snapshot)
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
const store = makeStore(baseSnapshot())
|
||||
const { container, unmount } = await renderPage(store)
|
||||
const row = container.querySelector('button[aria-label*="unread cmd"]') as HTMLButtonElement
|
||||
await act(async () => { row.click() })
|
||||
// read:false → the popover explains the output awaits the model's job_output
|
||||
@@ -236,15 +381,9 @@ describe('SubagentView background jobs', () => {
|
||||
startedAt: 1_000 + index,
|
||||
...(index % 2 === 0 ? {} : { finishedAt: 2_000 + index, detail: 'exit code: 1' }),
|
||||
}))
|
||||
const store = makeStore({
|
||||
current: 'root',
|
||||
byId: { root: { id: 'root', displayTitle: '主会话' } },
|
||||
subagentsByParent: {},
|
||||
jobsBySession: { root: many },
|
||||
})
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
jobsByOwner = { root: many }
|
||||
const store = makeStore({ byId: { root: { id: 'root', displayTitle: '主会话' } } })
|
||||
const { container, unmount } = await renderPage(store)
|
||||
expect(container.textContent).toContain('60 个后台任务 · 30 运行中')
|
||||
expect(container.querySelectorAll('button[aria-label*="bulk cmd"]')).toHaveLength(60)
|
||||
// Clicking a row anywhere in the long list still feeds the single popover.
|
||||
@@ -257,9 +396,7 @@ describe('SubagentView background jobs', () => {
|
||||
|
||||
it('kills a live job only after the two-click confirm', async () => {
|
||||
const store = makeStore(baseSnapshot())
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
const { container, unmount } = await renderPage(store)
|
||||
const kill = container.querySelector('button[aria-label="终止"]') as HTMLButtonElement
|
||||
await act(async () => { kill.click() })
|
||||
// First click only arms the confirm — no request leaves the page.
|
||||
@@ -271,18 +408,17 @@ describe('SubagentView background jobs', () => {
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('does not poll the output while the page is hidden', async () => {
|
||||
it('does not read jobs or poll output while the page is hidden', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const store = makeStore(baseSnapshot())
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: false, ctx: makeCtx(store) }),
|
||||
)
|
||||
const row = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement
|
||||
await act(async () => { row.click() })
|
||||
const { container, unmount } = await renderPage(store, false)
|
||||
// A hidden page issues no job read at all (it also has no rows to show).
|
||||
expect(jobListCalls).toEqual([])
|
||||
expect(container.textContent).not.toContain('后台任务')
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(10_000) })
|
||||
// One peek for the initial load; the 2s poll never ran while inactive.
|
||||
expect(outputCalls).toHaveLength(1)
|
||||
expect(jobListCalls).toEqual([])
|
||||
expect(outputCalls).toEqual([])
|
||||
unmount()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
|
||||
+39
-17
@@ -13,10 +13,11 @@ import {
|
||||
} from '../src/client/tasks-model.ts'
|
||||
import type {
|
||||
SidebarSessionSummary,
|
||||
SidebarSubagentCatalog,
|
||||
SidebarSubagentChildEntry,
|
||||
SidebarSubagentCatalogEntry,
|
||||
SidebarTeamMemberView,
|
||||
} from '../src/context-types.ts'
|
||||
import type { SubagentCatalogView } from '../src/client/subagent-catalog.ts'
|
||||
import type { LastActivity } from '../src/subagent-activity.ts'
|
||||
import type { WorkflowRunView } from '../src/workflow-runs.ts'
|
||||
|
||||
/** A summary row. */
|
||||
@@ -24,14 +25,19 @@ function summary(id: string, over: Partial<SidebarSessionSummary> = {}): Sidebar
|
||||
return { id, displayTitle: id, ...over }
|
||||
}
|
||||
|
||||
/** A catalog child row. */
|
||||
function child(id: string, over: Partial<SidebarSubagentChildEntry> = {}): SidebarSubagentChildEntry {
|
||||
return { kind: 'child', id, activity: 'running', hasChildren: false, mode: 'one-shot', ...over }
|
||||
/** One `subagentCatalog` projection row (DSH 0.1.7 shape). */
|
||||
function child(id: string, over: Partial<SidebarSubagentCatalogEntry> = {}): SidebarSubagentCatalogEntry {
|
||||
return { id, createdAt: 0, mode: 'one-shot', ...over }
|
||||
}
|
||||
|
||||
/** A ready catalog. */
|
||||
function catalog(entries: SidebarSubagentCatalog['entries']): SidebarSubagentCatalog {
|
||||
return { entries, parentAvailable: true, state: 'ready', error: null }
|
||||
/** A loaded catalog view (what the projection folds into). */
|
||||
function catalog(entries: SidebarSubagentCatalogEntry[]): SubagentCatalogView {
|
||||
return { entries, state: 'ready', error: null }
|
||||
}
|
||||
|
||||
/** The live channel's fold: only the RUNNING children appear (DSH 0.1.7). */
|
||||
function running(...ids: string[]): Record<string, LastActivity> {
|
||||
return Object.fromEntries(ids.map(id => [id, { text: `${id} is working` }]))
|
||||
}
|
||||
|
||||
/** A workflow run view. */
|
||||
@@ -71,7 +77,7 @@ describe('buildTasksModel', () => {
|
||||
it('walks the catalogs in pre-order and marks the current session', () => {
|
||||
const model = buildTasksModel(input({
|
||||
catalogs: {
|
||||
root: catalog([child('a'), child('b', { activity: 'inactive' })]),
|
||||
root: catalog([child('a'), child('b')]),
|
||||
a: catalog([child('a1')]),
|
||||
},
|
||||
byId: {
|
||||
@@ -79,6 +85,7 @@ describe('buildTasksModel', () => {
|
||||
a: summary('a'), b: summary('b'), a1: summary('a1'),
|
||||
},
|
||||
currentSessionId: 'a1',
|
||||
live: running('a', 'a1'),
|
||||
folded: false,
|
||||
}))
|
||||
expect(model.map(node => node.id)).toEqual(['root', 'a', 'a1', 'b'])
|
||||
@@ -86,6 +93,9 @@ describe('buildTasksModel', () => {
|
||||
expect(agents(model).find(node => node.id === 'a1')?.childAddress).toEqual({
|
||||
parentSessionId: 'a', childSessionId: 'a1', mode: 'one-shot',
|
||||
})
|
||||
// Activity comes from the live channel, and `b` (absent there) is settled.
|
||||
expect(agents(model).find(node => node.id === 'a')?.state).toBe('running')
|
||||
expect(agents(model).find(node => node.id === 'b')?.state).toBe('done')
|
||||
})
|
||||
|
||||
it('excludes Side Chat threads from the model', () => {
|
||||
@@ -108,7 +118,7 @@ describe('buildTasksModel', () => {
|
||||
}],
|
||||
})
|
||||
const model = buildTasksModel(input({
|
||||
catalogs: { root: catalog([child('m1', { activity: 'inactive' })]) },
|
||||
catalogs: { root: catalog([child('m1')]) },
|
||||
byId: { root: summary('root'), m1: summary('m1', { displayTitle: 'M1' }) },
|
||||
runs: [audit],
|
||||
folded: false,
|
||||
@@ -138,12 +148,15 @@ describe('buildTasksModel', () => {
|
||||
const model = buildTasksModel(input({
|
||||
catalogs: {
|
||||
root: catalog([
|
||||
child('done-1', { activity: 'inactive' }),
|
||||
child('done-2', { activity: 'inactive' }),
|
||||
child('live', { activity: 'running' }),
|
||||
child('mate', { activity: 'inactive' }),
|
||||
child('parent', { activity: 'inactive', hasChildren: true }),
|
||||
child('done-1'), child('done-2'), child('live'), child('mate'), child('parent'),
|
||||
]),
|
||||
// A KNOWN LEAF is a child whose OWN catalog loaded empty (0.1.7 rows
|
||||
// carry no `hasChildren`): only those two are fold candidates.
|
||||
'done-1': catalog([]),
|
||||
'done-2': catalog([]),
|
||||
'live': catalog([]),
|
||||
'mate': catalog([]),
|
||||
// `parent` has a child of its own, so it keeps its whole branch.
|
||||
parent: catalog([child('grandchild')]),
|
||||
},
|
||||
byId: {
|
||||
@@ -152,6 +165,7 @@ describe('buildTasksModel', () => {
|
||||
live: summary('live'), mate: summary('mate'),
|
||||
parent: summary('parent'), grandchild: summary('grandchild'),
|
||||
},
|
||||
live: running('live'),
|
||||
teamMembers: [member({ id: 'mate', status: 'idle' })],
|
||||
}))
|
||||
expect(model.map(node => node.id)).toEqual([
|
||||
@@ -161,9 +175,17 @@ describe('buildTasksModel', () => {
|
||||
expect(fold).toMatchObject({ parentId: 'root', count: 2, memberIds: ['done-1', 'done-2'] })
|
||||
})
|
||||
|
||||
it('keeps a child whose own catalog is unknown (never folds on missing data)', () => {
|
||||
const model = buildTasksModel(input({
|
||||
catalogs: { root: catalog([child('opaque')]) },
|
||||
byId: { root: summary('root'), opaque: summary('opaque') },
|
||||
}))
|
||||
expect(model.map(node => node.id)).toEqual(['root', 'opaque'])
|
||||
})
|
||||
|
||||
it('unfolds everything when folded is false', () => {
|
||||
const base = input({
|
||||
catalogs: { root: catalog([child('done-1', { activity: 'inactive' })]) },
|
||||
catalogs: { root: catalog([child('done-1')]) },
|
||||
byId: { root: summary('root'), 'done-1': summary('done-1') },
|
||||
folded: false,
|
||||
})
|
||||
@@ -204,7 +226,7 @@ describe('buildTasksModel: duplicate workflow members', () => {
|
||||
const model = buildTasksModel(input({
|
||||
catalogs: {
|
||||
root: catalog([child('a')]),
|
||||
a: catalog([child('m1', { activity: 'inactive' })]),
|
||||
a: catalog([child('m1')]),
|
||||
},
|
||||
byId: { root: summary('root'), a: summary('a'), m1: summary('m1') },
|
||||
runs: [audit],
|
||||
|
||||
+53
-26
@@ -12,9 +12,12 @@ import { renderRoot } from './test-utils.ts'
|
||||
import { SubagentView } from '../src/client/SubagentView.tsx'
|
||||
import type {
|
||||
Context,
|
||||
SidebarJobView,
|
||||
SidebarProjectionSnapshot,
|
||||
SidebarSessionList,
|
||||
SidebarSessionSummary,
|
||||
SidebarSubagentCatalog,
|
||||
SidebarSubagentAddress,
|
||||
SidebarSubagentCatalogEntry,
|
||||
} from '../src/context-types.ts'
|
||||
|
||||
/** A subscribable sessions-list snapshot (mirror of the runtime list feed). */
|
||||
@@ -36,16 +39,21 @@ function makeStore(initial: SidebarSessionList) {
|
||||
|
||||
type Store = ReturnType<typeof makeStore>
|
||||
|
||||
/** The navigation spy the page must reach (DSH 0.1.7 `ctx.uiWorkspace`). */
|
||||
interface NavigationSpy {
|
||||
opened: Array<SidebarSubagentAddress | string>
|
||||
}
|
||||
|
||||
/** The client context face SubagentView touches. */
|
||||
function makeCtx(store: Store, spies: { openSubagent?: (address: unknown) => void } = {}): Context {
|
||||
function makeCtx(store: Store, navigation?: NavigationSpy): Context {
|
||||
return {
|
||||
sessions: {
|
||||
list: store,
|
||||
setSubagentCatalogOpen: () => {},
|
||||
openSubagent: spies.openSubagent ?? (() => {}),
|
||||
open: () => {},
|
||||
refreshSubagents: async () => {},
|
||||
refreshProjections: async () => {},
|
||||
},
|
||||
get: (name: string) => (name === 'uiWorkspace' && navigation !== undefined
|
||||
? { openSession: (target: SidebarSubagentAddress | string) => { navigation.opened.push(target) } }
|
||||
: undefined),
|
||||
} as unknown as Context
|
||||
}
|
||||
|
||||
@@ -56,11 +64,16 @@ function jsonResponse(value: unknown): Response {
|
||||
let teamPayload: unknown = { available: false }
|
||||
const teamMutations: Array<{ method: string; body: Record<string, unknown> }> = []
|
||||
const fetchedMethods: string[] = []
|
||||
/** Job lists the stubbed `jobs.list` route answers with, keyed by OWNER session. */
|
||||
let jobsByOwner: Record<string, SidebarJobView[]> = {}
|
||||
|
||||
beforeEach(() => {
|
||||
teamPayload = { available: false }
|
||||
teamMutations.length = 0
|
||||
fetchedMethods.length = 0
|
||||
jobsByOwner = {
|
||||
root: [{ id: 'bash-1', kind: 'bash', label: 'sleep 300', status: 'running', startedAt: 1_000 }],
|
||||
}
|
||||
vi.stubGlobal('fetch', async (url: string | URL | Request, init?: RequestInit) => {
|
||||
const method = String(url).split('/').pop()
|
||||
fetchedMethods.push(method ?? '')
|
||||
@@ -68,6 +81,9 @@ beforeEach(() => {
|
||||
if (method === 'subagents.live') return jsonResponse({ ok: true, value: { live: {} } })
|
||||
if (method === 'workflows.list') return jsonResponse({ ok: true, value: { runs: [] } })
|
||||
if (method === 'teams.view') return jsonResponse({ ok: true, value: teamPayload })
|
||||
if (method === 'jobs.list') {
|
||||
return jsonResponse({ ok: true, value: { jobs: jobsByOwner[String(body.sessionId ?? '')] ?? [] } })
|
||||
}
|
||||
if (method === 'teams.taskCreate' || method === 'teams.taskUpdate') {
|
||||
teamMutations.push({ method: method ?? '', body })
|
||||
return jsonResponse({
|
||||
@@ -92,35 +108,42 @@ afterEach(() => {
|
||||
for (const el of document.querySelectorAll('body > div')) el.remove()
|
||||
})
|
||||
|
||||
/** A snapshot with N direct children of root (all settled one-shots). */
|
||||
/**
|
||||
* A snapshot with N direct children of root (all settled one-shots). The rows
|
||||
* come from the host's `subagentCatalog` projection and each child carries a
|
||||
* loaded EMPTY catalog of its own — DSH 0.1.7 rows have no `hasChildren`, so
|
||||
* that is what makes them known leaves (and therefore fold candidates).
|
||||
*/
|
||||
function snapshotWithChildren(count: number): SidebarSessionList {
|
||||
const byId: Record<string, SidebarSessionSummary> = {
|
||||
root: { id: 'root', displayTitle: '主会话', running: true },
|
||||
}
|
||||
const entries: SidebarSubagentCatalog['entries'] = []
|
||||
const projectionsBySession: Record<string, SidebarProjectionSnapshot> = {}
|
||||
const entries: SidebarSubagentCatalogEntry[] = []
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const id = `child-${index}`
|
||||
byId[id] = { id, displayTitle: id, origin: 'subagent', parentId: 'root', running: false }
|
||||
entries.push({ kind: 'child', id, activity: 'inactive', hasChildren: false, mode: 'one-shot', label: `子代理 ${index}` })
|
||||
entries.push({ id, createdAt: 1_000 + index, mode: 'one-shot', label: `子代理 ${index}` })
|
||||
projectionsBySession[id] = { values: { subagentCatalog: [] }, state: 'ready', error: null }
|
||||
}
|
||||
return {
|
||||
current: 'root',
|
||||
byId,
|
||||
subagentsByParent: {
|
||||
root: { entries, parentAvailable: true, state: 'ready', error: null },
|
||||
},
|
||||
jobsBySession: {
|
||||
root: [{ id: 'bash-1', kind: 'bash', label: 'sleep 300', status: 'running', startedAt: 1_000 }],
|
||||
},
|
||||
projectionsBySession.root = { values: { subagentCatalog: entries }, state: 'ready', error: null }
|
||||
return { byId, projectionsBySession }
|
||||
}
|
||||
|
||||
/** Flush the mount-time job reads (request → envelope → json → Promise.all). */
|
||||
async function flushJobs(): Promise<void> {
|
||||
for (let tick = 0; tick < 4; tick++) {
|
||||
await act(async () => { await Promise.resolve() })
|
||||
}
|
||||
}
|
||||
|
||||
describe('Tasks page interactions', () => {
|
||||
it('keeps the jobs drawer open below the agent threshold', () => {
|
||||
it('keeps the jobs drawer open below the agent threshold', async () => {
|
||||
const store = makeStore(snapshotWithChildren(2))
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
await flushJobs()
|
||||
// 3 agents (root + 2 children) < 8: the drawer renders its rows open.
|
||||
expect(container.textContent).toContain('sleep 300')
|
||||
expect(container.textContent).not.toContain('已自动折叠')
|
||||
@@ -132,6 +155,7 @@ describe('Tasks page interactions', () => {
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
await flushJobs()
|
||||
// 9 agents ≥ 8: collapsed, the auto note explains, no rows rendered.
|
||||
expect(container.textContent).not.toContain('sleep 300')
|
||||
expect(container.textContent).toContain('已自动折叠')
|
||||
@@ -162,9 +186,9 @@ describe('Tasks page interactions', () => {
|
||||
})
|
||||
|
||||
it('opens the node detail window on card click; the jump button goes to the transcript', async () => {
|
||||
const opened: unknown[] = []
|
||||
const navigation: NavigationSpy = { opened: [] }
|
||||
const store = makeStore(snapshotWithChildren(1))
|
||||
const ctx = makeCtx(store, { openSubagent: (address) => { opened.push(address) } })
|
||||
const ctx = makeCtx(store, navigation)
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx }),
|
||||
)
|
||||
@@ -174,14 +198,16 @@ describe('Tasks page interactions', () => {
|
||||
const node = container.querySelector('[data-graph-node="child-0"]') as HTMLElement
|
||||
await act(async () => { node.click() })
|
||||
// The card no longer jumps directly: it IS the detail affordance.
|
||||
expect(opened).toEqual([])
|
||||
expect(navigation.opened).toEqual([])
|
||||
const dialog = document.querySelector('[role="dialog"]') as HTMLElement
|
||||
expect(dialog).not.toBeNull()
|
||||
expect(dialog.textContent).toContain('节点详情')
|
||||
const jump = [...dialog.querySelectorAll('button')].find(button => button.textContent?.includes('查看转录'))
|
||||
expect(jump).toBeDefined()
|
||||
await act(async () => { jump?.click() })
|
||||
expect(opened).toEqual([{ parentSessionId: 'root', childSessionId: 'child-0', mode: 'one-shot' }])
|
||||
// The workspace face owns main-view selection since 0.1.6; ISessions.openSubagent
|
||||
// is gone from the runtime, so that is the call the page must make.
|
||||
expect(navigation.opened).toEqual([{ parentSessionId: 'root', childSessionId: 'child-0', mode: 'one-shot' }])
|
||||
unmount()
|
||||
})
|
||||
|
||||
@@ -218,9 +244,9 @@ describe('Tasks page interactions', () => {
|
||||
|
||||
describe('Tasks page graph interactions and team board', () => {
|
||||
it('activates a graph node after a background pointerdown (no click theft)', async () => {
|
||||
const opened: unknown[] = []
|
||||
const navigation: NavigationSpy = { opened: [] }
|
||||
const store = makeStore(snapshotWithChildren(1))
|
||||
const ctx = makeCtx(store, { openSubagent: (address) => { opened.push(address) } })
|
||||
const ctx = makeCtx(store, navigation)
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx }),
|
||||
)
|
||||
@@ -238,7 +264,7 @@ describe('Tasks page graph interactions and team board', () => {
|
||||
await act(async () => { node.click() })
|
||||
// The click is not stolen: the card's own action (its detail window) ran.
|
||||
expect(document.querySelector('[role="dialog"]')).not.toBeNull()
|
||||
expect(opened).toEqual([])
|
||||
expect(navigation.opened).toEqual([])
|
||||
unmount()
|
||||
})
|
||||
|
||||
@@ -391,6 +417,7 @@ describe('Tasks page: owned tasks, host-primitive controls, draggable output', (
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
await flushJobs()
|
||||
const row = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement
|
||||
await act(async () => { row.click() })
|
||||
const card = document.querySelector('[role="dialog"]') as HTMLElement
|
||||
|
||||
@@ -197,104 +197,3 @@ describe('skin contract: the plugin owns no color of its own', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Task-page migration guard (the shadcn/ui rework).
|
||||
*
|
||||
* The vendored components under `src/client/ui/**` and every migrated
|
||||
* task-page module must take their colors from a DSH token — either directly
|
||||
* (`var(--dsw-…)`) or through one of the shadcn aliases bridged in
|
||||
* `src/client/ui/theme.css` (`--background`, `--border`, …). Three ways that
|
||||
* can rot silently:
|
||||
*
|
||||
* 1. A color literal (`#hex`, `rgb()`, `oklch()`, …) — the host skin can no
|
||||
* longer repaint it, and the value stops flipping with dark mode.
|
||||
* 2. A Tailwind default-palette class (`bg-blue-500`, `text-white`) — its
|
||||
* value is defined by Tailwind's own theme, not by DSH.
|
||||
* 3. A `var()` in a migrated stylesheet pointing at neither a `--dsw-*` token
|
||||
* nor a bridged alias — a typo falls back to the initial color.
|
||||
*
|
||||
* Comments are stripped first: prose legitimately quotes the old values (the
|
||||
* Badge docstring records that upstream's `text-white` became the
|
||||
* `--destructive-foreground` token), and upstream issue numbers look like
|
||||
* three-digit hex colors.
|
||||
*/
|
||||
const VENDORED_UI_DIR = 'src/client/ui'
|
||||
const MIGRATED_TASK_FILES = [
|
||||
'src/client/SubagentView.tsx',
|
||||
'src/client/SubagentView.module.css',
|
||||
'src/client/TasksGraph.tsx',
|
||||
'src/client/TasksTree.tsx',
|
||||
'src/client/TaskWindow.tsx',
|
||||
'src/client/TeamBoard.tsx',
|
||||
'src/client/JobsDrawer.tsx',
|
||||
'src/client/TasksPopovers.tsx',
|
||||
'src/client/AnchoredPopover.tsx',
|
||||
'src/client/tasks-shared.tsx',
|
||||
'src/client/tasks-canvas.module.css',
|
||||
]
|
||||
|
||||
/** Drop block comments and whole-line `//` comments (URLs/strings stay intact). */
|
||||
function stripComments(source: string): string {
|
||||
return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '')
|
||||
}
|
||||
|
||||
/** Any spelled-out color value (the skin contract's forbidden set). */
|
||||
const COLOR_LITERAL = /#[0-9a-fA-F]{3,8}\b|\b(?:rgba?|hsla?|hwb|oklch|oklab|lab|lch|color-mix|light-dark)\(/
|
||||
|
||||
/** Tailwind's default palette families, which are not DSH tokens. */
|
||||
const PALETTE_CLASS
|
||||
= /\b(?:bg|text|border|ring|fill|stroke|from|via|to|outline|divide|shadow|accent|caret|decoration)-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|white|black)(?:-\d{2,3})?\b/
|
||||
|
||||
/** Paint declarations whose value must resolve to a token. */
|
||||
const PAINT_DECLARATION
|
||||
= /(?:^|;)\s*(background(?:-color|-image)?|color|border(?:-(?:top|right|bottom|left))?-color|border|outline(?:-color)?|fill|stroke|box-shadow|text-decoration-color|caret-color|accent-color|scrollbar-color)\s*:\s*([^;}]+)/g
|
||||
|
||||
/** A paint declaration naming no token at all may only be inert. */
|
||||
const INERT_PAINT = /^(?:none|transparent|currentcolor|inherit|initial|unset|revert)$/i
|
||||
|
||||
/** Every variable the theme bridge defines (or forwards) — the alias allowlist. */
|
||||
const THEME_ALIASES = new Set(
|
||||
[...readFileSync(resolve(ROOT, `${VENDORED_UI_DIR}/theme.css`), 'utf8').matchAll(/--([a-z0-9-]+)\s*:/g)]
|
||||
.map(match => match[1]),
|
||||
)
|
||||
|
||||
/** `--dsw-*` is the contract; the bridged aliases and runtime vars are the only indirection. */
|
||||
function tokenAllows(name: string): boolean {
|
||||
return name.startsWith('dsw-') || name.startsWith('tw-') || name.startsWith('radix-') || THEME_ALIASES.has(name)
|
||||
}
|
||||
|
||||
describe('skin contract: the migrated task page and the vendored ui/ own no color', () => {
|
||||
const uiFiles = readdirSync(resolve(ROOT, VENDORED_UI_DIR))
|
||||
.filter(name => /\.(?:tsx|ts|css)$/.test(name))
|
||||
.map(name => `${VENDORED_UI_DIR}/${name}`)
|
||||
const files = [...uiFiles, ...MIGRATED_TASK_FILES]
|
||||
|
||||
it('covers the vendored components and the migrated task-page modules', () => {
|
||||
expect(uiFiles.length).toBeGreaterThan(10)
|
||||
for (const file of files) expect(readFileSync(resolve(ROOT, file), 'utf8').length, file).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.each(files)('%s carries no color literal', (file) => {
|
||||
expect(stripComments(readFileSync(resolve(ROOT, file), 'utf8')), file).not.toMatch(COLOR_LITERAL)
|
||||
})
|
||||
|
||||
it.each(files)('%s uses no Tailwind default-palette class', (file) => {
|
||||
expect(stripComments(readFileSync(resolve(ROOT, file), 'utf8')), file).not.toMatch(PALETTE_CLASS)
|
||||
})
|
||||
|
||||
it('binds every paint declaration in the migrated stylesheets to a token', () => {
|
||||
for (const file of files.filter(name => name.endsWith('.css'))) {
|
||||
const styles = stripComments(readFileSync(resolve(ROOT, file), 'utf8'))
|
||||
for (const declaration of styles.matchAll(PAINT_DECLARATION)) {
|
||||
const [, property, value] = declaration
|
||||
const vars = [...(value ?? '').matchAll(/var\(--([a-z0-9-]+)/gi)].map(match => match[1] ?? '')
|
||||
if (vars.length === 0) {
|
||||
expect(INERT_PAINT.test((value ?? '').trim()), `${file}: ${property}: ${value}`).toBe(true)
|
||||
continue
|
||||
}
|
||||
for (const name of vars) expect(tokenAllows(name), `${file}: ${property}: var(--${name})`).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user