Merge remote-tracking branch 'origin/master' into xtr/sidebar-workspace-controls
This commit is contained in:
@@ -39,6 +39,7 @@ export { FixtureSession, TestSessions } from './sessions.ts'
|
||||
export { stubSettingsScope } from './settings-scope.ts'
|
||||
export type { StubSettingsScope } from './settings-scope.ts'
|
||||
export { TestWorkspaces } from './workspaces.ts'
|
||||
export { TestRemote } from './remote.ts'
|
||||
export { conversationSnapshot, workspaceListState } from './fixtures.ts'
|
||||
export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts'
|
||||
export { makeTranslate } from './translate.ts'
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/** Test-owned Remote face: `$on` subscriptions driven by the internal forwarded-event plumbing. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
|
||||
/**
|
||||
* Remote service test double for the forwarded-event path. Feature specs need
|
||||
* `ctx.remote.$on` to exist (their plugins inject `remote`) and need forwarded
|
||||
* host events to reach those subscribers, but not the generated namespaces or
|
||||
* the wire — so this double implements subscription and dispatch only.
|
||||
*
|
||||
* Dispatch is driven the same way production drives it: `client/runtime` owns the
|
||||
* host frame sink and hands each decoded `host/remote-event` frame to
|
||||
* `$dispatch`. A spec therefore exercises its refresh chains by calling
|
||||
* `$dispatch(name, args)` on this double.
|
||||
*
|
||||
* `$mount` rejects: a spec that reaches a generated namespace through this
|
||||
* double has outgrown it and needs the real Client Remote service.
|
||||
*
|
||||
* One deliberate asymmetry with production: a throwing listener propagates out
|
||||
* of the emit instead of being contained and logged, so a spec cannot lean on
|
||||
* this double for the containment guarantee `$on` documents — assert that
|
||||
* against the real service.
|
||||
*/
|
||||
export class TestRemote {
|
||||
private readonly subscriptions = new Map<string, Set<(...args: never[]) => void>>()
|
||||
|
||||
/**
|
||||
* Register the double as `ctx.remote`.
|
||||
* @param ctx - the spec's root Context.
|
||||
*/
|
||||
constructor(ctx: Context) {
|
||||
ctx.provide('remote', this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver one forwarded host event to its subscribers, standing in for the
|
||||
* carrier that owns the frame sink.
|
||||
* @param event - forwarded host event name.
|
||||
* @param args - the Host argument list, verbatim.
|
||||
*/
|
||||
$dispatch(event: string, args: readonly unknown[]): void {
|
||||
const listeners = this.subscriptions.get(event)
|
||||
if (listeners === undefined) return
|
||||
for (const listener of [...listeners]) listener(...args as never[])
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to one forwarded host event.
|
||||
* @param event - forwarded host event name.
|
||||
* @param listener - receives the Host argument list verbatim.
|
||||
* @returns disposer removing this subscription.
|
||||
*/
|
||||
$on(event: string, listener: (...args: never[]) => void): () => void {
|
||||
const listeners = this.subscriptions.get(event) ?? new Set()
|
||||
this.subscriptions.set(event, listeners)
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Generated-namespace mount, unsupported by this double.
|
||||
* @returns never; always rejects.
|
||||
*/
|
||||
$mount(): Promise<() => Promise<void>> {
|
||||
return Promise.reject(new Error('TestRemote: $mount needs the real Client Remote service'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* TestRemote's own contract: subscription and disposal, dispatch driven by the
|
||||
* internal plumbing event, the silent drop for an unsubscribed name, and the
|
||||
* `$mount` refusal that sends a spec to the real Client Remote service.
|
||||
*/
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { TestRemote } from '../src/remote.ts'
|
||||
|
||||
describe('TestRemote', () => {
|
||||
it('delivers a forwarded event to its subscribers and stops after disposal', async () => {
|
||||
const ctx = new Context()
|
||||
const remote = new TestRemote(ctx)
|
||||
const seen: string[] = []
|
||||
const off = remote.$on('settings/document-updated', (ns: string) => {
|
||||
seen.push(ns)
|
||||
})
|
||||
|
||||
ctx.remote.$dispatch('settings/document-updated', ['ui-theme', 1])
|
||||
expect(seen).toEqual(['ui-theme'])
|
||||
|
||||
off()
|
||||
ctx.remote.$dispatch('settings/document-updated', ['ui-theme', 2])
|
||||
expect(seen).toEqual(['ui-theme'])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('drops a forwarded event nobody subscribed to', async () => {
|
||||
const ctx = new Context()
|
||||
new TestRemote(ctx)
|
||||
// No subscriber for this name: the emit must be inert rather than throwing,
|
||||
// because the wire carries whatever the Host allowlist selected.
|
||||
expect(() => { ctx.remote.$dispatch('credentials/updated', ['DEEPSEEK_API_KEY']) }).not.toThrow()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('refuses $mount, which needs the real Client Remote service', async () => {
|
||||
const ctx = new Context()
|
||||
const remote = new TestRemote(ctx)
|
||||
await expect(remote.$mount()).rejects.toThrow('needs the real Client Remote service')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user