Merge remote-tracking branch 'github/master' into xtr/trajectory-inspection-ui
# Conflicts: # packages/client/runtime/src/client/sessions/session.ts # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/README.md # packages/client/ui-conversation/README.zh.md
This commit is contained in:
356 files changed
+8801
-1511
No files matched your search
@@ -0,0 +1,82 @@
|
||||
/** 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'
|
||||
|
||||
/**
|
||||
* 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,
|
||||
nodes: [],
|
||||
foldDegraded: false,
|
||||
partial: null,
|
||||
runningCalls: [],
|
||||
codeDispatches: new Map(),
|
||||
pending: [],
|
||||
queue: [],
|
||||
running: false,
|
||||
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 WorkspacesService
|
||||
* projects after both baselines land).
|
||||
* @returns the initial state of the test workspaces store.
|
||||
*/
|
||||
export function workspaceListState(): WorkspaceListState {
|
||||
return {
|
||||
items: [],
|
||||
state: 'idle',
|
||||
phase: 'ready',
|
||||
error: null,
|
||||
baselinesReady: true,
|
||||
recentWorkspaceId: undefined,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* jsdom slot test runtime: a real small runtime — Cordis `Context`, the
|
||||
* runtime `SlotsService`, 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 `dshClient`); 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
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/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 'cordis'
|
||||
import type { Fiber, Plugin } from '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 { SlotsService } 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 { TestWorkspaces } from './workspaces.ts'
|
||||
export { conversationSnapshot, workspaceListState } from './fixtures.ts'
|
||||
export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts'
|
||||
|
||||
/** Erased register face for the internal root call (the public declare seam 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 seam.
|
||||
*/
|
||||
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 SlotsService.
|
||||
* @param stabilize - the owning runtime's act wrapper.
|
||||
*/
|
||||
constructor(private readonly slots: SlotsService, 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', keyof NoInfer<D> & keyof SlotMap & string, undefined, object>>,
|
||||
): Promise<void> {
|
||||
await this.stabilize(() => {
|
||||
// Erased hop (same pattern as SlotsService's own implementation arm);
|
||||
// the declare signature above is the typed seam.
|
||||
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 SlotsService mounted on {@link SlotTestRuntime.ctx}. */
|
||||
readonly slots: SlotsService
|
||||
/** 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: SlotsService) {
|
||||
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 SlotsService, 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(SlotsService)
|
||||
await fiber.await()
|
||||
return new SlotTestRuntime(ctx, ctx.get('slots') as SlotsService)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* seam): `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 '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 SlotsService 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,398 @@
|
||||
/** Test-owned sessions face: the SlotsService host contract over declarative fixtures. */
|
||||
import type { Context } from 'cordis'
|
||||
import { createScope, scopeOf, SessionProvideChannel } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, ISessions, ObservableSnapshot, ProjectionsFace, SessionFace, SessionId,
|
||||
SessionListState, SessionProvideDescriptor, SessionSummary, SnapshotStore,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
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 `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 `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 `loadAllHistory` on the fixture's session face to exercise it.
|
||||
* @returns never — always throws.
|
||||
*/
|
||||
loadAllHistory(): never {
|
||||
throw new Error(`test session "${this.sessionId}": loadAllHistory 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: Context | 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: Context
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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 SessionsService
|
||||
* mirror): selection changes and provider-roster changes publish through
|
||||
* this one source — the member the SlotsService 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 (open/clear), newest last. */
|
||||
readonly calls: { method: 'open' | 'clear'; args: unknown[] }[] = []
|
||||
|
||||
/**
|
||||
* @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',
|
||||
})
|
||||
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) })
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 SessionsService 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): Context | 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 seam 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 })
|
||||
}
|
||||
|
||||
/** 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 })
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,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,147 @@
|
||||
/** Test-owned workspaces face: the renderer standard-kit observable plus recorded actions. */
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
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 - exactly one Host create spelling.
|
||||
* @returns the created Workspace view.
|
||||
*/
|
||||
async create(input: { name: string } | { 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>)
|
||||
const title = 'name' in input ? input.name : input.path
|
||||
return {
|
||||
workspaceId: `ws-${title}` as WorkspaceId,
|
||||
title,
|
||||
path: 'path' in input ? input.path : `/${input.name}`,
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user