Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/real-core-test-seams.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@conciv/core': patch
---

makeApp accepts an injectable engine-staleness probe, shared by /health, the rpc meta.engine procedure and the mcp server instructions
5 changes: 5 additions & 0 deletions .changeset/terminal-launch-opener-seam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@conciv/extension-terminal': patch
---

the terminal extension is created through `createTerminalExtension({openTerminal})`, so the host that spawns the terminal window is injectable; the default export is unchanged
3 changes: 2 additions & 1 deletion .fallowrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@
"packages/extensions/tanstack/test/host/conciv/extensions/connect-probe.tsx",
"packages/extensions/try-it/test/fixture/connect-pane-fixture.tsx",
"e2e/harnesses/vite.*.config.ts",
"apps/conciv/test/helpers/fake-core-router.ts"
"apps/conciv/test/commands/core-control.ts",
"apps/conciv/test/commands/core-testkit.ts"
]
}
1 change: 0 additions & 1 deletion apps/conciv/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@
"@conciv/storage-history": "workspace:^",
"@conciv/uno-preset": "workspace:*",
"@conciv/vitest-config": "workspace:*",
"@orpc/server": "catalog:",
"@solidjs/testing-library": "^0.8.10",
"@tanstack/ai": "catalog:",
"@tanstack/query-core": "^5.80.2",
Expand Down
221 changes: 124 additions & 97 deletions apps/conciv/test/chat-pane.browser.test.tsx

Large diffs are not rendered by default.

252 changes: 252 additions & 0 deletions apps/conciv/test/commands/core-control.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
import {join} from 'node:path'
import type {BrowserCommand, BrowserCommandContext} from 'vitest/node'
import type {EngineStaleness} from '@conciv/contract'
import type {HarnessSessionMeta} from '@conciv/protocol/harness-types'
import type {ScriptedTurn} from '@conciv/harness-testkit'
import type {CoreKit} from './core-testkit.js'
import type {RpcObserver} from '@conciv/extension-testkit/rpc-observer'

export type BootCoreInput = {
id: string
text?: string
resume?: boolean
displayName?: string
connect?: boolean
terminal?: boolean
history?: HarnessSessionMeta[]
allowedOrigins?: string[]
}

export type BootCoreResult = {base: string; wsBase: string; bootMs: number}

export type FaultSpec =
| {kind: 'fail'; path: string[]; status?: number}
| {kind: 'abort'; path?: string[]}
| {kind: 'gate'; path?: string[]}

type Fault = {
pending: () => number
awaitCaptured: (count: number) => Promise<void>
release: () => Promise<void>
dispose: () => Promise<void>
}

type CoreTestkit = typeof import('./core-testkit.js')

type TerminalState = {launches: number; succeeds: boolean}

type FileState = {
kit: CoreKit | null
staleness: {value: EngineStaleness}
faults: Map<string, Fault>
handles: {count: number}
terminal: TerminalState
observer: RpcObserver | null
}

const FRESH: EngineStaleness = {
stale: false,
changed: [],
tracked: ['@conciv/core'],
bootedAt: 0,
fingerprint: 'testkit0000',
}

const AWAIT_RPC_TIMEOUT_MS = 15_000

const files = new Map<string, FileState>()

function testkitOf(ctx: BrowserCommandContext): Promise<CoreTestkit> {
const nodeProject = ctx.project.vitest.getRootProject()
return nodeProject.import<CoreTestkit>(join(ctx.project.config.root, 'test/commands/core-testkit.ts'))
}

function stateOf(ctx: BrowserCommandContext): FileState {
const key = ctx.testPath ?? 'shared'
const existing = files.get(key)
if (existing) return existing
const created: FileState = {
kit: null,
staleness: {value: FRESH},
faults: new Map(),
handles: {count: 0},
terminal: {launches: 0, succeeds: true},
observer: null,
}
files.set(key, created)
return created
}

function kitOf(ctx: BrowserCommandContext): CoreKit {
const kit = stateOf(ctx).kit
if (!kit) throw new Error('no core is booted for this test file; call bootCore first')
return kit
}

function observerOf(ctx: BrowserCommandContext): RpcObserver {
const observer = stateOf(ctx).observer
if (!observer) throw new Error('no core is booted for this test file; call bootCore first')
return observer
}

function faultOf(ctx: BrowserCommandContext, handle: string): Fault {
const fault = stateOf(ctx).faults.get(handle)
if (!fault) throw new Error(`no fault is installed under the handle "${handle}"`)
return fault
}

const bootCore: BrowserCommand<[BootCoreInput]> = async (ctx, input): Promise<BootCoreResult> => {
const state = stateOf(ctx)
if (state.kit) throw new Error('a core is already booted for this test file; call closeCore first')
const startedAt = Date.now()
const openedTerminal = (): Promise<boolean> => {
state.terminal.launches += 1
return Promise.resolve(state.terminal.succeeds)
}
const {bootCoreKit, createTerminalExtension, observeRpc} = await testkitOf(ctx)
const kit = await bootCoreKit({
id: input.id,
text: input.text,
resume: input.resume,
displayName: input.displayName,
history: input.history,
allowedOrigins: input.allowedOrigins,
staleness: () => state.staleness.value,
...(input.terminal ? {extensions: [createTerminalExtension({openTerminal: openedTerminal})]} : {}),
...(input.connect
? {
connect: {
plan: (context) => ({
argv: ['claude', '--resume', context.harnessSessionId ?? 'new'],
env: {},
files: [],
}),
},
}
: {}),
})
state.kit = kit
state.observer = observeRpc(ctx.page)
return {base: kit.base, wsBase: kit.wsBase, bootMs: Date.now() - startedAt}
}

const closeCore: BrowserCommand<[]> = async (ctx): Promise<void> => {
const state = stateOf(ctx)
for (const fault of state.faults.values()) await fault.dispose()
state.faults.clear()
state.staleness.value = FRESH
state.terminal.launches = 0
state.terminal.succeeds = true
state.observer?.dispose()
state.observer = null
const kit = state.kit
state.kit = null
if (!kit) return
kit.harness.script.release()
await kit.cleanup()
}

const setStaleness: BrowserCommand<[EngineStaleness]> = (ctx, value): void => {
stateOf(ctx).staleness.value = value
}

const holdTurn: BrowserCommand<[]> = (ctx): void => {
kitOf(ctx).harness.script.hold()
}

const releaseTurn: BrowserCommand<[]> = (ctx): void => {
kitOf(ctx).harness.script.release()
}

const scriptError: BrowserCommand<[string]> = (ctx, message): void => {
kitOf(ctx).harness.script.scriptError(message)
}

const scriptTurn: BrowserCommand<[ScriptedTurn]> = (ctx, turn): string[] => kitOf(ctx).harness.script.scriptTurn(turn)

const setTerminalLaunch: BrowserCommand<[boolean]> = (ctx, succeeds): void => {
stateOf(ctx).terminal.succeeds = succeeds
}

const terminalLaunches: BrowserCommand<[]> = (ctx): number => stateOf(ctx).terminal.launches

const rpcCallCount: BrowserCommand<[string[]]> = (ctx, path): number => observerOf(ctx).completedCount({path})

const rpcMark: BrowserCommand<[]> = (ctx): number => observerOf(ctx).mark()

const awaitRpcCall: BrowserCommand<[string[], number]> = async (ctx, path, since): Promise<number | null> => {
const record = await observerOf(ctx).completed({path, since, timeout: AWAIT_RPC_TIMEOUT_MS})
return record.status
}

const installFault: BrowserCommand<[FaultSpec]> = async (ctx, spec): Promise<string> => {
const state = stateOf(ctx)
const {abortRpcCalls, failRpcCalls, gateRpcCalls} = await testkitOf(ctx)
state.handles.count += 1
const handle = `fault-${state.handles.count}`
if (spec.kind === 'gate') {
state.faults.set(handle, await gateRpcCalls(ctx.page, spec.path ? {path: spec.path} : {}))
return handle
}
const injector =
spec.kind === 'abort'
? await abortRpcCalls(ctx.page, spec.path ? {path: spec.path} : {})
: await failRpcCalls(ctx.page, {path: spec.path, ...(spec.status ? {status: spec.status} : {})})
state.faults.set(handle, {
pending: () => 0,
awaitCaptured: async () => {
throw new Error(`the fault "${handle}" is a ${spec.kind} fault, which never captures pending requests`)
},
release: () => {
injector.repair()
return Promise.resolve()
},
dispose: injector.dispose,
})
return handle
}

const releaseFault: BrowserCommand<[string]> = async (ctx, handle): Promise<void> => {
await faultOf(ctx, handle).release()
}

const faultPending: BrowserCommand<[string]> = (ctx, handle): number => faultOf(ctx, handle).pending()

const awaitFaultPending: BrowserCommand<[string, number]> = async (ctx, handle, count): Promise<void> => {
const timer: {value: ReturnType<typeof setTimeout> | null} = {value: null}
const deadline = new Promise<never>((_, reject) => {
timer.value = setTimeout(
() =>
reject(
new Error(
`the fault "${handle}" captured ${faultOf(ctx, handle).pending()} of ${count} pending requests within ${AWAIT_RPC_TIMEOUT_MS}ms`,
),
),
AWAIT_RPC_TIMEOUT_MS,
)
})
try {
await Promise.race([faultOf(ctx, handle).awaitCaptured(count), deadline])
} finally {
if (timer.value) clearTimeout(timer.value)
}
}

export const coreCommands = {
bootCore,
closeCore,
setStaleness,
holdTurn,
releaseTurn,
scriptError,
scriptTurn,
setTerminalLaunch,
terminalLaunches,
rpcCallCount,
rpcMark,
awaitRpcCall,
installFault,
releaseFault,
faultPending,
awaitFaultPending,
}
4 changes: 4 additions & 0 deletions apps/conciv/test/commands/core-testkit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export {bootCoreKit, type CoreKit} from '@conciv/extension-testkit/core-kit'
export {abortRpcCalls, failRpcCalls, gateRpcCalls} from '@conciv/extension-testkit/rpc-fault'
export {observeRpc, type RpcObserver} from '@conciv/extension-testkit/rpc-observer'
export {createTerminalExtension} from '@conciv/extension-terminal/server'
79 changes: 79 additions & 0 deletions apps/conciv/test/core-request-gate.browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import {afterAll, beforeAll, describe, expect, it} from 'vitest'
import {EventType} from '@tanstack/ai'
import {makeRpcClient, type RpcClient} from '@conciv/contract'
import {coreControl} from './helpers/core-control.js'
import {trackedFaults} from './helpers/tracked-faults.js'

const core: {rpc: RpcClient | null} = {rpc: null}
const faults = trackedFaults()

function statusOf(value: unknown): number | null {
if (typeof value !== 'object' || value === null || !('status' in value)) return null
return typeof value.status === 'number' ? value.status : null
}

function chunkType(value: unknown): unknown {
if (typeof value !== 'object' || value === null || !('type' in value)) return null
return value.type
}

function rpc(): RpcClient {
if (!core.rpc) throw new Error('the core kit did not boot')
return core.rpc
}

beforeAll(async () => {
const booted = await coreControl.bootCore({id: 'request-gate', allowedOrigins: [window.location.origin]})
core.rpc = makeRpcClient(booted.base)
}, 60_000)

afterAll(async () => {
core.rpc = null
await coreControl.closeCore()
}, 30_000)

describe('gated rpc requests against a real core', () => {
it('holds the subscribe request until release, then streams the real snapshot', async () => {
const {sessionId} = await rpc().sessions.create()
const gate = await faults.install({kind: 'gate', path: ['chat', 'subscribe']})
const arrived = {value: false}
const subscription = rpc()
.chat.subscribe({sessionId})
.then((iterator) => {
arrived.value = true
return iterator
})

await coreControl.awaitFaultPending(gate, 1)
expect(arrived.value).toBe(false)

await coreControl.releaseFault(gate)
const iterator = await subscription
const first = await iterator.next()

expect(chunkType(first.value)).toBe(EventType.MESSAGES_SNAPSHOT)
expect(await coreControl.faultPending(gate)).toBe(0)

await iterator.return?.()
}, 30_000)

it('keeps a cross-origin injected failure an rpc rejection rather than a transport error', async () => {
const {sessionId} = await rpc().sessions.create()
const fault = await faults.install({kind: 'fail', path: ['sessions', 'rename'], status: 500})

const rejection = await rpc()
.sessions.rename({sessionId, title: 'renamed by the fault test'})
.then(
() => null,
(reason: unknown) => reason,
)

expect(rejection).not.toBeInstanceOf(TypeError)
expect(String(rejection)).not.toMatch(/failed to fetch/i)
expect(statusOf(rejection)).toBe(500)

await coreControl.releaseFault(fault)
const renamed = await rpc().sessions.rename({sessionId, title: 'renamed by the fault test'})
expect(renamed.title).toBe('renamed by the fault test')
}, 30_000)
})
Loading
Loading