From 05aefbe79a37dcc3d47e5e6efcdd430b0359e615 Mon Sep 17 00:00:00 2001 From: lizhixuan Date: Sun, 11 Oct 2026 13:26:41 +0800 Subject: [PATCH 1/5] build: one client-externals table for tsdown and the bundle check; check every require --- scripts/check-client-bundle.mjs | 14 +++++++++----- scripts/client-externals.json | 11 +++++++++++ tsdown.config.ts | 19 +++++++------------ 3 files changed, 27 insertions(+), 17 deletions(-) create mode 100644 scripts/client-externals.json diff --git a/scripts/check-client-bundle.mjs b/scripts/check-client-bundle.mjs index 66a0678..19d8f71 100644 --- a/scripts/check-client-bundle.mjs +++ b/scripts/check-client-bundle.mjs @@ -1,6 +1,9 @@ // Asserts the browser bundle is the dsh loader's closure-factory artifact and -// carries the slot key. Runs in CI after `pnpm build`. +// carries the slot key, and requires nothing outside the shared externals +// table (scripts/client-externals.json, also read by tsdown.config.ts). Runs in +// CI after `pnpm build`. import { readFileSync } from 'node:fs' +import externals from './client-externals.json' with { type: 'json' } const js = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8') // Rolldown re-wraps the banner/footer across lines, so compare with whitespace @@ -11,8 +14,9 @@ const banner = squash('window.__ModuleLoader__.load({ id: "@refkit/dsh-plugin", if (!body.startsWith(banner)) { console.error('client bundle: missing loader banner'); process.exit(1) } if (!body.endsWith(squash('return module.exports; } });'))) { console.error('client bundle: missing loader footer'); process.exit(1) } if (!js.includes('"refkit_search"')) { console.error('client bundle: slot key not found'); process.exit(1) } -const forbidden = [...js.matchAll(/require\("(@deepseek-ai\/[^"]+)"\)/g)].map(m => m[1]) -const allowed = new Set(['@deepseek-ai/cordis', '@deepseek-ai/dsh-client-store', '@deepseek-ai/dsh-client-ui-slots', '@deepseek-ai/dsh-client-ui-primitives', '@deepseek-ai/dsh-client-ui-dockkit']) -const bad = forbidden.filter(id => !allowed.has(id)) -if (bad.length > 0) { console.error('client bundle: non-platform requires', bad); process.exit(1) } +// Every require("...") string-literal call in the bundle must name a table entry. +const allowed = new Set(externals) +const required = [...js.matchAll(/require\(\s*(["'`])(.*?)\1\s*\)/g)].map(m => m[2]) +const bad = [...new Set(required.filter(id => !allowed.has(id)))] +if (bad.length > 0) { console.error('client bundle: requires outside the externals table', bad); process.exit(1) } console.log('client bundle ok', js.length, 'bytes') diff --git a/scripts/client-externals.json b/scripts/client-externals.json new file mode 100644 index 0000000..d2d60d9 --- /dev/null +++ b/scripts/client-externals.json @@ -0,0 +1,11 @@ +[ + "react", + "react/jsx-runtime", + "react-dom", + "react-dom/client", + "@deepseek-ai/cordis", + "@deepseek-ai/dsh-client-store", + "@deepseek-ai/dsh-client-ui-slots", + "@deepseek-ai/dsh-client-ui-primitives", + "@deepseek-ai/dsh-client-ui-dockkit" +] diff --git a/tsdown.config.ts b/tsdown.config.ts index 593c59c..46db32d 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,21 +1,16 @@ import { existsSync } from 'node:fs' import { resolve } from 'node:path' import type { UserConfig } from 'tsdown' +import clientExternals from './scripts/client-externals.json' with { type: 'json' } const ID = '@refkit/dsh-plugin' -/** Platform seed modules the dsh web shell shares into the frozen module table. */ -export const CLIENT_EXTERNALS: readonly string[] = [ - 'react', - 'react/jsx-runtime', - 'react-dom', - 'react-dom/client', - '@deepseek-ai/cordis', - '@deepseek-ai/dsh-client-store', - '@deepseek-ai/dsh-client-ui-slots', - '@deepseek-ai/dsh-client-ui-primitives', - '@deepseek-ai/dsh-client-ui-dockkit', -] +/** + * Platform seed modules the dsh web shell shares into the frozen module table. + * The ids live in scripts/client-externals.json so the bundle check reads the + * very same table; the file is build-time only and never ships. + */ +export const CLIENT_EXTERNALS: readonly string[] = clientExternals /** Host-side externals: peers from the profile tree plus our declared runtime deps. */ const HOST_EXTERNAL: readonly (string | RegExp)[] = [ From 7d61af0149b4c16c035ad7ce2d15764f430ed6dc Mon Sep 17 00:00:00 2001 From: lizhixuan Date: Sun, 11 Oct 2026 13:41:42 +0800 Subject: [PATCH 2/5] test: client wiring and card phase dispatch --- package.json | 2 + pnpm-lock.yaml | 33 +++ tests/card.test.tsx | 92 ++++++++ tests/client-wiring.test.ts | 296 +++++++++++++++++++++++++ tests/settings-controller.test.ts | 30 +-- tests/support/primitives-form-model.ts | 35 +++ tsconfig.tests.json | 4 +- vitest.config.ts | 5 +- 8 files changed, 466 insertions(+), 31 deletions(-) create mode 100644 tests/card.test.tsx create mode 100644 tests/client-wiring.test.ts create mode 100644 tests/support/primitives-form-model.ts diff --git a/package.json b/package.json index 5463932..fe0c525 100644 --- a/package.json +++ b/package.json @@ -90,8 +90,10 @@ "@deepseek-ai/schemastery": "~3.18.4", "@types/node": "^22.20.0", "@types/react": "~18.3.1", + "@types/react-dom": "~18.3.0", "eslint": "^10.7.0", "react": "^18.3.1", + "react-dom": "^18.3.1", "tsdown": "0.22.2", "typescript": "~5.7.2", "typescript-eslint": "^8.64.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7616621..55b95e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -120,12 +120,18 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + '@types/react-dom': + specifier: ~18.3.0 + version: 18.3.7(@types/react@18.3.31) eslint: specifier: ^10.7.0 version: 10.11.0 react: specifier: ^18.3.1 version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) tsdown: specifier: 0.22.2 version: 0.22.2(typescript@5.7.3) @@ -1031,6 +1037,11 @@ packages: '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + '@types/react@18.3.31': resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} @@ -1539,6 +1550,11 @@ packages: quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -1583,6 +1599,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -2540,6 +2559,10 @@ snapshots: '@types/prop-types@15.7.15': {} + '@types/react-dom@18.3.7(@types/react@18.3.31)': + dependencies: + '@types/react': 18.3.31 + '@types/react@18.3.31': dependencies: '@types/prop-types': 15.7.15 @@ -3076,6 +3099,12 @@ snapshots: quansync@1.0.0: {} + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + react@18.3.1: dependencies: loose-envify: 1.4.0 @@ -3155,6 +3184,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.63.4 fsevents: 2.3.3 + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + semver@7.8.5: {} shebang-command@2.0.0: diff --git a/tests/card.test.tsx b/tests/card.test.tsx new file mode 100644 index 0000000..389f338 --- /dev/null +++ b/tests/card.test.tsx @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import { renderToStaticMarkup } from 'react-dom/server' +import { createRefkit, defineProvider, type EmittedReference, type LicenseId } from '@refkit/core' +import type { ToolCallCommonProps, ToolCallOwnerProps } from '@deepseek-ai/dsh-client-ui-tool/client' +import { RefkitCard } from '../src/client/Card.tsx' +import { resolveConfig } from '../src/config.ts' +import { cardMeta, renderSearch } from '../src/render.ts' +import { runSearch } from '../src/tools/search.ts' + +type Phase

= Extract['block'] +type ResultBlock = Phase<'result'> + +const CALL_ID = 'call-1' +const ARGS = '{"query":"tidal pool"}' + +/** The owner currency the tool tree hands every atomic view; the card reads none of it. */ +const common: ToolCallCommonProps = { + useDisclosure: () => ({ expanded: false, setExpanded: () => {}, toggle: () => {} }), + callId: CALL_ID, + toolName: 'refkit_search', + openFile: () => {}, + loadImage: async () => '', +} + +const head = { callId: CALL_ID, name: 'refkit_search', turn: 1, step: 1, time: 0, subCalls: [] } + +function result(block: Pick & Partial): ToolCallOwnerProps { + return { + ...common, + phase: 'result', + block: { kind: 'tool-result', seq: 2, time: 0, callId: CALL_ID, call: { name: 'refkit_search', argsRaw: ARGS }, callTime: 0, subCalls: [], ...block }, + } +} + +const render = (props: ToolCallOwnerProps): string => renderToStaticMarkup() +const count = (markup: string, needle: string): number => markup.split(needle).length - 1 + +const emit = (url: string, license: LicenseId, licenseVersion?: string): EmittedReference => ({ + modality: 'image', + title: `title ${url}`, + sourceUrl: url, + rights: { license, licenseVersion, author: 'Ada', rehostPolicy: 'cache-allowed', raw: { sourceTerms: 't', sourceUrl: url } }, + thumbnail: { url: `${url}/t.jpg` }, + visual: { width: 800, height: 600 }, +}) + +/** A settled refkit_search block as the tool emits it: rendered text content plus cardMeta presentation metadata. */ +async function settledSearch(): Promise { + const providers = [defineProvider({ id: 'museum', modalities: ['image'], search: async () => [emit('https://museum.example/1', 'CC-BY', '4.0'), emit('https://museum.example/2', 'CC0-1.0')] })] + const client = createRefkit({ providers, rerank: false, sourceConfidence: false, userAgent: false }) + const config = resolveConfig({}, {}) + const outcome = await runSearch({ query: 'tidal pool' }, { client: () => client, config: () => config }) + return result({ content: [{ type: 'text', text: renderSearch(outcome) }], isError: false, meta: cardMeta(outcome) }) +} + +describe('RefkitCard phase dispatch', () => { + it('preparing: says it is preparing and draws no skeleton grid', () => { + const markup = render({ ...common, phase: 'preparing', block: { ...head, phase: 'preparing' } }) + expect(markup).toContain('Preparing refkit search…') + expect(markup).not.toContain('rk-skeleton') + }) + + it('start: says it is searching over six skeleton tiles', () => { + const markup = render({ ...common, phase: 'start', block: { ...head, phase: 'start', argsRaw: ARGS } }) + expect(markup).toContain('Searching refkit sources…') + expect(count(markup, 'class="rk-skeleton"')).toBe(6) + }) + + it('result: heads the grid with the count and query, one tile per reference with its license chip', async () => { + const markup = render(await settledSearch()) + expect(markup).toContain('2 references for “tidal pool”') + const tiles = markup.split('class="rk-tile"').slice(1) + expect(tiles).toHaveLength(2) + expect(tiles[0]).toContain('CC-BY 4.0') + expect(tiles[0]).toContain('https://museum.example/1/t.jpg') + expect(tiles[1]).toContain('CC0-1.0') + expect(tiles[1]).toContain('https://museum.example/2/t.jpg') + expect(markup).not.toContain('rk-skeleton') + }) + + it('result with isError: shows the error text, or the error name and code when there is none', () => { + const withText = render(result({ content: [{ type: 'text', text: 'all 2 sources failed: museum: boom' }], isError: true })) + expect(withText).toBe('

all 2 sources failed: museum: boom
') + const bare = render(result({ content: [], isError: true, error: { name: 'ToolTimeout', code: 'timeout' } })) + expect(bare).toBe('
ToolTimeout: timeout
') + }) + + it('result that is not a search outcome: falls back to its plain text', () => { + const markup = render(result({ content: [{ type: 'text', text: 'search finished without card metadata' }], isError: false, meta: { version: 'drifted' } })) + expect(markup).toBe('
search finished without card metadata
') + }) +}) diff --git a/tests/client-wiring.test.ts b/tests/client-wiring.test.ts new file mode 100644 index 0000000..23f6ebb --- /dev/null +++ b/tests/client-wiring.test.ts @@ -0,0 +1,296 @@ +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { ConfigForm, ConfigForms, ConfigFormSnapshot, SettingsDescribeFace, SettingsMirrorSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client' +import type { RefkitSettingsFace } from '../src/client/settings-controller.ts' + +/** dsh's REAL SettingsFormModel behind the controller the page injects (see the helper). */ +vi.mock('@deepseek-ai/dsh-client-ui-primitives', async () => (await import('./support/primitives-form-model.ts')).primitivesFormModel()) + +const client = await import('../src/client/index.tsx') +const { RefkitCard } = await import('../src/client/Card.tsx') +const { RefkitSettingsPage } = await import('../src/client/SettingsPage.tsx') + +const TOOLVIEW = 'tool.call.toolview' +const ROW_CONFIG = 'plugins.row.config' + +/** The registration options the fake registry records (the real one checks them against SlotMap). */ +interface SlotOptions { + name: string + key?: string + priority?: number + registrant?: string + inject?: () => unknown +} +interface Registration { options: SlotOptions; component: unknown } + +/** + * The renderer's slot registry, reduced to what the plugin calls. As + * SlotRegistry.inject documents, the callback runs at once when the slot is + * already declared and otherwise inside the declaring register() call + * (`declare` here), where a throw would reach that other plugin's register. + */ +function fakeSlots(declared: readonly string[] = [TOOLVIEW, ROW_CONFIG]) { + const declaredSlots = new Set(declared) + const waiting = new Map void)[]>() + const live = new Set() + let registerError: Error | undefined + const face = { + inject(key: string, callback: () => () => void): () => void { + let dispose: (() => void) | undefined + let ended = false + const run = (): void => { if (!ended) dispose = callback() } + if (declaredSlots.has(key)) run() + else waiting.set(key, [...(waiting.get(key) ?? []), run]) + return () => { ended = true; dispose?.(); dispose = undefined } + }, + register(options: SlotOptions, component: unknown): () => void { + if (registerError) throw registerError + const entry: Registration = { options, component } + live.add(entry) + return () => { live.delete(entry) } + }, + } + return { + face, + registrations: (name: string): Registration[] => [...live].filter(entry => entry.options.name === name), + declare(key: string): void { + declaredSlots.add(key) + const runs = waiting.get(key) ?? [] + waiting.delete(key) + for (const run of runs) run() + }, + failRegister(error: Error): void { registerError = error }, + } +} + +/** + * The settings domain over one `refkit` namespace: a ConfigForm and the + * describe mirror (both counting subscribers, which is how a released + * controller shows), and whileServed driven by `serve` / `unserve` the way the + * mirror's notification drives it. + */ +function fakeConfigForms(storedKeys: readonly string[] = []) { + const formListeners = new Set<() => void>() + const mirrorListeners = new Set<() => void>() + const requested: string[] = [] + const writes: unknown[] = [] + const watches: { namespaces: readonly string[]; register: (served: ReadonlySet) => () => void; live?: () => void; ended: boolean }[] = [] + const formSnapshot: ConfigFormSnapshot> = { + status: 'ready', value: { limit: 12 }, base: { limit: 12 }, user: {}, revision: 1, writable: true, mode: 'host', + } + const mirrorSnapshot: SettingsMirrorSnapshot = { + status: 'ready', + error: null, + view: { + writable: true, + hasDocument: true, + namespaces: [{ ns: 'refkit', autoGenerate: false, schema: null, value: {}, applies: 'live', revision: 1, secrets: storedKeys.map(key => ({ path: [key], set: true })) }], + }, + } + const form: ConfigForm> = { + getSnapshot: () => formSnapshot, + subscribe: (listener) => { formListeners.add(listener); return () => { formListeners.delete(listener) } }, + mutate: async (ops) => { writes.push(structuredClone([...ops])); return true }, + set: async () => false, + unset: async () => false, + } + const mirror: SettingsDescribeFace = { + getSnapshot: () => mirrorSnapshot, + subscribe: (listener) => { mirrorListeners.add(listener); return () => { mirrorListeners.delete(listener) } }, + ensure: async () => {}, + acceptView: () => {}, + } + const service: Pick = { + get: (entryId: string): ConfigForm => { requested.push(entryId); return form as unknown as ConfigForm }, + describe: () => mirror, + whileServed: (namespaces, register) => { + const watch: (typeof watches)[number] = { namespaces: [...namespaces], register, ended: false } + watches.push(watch) + return () => { watch.ended = true; watch.live?.(); watch.live = undefined } + }, + } + return { + service, + requested, + writes, + subscribers: (): number => formListeners.size + mirrorListeners.size, + /** The Host starts serving `ns`: every open watch on it registers. */ + serve(ns: string): void { + for (const watch of watches) if (!watch.ended && !watch.live && watch.namespaces.includes(ns)) watch.live = watch.register(new Set([ns])) + }, + /** The Host stops serving every namespace: every live registration is disposed. */ + unserve(): void { + for (const watch of watches) { watch.live?.(); watch.live = undefined } + }, + } +} + +interface Services { + slots: ReturnType['face'] + configForms?: ReturnType['service'] +} + +/** One plugin fiber: the services it waited for, the error its body threw (if any), and its unload. */ +interface Fiber { deps: readonly string[]; error?: unknown; unload(): void } + +/** + * A cordis context reduced to inject / effect: `inject` starts a child fiber + * once every named service is provided (and never otherwise), and a throw in + * its body fails that fiber instead of reaching the caller, as cordis' + * asynchronous plugin start does; `effect` runs at once and hands back its + * disposer, which the fiber's unload also runs. + */ +function fakeCordis(services: Services) { + const fibers: Fiber[] = [] + const scope = (disposers: (() => void)[]) => ({ + ...services, + inject(deps: readonly string[], body: (child: ClientContext) => void): void { + if (!deps.every(name => services[name as keyof Services] !== undefined)) return + const childDisposers: (() => void)[] = [] + const child: Fiber = { deps, unload: () => { for (const dispose of childDisposers.splice(0).reverse()) dispose() } } + fibers.push(child) + try { body(scope(childDisposers) as unknown as ClientContext) } catch (error) { child.error = error } + }, + effect(execute: () => () => void, _label?: string): () => void { + const dispose = execute() + let done = false + const run = (): void => { if (!done) { done = true; dispose() } } + disposers.push(run) + return run + }, + }) + const root = scope([]) + return { + /** Load the client plugin as cordis would: its apply runs once every name in its `inject` is provided. */ + load(): void { root.inject(client.inject, ctx => client.apply(ctx)) }, + fiber: (dep: string): Fiber | undefined => fibers.find(fiber => fiber.deps.includes(dep)), + errors: (): unknown[] => fibers.filter(fiber => 'error' in fiber).map(fiber => fiber.error), + } +} + +function boot(options: { slots?: ReturnType; settings?: ReturnType | null } = {}) { + const slots = options.slots ?? fakeSlots() + const settings = options.settings === null ? undefined : (options.settings ?? fakeConfigForms(['braveToken'])) + const cordis = fakeCordis({ slots: slots.face, configForms: settings?.service }) + cordis.load() + return { slots, settings, cordis } +} + +const TOOLVIEW_OPTIONS = { name: TOOLVIEW, key: 'refkit_search', priority: 0, registrant: '@refkit/dsh-plugin' } +const PAGE_OPTIONS = { name: ROW_CONFIG, key: '@refkit/dsh-plugin#refkit', registrant: '@refkit/dsh-plugin' } + +let warn: MockInstance +beforeEach(() => { warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) }) +afterEach(() => { warn.mockRestore() }) + +describe('client apply', () => { + it('registers RefkitCard as the refkit_search toolview', () => { + const { slots, cordis } = boot() + expect(cordis.errors()).toEqual([]) + const toolviews = slots.registrations(TOOLVIEW) + expect(toolviews).toHaveLength(1) + expect(toolviews[0]!.options).toEqual(TOOLVIEW_OPTIONS) + expect(toolviews[0]!.component).toBe(RefkitCard) + expect(warn).not.toHaveBeenCalled() + }) + + it('registers the settings page under the refkit row only while the refkit namespace is served', () => { + const { slots, settings, cordis } = boot() + expect(slots.registrations(ROW_CONFIG)).toEqual([]) + settings!.serve('another-plugin') + expect(slots.registrations(ROW_CONFIG)).toEqual([]) + + settings!.serve('refkit') + const pages = slots.registrations(ROW_CONFIG) + expect(pages).toHaveLength(1) + expect(pages[0]!.options).toEqual({ ...PAGE_OPTIONS, inject: expect.any(Function) }) + expect(pages[0]!.component).toBe(RefkitSettingsPage) + + settings!.unserve() + expect(slots.registrations(ROW_CONFIG)).toEqual([]) + settings!.serve('refkit') + expect(slots.registrations(ROW_CONFIG)).toHaveLength(1) + expect(cordis.errors()).toEqual([]) + expect(warn).not.toHaveBeenCalled() + }) + + it("injects the settings controller's face over the refkit form", async () => { + const { slots, settings } = boot() + settings!.serve('refkit') + const inject = slots.registrations(ROW_CONFIG)[0]!.options.inject! + const face = inject() as RefkitSettingsFace + expect(inject()).toBe(face) + expect(settings!.requested).toEqual(['refkit']) + const state = face.hooks.refkitSettings.getSnapshot() + expect(state.configured).toEqual(['braveToken']) + expect(state.fields.limit.text).toBe('12') + face.edit('limit', '20') + face.save() + await new Promise(resolve => setTimeout(resolve, 0)) + expect(settings!.writes).toEqual([[{ op: 'set', path: ['limit'], value: 20 }]]) + }) + + it('unloading the settings fiber releases the controller, removes the page, and ends the watch', () => { + const { slots, settings, cordis } = boot() + settings!.serve('refkit') + expect(settings!.subscribers()).toBeGreaterThan(0) + expect(slots.registrations(ROW_CONFIG)).toHaveLength(1) + + cordis.fiber('configForms')!.unload() + expect(settings!.subscribers()).toBe(0) + expect(slots.registrations(ROW_CONFIG)).toEqual([]) + settings!.serve('refkit') + expect(slots.registrations(ROW_CONFIG)).toEqual([]) + expect(slots.registrations(TOOLVIEW)).toHaveLength(1) + }) +}) + +describe('client apply degrades without throwing', () => { + it('without the settings domain: the toolview still registers, the page never does, and nothing warns', () => { + const { slots, cordis } = boot({ settings: null }) + expect(cordis.errors()).toEqual([]) + expect(slots.registrations(TOOLVIEW).map(entry => entry.options)).toEqual([TOOLVIEW_OPTIONS]) + expect(slots.registrations(ROW_CONFIG)).toEqual([]) + // A deployment without the settings domain is ordinary: the optional child just stays pending. + expect(warn).not.toHaveBeenCalled() + }) + + it.each([ + ['already declared', [TOOLVIEW, ROW_CONFIG]], + ['declared later by another plugin', []], + ] as const)('slots.register throwing (slots %s) is logged, not thrown', (_when, declared) => { + const slots = fakeSlots(declared) + slots.failRegister(new Error('register refused')) + const { settings, cordis } = boot({ slots }) + expect(() => settings!.serve('refkit')).not.toThrow() + expect(() => slots.declare(TOOLVIEW)).not.toThrow() + expect(() => slots.declare(ROW_CONFIG)).not.toThrow() + expect(cordis.errors()).toEqual([]) + expect(slots.registrations(TOOLVIEW)).toEqual([]) + expect(slots.registrations(ROW_CONFIG)).toEqual([]) + expect(warn).toHaveBeenCalledWith('[refkit] toolview registration failed', expect.objectContaining({ message: 'register refused' })) + expect(warn).toHaveBeenCalledWith('[refkit] settings page registration failed', expect.objectContaining({ message: 'register refused' })) + }) + + it('whileServed throwing is logged, releases the controller, and registers no page', () => { + const settings = fakeConfigForms() + settings.service.whileServed = () => { throw new Error('mirror gone') } + const { slots, cordis } = boot({ settings }) + expect(cordis.errors()).toEqual([]) + expect(warn).toHaveBeenCalledWith('[refkit] settings page wiring failed', expect.objectContaining({ message: 'mirror gone' })) + expect(settings.subscribers()).toBe(0) + expect(slots.registrations(ROW_CONFIG)).toEqual([]) + expect(slots.registrations(TOOLVIEW)).toHaveLength(1) + }) + + it('a settings controller that cannot be built is logged and registers no page', () => { + const settings = fakeConfigForms() + settings.service.get = () => { throw new Error('no such namespace') } + const { slots, cordis } = boot({ settings }) + expect(cordis.errors()).toEqual([]) + expect(warn).toHaveBeenCalledWith('[refkit] settings page wiring failed', expect.objectContaining({ message: 'no such namespace' })) + settings.serve('refkit') + expect(slots.registrations(ROW_CONFIG)).toEqual([]) + expect(slots.registrations(TOOLVIEW)).toHaveLength(1) + }) +}) diff --git a/tests/settings-controller.test.ts b/tests/settings-controller.test.ts index 8d4b0e5..86d5254 100644 --- a/tests/settings-controller.test.ts +++ b/tests/settings-controller.test.ts @@ -1,33 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -/** - * dsh's REAL SettingsFormModel. `@deepseek-ai/dsh-client-ui-primitives` ships - * only its browser build (lib/index.js), whose imports (react-dom, clsx, shiki, - * @deepseek-ai/dsh-client-store, …) the platform supplies and this dev tree - * does not install, so vitest cannot load the module as a whole. The mock - * evaluates the build's own form-model region verbatim, with a minimal - * createSnapshotStore standing in for the one import that region uses. - */ -vi.mock('@deepseek-ai/dsh-client-ui-primitives', async () => { - const { readFileSync } = await import('node:fs') - const { createRequire } = await import('node:module') - const built = createRequire(import.meta.url).resolve('@deepseek-ai/dsh-client-ui-primitives') - const source = readFileSync(built, 'utf8') - const start = source.indexOf('//#region lib/types/settings-form/form-model.js') - const end = source.indexOf('//#endregion', start) - if (start < 0 || end < 0) throw new Error('form-model region not found in the primitives build') - const createSnapshotStore = (initial: T) => { - let snapshot = initial - const listeners = new Set<() => void>() - return { - getSnapshot: () => snapshot, - subscribe: (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener) } }, - set: (next: T) => { snapshot = next; for (const listener of [...listeners]) listener() }, - } - } - const load = new Function('createSnapshotStore', `${source.slice(start, end)}\nreturn { SettingsFormModel, settingsNumberField, settingsTextField }`) - return load(createSnapshotStore) as Record -}) +/** dsh's REAL SettingsFormModel, evaluated from the primitives build (see the helper). */ +vi.mock('@deepseek-ai/dsh-client-ui-primitives', async () => (await import('./support/primitives-form-model.ts')).primitivesFormModel()) const { createSettingsController } = await import('../src/client/settings-controller.ts') type Controller = ReturnType diff --git a/tests/support/primitives-form-model.ts b/tests/support/primitives-form-model.ts new file mode 100644 index 0000000..f9ea205 --- /dev/null +++ b/tests/support/primitives-form-model.ts @@ -0,0 +1,35 @@ +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' + +/** + * Module factory for `vi.mock('@deepseek-ai/dsh-client-ui-primitives', …)` + * that yields dsh's REAL SettingsFormModel. The package ships only its browser + * build (lib/index.js), whose imports (CSS modules, clsx, shiki, + * @deepseek-ai/dsh-client-store, …) the platform supplies and this dev tree + * does not install, so vitest cannot load the module as a whole. This + * evaluates the build's own form-model region verbatim, with a minimal + * createSnapshotStore standing in for the one import that region uses. + * + * vi.mock is hoisted above imports, so a test file reaches this through a + * dynamic import inside its factory: + * `vi.mock('@deepseek-ai/dsh-client-ui-primitives', async () => (await import('./support/primitives-form-model.ts')).primitivesFormModel())`. + * @returns the mocked module's exports. + */ +export function primitivesFormModel(): Record { + const built = createRequire(import.meta.url).resolve('@deepseek-ai/dsh-client-ui-primitives') + const source = readFileSync(built, 'utf8') + const start = source.indexOf('//#region lib/types/settings-form/form-model.js') + const end = source.indexOf('//#endregion', start) + if (start < 0 || end < 0) throw new Error('form-model region not found in the primitives build') + const createSnapshotStore = (initial: T) => { + let snapshot = initial + const listeners = new Set<() => void>() + return { + getSnapshot: () => snapshot, + subscribe: (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener) } }, + set: (next: T) => { snapshot = next; for (const listener of [...listeners]) listener() }, + } + } + const load = new Function('createSnapshotStore', `${source.slice(start, end)}\nreturn { SettingsFormModel, settingsNumberField, settingsTextField }`) + return load(createSnapshotStore) as Record +} diff --git a/tsconfig.tests.json b/tsconfig.tests.json index e4dae28..80720f0 100644 --- a/tsconfig.tests.json +++ b/tsconfig.tests.json @@ -1,5 +1,5 @@ { "extends": "./tsconfig.host.json", - "compilerOptions": { "composite": false, "declaration": false, "declarationMap": false, "emitDeclarationOnly": false, "noEmit": true, "rootDir": ".", "types": ["node"] }, - "include": ["src/index.ts", "src/config.ts", "src/render.ts", "src/tools/**/*.ts", "src/core/**/*.ts", "src/client/badges.ts", "src/client/copy.ts", "src/client/settings-model.ts", "src/client/settings-controller.ts", "tests/**/*.ts"] + "compilerOptions": { "composite": false, "declaration": false, "declarationMap": false, "emitDeclarationOnly": false, "noEmit": true, "rootDir": ".", "lib": ["ES2024", "DOM", "DOM.Iterable"], "jsx": "react-jsx", "types": ["node"] }, + "include": ["src/index.ts", "src/config.ts", "src/render.ts", "src/tools/**/*.ts", "src/core/**/*.ts", "src/client/**/*.ts", "src/client/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"] } diff --git a/vitest.config.ts b/vitest.config.ts index d510ff7..5e9a944 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,5 +1,8 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ - test: { include: ['tests/**/*.test.ts'], environment: 'node' }, + // The automatic JSX runtime (tsconfig.client.json's react-jsx) for tests/*.tsx as well: + // vite reads `jsx` from the tsconfig project that includes a file, and none includes tests/. + esbuild: { jsx: 'automatic' }, + test: { include: ['tests/**/*.test.ts', 'tests/**/*.test.tsx'], environment: 'node' }, }) From a03efe79a176b4bfc2eea242ec0c22c90424fc3e Mon Sep 17 00:00:00 2001 From: lizhixuan Date: Sun, 11 Oct 2026 13:46:28 +0800 Subject: [PATCH 3/5] feat(config): keep retired source ids valid in sources --- lib/client.js | 7 ++- lib/client.js.map | 2 +- lib/index.js | 20 ++++++- lib/types/client/settings-model.d.ts | 5 ++ lib/types/client/settings-model.d.ts.map | 2 +- lib/types/config.d.ts | 16 +++++- lib/types/config.d.ts.map | 2 +- src/client/settings-controller.ts | 4 +- src/client/settings-model.ts | 6 +++ src/config.ts | 22 +++++++- tests/config.test.ts | 68 +++++++++++++++++++++++- tests/settings-controller.test.ts | 26 +++++++++ tests/settings-model.test.ts | 14 ++++- 13 files changed, 179 insertions(+), 15 deletions(-) diff --git a/lib/client.js b/lib/client.js index ab242bc..e8fcad1 100644 --- a/lib/client.js +++ b/lib/client.js @@ -591,6 +591,11 @@ window.__ModuleLoader__.load({ "unsplash", "wikimedia-commons" ]; + /** + * Ids that once shipped and were since removed (mirrors `RETIRED_IDS`). `sources` still accepts + * them, so a stored one never blocks saving the page, but the page does not list them. + */ + const RETIRED_SOURCE_IDS = []; /** The number fields' schema bounds and defaults (mirrors the `Config` schema). */ const NUMBER_BOUNDS = { limit: { @@ -753,7 +758,7 @@ window.__ModuleLoader__.load({ const write = (ops) => scope.mutate(ops); const model = new _deepseek_ai_dsh_client_ui_primitives.SettingsFormModel(scope, [ ...NUMBER_FIELDS.map((field) => settingsBoundedNumberField(field, NUMBER_BOUNDS[field])), - settingsSourcesField("sources", SOURCE_IDS), + settingsSourcesField("sources", [...SOURCE_IDS, ...RETIRED_SOURCE_IDS]), (0, _deepseek_ai_dsh_client_ui_primitives.settingsTextField)("userAgent"), ...TOGGLE_FIELDS.map((field) => settingsBooleanField(field)) ], KEY_FIELDS_CLIENT.map((field) => secretSpec(field, write))); diff --git a/lib/client.js.map b/lib/client.js.map index 4136754..ec64f1c 100644 --- a/lib/client.js.map +++ b/lib/client.js.map @@ -1 +1 @@ -{"version":3,"file":"client.js","names":["SettingsFormModel","SettingsForm","SettingsValueField","SettingsSecretField","Button","Tag","Switch"],"sources":["../src/core/outcome.ts","../src/client/badges.ts","../src/client/copy.ts","../src/client/styles.ts","../src/client/Card.tsx","../src/client/settings-model.ts","../src/client/settings-controller.ts","../src/client/SettingsPage.tsx","../src/client/index.tsx"],"sourcesContent":["/**\n * Canonical result vocabulary shared by the host half (tool output, render,\n * presentation metadata) and the browser half (the card). No runtime\n * dependencies: plain data plus soft parsers, so a malformed or\n * version-drifted payload degrades to text instead of crashing a render.\n * @module @refkit/dsh-plugin/core\n */\n\nexport const MODALITIES = ['image', 'video', 'audio', 'text'] as const\nexport type Modality = (typeof MODALITIES)[number]\n\nexport const DECISIONS = ['allowed', 'allowed-with-attribution', 'denied', 'needs-review'] as const\nexport type Decision = (typeof DECISIONS)[number]\n\nexport const SOURCE_STATUSES = ['fulfilled', 'failed', 'skipped'] as const\nexport type SourceStatusKind = (typeof SOURCE_STATUSES)[number]\n\n/** Lossless JSON. */\nexport type Json = string | number | boolean | null | Json[] | { [key: string]: Json }\n\nexport interface VerdictSummary {\n decision: Decision\n reason: string\n confidence: 'high' | 'low'\n}\n\n/** One reference as the tool returns it and the card renders it. */\nexport interface RefTile {\n id: string\n modality: Modality\n provider: string\n canonicalUrl: string\n license: string\n title?: string\n kind?: string\n licenseVersion?: string\n author?: string\n thumbnail?: string\n preview?: string\n width?: number\n height?: number\n description?: string\n excerpt?: string\n tags?: string[]\n useVerdict?: VerdictSummary\n attribution?: string\n}\n\nexport interface SourceStatus {\n id: string\n status: SourceStatusKind\n reason?: string\n returned?: number\n}\n\n/** The canonical value of one refkit_search call. */\nexport interface SearchOutcome {\n query: string\n modalities: Modality[]\n intent?: string\n count: number\n references: RefTile[]\n nextCursor?: string\n sources: SourceStatus[]\n warnings: string[]\n note?: string\n /** Core's full SearchMeta, only when the caller passed `explain`. */\n meta?: Json\n}\n\n/** What the card receives: the outcome minus `meta`, descriptions cut, tags dropped. */\nexport type CardOutcome = Omit\n\nconst HTTP = /^https?:\\/\\//i\n\nfunction str(v: unknown): string | undefined {\n return typeof v === 'string' && v.length > 0 ? v : undefined\n}\nfunction num(v: unknown): number | undefined {\n return typeof v === 'number' && Number.isFinite(v) ? v : undefined\n}\n\n/** Shallow copy of `obj` without its undefined-valued own properties (dsh snapshots values as lossless JSON). */\nexport function defined(obj: T): T {\n return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) as T\n}\n\n/** Cut `text` to `max` code points, appending an ellipsis when anything was removed. */\nexport function trunc(text: string, max: number): string {\n const points = Array.from(text)\n if (points.length <= max) return text\n return points.slice(0, Math.max(0, max - 1)).join('') + '…'\n}\n\nfunction narrowVerdict(v: unknown): VerdictSummary | undefined {\n if (typeof v !== 'object' || v === null) return undefined\n const r = v as Record\n if (!DECISIONS.includes(r.decision as Decision)) return undefined\n if (r.confidence !== 'high' && r.confidence !== 'low') return undefined\n return { decision: r.decision as Decision, reason: str(r.reason) ?? '', confidence: r.confidence }\n}\n\n/** Soft-narrow one tile; null for anything unusable. Unknown keys are dropped. */\nexport function narrowTile(value: unknown): RefTile | null {\n if (typeof value !== 'object' || value === null) return null\n const r = value as Record\n const id = str(r.id)\n const provider = str(r.provider)\n const canonicalUrl = str(r.canonicalUrl)\n const license = str(r.license)\n if (id === undefined || provider === undefined || license === undefined) return null\n if (canonicalUrl === undefined || !HTTP.test(canonicalUrl)) return null\n if (!MODALITIES.includes(r.modality as Modality)) return null\n const tile: RefTile = { id, modality: r.modality as Modality, provider, canonicalUrl, license }\n for (const key of ['title', 'kind', 'licenseVersion', 'author', 'description', 'excerpt', 'attribution'] as const) {\n const s = str(r[key])\n if (s !== undefined) tile[key] = s\n }\n for (const key of ['thumbnail', 'preview'] as const) {\n const s = str(r[key])\n if (s !== undefined && HTTP.test(s)) tile[key] = s\n }\n for (const key of ['width', 'height'] as const) {\n const n = num(r[key])\n if (n !== undefined && n > 0) tile[key] = n\n }\n if (Array.isArray(r.tags)) {\n const tags = r.tags.filter((t): t is string => typeof t === 'string' && t.length > 0)\n if (tags.length > 0) tile.tags = tags\n }\n const verdict = narrowVerdict(r.useVerdict)\n if (verdict !== undefined) tile.useVerdict = verdict\n return tile\n}\n\nfunction narrowSource(v: unknown): SourceStatus | null {\n if (typeof v !== 'object' || v === null) return null\n const r = v as Record\n const id = str(r.id)\n if (id === undefined || !SOURCE_STATUSES.includes(r.status as SourceStatusKind)) return null\n const out: SourceStatus = { id, status: r.status as SourceStatusKind }\n const reason = str(r.reason)\n if (reason !== undefined) out.reason = reason\n const returned = num(r.returned)\n if (returned !== undefined) out.returned = returned\n return out\n}\n\n/** Soft-parse a canonical value or presentation metadata; null for the wrong shape. */\nexport function narrowOutcome(value: unknown): SearchOutcome | null {\n if (typeof value !== 'object' || value === null) return null\n const r = value as Record\n const query = typeof r.query === 'string' ? r.query : undefined\n if (query === undefined || typeof r.count !== 'number') return null\n if (!Array.isArray(r.modalities) || !Array.isArray(r.references) || !Array.isArray(r.sources)) return null\n if (!Array.isArray(r.warnings)) return null\n const modalities = r.modalities.filter((m): m is Modality => MODALITIES.includes(m as Modality))\n const references = r.references.map(narrowTile).filter((t): t is RefTile => t !== null)\n const sources = r.sources.map(narrowSource).filter((s): s is SourceStatus => s !== null)\n const warnings = r.warnings.filter((w): w is string => typeof w === 'string')\n const out: SearchOutcome = { query, modalities, count: r.count, references, sources, warnings }\n const intent = str(r.intent)\n if (intent !== undefined) out.intent = intent\n const nextCursor = str(r.nextCursor)\n if (nextCursor !== undefined) out.nextCursor = nextCursor\n const note = str(r.note)\n if (note !== undefined) out.note = note\n if (r.meta !== undefined) out.meta = r.meta as Json\n return out\n}\n","/**\n * Pure presentation helpers for the card: verdict badge classes and labels,\n * license chip text, source bucketing, tile aspect ratio. No DOM, no React,\n * so they are unit-tested directly.\n * @module @refkit/dsh-plugin/client/badges\n */\n\nimport type { Decision, RefTile, SourceStatus } from '../core/outcome.ts'\n\nconst LICENSE_CHARS = 20\n\nexport function verdictBadge(decision: Decision): { className: string; label: string } {\n switch (decision) {\n case 'allowed': return { className: 'rk-badge rk-badge-allowed', label: 'Allowed' }\n case 'allowed-with-attribution': return { className: 'rk-badge rk-badge-attribution', label: 'Credit required' }\n case 'denied': return { className: 'rk-badge rk-badge-denied', label: 'Not allowed' }\n case 'needs-review': return { className: 'rk-badge rk-badge-review', label: 'Needs review' }\n }\n}\n\nexport function licenseLabel(tile: Pick): string {\n const base = tile.licenseVersion ? `${tile.license} ${tile.licenseVersion}` : tile.license\n const points = Array.from(base)\n return points.length > LICENSE_CHARS ? points.slice(0, LICENSE_CHARS).join('') + '…' : base\n}\n\nexport function summarizeSources(sources: readonly SourceStatus[]): { fulfilled: SourceStatus[]; failed: SourceStatus[]; skipped: SourceStatus[] } {\n return {\n fulfilled: sources.filter(s => s.status === 'fulfilled'),\n failed: sources.filter(s => s.status === 'failed'),\n skipped: sources.filter(s => s.status === 'skipped'),\n }\n}\n\n/** CSS `aspect-ratio` value for a tile. */\nexport function aspectRatio(tile: Pick): string {\n return tile.width && tile.height && tile.width > 0 && tile.height > 0 ? `${tile.width} / ${tile.height}` : '4 / 3'\n}\n","/** UI strings for the card and the settings page. One object so a locale swap is one file. */\nexport const COPY = {\n preparing: 'Preparing refkit search…',\n searching: 'Searching refkit sources…',\n refsFor: (count: number, query: string) => `${count} reference${count === 1 ? '' : 's'} for “${query}”`,\n intent: (intent: string) => `intent: ${intent}`,\n more: 'more available — ask for the next page',\n failed: (n: number) => `${n} source${n === 1 ? '' : 's'} failed`,\n skipped: (n: number) => `${n} skipped`,\n open: 'Open',\n copyCredit: 'Copy credit',\n copied: 'Copied',\n copyFailed: 'Select and copy:',\n empty: 'No results. Try broader terms, another modality, or fewer controls.',\n legend: 'Verdict:',\n untitled: '(untitled)',\n settings: {\n summary: (configured: number, total: number) => `License-aware reference search · ${configured} of ${total} keys set`,\n // The shared form frame.\n unavailable: 'refkit is not running, so it cannot be configured right now.',\n readOnly: 'This deployment stores settings read-only.',\n saveFailed: 'The deployment did not accept these values; they were left for you to correct.',\n save: 'Save',\n saving: 'Saving…',\n overridden: 'Overridden',\n reset: 'Reset to default',\n // API keys.\n keysHeading: 'API keys',\n keysNote: 'Each key enables the sources named beside it; keyless sources need none. Keys are stored in plain text in this profile’s cordis.patch.yml (file mode 0600) and apply on the next search.',\n keyHint: (env: readonly string[]) => `Leave blank to keep the stored key. Environment fallback: ${env.join(' / ')}.`,\n keySet: 'Set',\n keyUnset: 'Not set',\n remove: 'Remove',\n removeLabel: (label: string) => `Remove the ${label} key`,\n removing: 'Removing…',\n removeBlocked: 'Save your other edits first: removing a key writes immediately.',\n removeFailed: 'Not removed; try again.',\n // Search tuning.\n searchHeading: 'Search',\n limit: 'Results per call',\n limitHint: 'Also caps per-item detail fetches for met, rijksmuseum and polyhaven.',\n poolFactor: 'Fusion pool factor',\n poolFactorHint: 'Rank-fusion pool multiplier.',\n deadlineMs: 'Search deadline (ms)',\n deadlineMsHint: 'Whole-search deadline.',\n timeoutMs: 'Per-source timeout (ms)',\n timeoutMsHint: 'How long one source may take.',\n range: (hint: string, bounds: { min: number; max: number; default: number }) => `${hint} ${bounds.min}–${bounds.max}, default ${bounds.default}.`,\n invalidRange: (bounds: { min: number; max: number }) => `Enter a whole number from ${bounds.min} to ${bounds.max}, or leave blank for the default.`,\n sources: 'Sources',\n sourcesHint: 'Comma-separated provider ids. A keyed source also needs its key.',\n sourcesPlaceholder: 'empty = all enabled sources',\n sourcesHelp: 'Source ids',\n sourcesInvalid: 'Unknown source id. Use the ids listed under the info button.',\n userAgent: 'User-Agent',\n userAgentHint: 'Sent with provider requests. Blank uses refkit-dsh-plugin/.',\n rerank: 'Lexical rerank',\n rerankHint: 'Rerank fused results over title, description, tags and excerpt.',\n sourceConfidence: 'Source confidence',\n sourceConfidenceHint: 'Down-weight sources whose batch never mentions the query.',\n },\n} as const\n","/**\n * Card and settings-page stylesheet, injected once as