refactor: apply repository naming contract

Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
Tianyi Cui
2026-08-13 00:36:22 +08:00
parent 101df7cf58
commit a2d0f7f411
3281 changed files with 21730 additions and 21592 deletions

View File

@@ -0,0 +1,95 @@
/**
* ui-job plugin halves: the browser entry's dictionary and header-slot
* registrations against the real SlotRegistry (with fiber teardown proving
* removal — HMR safety), the inert node entry, and the invariant companion's
* ownership reservation.
*/
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
import { apply, inject } from '../src/client/index.ts'
import { apply as applyNode } from '../src/index.ts'
import * as JobInvariant from '../src/invariant.ts'
import { en, NS, zh } from '../src/client/locales.ts'
/** Slot ledger reader: entry ids currently registered in the header list. */
function headerEntryIds(ctx: Context): (string | undefined)[] {
return ctx.slots
.entries('conversation.session.header.actions')
.map(entry => entry.options.id)
}
/** Boot the browser half over a real slot tree that declares the header list. */
async function bench(): Promise<{ ctx: Context; fiber: ReturnType<Context['plugin']> }> {
const ctx = new Context()
await ctx.plugin(SlotRegistry).await()
ctx.slots.register({
name: 'root',
children: {
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
},
} as never, () => null)
ctx.provide('sessions', {})
// The locale plugin binds a settings scope, which reads the connection handle
// and the forwarded-event port.
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
ctx.provide('remote', { $on: () => () => {} } as never)
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
await ctx.plugin({ inject: localeInject, apply: applyLocale }).await()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return { ctx, fiber }
}
describe('ui-job browser half', () => {
it('declares the services it binds', () => {
expect(inject).toEqual(['sessions', 'slots', 'locale'])
})
it('registers the header action, and fiber teardown removes it (HMR safety)', async () => {
const { ctx, fiber } = await bench()
expect(headerEntryIds(ctx)).toContain('job-list')
await fiber.dispose()
expect(headerEntryIds(ctx)).not.toContain('job-list')
})
it('registers both dictionaries under its own namespace and releases them with the fiber', async () => {
const { ctx, fiber } = await bench()
const translate = ctx.locale.bind(NS)
expect(translate('list.aria')).toBe(zh['list.aria'])
ctx.locale.setLocale('en')
expect(translate('list.aria')).toBe(en['list.aria'])
// Withdrawn dictionaries leave the key unresolved rather than translated.
await fiber.dispose()
expect(translate('list.aria')).not.toBe(en['list.aria'])
})
it('keeps the English dictionary key-identical to the Chinese source of truth', () => {
expect(Object.keys(en).sort()).toEqual(Object.keys(zh).sort())
})
})
describe('ui-job node half', () => {
it('contributes no host behavior', () => {
// The node half exists only so the plugin appears in the Loader tree.
expect(applyNode).not.toThrow()
})
})
describe('ui-job invariant companion', () => {
it('reserves package ownership under its declared companion name', async () => {
const ctx = new Context()
await ctx.plugin(InvariantRegistry, { enabled: true })
const fiber = ctx.plugin(JobInvariant)
await fiber.await()
expect(JobInvariant.name).toBe('client-ui-jobs-invariant')
expect(JobInvariant.inject).toEqual(['invariants'])
// Emitting an unrelated event proves the companion installed no audit.
expect(() => { (ctx.emit as (event: string) => void)('slots/changed') }).not.toThrow()
await fiber.dispose()
})
})

View File

@@ -0,0 +1,239 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionId, SessionListState, JobView } from '@deepseek-ai/dsh-client-runtime/client'
import { JobListAction, type JobListActionProps } from '../src/client/JobListAction.tsx'
import { zh } from '../src/client/locales.ts'
// Live rows render `now - startedAt`, so every assertion needs a pinned clock.
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(START)
})
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
})
const SESSION = 'session' as SessionId
const START = 1_700_000_000_000
const t: JobListActionProps['t'] = makeTranslate(zh)
function job(over: Partial<JobView> = {}): JobView {
return {
id: 'bash-1' as JobView['id'],
kind: 'bash',
label: 'pnpm run build',
status: 'running',
startedAt: START,
...over,
}
}
function props(jobs: readonly JobView[] | undefined): JobListActionProps {
const state = {
ids: [SESSION],
byId: {},
current: SESSION,
phase: 'ready',
subagentsByParent: {},
jobsBySession: jobs === undefined ? {} : { [SESSION]: jobs },
currentAddress: undefined,
} satisfies SessionListState
function useSessions<T>(select: (snapshot: SessionListState) => T): T {
return select(state)
}
return { sessionId: SESSION, useSessions, t } as unknown as JobListActionProps
}
/**
* Rows in render order as `[kind, label, status, duration]`. Adjacent spans
* carry no whitespace between them, so the cells are read one element at a
* time rather than split out of a flattened string.
*/
function rowCells(): string[][] {
return within(screen.getByRole('list', { name: zh['list.aria'] }))
.getAllByRole('listitem')
.map(row => [...row.children]
.map(cell => cell.textContent ?? '')
.filter(text => text !== ''))
}
describe('JobListAction visibility', () => {
it('renders nothing while the session has no jobs', () => {
const { container } = render(<JobListAction {...props(undefined)} />)
expect(container.innerHTML).toBe('')
})
it('counts only live jobs, and falls back to the total when none are live', () => {
const { rerender } = render(<JobListAction {...props([job(), job({ id: 'bash-2' as JobView['id'] })])} />)
expect(screen.getByRole('button', { name: '2 个后台任务运行中' })).toBeDefined()
rerender(<JobListAction {...props([job({ status: 'completed', finishedAt: START + 3_000 })])} />)
expect(screen.getByRole('button', { name: '1 个后台任务' })).toBeDefined()
})
it('closes and unmounts when the last job disappears while the list is open', () => {
const { container, rerender } = render(<JobListAction {...props([job()])} />)
fireEvent.click(screen.getByRole('button'))
expect(screen.getByRole('list', { name: zh['list.aria'] })).toBeDefined()
rerender(<JobListAction {...props([])} />)
expect(container.innerHTML).toBe('')
})
})
describe('JobListAction rows', () => {
it('orders live jobs by start, then settled jobs newest-first', () => {
render(<JobListAction {...props([
job({ id: 'bash-3' as JobView['id'], label: 'old done', status: 'completed', startedAt: START, finishedAt: START + 1_000 }),
job({ id: 'bash-4' as JobView['id'], label: 'new done', status: 'failed', startedAt: START, finishedAt: START + 9_000 }),
job({ id: 'bash-2' as JobView['id'], label: 'later live', startedAt: START + 5_000 }),
job({ id: 'bash-1' as JobView['id'], label: 'earlier live', startedAt: START }),
])} />)
fireEvent.click(screen.getByRole('button'))
expect(rowCells()).toEqual([
['bash', 'earlier live', '运行中', '0秒'],
['bash', 'later live', '运行中', '0秒'],
['bash', 'new done', '已失败', '9秒'],
['bash', 'old done', '已完成', '1秒'],
])
})
it('breaks a settled tie on start order so map iteration never decides it', () => {
render(<JobListAction {...props([
job({ id: 'bash-2' as JobView['id'], label: 'second', status: 'completed', startedAt: START + 10, finishedAt: START + 100 }),
job({ id: 'bash-1' as JobView['id'], label: 'first', status: 'completed', startedAt: START, finishedAt: START + 100 }),
])} />)
fireEvent.click(screen.getByRole('button'))
expect(rowCells().map(cells => cells[1])).toEqual(['first', 'second'])
})
it('prefers the producer detail over the generic status word', () => {
render(<JobListAction {...props([
job({ status: 'killed', detail: 'signal: SIGTERM', finishedAt: START + 2_000 }),
])} />)
fireEvent.click(screen.getByRole('button'))
expect(rowCells()[0]).toContain('signal: SIGTERM')
})
it('renders every status word, including the stopping transition', () => {
render(<JobListAction {...props([
job({ id: 'bash-1' as JobView['id'], label: 'a', status: 'running' }),
job({ id: 'bash-2' as JobView['id'], label: 'b', status: 'stopping' }),
job({ id: 'bash-3' as JobView['id'], label: 'c', status: 'completed', finishedAt: START }),
job({ id: 'bash-4' as JobView['id'], label: 'd', status: 'killed', finishedAt: START }),
job({ id: 'bash-5' as JobView['id'], label: 'e', status: 'failed', finishedAt: START }),
])} />)
fireEvent.click(screen.getByRole('button'))
const words = rowCells().map(cells => cells[2])
expect(new Set(words)).toEqual(new Set(['运行中', '正在停止', '已完成', '已取消', '已失败']))
})
})
describe('JobListAction duration', () => {
it('advances a live row once per second and freezes a settled one', () => {
vi.setSystemTime(START + 1_000)
render(<JobListAction {...props([
job({ id: 'bash-1' as JobView['id'], label: 'live' }),
job({ id: 'bash-2' as JobView['id'], label: 'done', status: 'completed', finishedAt: START + 4_000 }),
])} />)
fireEvent.click(screen.getByRole('button'))
expect(rowCells()[0]).toContain('1秒')
expect(rowCells()[1]).toContain('4秒')
act(() => { vi.advanceTimersByTime(2_000) })
expect(rowCells()[0]).toContain('3秒')
expect(rowCells()[1]).toContain('4秒')
})
it('widens to minutes and then hours, and never shows a negative figure', () => {
render(<JobListAction {...props([
job({ id: 'bash-1' as JobView['id'], label: 'm', status: 'completed', finishedAt: START + 125_000 }),
job({ id: 'bash-2' as JobView['id'], label: 'h', status: 'completed', finishedAt: START + 7_380_000 }),
// A clock that moved backwards must not render a negative duration.
job({ id: 'bash-3' as JobView['id'], label: 'skew', status: 'completed', startedAt: START + 5_000, finishedAt: START }),
])} />)
fireEvent.click(screen.getByRole('button'))
expect(rowCells().map(cells => cells[3])).toEqual(['2小时3分', '2分5秒', '0秒'])
})
it('runs no clock while the list is closed', () => {
const interval = vi.spyOn(globalThis, 'setInterval')
render(<JobListAction {...props([job()])} />)
expect(interval).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button'))
expect(interval).toHaveBeenCalledTimes(1)
})
it('runs no clock for an open list holding only settled jobs', () => {
const interval = vi.spyOn(globalThis, 'setInterval')
render(<JobListAction {...props([job({ status: 'completed', finishedAt: START })])} />)
fireEvent.click(screen.getByRole('button'))
expect(interval).not.toHaveBeenCalled()
})
})
describe('JobListAction dismissal', () => {
it('closes on Escape and returns focus to the trigger', () => {
render(<JobListAction {...props([job()])} />)
const trigger = screen.getByRole('button')
fireEvent.click(trigger)
expect(trigger.getAttribute('aria-expanded')).toBe('true')
fireEvent.keyDown(trigger, { key: 'Escape' })
expect(trigger.getAttribute('aria-expanded')).toBe('false')
expect(document.activeElement).toBe(trigger)
})
it('ignores other keys and a closed-list Escape', () => {
render(<JobListAction {...props([job()])} />)
const trigger = screen.getByRole('button')
fireEvent.keyDown(trigger, { key: 'Escape' })
expect(trigger.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(trigger)
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
expect(trigger.getAttribute('aria-expanded')).toBe('true')
})
it('closes on an outside pointer press but not on one inside', () => {
render(<JobListAction {...props([job()])} />)
const trigger = screen.getByRole('button')
fireEvent.click(trigger)
fireEvent.pointerDown(screen.getByRole('list', { name: zh['list.aria'] }))
expect(trigger.getAttribute('aria-expanded')).toBe('true')
fireEvent.pointerDown(document.body)
expect(trigger.getAttribute('aria-expanded')).toBe('false')
})
})
describe('JobListAction wire tolerance', () => {
it('treats a settled job with no finishedAt as zero-duration and sorts it by start', () => {
// `finishedAt` is optional on the wire; the Host always sets it, so this
// covers a producer or carrier that ever stops doing so.
render(<JobListAction {...props([
job({ id: 'bash-1' as JobView['id'], label: 'no finish', status: 'completed' }),
job({ id: 'bash-2' as JobView['id'], label: 'finished', status: 'completed', startedAt: START - 1_000, finishedAt: START + 2_000 }),
])} />)
fireEvent.click(screen.getByRole('button'))
expect(rowCells().map(cells => [cells[1], cells[3]])).toEqual([
['finished', '3秒'],
['no finish', '0秒'],
])
})
it('falls back to start order when neither settled job carries a finish time', () => {
render(<JobListAction {...props([
job({ id: 'bash-2' as JobView['id'], label: 'later', status: 'failed', startedAt: START + 1_000 }),
job({ id: 'bash-1' as JobView['id'], label: 'earlier', status: 'failed', startedAt: START }),
])} />)
fireEvent.click(screen.getByRole('button'))
expect(rowCells().map(cells => cells[1])).toEqual(['later', 'earlier'])
})
})