mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
511 lines
19 KiB
TypeScript
511 lines
19 KiB
TypeScript
import { Context } from '@deepseek-ai/cordis'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import type {
|
|
ISessions, SessionListState, SessionSummary,
|
|
} from '@deepseek-ai/dsh-api-session-controller/client'
|
|
import type {
|
|
IWorkspaces, WorkspaceId, WorkspaceSnapshot, WorkspaceView,
|
|
} from '@deepseek-ai/dsh-api-workspace-controller/client'
|
|
import {
|
|
RpcId,
|
|
type IApiClient,
|
|
type RpcError,
|
|
type RpcResponse,
|
|
} from '@deepseek-ai/dsh-client-connection/client'
|
|
import type { ClientRemote, DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client'
|
|
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
|
|
import { SessionId } from '@deepseek-ai/dsh-session/types'
|
|
import { DirectoryBrowseError, UiWorkspaceService } from '../src/client/navigation.ts'
|
|
|
|
const sid = (id: string): SessionId => SessionId(id)
|
|
const wid = (id: string): WorkspaceId => id as WorkspaceId
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
function workspace(
|
|
id: string,
|
|
sessionIds: readonly SessionId[] = [],
|
|
createdAt = '2026-01-01T00:00:00.000Z',
|
|
): WorkspaceView {
|
|
return {
|
|
workspaceId: wid(id),
|
|
path: `/w/${id}`,
|
|
title: id,
|
|
sessionIds,
|
|
createdAt,
|
|
updatedAt: createdAt,
|
|
}
|
|
}
|
|
|
|
function summary(id: string, overrides: Partial<SessionSummary> = {}): SessionSummary {
|
|
return {
|
|
id: sid(id),
|
|
displayTitle: id,
|
|
running: false,
|
|
blank: false,
|
|
updatedAt: 0,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function sessionState(
|
|
summaries: readonly SessionSummary[] = [],
|
|
current?: SessionId,
|
|
phase: SessionListState['phase'] = 'ready',
|
|
): SessionListState {
|
|
return {
|
|
ids: summaries.map(item => item.id),
|
|
byId: Object.fromEntries(summaries.map(item => [item.id, item])),
|
|
current,
|
|
phase,
|
|
subagentsByParent: {},
|
|
jobsBySession: {},
|
|
currentAddress: undefined,
|
|
}
|
|
}
|
|
|
|
function workspaceState(
|
|
items: WorkspaceSnapshot['items'] = [],
|
|
archivedSessionIds: readonly SessionId[] = [],
|
|
phase: WorkspaceSnapshot['phase'] = 'ready',
|
|
): WorkspaceSnapshot {
|
|
return {
|
|
items,
|
|
archivedSessionIds,
|
|
phase,
|
|
state: phase === 'ready' ? 'idle' : 'loading',
|
|
error: null,
|
|
}
|
|
}
|
|
|
|
class MutableSource<T> {
|
|
private readonly listeners = new Set<() => void>()
|
|
|
|
constructor(private value: T) {}
|
|
|
|
getSnapshot(): T {
|
|
return this.value
|
|
}
|
|
|
|
subscribe(listener: () => void): () => void {
|
|
this.listeners.add(listener)
|
|
return () => { this.listeners.delete(listener) }
|
|
}
|
|
|
|
set(value: T): void {
|
|
this.value = value
|
|
for (const listener of [...this.listeners]) listener()
|
|
}
|
|
|
|
update(update: (value: T) => T): void {
|
|
this.set(update(this.value))
|
|
}
|
|
|
|
listenersSnapshot(): readonly (() => void)[] {
|
|
return [...this.listeners]
|
|
}
|
|
}
|
|
|
|
class FakeSessions {
|
|
readonly list: MutableSource<SessionListState>
|
|
readonly create: ReturnType<typeof vi.fn<ISessions['create']>>
|
|
readonly open: ReturnType<typeof vi.fn<(id: SessionId) => void>>
|
|
readonly clear: ReturnType<typeof vi.fn<() => void>>
|
|
|
|
constructor(initial: SessionListState) {
|
|
this.list = new MutableSource(initial)
|
|
this.create = vi.fn<ISessions['create']>(async options =>
|
|
options?.sessionId ?? sid(`created-${String(options?.workspaceId ?? 'none')}`))
|
|
this.open = vi.fn((id: SessionId) => {
|
|
this.list.update(state => ({ ...state, current: id }))
|
|
})
|
|
this.clear = vi.fn(() => {
|
|
this.list.update(state => ({ ...state, current: undefined }))
|
|
})
|
|
}
|
|
}
|
|
|
|
class FakeWorkspaces implements IWorkspaces {
|
|
readonly list: MutableSource<WorkspaceSnapshot>
|
|
readonly archiveCalls: SessionId[] = []
|
|
onArchive: IWorkspaces['archiveSession'] = async (sessionId) => {
|
|
this.list.update(state => ({
|
|
...state,
|
|
archivedSessionIds: [...state.archivedSessionIds, sessionId],
|
|
}))
|
|
}
|
|
|
|
declare readonly create: IWorkspaces['create']
|
|
declare readonly rename: IWorkspaces['rename']
|
|
declare readonly delete: IWorkspaces['delete']
|
|
declare readonly insertBefore: IWorkspaces['insertBefore']
|
|
declare readonly insertSessionBefore: IWorkspaces['insertSessionBefore']
|
|
|
|
constructor(initial: WorkspaceSnapshot) {
|
|
this.list = new MutableSource(initial)
|
|
}
|
|
|
|
archiveSession(sessionId: SessionId): Promise<void> {
|
|
this.archiveCalls.push(sessionId)
|
|
return this.onArchive(sessionId)
|
|
}
|
|
}
|
|
|
|
let nextRpcId = 0
|
|
|
|
function ok<T>(value: T): RpcResponse<T> {
|
|
return { rpcId: RpcId(`workspace-test-${nextRpcId++}`), result: { ok: true, value } }
|
|
}
|
|
|
|
function failed<T>(error: RpcError): RpcResponse<T> {
|
|
return { rpcId: RpcId(`workspace-test-${nextRpcId++}`), result: { ok: false, error } }
|
|
}
|
|
|
|
const listing: DirectoryListing = {
|
|
path: '/home/u',
|
|
home: '/home/u',
|
|
crumbs: [{ name: '/', path: '/', hidden: false }],
|
|
entries: [{ name: 'project', path: '/home/u/project', hidden: false }],
|
|
truncated: false,
|
|
}
|
|
|
|
class FakeApiClient implements IApiClient {
|
|
readonly calls: Array<{ readonly method: string; readonly payload: unknown }> = []
|
|
|
|
onDescribe: IApiClient['host']['describe'] = () => Promise.resolve(ok({
|
|
version: 'test',
|
|
cwd: '/home/u',
|
|
attachedSessions: 0,
|
|
home: '/home/u',
|
|
canOpenPath: true,
|
|
}))
|
|
onOpenPath: IApiClient['host']['openPath'] = () => Promise.resolve(ok({ opened: true }))
|
|
|
|
declare readonly skills: IApiClient['skills']
|
|
declare readonly agentPresets: IApiClient['agentPresets']
|
|
declare readonly settings: IApiClient['settings']
|
|
declare readonly credentials: IApiClient['credentials']
|
|
declare readonly llm: IApiClient['llm']
|
|
|
|
readonly host: IApiClient['host'] = {
|
|
describe: (payload, signal) => this.record('host.describe', payload, this.onDescribe(payload, signal)),
|
|
openPath: (payload, signal) => this.record('host.openPath', payload, this.onOpenPath(payload, signal)),
|
|
}
|
|
|
|
callsOf(method: string): unknown[] {
|
|
return this.calls.filter(call => call.method === method).map(call => call.payload)
|
|
}
|
|
|
|
private record<T>(method: string, payload: unknown, response: Promise<T>): Promise<T> {
|
|
this.calls.push({ method, payload })
|
|
return response
|
|
}
|
|
}
|
|
|
|
/** The directory-picking Remote namespace, recorded and scripted per case. */
|
|
class FakeDirectoryPicker {
|
|
readonly calls: { method: string; payload: unknown }[] = []
|
|
|
|
onPick: () => Promise<RemoteResult<string | null>> = () => Promise.resolve({ ok: true, value: null })
|
|
onList: () => Promise<RemoteResult<DirectoryListing>> = () => Promise.resolve({ ok: true, value: listing })
|
|
onCreateDirectory: () => Promise<RemoteResult<string>> =
|
|
() => Promise.resolve({ ok: true, value: '/home/u/new' })
|
|
|
|
readonly remote: ClientRemote['directoryPicker'] = {
|
|
pick: () => this.record('pick', {}, this.onPick()),
|
|
list: (path?: string) => this.record('list', { path }, this.onList()),
|
|
createDirectory: (path: string, name: string) =>
|
|
this.record('createDirectory', { path, name }, this.onCreateDirectory()),
|
|
}
|
|
|
|
callsOf(method: string): unknown[] {
|
|
return this.calls.filter(call => call.method === method).map(call => call.payload)
|
|
}
|
|
|
|
private record<T>(method: string, payload: unknown, result: Promise<T>): Promise<T> {
|
|
this.calls.push({ method, payload })
|
|
return result
|
|
}
|
|
}
|
|
|
|
interface BenchOptions {
|
|
readonly workspaces?: WorkspaceSnapshot
|
|
readonly sessions?: SessionListState
|
|
}
|
|
|
|
function bench(options: BenchOptions = {}) {
|
|
const ctx = new Context()
|
|
const api = new FakeApiClient()
|
|
const directoryPicker = new FakeDirectoryPicker()
|
|
const workspaces = new FakeWorkspaces(options.workspaces ?? workspaceState([], [], 'pending'))
|
|
const sessions = new FakeSessions(options.sessions ?? sessionState([], undefined, 'pending'))
|
|
const uiWorkspace = new UiWorkspaceService(
|
|
ctx,
|
|
api,
|
|
directoryPicker.remote,
|
|
workspaces,
|
|
sessions as unknown as ISessions,
|
|
)
|
|
return { api, ctx, directoryPicker, sessions, uiWorkspace, workspaces }
|
|
}
|
|
|
|
async function flush(): Promise<void> {
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
}
|
|
|
|
describe('UiWorkspaceService', () => {
|
|
it('reuses only an unarchived member blank and coalesces concurrent creation', async () => {
|
|
const b = bench()
|
|
const memberBlank = sid('member-blank')
|
|
const archivedBlank = sid('archived-blank')
|
|
const summaries: readonly SessionSummary[] = [
|
|
summary('stray', { blank: true, cwd: '/w/alpha' }),
|
|
summary('member-blank', { blank: true, cwd: '/w/alpha' }),
|
|
summary('active', { cwd: '/w/beta' }),
|
|
summary('archived-blank', { blank: true, cwd: '/w/gamma' }),
|
|
]
|
|
b.workspaces.list.set(workspaceState([
|
|
workspace('alpha', [memberBlank]),
|
|
workspace('beta', [sid('active')]),
|
|
workspace('gamma', [archivedBlank]),
|
|
], [archivedBlank]))
|
|
b.sessions.list.set({
|
|
...sessionState(summaries, memberBlank),
|
|
ids: [sid('missing'), ...summaries.map(item => item.id)],
|
|
})
|
|
|
|
await expect(Promise.all([
|
|
b.uiWorkspace.connectWorkspace(wid('alpha')),
|
|
b.uiWorkspace.connectWorkspace(wid('alpha')),
|
|
])).resolves.toEqual([memberBlank, memberBlank])
|
|
expect(b.sessions.create).not.toHaveBeenCalled()
|
|
|
|
const creation = Promise.withResolvers<SessionId>()
|
|
b.sessions.create.mockImplementation(() => creation.promise)
|
|
const first = b.uiWorkspace.connectWorkspace(wid('beta'))
|
|
const second = b.uiWorkspace.connectWorkspace(wid('beta'))
|
|
expect(b.sessions.create).toHaveBeenCalledTimes(1)
|
|
creation.resolve(sid('fresh-beta'))
|
|
await expect(Promise.all([first, second])).resolves.toEqual([sid('fresh-beta'), sid('fresh-beta')])
|
|
|
|
b.sessions.create.mockImplementation(async options => sid(`fresh-${String(options?.workspaceId)}`))
|
|
await expect(b.uiWorkspace.connectWorkspace(wid('gamma'))).resolves.toBe(sid('fresh-gamma'))
|
|
expect(b.sessions.create).toHaveBeenLastCalledWith({ workspaceId: wid('gamma') })
|
|
await expect(b.uiWorkspace.connectWorkspace(wid('ghost')))
|
|
.rejects.toThrow('uiWorkspace.connectWorkspace: unknown workspace ghost')
|
|
})
|
|
|
|
it('targets an explicit, current-session, then recent Workspace and reports failed starts', async () => {
|
|
const current = summary('current', { cwd: '/w/current-home', updatedAt: 1 })
|
|
const recent = summary('recent', { cwd: '/w/recent-home', updatedAt: 2 })
|
|
const b = bench({
|
|
sessions: sessionState([current, recent], current.id),
|
|
workspaces: workspaceState([
|
|
workspace('current-home', [current.id]),
|
|
workspace('recent-home', [recent.id]),
|
|
]),
|
|
})
|
|
b.sessions.create.mockImplementation(async options => sid(`opened-${String(options?.workspaceId)}`))
|
|
|
|
b.uiWorkspace.startSession(wid('recent-home'))
|
|
await vi.waitFor(() => {
|
|
expect(b.sessions.open).toHaveBeenLastCalledWith(sid('opened-recent-home'))
|
|
})
|
|
|
|
b.sessions.open(current.id)
|
|
b.uiWorkspace.startSession()
|
|
await vi.waitFor(() => {
|
|
expect(b.sessions.open).toHaveBeenLastCalledWith(sid('opened-current-home'))
|
|
})
|
|
|
|
b.sessions.clear()
|
|
b.uiWorkspace.startSession()
|
|
await vi.waitFor(() => {
|
|
expect(b.sessions.open).toHaveBeenLastCalledWith(sid('opened-recent-home'))
|
|
})
|
|
|
|
const empty = bench()
|
|
empty.uiWorkspace.startSession()
|
|
expect(empty.sessions.clear).toHaveBeenCalledOnce()
|
|
|
|
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
|
b.sessions.create.mockRejectedValueOnce(new Error('create failed'))
|
|
b.uiWorkspace.startSession(wid('recent-home'))
|
|
await vi.waitFor(() => {
|
|
expect(warning).toHaveBeenCalledWith('new session failed:', expect.any(Error))
|
|
})
|
|
})
|
|
|
|
it('opens the recent Workspace after both baselines arrive', async () => {
|
|
const b = bench()
|
|
b.sessions.create.mockResolvedValue(sid('initial'))
|
|
|
|
const stableFirst = workspace('stable-first', [], '2026-01-01T00:00:00.000Z')
|
|
const recent = workspace('recent', [], '2026-01-02T00:00:00.000Z')
|
|
b.workspaces.list.set(workspaceState([stableFirst, recent]))
|
|
expect(b.sessions.create).not.toHaveBeenCalled()
|
|
b.sessions.list.set(sessionState())
|
|
|
|
await vi.waitFor(() => {
|
|
expect(b.sessions.open).toHaveBeenCalledWith(sid('initial'))
|
|
})
|
|
expect(b.sessions.create).toHaveBeenCalledWith({ workspaceId: wid('recent') })
|
|
expect(b.workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual([
|
|
wid('stable-first'), wid('recent'),
|
|
])
|
|
})
|
|
|
|
it('uses Workspace creation time when members are absent and preserves Host tie order', async () => {
|
|
const b = bench()
|
|
b.sessions.create.mockResolvedValue(sid('initial'))
|
|
|
|
b.workspaces.list.set(workspaceState([
|
|
workspace('newest', [sid('missing')], '2026-03-01T00:00:00.000Z'),
|
|
workspace('same-time', [], '2026-03-01T00:00:00.000Z'),
|
|
workspace('older', [], '2026-01-01T00:00:00.000Z'),
|
|
]))
|
|
b.sessions.list.set(sessionState())
|
|
|
|
await vi.waitFor(() => {
|
|
expect(b.sessions.open).toHaveBeenCalledWith(sid('initial'))
|
|
})
|
|
expect(b.sessions.create).toHaveBeenCalledWith({ workspaceId: wid('newest') })
|
|
})
|
|
|
|
it('retries failed initial selection and never overwrites a later selection', async () => {
|
|
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
|
const b = bench()
|
|
let attempts = 0
|
|
b.sessions.create.mockImplementation(() => ++attempts === 1
|
|
? Promise.reject(new Error('attach exploded'))
|
|
: Promise.resolve(sid('retry')))
|
|
b.workspaces.list.set(workspaceState([workspace('recent')]))
|
|
b.sessions.list.set(sessionState())
|
|
await vi.waitFor(() => {
|
|
expect(warning).toHaveBeenCalledWith('initial workspace selection failed:', expect.any(Error))
|
|
})
|
|
b.workspaces.list.update(state => ({ ...state, items: [...state.items] }))
|
|
await vi.waitFor(() => {
|
|
expect(b.sessions.open).toHaveBeenCalledWith(sid('retry'))
|
|
})
|
|
expect(attempts).toBe(2)
|
|
|
|
const changed = bench()
|
|
const pending = Promise.withResolvers<SessionId>()
|
|
changed.sessions.create.mockImplementation(() => pending.promise)
|
|
changed.workspaces.list.set(workspaceState([workspace('recent')]))
|
|
changed.sessions.list.set(sessionState())
|
|
await vi.waitFor(() => { expect(changed.sessions.create).toHaveBeenCalledOnce() })
|
|
changed.sessions.open(sid('manual'))
|
|
pending.resolve(sid('automatic'))
|
|
await flush()
|
|
expect(changed.sessions.open).toHaveBeenCalledTimes(1)
|
|
expect(changed.sessions.open).toHaveBeenCalledWith(sid('manual'))
|
|
})
|
|
|
|
it('stops initial navigation when its Cordis lifetime is disposed', async () => {
|
|
const success = bench()
|
|
const resolved = Promise.withResolvers<SessionId>()
|
|
success.sessions.create.mockImplementation(() => resolved.promise)
|
|
success.workspaces.list.set(workspaceState([workspace('recent')]))
|
|
success.sessions.list.set(sessionState())
|
|
await vi.waitFor(() => { expect(success.sessions.create).toHaveBeenCalledOnce() })
|
|
await success.ctx.fiber.dispose()
|
|
resolved.resolve(sid('late'))
|
|
await flush()
|
|
expect(success.sessions.open).not.toHaveBeenCalled()
|
|
success.workspaces.list.set(workspaceState([workspace('ignored')]))
|
|
expect(success.sessions.create).toHaveBeenCalledOnce()
|
|
|
|
const failure = bench()
|
|
const rejected = Promise.withResolvers<SessionId>()
|
|
failure.sessions.create.mockImplementation(() => rejected.promise)
|
|
failure.workspaces.list.set(workspaceState([workspace('recent')]))
|
|
failure.sessions.list.set(sessionState())
|
|
await vi.waitFor(() => { expect(failure.sessions.create).toHaveBeenCalledOnce() })
|
|
const staleReconciles = failure.workspaces.list.listenersSnapshot()
|
|
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
|
await failure.ctx.fiber.dispose()
|
|
rejected.reject(new Error('late failure'))
|
|
await flush()
|
|
for (const reconcile of staleReconciles) reconcile()
|
|
expect(warning).not.toHaveBeenCalled()
|
|
expect(failure.sessions.create).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('clears a current Session only after it enters the archive baseline', () => {
|
|
const current = summary('current')
|
|
const idle = summary('idle')
|
|
const b = bench({
|
|
sessions: sessionState([current, idle], current.id),
|
|
workspaces: workspaceState([workspace('one', [current.id, idle.id])]),
|
|
})
|
|
|
|
b.workspaces.list.update(state => ({ ...state, archivedSessionIds: [idle.id] }))
|
|
expect(b.sessions.clear).not.toHaveBeenCalled()
|
|
b.workspaces.list.update(state => ({ ...state, archivedSessionIds: [current.id] }))
|
|
expect(b.sessions.clear).toHaveBeenCalledOnce()
|
|
|
|
b.sessions.open(idle.id)
|
|
b.workspaces.list.update(state => ({ ...state, archivedSessionIds: [idle.id] }))
|
|
expect(b.sessions.clear).toHaveBeenCalledTimes(2)
|
|
|
|
const archived = bench({
|
|
sessions: sessionState([current], current.id),
|
|
workspaces: workspaceState([workspace('one', [current.id])], [current.id]),
|
|
})
|
|
expect(archived.sessions.clear).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('forwards archive commands and preserves failures', async () => {
|
|
const idle = sid('idle')
|
|
const b = bench()
|
|
|
|
await b.uiWorkspace.archiveSession(idle)
|
|
expect(b.workspaces.archiveCalls).toEqual([idle])
|
|
|
|
b.workspaces.onArchive = () => Promise.reject(new Error('archive rejected'))
|
|
await expect(b.uiWorkspace.archiveSession(idle)).rejects.toThrow('archive rejected')
|
|
expect(b.workspaces.archiveCalls).toEqual([idle, idle])
|
|
})
|
|
|
|
it('passes directory operations to the Host and preserves structured browse failures', async () => {
|
|
const b = bench()
|
|
b.directoryPicker.onPick = () => Promise.resolve({ ok: true, value: '/w/alpha' })
|
|
await expect(b.uiWorkspace.pickDirectory()).resolves.toBe('/w/alpha')
|
|
b.directoryPicker.onPick = () => Promise.resolve({ ok: true, value: null })
|
|
await expect(b.uiWorkspace.pickDirectory()).resolves.toBeNull()
|
|
expect(b.directoryPicker.callsOf('pick')).toEqual([{}, {}])
|
|
|
|
await expect(b.uiWorkspace.listDirectory()).resolves.toEqual(listing)
|
|
await expect(b.uiWorkspace.listDirectory('/home/u')).resolves.toEqual(listing)
|
|
expect(b.directoryPicker.callsOf('list')).toEqual([{ path: undefined }, { path: '/home/u' }])
|
|
await expect(b.uiWorkspace.createDirectory('/home/u', 'new')).resolves.toBe('/home/u/new')
|
|
expect(b.directoryPicker.callsOf('createDirectory')).toEqual([{ path: '/home/u', name: 'new' }])
|
|
await expect(b.uiWorkspace.openPath('/w/alpha/file.ts')).resolves.toBeUndefined()
|
|
expect(b.api.callsOf('host.openPath')).toEqual([{ path: '/w/alpha/file.ts' }])
|
|
|
|
b.directoryPicker.onPick = () => Promise.resolve({
|
|
ok: false, error: { code: 'internal', message: 'no chooser', details: {} },
|
|
})
|
|
await expect(b.uiWorkspace.pickDirectory()).rejects.toThrow('directory picker failed: no chooser')
|
|
b.directoryPicker.onList = () => Promise.resolve({
|
|
ok: false, error: { code: 'directory-unreadable', message: 'denied', details: { path: '/private' } },
|
|
})
|
|
const listFailure = b.uiWorkspace.listDirectory('/private')
|
|
await expect(listFailure).rejects.toBeInstanceOf(DirectoryBrowseError)
|
|
await expect(listFailure).rejects.toMatchObject({ rpcError: { code: 'directory-unreadable' } })
|
|
b.directoryPicker.onCreateDirectory = () => Promise.resolve({
|
|
ok: false, error: { code: 'directory-exists', message: 'taken', details: { path: '/home/u/new' } },
|
|
})
|
|
await expect(b.uiWorkspace.createDirectory('/home/u', 'new')).rejects.toMatchObject({
|
|
rpcError: { code: 'directory-exists' },
|
|
})
|
|
b.api.onOpenPath = () => Promise.resolve(failed({ code: 'internal', message: 'boom', details: {} }))
|
|
await expect(b.uiWorkspace.openPath('/missing')).rejects.toThrow('path open failed: boom')
|
|
})
|
|
})
|