Merge updated PR #500 into codex/tool-json-schema-dsl
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> {
|
override async respond(message: ClientResponse): Promise<RpcReceipt> {
|
||||||
this.onEnvelope(message)
|
this.onEnvelope(message)
|
||||||
return this.api.respond(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). */
|
/** Run deferred teardowns whose session is no longer watched (called when the watch moves). */
|
||||||
private sweepDeferred(): void {
|
private sweepDeferred(): void {
|
||||||
for (const id of [...this.deferredRemovals]) {
|
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
|
if (id === this.watched) continue
|
||||||
// Still absent from the list? (A re-added id cancels the deferred teardown.)
|
// Still absent from the list? (A re-added id cancels the deferred teardown.)
|
||||||
if (this.list.getSnapshot().byId[id] !== undefined) {
|
if (this.list.getSnapshot().byId[id] !== undefined) {
|
||||||
@@ -212,6 +215,9 @@ export class SessionsService {
|
|||||||
}
|
}
|
||||||
const record = this.scopes.get(id)
|
const record = this.scopes.get(id)
|
||||||
this.deferredRemovals.delete(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) {
|
if (record !== undefined) {
|
||||||
this.scopes.delete(id)
|
this.scopes.delete(id)
|
||||||
void record.fiber.dispose()
|
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
|
* Real-bundle smoke: the actual tsdown client bundle of ui-layout runs
|
||||||
* through the loader chain (execute → handoff → factory(require) → apply →
|
* 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`).
|
* build product; `pnpm --filter @deepseek-ai/dsh-client-ui-layout build`).
|
||||||
*/
|
*/
|
||||||
import { readFileSync } from 'node:fs'
|
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/)
|
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 () => {
|
it('unload is the P-I stub', async () => {
|
||||||
const b = bench([], {})
|
const b = bench([], {})
|
||||||
await expect(b.loader.unload('x')).rejects.toThrow(/not implemented/)
|
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: 爆了/)
|
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.
|
// The slot definition (registered from root) survives; a new occupant may register.
|
||||||
expect(() => ctx.slots.register('t-single', C)).not.toThrow()
|
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
|
if (disposed) return
|
||||||
disposed = true
|
disposed = true
|
||||||
const at = list.indexOf(entry)
|
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 (at >= 0) list.splice(at, 1)
|
||||||
if (list.length === 0) this.byTool.delete(tool)
|
if (list.length === 0) this.byTool.delete(tool)
|
||||||
this.bump()
|
this.bump()
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const sid = (s: string): SessionId => s as SessionId
|
|||||||
const SCOPE_TAG: symbol = (() => {
|
const SCOPE_TAG: symbol = (() => {
|
||||||
const recorded: (string | symbol)[] = []
|
const recorded: (string | symbol)[] = []
|
||||||
const spy = new Proxy(new Context(), {
|
const spy = new Proxy(new Context(), {
|
||||||
get(target, prop, receiver) {
|
get(target, prop, receiver): unknown {
|
||||||
recorded.push(prop)
|
recorded.push(prop)
|
||||||
return Reflect.get(target, prop, receiver)
|
return Reflect.get(target, prop, receiver)
|
||||||
},
|
},
|
||||||
@@ -46,6 +46,7 @@ async function bench(opts?: { layout?: boolean; sessions?: boolean }) {
|
|||||||
}
|
}
|
||||||
return scoped
|
return scoped
|
||||||
}
|
}
|
||||||
|
const createMock = vi.fn(() => Promise.resolve(sid('new-1')))
|
||||||
const sessionsFake = {
|
const sessionsFake = {
|
||||||
manager: {
|
manager: {
|
||||||
get: (id: SessionId) => {
|
get: (id: SessionId) => {
|
||||||
@@ -60,7 +61,7 @@ async function bench(opts?: { layout?: boolean; sessions?: boolean }) {
|
|||||||
return s
|
return s
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
create: vi.fn(() => Promise.resolve(sid('new-1'))),
|
create: createMock,
|
||||||
scope: (id: SessionId) => (id === sid('new-1') ? mint(id) : scopes.get(id)),
|
scope: (id: SessionId) => (id === sid('new-1') ? mint(id) : scopes.get(id)),
|
||||||
} as unknown as SessionsService
|
} as unknown as SessionsService
|
||||||
if (opts?.sessions !== false) ctx.provide('sessions', sessionsFake)
|
if (opts?.sessions !== false) ctx.provide('sessions', sessionsFake)
|
||||||
@@ -70,7 +71,7 @@ async function bench(opts?: { layout?: boolean; sessions?: boolean }) {
|
|||||||
await fiber.await()
|
await fiber.await()
|
||||||
const svc = ctx.get('conversation') as ConversationService
|
const svc = ctx.get('conversation') as ConversationService
|
||||||
const scopedSvc = (id: SessionId) => mint(id).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', () => {
|
describe('send / cancel', () => {
|
||||||
@@ -122,7 +123,7 @@ describe('startSession chain', () => {
|
|||||||
it('creates, navigates, then sends through the new scope', async () => {
|
it('creates, navigates, then sends through the new scope', async () => {
|
||||||
const b = await bench()
|
const b = await bench()
|
||||||
await b.svc.startSession({ cwd: '/proj', text: 'first', mode: 'queue' })
|
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.layoutFake.open).toHaveBeenCalledWith(sid('new-1'))
|
||||||
expect(b.sessionDoubles.get(sid('new-1'))!.prompt).toHaveBeenCalledWith(
|
expect(b.sessionDoubles.get(sid('new-1'))!.prompt).toHaveBeenCalledWith(
|
||||||
[{ type: 'text', text: 'first' }], 'queue')
|
[{ type: 'text', text: 'first' }], 'queue')
|
||||||
@@ -131,7 +132,7 @@ describe('startSession chain', () => {
|
|||||||
it('omits cwd from create when not chosen', async () => {
|
it('omits cwd from create when not chosen', async () => {
|
||||||
const b = await bench()
|
const b = await bench()
|
||||||
await b.svc.startSession({ text: 't', mode: 'steer' })
|
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 () => {
|
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 SCOPE_TAG: symbol = (() => {
|
||||||
const recorded: (string | symbol)[] = []
|
const recorded: (string | symbol)[] = []
|
||||||
const spy = new Proxy(new Context(), {
|
const spy = new Proxy(new Context(), {
|
||||||
get(target, prop, receiver) {
|
get(target, prop, receiver): unknown {
|
||||||
recorded.push(prop)
|
recorded.push(prop)
|
||||||
return Reflect.get(target, prop, receiver)
|
return Reflect.get(target, prop, receiver)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -117,6 +117,10 @@ export function createSnapshotStore<T>(
|
|||||||
* (quota, private mode) only disable persistence, never break the store.
|
* (quota, private mode) only disable persistence, never break the store.
|
||||||
*/
|
*/
|
||||||
function attachPersistence<T>(api: StoreApi<T>, name: string): void {
|
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 {
|
try {
|
||||||
const raw = localStorage.getItem(name)
|
const raw = localStorage.getItem(name)
|
||||||
if (raw !== null) {
|
if (raw !== null) {
|
||||||
|
|||||||
@@ -50,9 +50,9 @@ describe.skipIf(!built)('mountWebPlugins + registry', () => {
|
|||||||
'@deepseek-ai/dsh-client-ui-theme',
|
'@deepseek-ai/dsh-client-ui-theme',
|
||||||
'@deepseek-ai/dsh-client-i18n',
|
'@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) {
|
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`)
|
expect(row.url).toBe(`/plugins/${row.id}/client.js`)
|
||||||
}
|
}
|
||||||
registry.dispose()
|
registry.dispose()
|
||||||
|
|||||||
@@ -40,10 +40,12 @@ interface PackageManifest {
|
|||||||
bin?: string | Record<string, string>
|
bin?: string | Record<string, string>
|
||||||
exports?: Record<
|
exports?: Record<
|
||||||
string,
|
string,
|
||||||
|
| string
|
||||||
| {
|
| {
|
||||||
types?: string
|
types?: string
|
||||||
default?: string
|
default?: string
|
||||||
}
|
}
|
||||||
|
| null
|
||||||
| undefined
|
| undefined
|
||||||
>
|
>
|
||||||
files?: string[]
|
files?: string[]
|
||||||
@@ -119,11 +121,11 @@ function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] {
|
|||||||
// (single-artifact ruling: dist/ retired, ./client resolves lib/client.js).
|
// (single-artifact ruling: dist/ retired, ./client resolves lib/client.js).
|
||||||
// Keyed on the artifact path, not the subpath name: apiproxy's ./client is
|
// Keyed on the artifact path, not the subpath name: apiproxy's ./client is
|
||||||
// a browser-safe source channel, not a bundle.
|
// 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.
|
// 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).
|
// 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,
|
...extras,
|
||||||
// Subpaths whose runtime default is the tsc-emitted tree (lib/types/*.js —
|
// Subpaths whose runtime default is the tsc-emitted tree (lib/types/*.js —
|
||||||
// browser-safe source channels rehomed off src so plain Node can import
|
// 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. */
|
/** Whether any export's runtime default points into the tsc-emitted lib/types tree. */
|
||||||
function usesEmittedTreeDefaults(manifest: PackageManifest): boolean {
|
function usesEmittedTreeDefaults(manifest: PackageManifest): boolean {
|
||||||
return Object.values(manifest.exports ?? {}).some(entry =>
|
return Object.keys(manifest.exports ?? {}).some(subpath =>
|
||||||
typeof entry === 'object' && entry !== null
|
exportDefault(manifest, subpath)?.startsWith('./lib/types/') === true)
|
||||||
&& typeof (entry as { default?: unknown }).default === 'string'
|
|
||||||
&& ((entry as { default: string }).default).startsWith('./lib/types/'))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] {
|
function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] {
|
||||||
@@ -177,13 +185,16 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] {
|
|||||||
if (manifest.types !== 'lib/types/index.d.ts') {
|
if (manifest.types !== 'lib/types/index.d.ts') {
|
||||||
errors.push(`${label}: package.json must set "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"`)
|
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"`)
|
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') {
|
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"`)
|
errors.push(`${label}: package.json exports["./invariant"].types must be "./lib/types/invariant.d.ts"`)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user