From 2431caa9ab08c48c91e04d06335de9119355f0d3 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:00:12 +0800 Subject: [PATCH] fix: ci docs/lint fix: ci docs/lint ci: coverage --- .../client/connection/src/client/fixture.ts | 6 ++ .../connection/tests/client-apply.spec.ts | 64 ++++++++++++ .../runtime/src/client/sessions/service.ts | 6 ++ .../client/runtime/tests/client-apply.spec.ts | 64 ++++++++++++ .../runtime/tests/client-loader-bundle.e2e.ts | 2 +- .../runtime/tests/client-loader.spec.ts | 97 +++++++++++++++++++ .../client/runtime/tests/invariant.spec.ts | 47 +++++++++ .../runtime/tests/sessions-service.spec.ts | 52 ++++++++++ .../runtime/tests/slots-service.spec.ts | 15 +++ .../src/client/toolviews/registry.ts | 3 +- .../tests/service-orchestration.spec.ts | 11 ++- .../tests/service-stores.spec.ts | 2 +- packages/client/web-react/src/store/index.ts | 4 + .../host/runtime/tests/web-plugins.e2e.ts | 4 +- scripts/check-workspace-constraints.ts | 31 ++++-- 15 files changed, 388 insertions(+), 20 deletions(-) create mode 100644 packages/client/connection/tests/client-apply.spec.ts create mode 100644 packages/client/runtime/tests/client-apply.spec.ts create mode 100644 packages/client/runtime/tests/invariant.spec.ts diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 4f4cdaa256..7d4a93e888 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -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 { this.onEnvelope(message) return this.api.respond(message) diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts new file mode 100644 index 0000000000..89b854de5b --- /dev/null +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -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 { + 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) + }) +}) diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index eb3268e10b..2b4cf9677e 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -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() diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts new file mode 100644 index 0000000000..bb98bca98e --- /dev/null +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -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 { + 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 + }) +}) diff --git a/packages/client/runtime/tests/client-loader-bundle.e2e.ts b/packages/client/runtime/tests/client-loader-bundle.e2e.ts index ebb0f143b9..b8932b0c62 100644 --- a/packages/client/runtime/tests/client-loader-bundle.e2e.ts +++ b/packages/client/runtime/tests/client-loader-bundle.e2e.ts @@ -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' diff --git a/packages/client/runtime/tests/client-loader.spec.ts b/packages/client/runtime/tests/client-loader.spec.ts index b94d69f8bf..383098d0fe 100644 --- a/packages/client/runtime/tests/client-loader.spec.ts +++ b/packages/client/runtime/tests/client-loader.spec.ts @@ -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, + 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/) + }) +}) diff --git a/packages/client/runtime/tests/invariant.spec.ts b/packages/client/runtime/tests/invariant.spec.ts new file mode 100644 index 0000000000..cdfbba3f7d --- /dev/null +++ b/packages/client/runtime/tests/invariant.spec.ts @@ -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 { + 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() + }) +}) diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 1f908cca9c..f3989c4532 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -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() + }) + +}) diff --git a/packages/client/runtime/tests/slots-service.spec.ts b/packages/client/runtime/tests/slots-service.spec.ts index 54b8abd658..f53a22bb0a 100644 --- a/packages/client/runtime/tests/slots-service.spec.ts +++ b/packages/client/runtime/tests/slots-service.spec.ts @@ -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() + }) + }) diff --git a/packages/client/ui-conversation/src/client/toolviews/registry.ts b/packages/client/ui-conversation/src/client/toolviews/registry.ts index 9fe9226b5b..76e447f386 100644 --- a/packages/client/ui-conversation/src/client/toolviews/registry.ts +++ b/packages/client/ui-conversation/src/client/toolviews/registry.ts @@ -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() diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index 9c8e84e55b..37c9c0b3e1 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -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 () => { diff --git a/packages/client/ui-conversation/tests/service-stores.spec.ts b/packages/client/ui-conversation/tests/service-stores.spec.ts index e7c77bc182..7e5477ab56 100644 --- a/packages/client/ui-conversation/tests/service-stores.spec.ts +++ b/packages/client/ui-conversation/tests/service-stores.spec.ts @@ -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) }, diff --git a/packages/client/web-react/src/store/index.ts b/packages/client/web-react/src/store/index.ts index 5c20d26150..9194eeab86 100644 --- a/packages/client/web-react/src/store/index.ts +++ b/packages/client/web-react/src/store/index.ts @@ -117,6 +117,10 @@ export function createSnapshotStore( * (quota, private mode) only disable persistence, never break the store. */ function attachPersistence(api: StoreApi, 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) { diff --git a/packages/host/runtime/tests/web-plugins.e2e.ts b/packages/host/runtime/tests/web-plugins.e2e.ts index 8d89d8a16e..45034dfa22 100644 --- a/packages/host/runtime/tests/web-plugins.e2e.ts +++ b/packages/host/runtime/tests/web-plugins.e2e.ts @@ -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() diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index ac0854c413..0a664ca988 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -40,10 +40,12 @@ interface PackageManifest { bin?: string | Record 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"`) }