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:54:38 +08:00
parent 101df7cf58
commit a2d0f7f411
3281 files changed
+21730 -21592

No files matched your search

@@ -0,0 +1,89 @@
/** Session/workspace fixture shapes and snapshot defaults for the test runtime. */
import type {
ConversationSnapshot, ISession, SessionId, SessionSummary, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS,
} from '@deepseek-ai/dsh-client-runtime/client'
/**
* Fixture overrides for the session behavior face: any subset of the
* production ISession verbs (typed against it, so a face change surfaces
* here at compile time), plus extra members feature-specific casts consume.
* The open Record tail means a misnamed EXTRA member is not caught by the
* compiler (it grafts as dead weight); the ISession verbs stay safe — a
* misnamed verb leaves the fail-loud stub in place, which names itself at
* the first call.
*/
export type SessionBehaviorOverrides = Partial<ISession> & Record<string, unknown>
/**
* act-wrapped mutation runner shared by every runtime object: public mutators
* funnel through it so tests never handle SlotCore microtask batching or
* React act themselves.
*/
export type Stabilizer = (fn: () => void | Promise<void>) => Promise<void>
/**
* Session fixture accepted by {@link TestSessions.add}: identity plus optional
* snapshot/list-row overrides and the session behavior face the feature under
* test actually calls (kept open — the runtime never fakes methods a test did
* not supply, so an unstubbed call fails loud at the call site).
*/
export interface SessionFixture {
id: string
/** Overrides merged over {@link conversationSnapshot} (sessionId comes from `id`). */
snapshot?: Partial<Omit<ConversationSnapshot, 'sessionId'>>
/** List-row overrides merged over the defaults derived from `id`. */
summary?: Partial<Omit<SessionSummary, 'id'>>
/** Session behavior face: exactly the methods the feature under test calls (ISession subset + extras). */
session?: SessionBehaviorOverrides
}
/**
* A complete quiescent conversation snapshot (open window, no traffic).
* @param sessionId - owning session id.
* @returns the snapshot; spread fixture overrides on top.
*/
export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot {
return {
sessionId,
views: EMPTY_CONVERSATION_VIEWS,
chat: EMPTY_CHAT_SNAPSHOT,
nodes: [],
turnTimings: new Map(),
turnEnds: new Map(),
partial: null,
runningCalls: [],
pending: [],
queue: [],
running: false,
subagent: null,
composerPhase: 'active',
removed: false,
openState: 'open',
openError: null,
hasMore: false,
loadingOlder: false,
promptError: null,
blank: false,
lastAgentError: null,
}
}
/**
* A ready workspace list with no workspaces (the shape WorkspaceRuntime
* projects after both baselines land).
* @returns the initial state of the test workspaces store.
*/
export function workspaceListState(): WorkspaceListState {
return {
items: [],
archivedSessionIds: [],
state: 'idle',
phase: 'ready',
error: null,
baselinesReady: true,
recentWorkspaceId: undefined,
}
}
@@ -0,0 +1,381 @@
/**
* jsdom slot test runtime: a real small runtime — Cordis `Context`, the
* runtime `SlotRegistry`, and the web-react renderer — assembled around
* test-owned session/workspace doubles, so feature specs exercise
* declaration, registration, scope, store, inject, rendering, updates, and
* disposal without hand-building the machinery per suite.
*
* Not part of the product plugin graph (no `dsh.client`); feature packages
* depend on it in devDependencies only. It copies no SlotCore/renderer/store
* machinery — everything mounts the production implementations.
* @module @deepseek-ai/dsh-client-test-runtime
*/
/* oxlint-disable typescript/no-redundant-type-constituents --
* `keyof SlotMap & string` is the declare-merge key pattern (see ui-slots):
* this compilation unit sees only the runtime's 'root' row, but consumer
* programs merge their own keys in; the rule fires on the narrow-map view. */
import { Context, Inject } from '@deepseek-ai/cordis'
import type { Fiber, Plugin } from '@deepseek-ai/cordis'
import { createElement, Fragment, useSyncExternalStore } from 'react'
import type { ReactNode } from 'react'
import { act, render, within } from '@testing-library/react'
import type { RenderResult } from '@testing-library/react'
import type { queries } from '@testing-library/dom'
import type { BoundFunctions } from '@testing-library/dom'
import {
ConversationEventRegistry, ConversationViewRegistry, SlotRegistry,
} from '@deepseek-ai/dsh-client-runtime/client'
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
import type {
ChildrenDecl, ComposedProps, OwnerOf, SlotComponent, SlotMap, SlotRendererHost, StoreInstanceLike,
} from '@deepseek-ai/dsh-client-ui-slots'
import { registerDomSnapshotSerializer } from './snapshot.ts'
import { TestSessions } from './sessions.ts'
import { TestWorkspaces } from './workspaces.ts'
import type { Stabilizer } from './fixtures.ts'
export { domSnapshotSerializer, registerDomSnapshotSerializer } from './snapshot.ts'
export { FixtureSession, TestSessions } from './sessions.ts'
export { stubSettingsScope } from './settings-scope.ts'
export type { StubSettingsScope } from './settings-scope.ts'
export { TestWorkspaces } from './workspaces.ts'
export { TestRemote } from './remote.ts'
export { conversationSnapshot, workspaceListState } from './fixtures.ts'
export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts'
export { makeTranslate } from './translate.ts'
export { usePinnedBrowserLanguages } from './locale-env.ts'
/** Erased register face for the internal root call (the public declaration contract holds the typing). */
type ErasedRegister = (options: object, component: unknown) => () => void
/**
* One rendered slot's local view, from {@link SlotTestRuntime.renderSlot}:
* the `data-slot` wrapper is the snapshot root (`expect(view.container)
* .toMatchSnapshot()` captures exactly this slot's output), Testing Library
* queries are bound inside it, and `update` re-renders with new owner props.
*/
export interface SlotView<K extends keyof SlotMap & string> {
/** The `<div data-slot="<key>">` wrapper around the slot's rendered output. */
readonly container: HTMLElement
/** Testing Library queries scoped to {@link SlotView.container}. */
readonly view: BoundFunctions<typeof queries>
/**
* Replace the owner props and flush the re-render (the render-site update:
* in production the owner recomputes the share and React re-renders).
* @param owner - the next owner props share.
*/
update(owner: OwnerOf<K>): void
}
/**
* Mounted feature plugin handle: the live fiber plus an act-wrapped,
* idempotent dispose (unload cascade: entries, declared child slots, store
* instances, and provided services all fall together).
*/
export interface FeatureHandle {
/** The plugin's live Cordis fiber (state assertions, escape hatch). */
readonly fiber: Fiber
/**
* Dispose the plugin fiber inside React act; repeated calls no-op.
* @returns completion of the unload cascade.
*/
dispose(): Promise<void>
}
/**
* Owner-props cell behind the auto frame: one external store the frame
* subscribes to, so {@link SlotTestRuntime.renderSlot} and
* {@link SlotView.update} drive React through the standard uSES boundary.
*/
class OwnerPropsCell {
private readonly owners = new Map<string, object>()
private readonly listeners = new Set<() => void>()
private version = 0
/** Snapshot version for uSES pairing (bumped on every set). */
readonly getVersion = (): number => this.version
/**
* Subscribe to owner-props changes.
* @param fn - change callback.
* @returns unsubscribe.
*/
readonly subscribe = (fn: () => void): (() => void) => {
this.listeners.add(fn)
return () => { this.listeners.delete(fn) }
}
/**
* Install or replace one key's owner props and notify (synchronous; the
* caller wraps in act).
* @param key - slot key.
* @param owner - owner props share.
*/
set(key: string, owner: object): void {
this.owners.set(key, owner)
this.version += 1
for (const fn of [...this.listeners]) fn()
}
/** Keys with supplied owner props, in first-supply order. */
entries(): readonly (readonly [string, object])[] {
return [...this.owners.entries()]
}
}
/**
* The test-owned 'root' occupant: declares the child slots a suite needs
* through the REAL `slots.register`, with a caller-supplied minimal frame —
* the runtime never guesses a feature's page structure.
*/
export class TestRoot {
private disposeEntry: (() => void) | undefined
/**
* @param slots - the runtime SlotRegistry.
* @param stabilize - the owning runtime's act wrapper.
*/
constructor(private readonly slots: SlotRegistry, private readonly stabilize: Stabilizer) {}
/**
* Register the root frame, declaring (and thereby claiming) the child
* slots. One declaration per runtime — a second call fails loud in the
* core ('root' is a single slot).
* @param children - child-slot declaration table (declaration + render authorization + runtime spec).
* @param frame - minimal frame component; its props derive from the declared keys (composed-props contract).
* @returns completion of the act-wrapped registration.
*/
async declare<const D extends ChildrenDecl>(
children: D,
frame: SlotComponent<ComposedProps<'root', never, keyof NoInfer<D> & keyof SlotMap & string, undefined, object>>,
): Promise<void> {
await this.stabilize(() => {
// Erased hop (same pattern as SlotRegistry's own implementation arm);
// the declaration signature above is the typed contract.
this.disposeEntry = (this.slots.register as unknown as ErasedRegister)({ name: 'root', children }, frame)
})
}
/** Remove the root registration and collapse its declarations (runtime dispose path). */
release(): void {
this.disposeEntry?.()
this.disposeEntry = undefined
}
}
/**
* The assembled test runtime. Obtain via {@link SlotTestRuntime.create};
* dispose with {@link SlotTestRuntime.dispose} (afterEach). Public mutators
* are act-wrapped throughout — tests never handle SlotCore microtask
* batching or React act themselves.
*/
export class SlotTestRuntime {
/** The runtime's Cordis root (escape hatch: extra services via `ctx.provide`, raw `ctx.plugin` mounts). */
readonly ctx: Context
/** The production SlotRegistry mounted on {@link SlotTestRuntime.ctx}. */
readonly slots: SlotRegistry
/** The test-owned 'root' occupant. */
readonly root: TestRoot
/** Sessions double (list/current observable, cells, scopes, behavior faces). */
readonly sessions: TestSessions
/** Workspaces double (list observable, recorded intent actions). */
readonly workspaces: TestWorkspaces
private readonly stabilizer: Stabilizer = async (fn) => {
await act(async () => { await fn() })
}
private host: SlotRendererHost | undefined
private readonly views: RenderResult[] = []
private readonly handles: FeatureHandle[] = []
private disposed = false
/** Auto-frame state ({@link SlotTestRuntime.declare} / {@link SlotTestRuntime.renderSlot}). */
private readonly ownerCell = new OwnerPropsCell()
private readonly autoDeclared = new Set<string>()
private autoRootView: RenderResult | undefined
private constructor(ctx: Context, slots: SlotRegistry) {
this.ctx = ctx
this.slots = slots
this.root = new TestRoot(slots, this.stabilizer)
this.sessions = new TestSessions(this.stabilizer, ctx)
this.workspaces = new TestWorkspaces(this.stabilizer)
ctx.provide('sessions', this.sessions)
ctx.provide('workspaces', this.workspaces)
// Capturing install: the production renderer does the rendering; the
// wrapper only takes the host face for storeOf (no machinery copied).
const renderer = createSlotRenderer()
slots.install({
renderRoot: (host, ownerProps) => {
this.host = host
return renderer.renderRoot(host, ownerProps)
},
})
}
/**
* Assemble a runtime: real Context, mounted SlotRegistry, installed
* renderer, and the session/workspace doubles provided as services.
* @returns the ready runtime.
*/
static async create(): Promise<SlotTestRuntime> {
registerDomSnapshotSerializer()
const ctx = new Context()
const fiber = ctx.plugin(SlotRegistry)
await fiber.await()
await ctx.plugin(ConversationEventRegistry).await()
await ctx.plugin(ConversationViewRegistry).await()
return new SlotTestRuntime(ctx, ctx.get('slots') as SlotRegistry)
}
/**
* Provide an extra service the feature under test injects (e.g. a layout
* fake). Sugar over `ctx.provide`, typed against the Context declaration
* merge: for a declared service name the fake must be a subset of that
* service's outward face (Partial — supply only what the feature calls),
* so a production face change breaks the fake at compile time. Undeclared
* names stay unchecked (ad-hoc test services).
* @param name - service name.
* @param value - service implementation (test double).
*/
provide<K extends string>(name: K, value: K extends keyof Context ? Partial<Context[K]> : unknown): void {
this.ctx.provide(name, value)
}
/**
* Mount a feature plugin on a real fiber. Required services are prechecked
* so a missing provider fails loud instead of suspending the fiber forever
* (deliberate load-order suspension tests use `ctx.plugin` directly).
* @param plugin - plugin value (function, class, or `{ inject, apply }` object).
* @returns handle owning the fiber's explicit disposal.
*/
async mount(plugin: Plugin): Promise<FeatureHandle> {
const required = Object.keys(Inject.resolve((plugin as { inject?: Inject }).inject))
const missing = required.filter(name => this.ctx.get(name) === undefined)
if (missing.length > 0) {
throw new Error(`mount would suspend: missing service(s) ${missing.join(', ')} — provide() them first`)
}
const fiber = this.ctx.plugin(plugin)
await this.stabilizer(async () => {
await fiber.await()
})
let disposed = false
const handle: FeatureHandle = {
fiber,
dispose: async () => {
if (disposed) return
disposed = true
await this.stabilizer(() => fiber.dispose())
},
}
this.handles.push(handle)
return handle
}
/**
* Render the root slot tree through the ctx-level entry (the shell's own
* entry point): `ctx.slots.renderSlot('root', {})` under Testing Library.
* @returns the Testing Library view.
*/
renderRoot(): RenderResult {
const view = render(createElement(Fragment, null, this.slots.renderSlot('root', {})))
this.views.push(view)
return view
}
/**
* Declare child slots under an auto-generated root frame — the single-slot
* mounting path for local DOM snapshots. Each key later supplied through
* {@link SlotTestRuntime.renderSlot} renders inside its own
* `<div data-slot="<key>">` wrapper (the snapshot root). Mutually exclusive
* with {@link TestRoot.declare} ('root' is a single slot); one call per
* runtime.
* @param children - child-slot declaration table (same contract as TestRoot.declare).
* @returns completion of the act-wrapped registration.
*/
async declare(children: ChildrenDecl): Promise<void> {
for (const key of Object.keys(children)) this.autoDeclared.add(key)
const cell = this.ownerCell
const AutoFrame = (props: { renderSlot: (key: string, owner: object) => ReactNode }) => {
useSyncExternalStore(cell.subscribe, cell.getVersion)
return createElement(Fragment, null, cell.entries().map(([key, owner]) =>
createElement('div', { 'data-slot': key, key }, props.renderSlot(key, owner))))
}
await this.root.declare(children as never, AutoFrame as never)
}
/**
* Render one declared slot with its owner props and return the local view.
* The whole root tree mounts through the production assembly path
* (renderer, scope providers, store axis); only this key's output lands in
* the returned container. Call again with another key to view a sibling
* slot of the same tree.
* @param key - a key declared through {@link SlotTestRuntime.declare}.
* @param owner - owner props share for the render site.
* @returns the slot-local view (snapshot container, scoped queries, owner updates).
*/
renderSlot<K extends keyof SlotMap & string>(key: K, owner: OwnerOf<K>): SlotView<K> {
if (!this.autoDeclared.has(key)) {
throw new Error(`renderSlot('${key}') without declare() — declare the key first (or use root.declare for a custom frame)`)
}
const install = (next: object): void => {
// Synchronous cell write inside act: the frame re-renders through uSES.
act(() => {
this.ownerCell.set(key, next)
})
}
install(owner)
this.autoRootView ??= this.renderRoot()
const container = this.autoRootView.container.querySelector(`[data-slot="${key}"]`)
if (!(container instanceof HTMLElement)) {
throw new Error(`renderSlot('${key}'): the auto frame rendered no wrapper — was the runtime already disposed?`)
}
return { container, view: within(container), update: install }
}
/**
* Resolve the store instance the renderer would hand a slot's component
* (identity assertions, action-driven writes). Requires a prior
* {@link SlotTestRuntime.renderRoot} — the host face exists only inside the
* installed renderer, exactly as in production.
* @param key - slot key whose first entry declares the store.
* @param scopeKey - session id for session-scope slots; omit for root scope.
* @returns the live store instance.
*/
storeOf(key: keyof SlotMap & string, scopeKey?: string): StoreInstanceLike {
if (this.host === undefined) {
throw new Error('storeOf before renderRoot() — the host face exists only inside the installed renderer')
}
const entry = this.host.entriesOf(key)[0]
if (entry === undefined) throw new Error(`storeOf('${key}'): no registration on the ledger`)
const instance = this.host.storeOf(entry, scopeKey)
if (instance === undefined) throw new Error(`storeOf('${key}'): the entry declares no store`)
return instance
}
/**
* Flush pending ledger/store notifications inside act — for mutations made
* outside the runtime's own methods (e.g. a direct `slots.register`).
* @returns completion of the act pass.
*/
async flush(): Promise<void> {
await this.stabilizer(() => {})
}
/**
* Tear down: unmount React trees first, then dispose feature fibers, the
* root registration, minted session scopes, and persisted test state.
* Idempotent.
* @returns completion of the teardown.
*/
async dispose(): Promise<void> {
if (this.disposed) return
this.disposed = true
this.autoRootView = undefined
for (const view of this.views.splice(0)) view.unmount()
for (const handle of this.handles.splice(0)) await handle.dispose()
this.root.release()
await this.sessions.disposeScopes()
localStorage.clear()
}
}
@@ -0,0 +1,32 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-test-runtime`.
* @module @deepseek-ai/dsh-client-test-runtime/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-test-runtime'
/** Cordis companion plugin name. */
export const name = 'client-test-runtime-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this test-support package owns no production event
* stream or mutable data — it assembles the runtime SlotRegistry and renderer
* (whose packages own their invariants) around test doubles; its own behavior
* is exercised by its package tests.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
@@ -0,0 +1,29 @@
/**
* Browser-language pin for specs that assert localized copy. A fresh
* LocaleRuntime with no stored preference opens in the language `navigator`
* asks for, and jsdom reports the runner's own (`en-US`) — so a spec asserting
* the product's Chinese copy states the browser it assumes instead of
* inheriting the machine's.
*/
import { afterEach, beforeEach } from 'vitest'
/**
* Pin `navigator.languages`/`navigator.language` for every test in the
* calling file (or describe block), restoring the environment's own values
* afterwards. Call at suite level, like the other vitest hooks.
* @param primary - most preferred BCP 47 tag; also becomes `navigator.language`.
* @param rest - further tags in preference order.
*/
export function usePinnedBrowserLanguages(primary: string, ...rest: string[]): void {
beforeEach(() => {
Object.defineProperty(navigator, 'languages', { value: [primary, ...rest], configurable: true })
Object.defineProperty(navigator, 'language', { value: primary, configurable: true })
})
afterEach(() => {
// Deleting the own properties uncovers the environment's own accessors
// again (Navigator declares both readonly, hence the erased receiver).
const own = navigator as unknown as Record<string, unknown>
delete own.languages
delete own.language
})
}
@@ -0,0 +1,66 @@
/** Test-owned Remote face: `$on` subscriptions driven by the internal forwarded-event plumbing. */
import type { Context } from '@deepseek-ai/cordis'
/**
* Remote service test double for the forwarded-event path. Feature specs need
* `ctx.remote.$on` to exist (their plugins inject `remote`) and need forwarded
* host events to reach those subscribers, but not the generated namespaces or
* the wire — so this double implements subscription and dispatch only.
*
* Dispatch is driven the same way production drives it: `client/runtime` owns the
* host frame sink and hands each decoded `host/remote-event` frame to
* `$dispatch`. A spec therefore exercises its refresh chains by calling
* `$dispatch(name, args)` on this double.
*
* `$mount` rejects: a spec that reaches a generated namespace through this
* double has outgrown it and needs the real Client Remote service.
*
* One deliberate asymmetry with production: a throwing listener propagates out
* of the emit instead of being contained and logged, so a spec cannot lean on
* this double for the containment guarantee `$on` documents — assert that
* against the real service.
*/
export class TestRemote {
private readonly subscriptions = new Map<string, Set<(...args: never[]) => void>>()
/**
* Register the double as `ctx.remote`.
* @param ctx - the spec's root Context.
*/
constructor(ctx: Context) {
ctx.provide('remote', this)
}
/**
* Deliver one forwarded host event to its subscribers, standing in for the
* carrier that owns the frame sink.
* @param event - forwarded host event name.
* @param args - the Host argument list, verbatim.
*/
$dispatch(event: string, args: readonly unknown[]): void {
const listeners = this.subscriptions.get(event)
if (listeners === undefined) return
for (const listener of [...listeners]) listener(...args as never[])
}
/**
* Subscribe to one forwarded host event.
* @param event - forwarded host event name.
* @param listener - receives the Host argument list verbatim.
* @returns disposer removing this subscription.
*/
$on(event: string, listener: (...args: never[]) => void): () => void {
const listeners = this.subscriptions.get(event) ?? new Set()
this.subscriptions.set(event, listeners)
listeners.add(listener)
return () => { listeners.delete(listener) }
}
/**
* Generated-namespace mount, unsupported by this double.
* @returns never; always rejects.
*/
$mount(): Promise<() => Promise<void>> {
return Promise.reject(new Error('TestRemote: $mount needs the real Client Remote service'))
}
}
@@ -0,0 +1,526 @@
/** Test-owned sessions face: the SlotRegistry host contract over declarative fixtures. */
import type { Context } from '@deepseek-ai/cordis'
import type { AttachmentIdType } from '@deepseek-ai/dsh-attachment'
import { createScope, scopeOf, SessionProvideChannel } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type {
AgentContext, ConversationSnapshot, ISessions, ObservableSnapshot, ProjectionsFace, SessionFace, SessionId,
SessionListState, SessionProvideDescriptor, SessionSearchResultItem, SessionSummary, SnapshotStore,
SubagentAddress,
} from '@deepseek-ai/dsh-client-runtime/client'
// The double reports the wire schema's own search bound, like the production
// service — a transport-varying limit would be a fiction no client can see.
import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { HostObservable, SessionMaybeProvideInfo, SessionProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
import { conversationSnapshot } from './fixtures.ts'
import type { SessionFixture, Stabilizer } from './fixtures.ts'
/**
* The fixture-backed session face: conversation reads delegate to the
* fixture's snapshot store; ISession verbs are fail-loud stubs unless the
* fixture supplies them (the runtime never fakes behavior a test did not
* declare — an unstubbed call names itself instead of half-working). Extra
* fixture methods are grafted verbatim for feature-side casts.
*/
export class FixtureSession implements SessionFace {
/**
* The useProjection seat: identity-stable per-key faces over the fixture's
* projection values (set via {@link TestSessions.setProjection}).
*/
readonly projections: ProjectionsFace & { set(key: string, value: unknown): void }
/**
* @param sessionId - host identity (branded view of the fixture id).
* @param store - conversation snapshot store (updateSnapshot writes it).
* @param overrides - fixture-declared behavior face, grafted over the stubs.
*/
constructor(
readonly sessionId: SessionId,
private readonly store: SnapshotStore<ConversationSnapshot>,
overrides: Record<string, unknown>,
) {
const values = new Map<string, unknown>()
const listeners = new Map<string, Set<() => void>>()
const faces = new Map<string, ObservableSnapshot<unknown>>()
this.projections = {
faceOf: (key: string) => {
let face = faces.get(key)
if (face === undefined) {
face = {
getSnapshot: () => values.get(key),
subscribe: (fn: () => void) => {
const set = listeners.get(key) ?? new Set()
set.add(fn)
listeners.set(key, set)
return () => { set.delete(fn) }
},
}
faces.set(key, face)
}
return face
},
set: (key: string, value: unknown) => {
values.set(key, value)
for (const fn of [...(listeners.get(key) ?? [])]) fn()
},
}
Object.assign(this, overrides)
}
/** @returns the fixture conversation snapshot (useSession read side). */
getSnapshot(): ConversationSnapshot {
return this.store.getSnapshot()
}
/**
* Subscribe to fixture snapshot changes.
* @param fn - change callback.
* @returns unsubscribe.
*/
subscribe(fn: () => void): () => void {
return this.store.subscribe(fn)
}
/**
* Fail-loud stub; supply `prompt` on the fixture's session face to exercise it.
* @returns never — always throws.
*/
prompt(): never {
throw new Error(`test session "${this.sessionId}": prompt is not stubbed — supply it on the fixture's session face`)
}
/**
* Fail-loud stub; supply `readAttachment` on the fixture's session face to exercise it.
* @param _attachmentId - opaque durable attachment id.
* @returns never — always throws.
*/
readAttachment(_attachmentId: AttachmentIdType): never {
throw new Error(`test session "${this.sessionId}": readAttachment is not stubbed — supply it on the fixture's session face`)
}
/**
* Fail-loud stub; supply `updateQueue` on the fixture's session face to exercise it.
* @returns never — always throws.
*/
updateQueue(): never {
throw new Error(`test session "${this.sessionId}": updateQueue is not stubbed — supply it on the fixture's session face`)
}
/**
* Fail-loud stub; supply `cancel` on the fixture's session face to exercise it.
* @returns never — always throws.
*/
cancel(): never {
throw new Error(`test session "${this.sessionId}": cancel is not stubbed — supply it on the fixture's session face`)
}
/**
* Fail-loud stub; supply `command` on the fixture's session face to exercise it.
* @returns never — always throws.
*/
command(): never {
throw new Error(`test session "${this.sessionId}": command is not stubbed — supply it on the fixture's session face`)
}
/**
* Fail-loud stub; supply `loadOlder` on the fixture's session face to exercise it.
* @returns never — always throws.
*/
loadOlder(): never {
throw new Error(`test session "${this.sessionId}": loadOlder is not stubbed — supply it on the fixture's session face`)
}
/**
* Fail-loud stub; supply `rename` on the fixture's session face to exercise it.
* @returns never — always throws.
*/
rename(): never {
throw new Error(`test session "${this.sessionId}": rename is not stubbed — supply it on the fixture's session face`)
}
}
/** One live test session: fixture-derived stores plus its minted scope state. */
interface SessionRecord {
summary: SessionSummary
snapshot: SnapshotStore<ConversationSnapshot>
session: FixtureSession
scope: AgentContext | undefined
scopeFiber: { dispose(): Promise<void> } | undefined
/** Materialized standard-props bundle (identity-stable per session; invalidated on roster change). */
provideInfo: SessionProvideInfo | undefined
}
/** Test binding shape handed to provider resolvers and feature injects (a SessionBinding whose session is the fixture face). */
export interface TestSessionBinding {
readonly sessionId: SessionId
readonly session: FixtureSession
readonly ctx: AgentContext
}
/**
* Sessions test double behind the renderer host and feature injects: owns the
* list/current observable, the standard-props provide channel (the runtime's
* `useSession` contribution included), scope minting through the production
* `createScope`, and the session behavior face supplied per fixture.
*
* Implements the same ISessions face features receive as `ctx.sessions`, so
* a production face change breaks this double at compile time; the extra
* members (add/updateSnapshot/setCurrent/remove/behavior/calls/stubSearch and
* the legacy provideInfo/maybeProvideInfo lookups) are bench-only surface.
*/
export class TestSessions implements ISessions {
/** The useSessions standard feed (list rows + current selection). */
readonly list: SnapshotStore<SessionListState>
/**
* Atomic current-session provide projection (production SessionRuntime
* mirror): selection changes and provider-roster changes publish through
* this one source — the member the SlotRegistry host face hands the
* renderer's SessionProvider.
*/
readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
private readonly records = new Map<SessionId, SessionRecord>()
/** The production provide channel (roster, materialization rules, current projection) — no test-side mirror. */
private readonly channel: SessionProvideChannel
/** Calls observed on the service-level face, newest last. */
readonly calls: {
method: 'open' | 'openSubagent' | 'setSubagentCatalogOpen' | 'refreshSubagents'
| 'clear' | 'search' | 'fork'
args: unknown[]
}[] = []
/** The wire schema's `session.search` result bound (production parity). */
readonly searchResultLimit = SESSION_SEARCH_RESULT_LIMIT
/** Replaceable search behavior (see {@link TestSessions.stubSearch}). */
private searchStub: ((query: string, signal: AbortSignal) => { items: SessionSearchResultItem[]; hasMore: boolean }) | undefined
/**
* @param stabilize - the owning runtime's act wrapper.
* @param rootCtx - the runtime's Cordis root; scope fibers mount under it.
*/
constructor(private readonly stabilize: Stabilizer, private readonly rootCtx: Context) {
this.list = createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
})
this.channel = new SessionProvideChannel({
rebuildBundles: () => {
for (const record of this.records.values()) {
if (record.provideInfo !== undefined) {
record.provideInfo = this.channel.materializeInfo(this.bindingOf(record.session.sessionId, record))
}
}
},
resolveCurrent: () => this.maybeProvideInfo(this.list.getSnapshot().current),
})
this.currentProvideInfo = this.channel.currentProvideInfo
// The projection follows every current write, as in production.
this.list.subscribe(() => { this.channel.publishCurrent() })
}
/**
* Add a session from a fixture and (by default) make it current.
* @param fixture - identity + snapshot/summary overrides + behavior face.
* @param opts - pass `current: false` to add without selecting.
* @returns the stable session id (branded view of `fixture.id`).
*/
async add(fixture: SessionFixture, opts?: { current?: boolean }): Promise<SessionId> {
const id = fixture.id as SessionId
if (this.records.has(id)) throw new Error(`test session "${id}" already added`)
const summary: SessionSummary = {
id,
displayTitle: fixture.id,
running: false,
blank: false,
updatedAt: this.records.size + 1,
...fixture.summary,
}
const snapshot = createSnapshotStore<ConversationSnapshot>({
...conversationSnapshot(id),
...fixture.snapshot,
})
this.records.set(id, {
summary,
snapshot,
session: new FixtureSession(id, snapshot, fixture.session ?? {}),
scope: undefined,
scopeFiber: undefined,
provideInfo: undefined,
})
await this.stabilize(() => {
this.list.update((draft) => {
draft.ids.push(id)
draft.byId[id] = summary
if (opts?.current !== false) draft.current = id
})
})
return id
}
/**
* Update a session's conversation snapshot through an immer draft (the
* live-stream stand-in: components subscribed via useSession re-render).
* @param id - session id.
* @param mutate - draft mutator.
*/
async updateSnapshot(id: string, mutate: (draft: ConversationSnapshot) => void): Promise<void> {
const record = this.require(id)
await this.stabilize(() => { record.snapshot.update(mutate) })
}
/**
* Update a session's list row (the wire-echo stand-in: title settles,
* running flips — components subscribed via useSessions re-render).
* @param id - session id.
* @param patch - summary fields to merge over the row.
*/
async updateSummary(id: string, patch: Partial<Omit<SessionSummary, 'id'>>): Promise<void> {
const record = this.require(id)
record.summary = { ...record.summary, ...patch }
await this.stabilize(() => {
this.list.update((draft) => { draft.byId[id as SessionId] = record.summary })
})
}
/**
* Switch the current selection (undefined = the no-session empty state).
* @param id - session id to select, or undefined to clear.
*/
async setCurrent(id: string | undefined): Promise<void> {
if (id !== undefined) this.require(id)
await this.stabilize(() => {
this.list.update((draft) => { draft.current = id as SessionId | undefined })
})
}
/**
* Remove a session: list row, scope fiber, and per-session store instances
* (with persisted state) die together — the same single lifecycle axis the
* production SessionRuntime drives on session death, minus staging.
* @param id - session id.
*/
async remove(id: string): Promise<void> {
const record = this.require(id)
this.records.delete(id as SessionId)
await this.stabilize(async () => {
this.list.update((draft) => {
draft.ids = draft.ids.filter(existing => existing !== id)
const { [id as SessionId]: _dead, ...rest } = draft.byId
draft.byId = rest
if (draft.current === id) draft.current = undefined
})
if (record.scopeFiber !== undefined) await record.scopeFiber.dispose()
this.rootCtx.get('slots')?.pruneStoreScope(id)
})
}
/**
* Register a per-session standard-props provider (production `provide`
* contract: hooks become `use<Name>` selector hooks on the render side,
* props spread verbatim; duplicate names fail loud at materialization).
* @param descriptor - static member roster plus per-session resolver.
* @returns disposer removing the provider.
*/
provide(descriptor: SessionProvideDescriptor): () => void {
return this.channel.provide(descriptor)
}
/**
* Resolve the definite per-session standard-props bundle (host face member).
* @param id - session id.
* @returns the identity-stable bundle, or undefined for unknown sessions.
*/
provideInfo(id: string): SessionProvideInfo | undefined {
const record = this.records.get(id as SessionId)
if (record === undefined) return undefined
record.provideInfo ??= this.channel.materializeInfo(this.bindingOf(id as SessionId, record))
return record.provideInfo
}
/**
* Resolve the current-session-optional standard kit (host face member):
* unknown or absent ids return the static no-session projection.
* @param id - current session id, when selected.
* @returns a definite or no-session provide bundle.
*/
maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
return (id === undefined ? undefined : this.provideInfo(id)) ?? this.channel.maybeInfo
}
/**
* Resolve (mint on first touch) the session-scoped Cordis context through
* the production `createScope`, so real `scopeOf`/scope-addressed services
* resolve it.
* @param id - session id.
* @returns the scoped context, or undefined for unknown sessions.
*/
scope(id: string): AgentContext | undefined {
const record = this.records.get(id as SessionId)
if (record === undefined) return undefined
if (record.scope === undefined) {
const handle = createScope(this.rootCtx, id as SessionId)
record.scope = handle.ctx
record.scopeFiber = handle.fiber
}
return record.scope
}
/**
* Session assembly binding (inject factories and provide resolvers receive it).
* @param id - session id.
* @returns sessionId + behavior face + scoped ctx, or undefined when unknown.
*/
binding(id: string): TestSessionBinding | undefined {
const record = this.records.get(id as SessionId)
if (record === undefined) return undefined
return this.bindingOf(id as SessionId, record)
}
/**
* Read the session scope tag off a context (service-method boundary mirror).
* @param ctx - any client context.
* @returns the session id, or undefined on root contexts.
*/
scopeOf(ctx: Context): SessionId | undefined {
return scopeOf(ctx)
}
/**
* Resolve the scoped session face off a context (production `sessionOf`
* mirror).
* @param ctx - any client context.
* @returns the fixture session face, or undefined off-scope.
*/
sessionOf(ctx: Context): SessionFace | undefined {
const id = scopeOf(ctx)
if (id === undefined) return undefined
return this.records.get(id)?.session
}
/**
* Service-level selection call (recorded, then applied to the list store
* synchronously — inject callbacks call this outside any act window; the
* store notify is microtask-batched so the next stabilized step observes it).
* @param id - session id.
*/
open(id: SessionId): void {
this.calls.push({ method: 'open', args: [id] })
this.require(id)
this.list.update((draft) => {
draft.current = id
draft.currentAddress = undefined
})
}
/** Open an existing fixture through its catalog address. */
openSubagent(address: SubagentAddress): void {
this.calls.push({ method: 'openSubagent', args: [address] })
this.require(address.childSessionId)
this.list.update((draft) => {
draft.current = address.childSessionId
draft.currentAddress = address
})
}
/** Resolve the current fixture's retained catalog address. */
subagentAddress(id: SessionId): SubagentAddress | undefined {
const address = this.list.getSnapshot().currentAddress
return address?.childSessionId === id ? address : undefined
}
/** Record catalog consumption; fixture callers drive snapshots explicitly. */
setSubagentCatalogOpen(parentSessionId: SessionId, open: boolean): void {
this.calls.push({ method: 'setSubagentCatalogOpen', args: [parentSessionId, open] })
}
/** Record a catalog refresh; fixture callers drive snapshots explicitly. */
refreshSubagents(parentSessionId: SessionId): Promise<void> {
this.calls.push({ method: 'refreshSubagents', args: [parentSessionId] })
return Promise.resolve()
}
/** Apply a confirmed preset switch into the fixture list, as production does. */
noteAgentPreset(sessionId: SessionId, agentPreset: string): void {
this.list.update((draft) => {
const summary = draft.byId[sessionId]
if (summary !== undefined) draft.byId[sessionId] = { ...summary, agentPreset }
})
}
/** Clear the current selection (recorded; the production no-session flow). */
clear(): void {
this.calls.push({ method: 'clear', args: [] })
this.list.update((draft) => {
draft.current = undefined
draft.currentAddress = undefined
})
}
/**
* Replace the sidebar-search result page (the call is still recorded).
* @param impl - hits for a query, as the Host would rank them.
*/
stubSearch(impl: (query: string, signal: AbortSignal) => { items: SessionSearchResultItem[]; hasMore: boolean }): void {
this.searchStub = impl
}
/**
* Content search over the fixture corpus (recorded). The default answers an
* empty page: content ranking is Host behavior, so a scenario that asserts
* hits declares them through {@link TestSessions.stubSearch}.
* @param query - non-blank literal phrase.
* @param signal - cancellation for a superseded search (recorded and forwarded).
* @returns the stubbed or empty result page.
*/
search(query: string, signal: AbortSignal): ReturnType<ISessions['search']> {
this.calls.push({ method: 'search', args: [query, signal] })
return Promise.resolve({ ok: true, value: this.searchStub?.(query, signal) ?? { items: [], hasMore: false } })
}
/**
* Recorded fork stub: no child materializes (benches asserting the full
* fork flow drive the production service; this face only proves the call).
* @param opts - source session id, optional cut anchor, and client title policy.
* @returns the source id (no child record is created).
*/
fork(opts: { sessionId: SessionId; atSeq?: number; increaseTitle?: boolean }): Promise<SessionId> {
this.calls.push({ method: 'fork', args: [opts] })
return Promise.resolve(opts.sessionId)
}
/**
* The session face of a fixture (typed view for assertions; fixture
* behavior methods are grafted onto it).
* @param id - session id.
* @returns the FixtureSession the binding and provide channel carry.
*/
behavior(id: string): FixtureSession {
return this.require(id).session
}
/** Dispose minted scope fibers (runtime dispose path). */
async disposeScopes(): Promise<void> {
for (const record of this.records.values()) {
if (record.scopeFiber !== undefined) {
await record.scopeFiber.dispose()
record.scope = undefined
record.scopeFiber = undefined
}
}
}
private bindingOf(id: SessionId, record: SessionRecord): TestSessionBinding {
const ctx = this.scope(id)
/* v8 ignore next 2 -- bindingOf only runs for a live record, whose scope
* always resolves; kept so a future caller cannot mint a ctx-less binding. */
if (ctx === undefined) throw new Error(`test session "${id}" resolved no scope`)
return { sessionId: id, session: record.session, ctx }
}
private require(id: string): SessionRecord {
const record = this.records.get(id as SessionId)
if (record === undefined) throw new Error(`test session "${id}" is not added`)
return record
}
}
@@ -0,0 +1,54 @@
/** Test double for the client settings-scope seam. */
import { vi } from 'vitest'
import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
/** Handle over one stubbed scope: the scope, its write spy, and publication controls. */
export interface StubSettingsScope<T> {
/** The scope face handed to the service under test. */
scope: SettingsScope<T>
/** Spy behind `scope.set`; resolves immediately. */
set: ReturnType<typeof vi.fn>
/** Spy behind `scope.unset`; resolves immediately. */
unset: ReturnType<typeof vi.fn>
/** @returns how many listeners are currently subscribed (disposal assertions). */
listenerCount(): number
/**
* Replace part of the snapshot and notify subscribers, as a Host
* acceptance would.
* @param next - snapshot fields to replace.
*/
publish(next: Partial<SettingsScopeSnapshot<T>>): void
}
/**
* Build an in-memory settings scope for service specs: starts in the host
* loading state, records writes, and lets the test publish Host acceptances.
* @returns the stub handle.
*/
export function stubSettingsScope<T>(): StubSettingsScope<T> {
let snapshot: SettingsScopeSnapshot<T> = {
status: 'loading', value: undefined, base: undefined, user: undefined,
revision: undefined, writable: false, mode: 'host',
}
const listeners = new Set<() => void>()
const set = vi.fn(() => Promise.resolve())
const unset = vi.fn(() => Promise.resolve())
return {
scope: {
getSnapshot: () => snapshot,
subscribe: (listener) => {
listeners.add(listener)
return () => { listeners.delete(listener) }
},
set,
unset,
},
set,
unset,
listenerCount: () => listeners.size,
publish: (next) => {
snapshot = { ...snapshot, ...next }
for (const listener of [...listeners]) listener()
},
}
}
@@ -0,0 +1,89 @@
/**
* DOM snapshot hygiene: a vitest snapshot serializer that keeps `.snap`
* files structural. Two normalizations, both on a clone (the live DOM is
* untouched, so class/tag queries keep working):
*
* - CSS-module scoped class names (`_frame_334d2d`, this repo's
* `_[local]_[hash]` shape) fold back to their semantic local (`frame`), so
* CSS edits do not churn snapshots.
* - `<svg>` internals collapse to a `data-content` fingerprint on the svg
* element: path geometry is print noise, but the fingerprint still flips
* when an icon's artwork actually changes.
*/
import { expect } from 'vitest'
import type { SnapshotSerializer } from 'vitest'
/** One scoped class token: `_<local>_<hash>` (local may itself contain underscores). */
const SCOPED_CLASS = /^_(.+)_[a-z0-9]+$/
/** Fold scoped tokens in one class attribute value; foreign tokens pass through. */
function normalizeClassValue(value: string): string {
return value
.split(/\s+/)
.filter(token => token !== '')
.map(token => token.replace(SCOPED_CLASS, '$1'))
.join(' ')
}
/** FNV-1a 32-bit over the svg markup: deterministic, dependency-free fingerprint. */
function fingerprint(markup: string): string {
let hash = 0x811c9dc5
for (let i = 0; i < markup.length; i++) {
hash ^= markup.charCodeAt(i)
hash = Math.imul(hash, 0x01000193)
}
return (hash >>> 0).toString(16).padStart(8, '0')
}
/** svg elements of a subtree, the root included when it is one. */
function svgsOf(root: Element): Element[] {
const svgs: Element[] = [...root.querySelectorAll('svg')]
if (root.tagName.toLowerCase() === 'svg') svgs.unshift(root)
return svgs
}
/** Whether serializing this subtree needs a normalized clone. */
function needsNormalization(root: Element): boolean {
const scoped = [root, ...root.querySelectorAll('[class]')].some((el) => {
const value = el.getAttribute('class')
return value !== null && value.split(/\s+/).some(token => SCOPED_CLASS.test(token))
})
return scoped || svgsOf(root).some(svg => svg.childNodes.length > 0)
}
/**
* The serializer plugin. Matches DOM elements whose subtree carries a scoped
* class or svg internals; serializes a normalized clone, which no longer
* matches, so printing falls through to the built-in DOM element serializer.
*/
export const domSnapshotSerializer: SnapshotSerializer = {
test(value: unknown): boolean {
return typeof Element !== 'undefined' && value instanceof Element && needsNormalization(value)
},
serialize(value, config, indentation, depth, refs, printer): string {
const clone = (value as Element).cloneNode(true) as Element
for (const el of [clone, ...clone.querySelectorAll('[class]')]) {
const raw = el.getAttribute('class')
if (raw !== null) el.setAttribute('class', normalizeClassValue(raw))
}
for (const svg of svgsOf(clone)) {
if (svg.childNodes.length === 0) continue
svg.setAttribute('data-content', fingerprint(svg.innerHTML))
svg.replaceChildren()
}
return printer(clone, config, indentation, depth, refs)
},
}
let registered = false
/**
* Register {@link domSnapshotSerializer} with vitest's expect (idempotent).
* SlotTestRuntime.create() calls this; specs that snapshot DOM outside the
* runtime import and call it themselves.
*/
export function registerDomSnapshotSerializer(): void {
if (registered) return
registered = true
expect.addSnapshotSerializer(domSnapshotSerializer)
}
@@ -0,0 +1,32 @@
/**
* Test double of the locale lookup chain: a translate stub over plain
* dictionaries, mirroring LocaleRuntime's resolution order (first dictionary
* that owns the key wins, then the key itself stays visible) and its
* `{name}` template interpolation. Specs stub the framework-injected `t`
* seat with `makeTranslate(zh, commonZh)` instead of re-implementing the
* chain per suite.
*/
/**
* Build a translate stub resolving through `dicts` in order (namespace
* first, then the shared common vocabulary), falling back to the key.
* @param dicts - dictionaries consulted in order.
* @returns the translate function (assignable to any `XxxProps['t']` seat).
*/
export function makeTranslate(
...dicts: readonly Record<string, string>[]
): (key: string, params?: Record<string, unknown>) => string {
return (key, params) => {
let template = key
for (const dict of dicts) {
const hit = dict[key]
if (hit !== undefined) {
template = hit
break
}
}
if (!params) return template
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
name in params ? String(params[name]) : match)
}
}
@@ -0,0 +1,215 @@
/** Test-owned workspaces face: the renderer standard-kit observable plus recorded actions. */
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type {
DirectoryListing, IWorkspaces, SessionId, SnapshotStore, WorkspaceId, WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import { workspaceListState } from './fixtures.ts'
import type { Stabilizer } from './fixtures.ts'
/**
* Workspaces test double. Implements the same IWorkspaces face features
* receive as `ctx.workspaces`, so a production face change breaks this
* double at compile time. Every action records into {@link
* TestWorkspaces.calls}; defaults are inert echoes — feature tests needing
* richer behavior replace them via {@link TestWorkspaces.stub}.
*/
export class TestWorkspaces implements IWorkspaces {
/** The useWorkspaces standard feed. */
readonly list: SnapshotStore<WorkspaceListState>
/** Calls observed on the action face, newest last. */
readonly calls: { method: string; args: unknown[] }[] = []
/** Replaceable action seat: feature tests may stub richer behavior. */
private readonly stubs = new Map<string, (...args: unknown[]) => unknown>()
/**
* @param stabilize - the owning runtime's act wrapper.
*/
constructor(private readonly stabilize: Stabilizer) {
this.list = createSnapshotStore<WorkspaceListState>(workspaceListState())
}
/**
* Update the workspace list state through an immer draft.
* @param mutate - draft mutator.
*/
async update(mutate: (draft: WorkspaceListState) => void): Promise<void> {
await this.stabilize(() => { this.list.update(mutate) })
}
/**
* Replace an action's behavior (the recorded call is still appended first).
* @param method - action name (e.g. 'connectWorkspace').
* @param impl - replacement behavior.
*/
stub(method: string, impl: (...args: unknown[]) => unknown): void {
this.stubs.set(method, impl)
}
/**
* Connect a workspace to its reusable/new blank session (recorded). The
* default resolves the workspace id back as the session id; stub for
* cross-session flows.
* @param workspaceId - target workspace.
* @returns the connected session id.
*/
async connectWorkspace(workspaceId: WorkspaceId): Promise<SessionId> {
this.calls.push({ method: 'connectWorkspace', args: [workspaceId] })
const stub = this.stubs.get('connectWorkspace')
if (stub !== undefined) return await (stub(workspaceId) as Promise<SessionId>)
return `session-of-${workspaceId}` as SessionId
}
/**
* New-session flow (recorded; stubbed behavior runs when installed).
* @param workspaceId - optional explicit workspace target.
*/
startSession(workspaceId?: WorkspaceId): void {
this.calls.push({ method: 'startSession', args: [workspaceId] })
this.stubs.get('startSession')?.(workspaceId)
}
/**
* Create a Workspace (recorded). The default echoes a view derived from
* the input; stub for failure or list-coupled flows.
* @param input - the Host create payload.
* @returns the created Workspace view.
*/
async create(input: { path: string }): Promise<WorkspaceView> {
this.calls.push({ method: 'create', args: [input] })
const stub = this.stubs.get('create')
if (stub !== undefined) return await (stub(input) as Promise<WorkspaceView>)
return {
workspaceId: `ws-${input.path}` as WorkspaceId,
title: input.path,
path: input.path,
sessionIds: [],
} as unknown as WorkspaceView
}
/**
* Open a path with the host OS default application (recorded; default no-op).
* @param path - host-resolvable path.
*/
async openPath(path: string): Promise<void> {
this.calls.push({ method: 'openPath', args: [path] })
await (this.stubs.get('openPath')?.(path) as Promise<void> | undefined)
}
/**
* Directory picker (recorded). The default cancels (null); stub to select.
* @returns the picked path, or null.
*/
async pickDirectory(): Promise<string | null> {
this.calls.push({ method: 'pickDirectory', args: [] })
const stub = this.stubs.get('pickDirectory')
if (stub !== undefined) return await (stub() as Promise<string | null>)
return null
}
/**
* Browse listing (recorded). The default serves an empty home level; stub
* to shape a tree.
* @param path - absolute directory to list; absent lists the home level.
* @returns the level's listing.
*/
async listDirectory(path?: string, signal?: AbortSignal): Promise<DirectoryListing> {
// The signal is recorded and forwarded like the production face passes
// it to the wire, so cancellation integration tests can observe or
// reject on a superseded scan.
this.calls.push({ method: 'listDirectory', args: [path, signal] })
const stub = this.stubs.get('listDirectory')
if (stub !== undefined) return await (stub(path, signal) as Promise<DirectoryListing>)
// The chain runs root-to-target inclusive, per the DirectoryListing
// contract — a bare root crumb would mislabel the level in browsers
// driven by this double.
return {
path: '/home/test',
home: '/home/test',
crumbs: [
{ name: '/', path: '/', hidden: false },
{ name: 'home', path: '/home', hidden: false },
{ name: 'test', path: '/home/test', hidden: false },
],
entries: [],
truncated: false,
}
}
/**
* Browse child creation (recorded). The default joins parent and name.
* @param path - absolute existing parent directory.
* @param name - single path segment.
* @returns the created directory's absolute path.
*/
async createDirectory(path: string, name: string): Promise<string> {
this.calls.push({ method: 'createDirectory', args: [path, name] })
const stub = this.stubs.get('createDirectory')
if (stub !== undefined) return await (stub(path, name) as Promise<string>)
return `${path}/${name}`
}
/**
* Rename a Workspace (recorded). The default echoes a minimal view.
* @param workspaceId - target workspace.
* @param title - new title.
* @returns the updated view.
*/
async rename(workspaceId: WorkspaceId, title: string): Promise<WorkspaceView> {
this.calls.push({ method: 'rename', args: [workspaceId, title] })
const stub = this.stubs.get('rename')
if (stub !== undefined) return await (stub(workspaceId, title) as Promise<WorkspaceView>)
return { workspaceId, title, path: `/${title}`, sessionIds: [] } as unknown as WorkspaceView
}
/**
* Delete a Workspace (recorded; default no-op).
* @param workspaceId - target workspace.
*/
async delete(workspaceId: WorkspaceId): Promise<void> {
this.calls.push({ method: 'delete', args: [workspaceId] })
await (this.stubs.get('delete')?.(workspaceId) as Promise<void> | undefined)
}
/**
* Move a Workspace in display order (recorded; default no-op).
* @param workspaceId - Workspace to move.
* @param beforeWorkspaceId - Anchor; omitted appends.
*/
async insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise<void> {
this.calls.push({ method: 'insertBefore', args: [workspaceId, beforeWorkspaceId] })
await (this.stubs.get('insertBefore')?.(workspaceId, beforeWorkspaceId) as Promise<void> | undefined)
}
/**
* Move an accounted session (recorded). The default echoes a minimal view.
* @param workspaceId - target workspace.
* @param sessionId - session to move.
* @param beforeSessionId - anchor; omitted appends.
* @returns the updated view.
*/
async insertSessionBefore(workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId): Promise<WorkspaceView> {
this.calls.push({ method: 'insertSessionBefore', args: [workspaceId, sessionId, beforeSessionId] })
const stub = this.stubs.get('insertSessionBefore')
if (stub !== undefined) return await (stub(workspaceId, sessionId, beforeSessionId) as Promise<WorkspaceView>)
return { workspaceId, title: '', path: '', sessionIds: [sessionId] } as unknown as WorkspaceView
}
/**
* Archive a session (recorded). The default mirrors the production face's
* observable effect: the id joins the list state's archive set.
* @param sessionId - session to archive.
*/
async archiveSession(sessionId: SessionId): Promise<void> {
this.calls.push({ method: 'archiveSession', args: [sessionId] })
const stub = this.stubs.get('archiveSession')
if (stub !== undefined) {
await (stub(sessionId) as Promise<void>)
return
}
await this.update((draft) => {
draft.archivedSessionIds = [...draft.archivedSessionIds, sessionId]
})
}
}