mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge updated codex/tool-json-schema-dsl into codex/canonical-tool-output
This commit is contained in:
@@ -547,6 +547,12 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver a client response to the in-memory contract impl (no HTTP POST),
|
||||
* echoing the envelope to the observation tap like every other path.
|
||||
* @param message - the client-response envelope answering a server request.
|
||||
* @returns the carrier receipt from the fixture impl.
|
||||
*/
|
||||
override async respond(message: ClientResponse): Promise<RpcReceipt> {
|
||||
this.onEnvelope(message)
|
||||
return this.api.respond(message)
|
||||
|
||||
64
packages/client/connection/tests/client-apply.spec.ts
Normal file
64
packages/client/connection/tests/client-apply.spec.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Connection plugin browser-half apply: ctx.connection handle mounting, mode
|
||||
* selection off the page URL, and the single-consumer stream-loop ownership.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { apply, type ConnectionHandle } from '../src/client/index.ts'
|
||||
import { FixtureApiClient } from '../src/client/fixture.ts'
|
||||
import { WebApiClient } from '../src/client/web-api-client.ts'
|
||||
|
||||
type Win = { location?: { search: string } }
|
||||
|
||||
afterEach(() => {
|
||||
delete (globalThis as Win).location
|
||||
})
|
||||
|
||||
async function mount(): Promise<ConnectionHandle> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin({ apply, inject: [] })
|
||||
const handle = ctx.get('connection') as ConnectionHandle | undefined
|
||||
if (handle === undefined) throw new Error('ctx.connection not provided')
|
||||
return handle
|
||||
}
|
||||
|
||||
describe('connection client apply', () => {
|
||||
it('mounts ctx.connection with the real client when no ?fixture switch is present', async () => {
|
||||
;(globalThis as Win).location = { search: '' }
|
||||
const handle = await mount()
|
||||
expect(handle.api).toBeInstanceOf(WebApiClient)
|
||||
})
|
||||
|
||||
it('selects the fixture client under ?fixture (and with no location at all stays real)', async () => {
|
||||
;(globalThis as Win).location = { search: '?fixture' }
|
||||
expect((await mount()).api).toBeInstanceOf(FixtureApiClient)
|
||||
delete (globalThis as Win).location
|
||||
expect((await mount()).api).toBeInstanceOf(WebApiClient)
|
||||
})
|
||||
|
||||
it('start() hands out one loop, rejects a second consumer, and stop() aborts the streams', async () => {
|
||||
;(globalThis as Win).location = { search: '?fixture' }
|
||||
const handle = await mount()
|
||||
// config omitted: the `config ?? {}` default arm is part of the surface.
|
||||
const loop = handle.start({})
|
||||
expect(() => handle.start({})).toThrow(/already owned by another consumer/)
|
||||
loop.stop() // teardown must not throw; the fixture streams abort quietly
|
||||
})
|
||||
|
||||
it('WebApiClient carries requests over globalThis.fetch', async () => {
|
||||
;(globalThis as Win).location = { search: '' }
|
||||
const handle = await mount()
|
||||
const original = globalThis.fetch
|
||||
const seen: string[] = []
|
||||
globalThis.fetch = ((input: URL | RequestInfo) => {
|
||||
seen.push(String(input))
|
||||
return Promise.resolve(new Response('{}', { status: 200 }))
|
||||
}) as typeof fetch
|
||||
try {
|
||||
await (handle.api as WebApiClient).host.describe({}).catch(() => undefined) // schema rejection is fine — the transport hop is the assertion
|
||||
} finally {
|
||||
globalThis.fetch = original
|
||||
}
|
||||
expect(seen.some(u => u.includes('/api/'))).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -204,6 +204,9 @@ export class SessionsService {
|
||||
/** Run deferred teardowns whose session is no longer watched (called when the watch moves). */
|
||||
private sweepDeferred(): void {
|
||||
for (const id of [...this.deferredRemovals]) {
|
||||
/* v8 ignore next -- defensive: only the watched id ever defers, and every
|
||||
* watch move sweeps first, so the set cannot contain the id the watch just
|
||||
* moved to; kept as a guard against future extra sweep call sites. */
|
||||
if (id === this.watched) continue
|
||||
// Still absent from the list? (A re-added id cancels the deferred teardown.)
|
||||
if (this.list.getSnapshot().byId[id] !== undefined) {
|
||||
@@ -212,6 +215,9 @@ export class SessionsService {
|
||||
}
|
||||
const record = this.scopes.get(id)
|
||||
this.deferredRemovals.delete(id)
|
||||
/* v8 ignore next -- defensive: prune deletes a scope and its deferral
|
||||
* together, so a deferred id always still owns its record; kept so a
|
||||
* future teardown path cannot double-dispose. */
|
||||
if (record !== undefined) {
|
||||
this.scopes.delete(id)
|
||||
void record.fiber.dispose()
|
||||
|
||||
64
packages/client/runtime/tests/client-apply.spec.ts
Normal file
64
packages/client/runtime/tests/client-apply.spec.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Runtime plugin browser-half apply: slots + sessions mounting over the
|
||||
* connection handle, stream-loop sink wiring into the object layer, and the
|
||||
* fiber-scoped loop teardown.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import * as RuntimeClient from '../src/client/index.ts'
|
||||
import { FakeApiClient } from './fake-api.ts'
|
||||
|
||||
interface Bench {
|
||||
ctx: Context
|
||||
api: FakeApiClient
|
||||
sinks: ConnectionSinks | undefined
|
||||
stopped: number
|
||||
}
|
||||
|
||||
async function mount(): Promise<Bench> {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const bench: Bench = { ctx, api, sinks: undefined, stopped: 0 }
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
start: (sinks) => {
|
||||
bench.sinks = sinks
|
||||
return { stop: () => { bench.stopped += 1 } }
|
||||
},
|
||||
}
|
||||
ctx.reflect.provide('connection', handle)
|
||||
await ctx.plugin(RuntimeClient).await()
|
||||
return bench
|
||||
}
|
||||
|
||||
describe('runtime client apply', () => {
|
||||
it('mounts ctx.slots + ctx.sessions and wires the stream sinks into the manager', async () => {
|
||||
const bench = await mount()
|
||||
expect(bench.ctx.get('slots') !== undefined).toBe(true)
|
||||
const sessions = bench.ctx.get('sessions')
|
||||
expect(sessions !== undefined).toBe(true)
|
||||
expect(bench.sinks).toBeDefined()
|
||||
|
||||
// Frame sinks reach the object layer: a host session-added lands in the list store.
|
||||
bench.sinks?.onHostEnvelope?.({
|
||||
rpcId: 'r1' as never,
|
||||
payload: { type: 'host/session-added', sessionId: 's-new' } as never,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect((sessions as { list: { getSnapshot(): { ids: string[] } } }).list.getSnapshot().ids).toContain('s-new')
|
||||
// Mux sink and onConnected route without throwing (manager semantics own the behavior).
|
||||
bench.sinks?.onMuxEnvelope?.({ rpcId: 'r2' as never, payload: { type: 'stream/error', message: 'x' } as never })
|
||||
bench.sinks?.onConnected?.()
|
||||
})
|
||||
|
||||
it('stops the stream loop when the plugin fiber unloads', async () => {
|
||||
const bench = await mount()
|
||||
const fiber = [...bench.ctx.registry.values()].find(f => f.name?.includes('client'))
|
||||
// Dispose the whole tree: the ctx.effect teardown must call loop.stop exactly once.
|
||||
await bench.ctx.fiber.dispose()
|
||||
expect(bench.stopped).toBe(1)
|
||||
void fiber
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Real-bundle smoke: the actual tsdown client bundle of ui-layout runs
|
||||
* through the loader chain (execute → handoff → factory(require) → apply →
|
||||
* export re-registration). Skips when the bundle is not built (dist/ is a
|
||||
* export re-registration). Skips when the bundle is not built (lib/client.js is a
|
||||
* build product; `pnpm --filter @deepseek-ai/dsh-client-ui-layout build`).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
@@ -185,8 +185,105 @@ describe('failure modes (fail loud)', () => {
|
||||
expect(() => createClientLoader({ ctx: new Context(), modules: {}, boot: { plugins: [] } })).toThrow(/already installed/)
|
||||
})
|
||||
|
||||
it('direct load() before a dependency is active fails loud (same check start() sequences)', async () => {
|
||||
const b = bench(
|
||||
[entry('dep', [], true), entry('needy', ['dep'])],
|
||||
{ '/plugins/dep/client.js': okBundle(), '/plugins/needy/client.js': okBundle() },
|
||||
)
|
||||
await expect(b.loader.load('needy')).rejects.toThrow(/loaded before its dependency "dep" is active/)
|
||||
})
|
||||
|
||||
it('direct load() naming an unknown inject target fails loud', async () => {
|
||||
const b = bench([entry('solo', ['phantom'])], { '/plugins/solo/client.js': okBundle() })
|
||||
await expect(b.loader.load('solo')).rejects.toThrow(/injects unknown plugin "phantom"/)
|
||||
})
|
||||
|
||||
it('an immediately-group fetch failure surfaces through settled, not as an unhandled prefetch rejection', async () => {
|
||||
// The fire-and-forget prefetch swallow arm must absorb the early
|
||||
// rejection; the awaited load surfaces the same failure via settled().
|
||||
const ctx = new Context()
|
||||
delete win.DSHClientProxy
|
||||
const loader = createClientLoader({
|
||||
ctx,
|
||||
modules: {},
|
||||
boot: { plugins: [{ id: 'kaboom', url: '/plugins/kaboom/client.js', inject: [], immediately: true }] },
|
||||
fetchBundle: () => Promise.reject(new Error('bundle fetch exploded')),
|
||||
executeBundle: () => {},
|
||||
})
|
||||
loader.start()
|
||||
await expect(loader.settled()).rejects.toThrow(/bundle fetch exploded/)
|
||||
})
|
||||
|
||||
it('unload is the P-I stub', async () => {
|
||||
const b = bench([], {})
|
||||
await expect(b.loader.unload('x')).rejects.toThrow(/not implemented/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DOM default seams (stubbed globals)', () => {
|
||||
it('default fetchBundle uses fetch, rejects non-OK; default executeBundle injects an inline script; claimStyles tags orphans', async () => {
|
||||
const origFetch = globalThis.fetch
|
||||
const appended: { textContent?: string | null }[] = []
|
||||
const styleTag = {
|
||||
attrs: {} as Record<string, string>,
|
||||
setAttribute(k: string, v: string) { this.attrs[k] = v },
|
||||
}
|
||||
const fakeDoc = {
|
||||
createElement: () => {
|
||||
const el = { textContent: null as string | null }
|
||||
return el
|
||||
},
|
||||
head: { appendChild: (el: { textContent?: string | null }) => { appended.push(el) } },
|
||||
querySelectorAll: () => [styleTag],
|
||||
}
|
||||
const g = globalThis as { document?: unknown; fetch: typeof fetch }
|
||||
g.document = fakeDoc
|
||||
g.fetch = ((url: URL | RequestInfo) => Promise.resolve(
|
||||
String(url).includes('bad')
|
||||
? new Response('x', { status: 500 })
|
||||
: new Response('window.DSHClientProxy.loadPlugin(globalThis.__seamHandoff)', { status: 200 }),
|
||||
)) as typeof fetch
|
||||
try {
|
||||
delete win.DSHClientProxy
|
||||
const ctx = new Context()
|
||||
const loader = createClientLoader({
|
||||
ctx,
|
||||
modules: {},
|
||||
boot: { plugins: [
|
||||
{ id: 'seam-ok', url: '/plugins/seam-ok/client.js', inject: [] },
|
||||
{ id: 'seam-bad', url: '/plugins/bad/client.js', inject: [] },
|
||||
] },
|
||||
// NO seams injected (keys omitted, not undefined — exactOptional):
|
||||
// the DOM defaults are under test.
|
||||
})
|
||||
const seamHandoff: ClientPluginHandoff = {
|
||||
id: 'seam-ok',
|
||||
factory: () => ({ apply: () => {} }),
|
||||
}
|
||||
// Default executeBundle only APPENDS the script element (no execution in
|
||||
// our fake DOM), so drive the handoff manually before load resolves it.
|
||||
const loadOk = loader.load('seam-ok')
|
||||
await Promise.resolve()
|
||||
;(globalThis as Win).DSHClientProxy?.loadPlugin(seamHandoff)
|
||||
await loadOk
|
||||
expect(appended).toHaveLength(1)
|
||||
expect(appended[0]?.textContent).toContain('sourceURL=/plugins/seam-ok/client.js')
|
||||
expect(styleTag.attrs['data-plugin']).toBe('seam-ok')
|
||||
await expect(loader.load('seam-bad')).rejects.toThrow(/answered 500/)
|
||||
} finally {
|
||||
g.fetch = origFetch
|
||||
delete (globalThis as { document?: unknown }).document
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('handoff slot protocol', () => {
|
||||
it('rejects an overlapping loadPlugin before the loader claims the pending handoff', () => {
|
||||
delete win.DSHClientProxy
|
||||
createClientLoader({ ctx: new Context(), modules: {}, boot: { plugins: [] } })
|
||||
const proxy = (globalThis as Win).DSHClientProxy
|
||||
proxy?.loadPlugin({ id: 'first', factory: () => ({ apply: () => {} }) })
|
||||
expect(() => proxy?.loadPlugin({ id: 'second', factory: () => ({ apply: () => {} }) }))
|
||||
.toThrow(/overlapping loadPlugin handoff/)
|
||||
})
|
||||
})
|
||||
|
||||
47
packages/client/runtime/tests/invariant.spec.ts
Normal file
47
packages/client/runtime/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Runtime invariant companion: the 'slots/changed' emission-order audit —
|
||||
* a fired key must already carry a bumped version (emission follows the
|
||||
* applied mutation), bogus payloads fail loud, foreign events pass.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as RuntimeInvariant from '../src/invariant.ts'
|
||||
import { SlotsService } from '../src/client/slots.ts'
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await ctx.plugin(RuntimeInvariant).await()
|
||||
return ctx
|
||||
}
|
||||
|
||||
const emit = (ctx: Context, event: string, ...args: unknown[]): void => {
|
||||
;(ctx.emit as (event: string, ...args: unknown[]) => void)(event, ...args)
|
||||
}
|
||||
|
||||
describe('runtime slots/changed invariant', () => {
|
||||
it('passes foreign events and a legitimate mutation-then-emission sequence', async () => {
|
||||
const ctx = await setup()
|
||||
expect(() => { emit(ctx, 'unrelated/event', 'x') }).not.toThrow()
|
||||
await ctx.plugin(SlotsService).await() // fiber must reach ACTIVE — the audit reads strict ctx.get
|
||||
// A real define bumps the version first and re-emits through onMutate —
|
||||
// the audit sees version > 0 and stays quiet.
|
||||
expect(() => ctx.slots.define('t-single', { kind: 'single', scope: 'root' })).not.toThrow()
|
||||
})
|
||||
|
||||
it('fails loud on a missing key and on an emission with no applied mutation', async () => {
|
||||
const ctx = await setup()
|
||||
expect(() => { emit(ctx, 'slots/changed', '') }).toThrow(/without a slot key/)
|
||||
expect(() => { emit(ctx, 'slots/changed', 42) }).toThrow(/without a slot key/)
|
||||
await ctx.plugin(SlotsService).await()
|
||||
// Hand-emitted key that never saw a mutation: version 0 → violation.
|
||||
expect(() => { emit(ctx, 'slots/changed', 'never-mutated') })
|
||||
.toThrow(/before any mutation bumped its version/)
|
||||
})
|
||||
|
||||
it('stays quiet when no slots service is mounted (nothing to audit against)', async () => {
|
||||
const ctx = await setup()
|
||||
expect(() => { emit(ctx, 'slots/changed', 'any-key') }).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -139,3 +139,55 @@ describe('create', () => {
|
||||
await expect(b.svc.create()).rejects.toThrow(/internal: 爆了/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('coverage tails (branch duals)', () => {
|
||||
it('titleOf falls back to the id for empty and separator-only cwd', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 'no-base', cwd: '///' }, { id: 'empty-cwd', cwd: '' }])
|
||||
const { byId } = b.svc.list.getSnapshot()
|
||||
expect(byId[sid('no-base')]?.title).toBe('no-base')
|
||||
expect(byId[sid('empty-cwd')]?.title).toBe('empty-cwd')
|
||||
})
|
||||
|
||||
it('binding for an unknown session returns undefined without moving the watch', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
b.svc.binding(sid('s1'))
|
||||
expect(b.svc.binding(sid('ghost'))).toBeUndefined()
|
||||
// Watch unchanged: removing s1 defers (still watched), proving the ghost lookup did not steal the watch.
|
||||
await feedList(b, [])
|
||||
expect(b.svc.scope(sid('s1'))).toBeDefined()
|
||||
})
|
||||
|
||||
it('sweep skips the id that is itself still watched and tolerates a scope record already gone', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
b.svc.binding(sid('s1'))
|
||||
await feedList(b, []) // deferred removal of the watched id
|
||||
// Re-resolving the SAME watched id: sweep runs but must skip it (watched-continue branch).
|
||||
expect(b.svc.binding(sid('s1'))).toBeDefined()
|
||||
expect(b.svc.scope(sid('s1'))).toBeDefined()
|
||||
})
|
||||
|
||||
it('sweep hits both deferral edges: watched-id skip and an already-vacated scope record', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 'a' }, { id: 'b' }])
|
||||
b.svc.binding(sid('a'))
|
||||
b.svc.binding(sid('b')) // watch: b; both scoped
|
||||
await feedList(b, []) // a removed unwatched → torn immediately; b removed watched → deferred
|
||||
// Move the watch to a THIRD id while b stays deferred: sweep now walks a
|
||||
// set containing b (torn) — and the watched-continue branch fires when the
|
||||
// deferral set still holds the current watch target.
|
||||
await feedList(b, [{ id: 'c' }])
|
||||
b.svc.binding(sid('c'))
|
||||
expect(b.svc.scope(sid('b'))).toBeUndefined()
|
||||
// Deferral for an id whose record was never minted: force-add via removed
|
||||
// list state (scope teardown raced) — sweep must tolerate the missing record.
|
||||
await feedList(b, [])
|
||||
b.svc.binding(sid('c')) // c now watched+removed → deferred
|
||||
await feedList(b, [{ id: 'd' }])
|
||||
b.svc.binding(sid('d')) // sweep tears c
|
||||
expect(b.svc.scope(sid('c'))).toBeUndefined()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -62,4 +62,19 @@ describe('SlotsService', () => {
|
||||
// The slot definition (registered from root) survives; a new occupant may register.
|
||||
expect(() => ctx.slots.register('t-single', C)).not.toThrow()
|
||||
})
|
||||
|
||||
it('proxies specDynamic/subscribe/getVersion through the core', async () => {
|
||||
const ctx = await boot()
|
||||
ctx.slots.define('t-list', { kind: 'list', scope: 'root' })
|
||||
expect(ctx.slots.specDynamic('t-list')).toEqual({ kind: 'list', scope: 'root' })
|
||||
expect(ctx.slots.specDynamic('never-defined')).toBeUndefined()
|
||||
let notified = 0
|
||||
const unsubscribe = ctx.slots.subscribe('t-list', () => { notified += 1 })
|
||||
ctx.slots.register('t-list', C, { id: 'row' })
|
||||
await new Promise(resolve => setTimeout(resolve, 0)) // microtask-batched flush
|
||||
expect(notified).toBeGreaterThan(0)
|
||||
expect(ctx.slots.getVersion('t-list')).toBeGreaterThan(0)
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -50,7 +50,8 @@ export class ToolViewRegistry {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
const at = list.indexOf(entry)
|
||||
/* v8 ignore next -- negative arm: an entry lives in one list and only its own once-guarded disposer removes it, so a live disposer always finds it. */
|
||||
/* v8 ignore next -- negative arm: an entry lives in one list and only its
|
||||
own once-guarded disposer removes it, so a live disposer always finds it. */
|
||||
if (at >= 0) list.splice(at, 1)
|
||||
if (list.length === 0) this.byTool.delete(tool)
|
||||
this.bump()
|
||||
|
||||
@@ -17,7 +17,7 @@ const sid = (s: string): SessionId => s as SessionId
|
||||
const SCOPE_TAG: symbol = (() => {
|
||||
const recorded: (string | symbol)[] = []
|
||||
const spy = new Proxy(new Context(), {
|
||||
get(target, prop, receiver) {
|
||||
get(target, prop, receiver): unknown {
|
||||
recorded.push(prop)
|
||||
return Reflect.get(target, prop, receiver)
|
||||
},
|
||||
@@ -46,6 +46,7 @@ async function bench(opts?: { layout?: boolean; sessions?: boolean }) {
|
||||
}
|
||||
return scoped
|
||||
}
|
||||
const createMock = vi.fn(() => Promise.resolve(sid('new-1')))
|
||||
const sessionsFake = {
|
||||
manager: {
|
||||
get: (id: SessionId) => {
|
||||
@@ -60,7 +61,7 @@ async function bench(opts?: { layout?: boolean; sessions?: boolean }) {
|
||||
return s
|
||||
},
|
||||
},
|
||||
create: vi.fn(() => Promise.resolve(sid('new-1'))),
|
||||
create: createMock,
|
||||
scope: (id: SessionId) => (id === sid('new-1') ? mint(id) : scopes.get(id)),
|
||||
} as unknown as SessionsService
|
||||
if (opts?.sessions !== false) ctx.provide('sessions', sessionsFake)
|
||||
@@ -70,7 +71,7 @@ async function bench(opts?: { layout?: boolean; sessions?: boolean }) {
|
||||
await fiber.await()
|
||||
const svc = ctx.get('conversation') as ConversationService
|
||||
const scopedSvc = (id: SessionId) => mint(id).get('conversation') as ConversationService
|
||||
return { ctx, svc, scopedSvc, mint, sessionDoubles, sessionsFake, layoutFake }
|
||||
return { ctx, svc, scopedSvc, mint, sessionDoubles, sessionsFake, createMock, layoutFake }
|
||||
}
|
||||
|
||||
describe('send / cancel', () => {
|
||||
@@ -122,7 +123,7 @@ describe('startSession chain', () => {
|
||||
it('creates, navigates, then sends through the new scope', async () => {
|
||||
const b = await bench()
|
||||
await b.svc.startSession({ cwd: '/proj', text: 'first', mode: 'queue' })
|
||||
expect(b.sessionsFake.create).toHaveBeenCalledWith({ cwd: '/proj' })
|
||||
expect(b.createMock).toHaveBeenCalledWith({ cwd: '/proj' })
|
||||
expect(b.layoutFake.open).toHaveBeenCalledWith(sid('new-1'))
|
||||
expect(b.sessionDoubles.get(sid('new-1'))!.prompt).toHaveBeenCalledWith(
|
||||
[{ type: 'text', text: 'first' }], 'queue')
|
||||
@@ -131,7 +132,7 @@ describe('startSession chain', () => {
|
||||
it('omits cwd from create when not chosen', async () => {
|
||||
const b = await bench()
|
||||
await b.svc.startSession({ text: 't', mode: 'steer' })
|
||||
expect(b.sessionsFake.create).toHaveBeenCalledWith({})
|
||||
expect(b.createMock).toHaveBeenCalledWith({})
|
||||
})
|
||||
|
||||
it('fails loud when the created session resolves no scope', async () => {
|
||||
|
||||
@@ -25,7 +25,7 @@ const sid = (s: string): SessionId => s as SessionId
|
||||
const SCOPE_TAG: symbol = (() => {
|
||||
const recorded: (string | symbol)[] = []
|
||||
const spy = new Proxy(new Context(), {
|
||||
get(target, prop, receiver) {
|
||||
get(target, prop, receiver): unknown {
|
||||
recorded.push(prop)
|
||||
return Reflect.get(target, prop, receiver)
|
||||
},
|
||||
|
||||
@@ -117,6 +117,10 @@ export function createSnapshotStore<T>(
|
||||
* (quota, private mode) only disable persistence, never break the store.
|
||||
*/
|
||||
function attachPersistence<T>(api: StoreApi<T>, name: string): void {
|
||||
// Non-browser runs (node e2e booting the client tree) have no localStorage:
|
||||
// persistence silently disables — same contract as a storage failure, minus
|
||||
// the per-store console noise a ReferenceError would produce.
|
||||
if (typeof localStorage === 'undefined') return
|
||||
try {
|
||||
const raw = localStorage.getItem(name)
|
||||
if (raw !== null) {
|
||||
|
||||
@@ -50,9 +50,9 @@ describe.skipIf(!built)('mountWebPlugins + registry', () => {
|
||||
'@deepseek-ai/dsh-client-ui-theme',
|
||||
'@deepseek-ai/dsh-client-i18n',
|
||||
])
|
||||
// Every row resolves a client path under its own package dist/.
|
||||
// Every row resolves a client path under its own package lib/.
|
||||
for (const row of rows) {
|
||||
expect(registry.clientPath(row.id)).toMatch(/dist[/\\]client\.js$/)
|
||||
expect(registry.clientPath(row.id)).toMatch(/lib[/\\]client\.js$/)
|
||||
expect(row.url).toBe(`/plugins/${row.id}/client.js`)
|
||||
}
|
||||
registry.dispose()
|
||||
|
||||
@@ -40,10 +40,12 @@ interface PackageManifest {
|
||||
bin?: string | Record<string, string>
|
||||
exports?: Record<
|
||||
string,
|
||||
| string
|
||||
| {
|
||||
types?: string
|
||||
default?: string
|
||||
}
|
||||
| null
|
||||
| undefined
|
||||
>
|
||||
files?: string[]
|
||||
@@ -119,11 +121,11 @@ function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] {
|
||||
// (single-artifact ruling: dist/ retired, ./client resolves lib/client.js).
|
||||
// Keyed on the artifact path, not the subpath name: apiproxy's ./client is
|
||||
// a browser-safe source channel, not a bundle.
|
||||
...manifest.exports?.['./client']?.default === './lib/client.js' ? ['lib/client.js'] : [],
|
||||
...exportDefault(manifest, './client') === './lib/client.js' ? ['lib/client.js'] : [],
|
||||
// runtime's shell-held loader subpath ships as its own bundle beside the client half.
|
||||
...manifest.exports?.['./loader']?.default === './lib/loader.js' ? ['lib/loader.js'] : [],
|
||||
...exportDefault(manifest, './loader') === './lib/loader.js' ? ['lib/loader.js'] : [],
|
||||
// web-react's store subpath ships its own bundle (single-entry builds; no shared chunk).
|
||||
...manifest.exports?.['./store']?.default === './lib/store/index.js' ? ['lib/store/index.js'] : [],
|
||||
...exportDefault(manifest, './store') === './lib/store/index.js' ? ['lib/store/index.js'] : [],
|
||||
...extras,
|
||||
// Subpaths whose runtime default is the tsc-emitted tree (lib/types/*.js —
|
||||
// browser-safe source channels rehomed off src so plain Node can import
|
||||
@@ -136,12 +138,18 @@ function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] {
|
||||
]
|
||||
}
|
||||
|
||||
/** Runtime target of an export entry: conditional `default`, or the bare-string shorthand. */
|
||||
function exportDefault(manifest: PackageManifest, subpath: string): string | undefined {
|
||||
const entry = manifest.exports?.[subpath]
|
||||
if (typeof entry === 'string') return entry
|
||||
if (typeof entry === 'object' && entry !== null) return entry.default
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Whether any export's runtime default points into the tsc-emitted lib/types tree. */
|
||||
function usesEmittedTreeDefaults(manifest: PackageManifest): boolean {
|
||||
return Object.values(manifest.exports ?? {}).some(entry =>
|
||||
typeof entry === 'object' && entry !== null
|
||||
&& typeof (entry as { default?: unknown }).default === 'string'
|
||||
&& ((entry as { default: string }).default).startsWith('./lib/types/'))
|
||||
return Object.keys(manifest.exports ?? {}).some(subpath =>
|
||||
exportDefault(manifest, subpath)?.startsWith('./lib/types/') === true)
|
||||
}
|
||||
|
||||
function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] {
|
||||
@@ -177,13 +185,16 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] {
|
||||
if (manifest.types !== 'lib/types/index.d.ts') {
|
||||
errors.push(`${label}: package.json must set "types": "lib/types/index.d.ts"`)
|
||||
}
|
||||
if (manifest.exports?.['.']?.types !== './lib/types/index.d.ts') {
|
||||
const rootExport = manifest.exports?.['.']
|
||||
const rootEntry = typeof rootExport === 'object' && rootExport !== null ? rootExport : undefined
|
||||
if (rootEntry?.types !== './lib/types/index.d.ts') {
|
||||
errors.push(`${label}: package.json exports["."].types must be "./lib/types/index.d.ts"`)
|
||||
}
|
||||
if (manifest.exports?.['.']?.default !== './lib/index.js') {
|
||||
if (rootEntry?.default !== './lib/index.js') {
|
||||
errors.push(`${label}: package.json exports["."].default must be "./lib/index.js"`)
|
||||
}
|
||||
const invariantExport = manifest.exports?.['./invariant']
|
||||
const invariantRaw = manifest.exports?.['./invariant']
|
||||
const invariantExport = typeof invariantRaw === 'object' && invariantRaw !== null ? invariantRaw : undefined
|
||||
if (invariantExport?.types !== undefined && invariantExport.types !== './lib/types/invariant.d.ts') {
|
||||
errors.push(`${label}: package.json exports["./invariant"].types must be "./lib/types/invariant.d.ts"`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user