241 lines
10 KiB
TypeScript
241 lines
10 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import { testTerminalHostPlugin } from './test-terminal-host-plugin'
|
|
import { mkdir, mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { pathToFileURL } from 'node:url'
|
|
import { build } from 'esbuild'
|
|
import type { ChatMessage, SessionMeta, ToolCallRecord } from '../src/shared/types'
|
|
|
|
interface SessionStoreApi {
|
|
listSessions(): Promise<SessionMeta[]>
|
|
createSession(title?: string): Promise<SessionMeta>
|
|
appendMessage(sessionId: string, message: ChatMessage): Promise<void>
|
|
patchMessage(sessionId: string, messageId: string, patch: Partial<ChatMessage>): Promise<void>
|
|
}
|
|
|
|
interface IpcModule {
|
|
registerIpc(getWindow: () => unknown): void
|
|
sessions: SessionStoreApi
|
|
}
|
|
|
|
interface RegressionState {
|
|
userData: string
|
|
handlers: Map<string, (...args: unknown[]) => unknown>
|
|
}
|
|
|
|
const STATE_KEY = '__YCODE_SESSION_STORE_REGRESSION__'
|
|
|
|
function tool(id: string, status: ToolCallRecord['status'] = 'running'): ToolCallRecord {
|
|
return {
|
|
id,
|
|
name: 'shell',
|
|
args: { command: 'test' },
|
|
argsRaw: '{"command":"test"}',
|
|
status,
|
|
output: `output-${id}`
|
|
}
|
|
}
|
|
|
|
function makeMessage(index: number): ChatMessage {
|
|
return {
|
|
id: `assistant-${index}`,
|
|
role: 'assistant',
|
|
content: `streaming-${index}`,
|
|
tools: [tool(`tool-${index}`)],
|
|
parts: [
|
|
{ type: 'text', text: `streaming-${index}` },
|
|
{ type: 'tool', toolId: `tool-${index}` }
|
|
],
|
|
createdAt: index,
|
|
status: 'streaming'
|
|
}
|
|
}
|
|
|
|
async function readStoredSessions(path: string): Promise<Array<SessionMeta & { messages: ChatMessage[] }>> {
|
|
const parsed = JSON.parse(await readFile(path, 'utf8')) as {
|
|
sessions: Array<SessionMeta & { messages: ChatMessage[] }>
|
|
}
|
|
assert.ok(Array.isArray(parsed.sessions), 'sessions.json should contain a sessions array')
|
|
return parsed.sessions
|
|
}
|
|
|
|
function installHandlers(module: IpcModule, state: RegressionState): void {
|
|
module.registerIpc(() => ({ webContents: { send: () => undefined } }))
|
|
const invoke = async (channel: string, ...args: unknown[]): Promise<unknown> => {
|
|
const handler = state.handlers.get(channel)
|
|
if (!handler) throw new Error(`Missing IPC handler: ${channel}`)
|
|
return handler({}, ...args)
|
|
}
|
|
;(state as RegressionState & { invoke: typeof invoke }).invoke = invoke
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const tempRoot = await mkdtemp(join(tmpdir(), 'ycode-session-store-'))
|
|
const state: RegressionState = {
|
|
userData: join(tempRoot, 'userData'),
|
|
handlers: new Map()
|
|
}
|
|
const globals = globalThis as typeof globalThis & Record<string, unknown>
|
|
const previous = globals[STATE_KEY]
|
|
globals[STATE_KEY] = state
|
|
|
|
try {
|
|
const ipcPath = join(process.cwd(), 'src', 'main', 'ipc.ts')
|
|
const sessionsPath = join(process.cwd(), 'src', 'main', 'sessions.ts')
|
|
const outfile = join(tempRoot, 'session-store.mjs')
|
|
await build({
|
|
stdin: {
|
|
contents: `export { registerIpc } from ${JSON.stringify(ipcPath)};\nexport * as sessions from ${JSON.stringify(sessionsPath)};`,
|
|
resolveDir: process.cwd(),
|
|
sourcefile: 'session-store-regression-entry.ts',
|
|
loader: 'ts'
|
|
},
|
|
outfile,
|
|
bundle: true,
|
|
external: ['node-pty'],
|
|
platform: 'node',
|
|
format: 'esm',
|
|
banner: { js: `import { createRequire as createTestRequire } from 'node:module'; const require = createTestRequire(import.meta.url);` },
|
|
logLevel: 'silent',
|
|
plugins: [
|
|
testTerminalHostPlugin,
|
|
{
|
|
name: 'session-store-electron-mock',
|
|
setup(pluginBuild) {
|
|
pluginBuild.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'mock-electron' }))
|
|
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-electron' }, () => ({
|
|
loader: 'js',
|
|
contents: `
|
|
const state = globalThis.${STATE_KEY};
|
|
export const app = {
|
|
getPath: (name) => name === 'userData' ? state.userData : state.userData,
|
|
getName: () => 'YCode',
|
|
getVersion: () => '0.1.0'
|
|
};
|
|
export const safeStorage = {
|
|
isEncryptionAvailable: () => false,
|
|
encryptString: (value) => Buffer.from(value),
|
|
decryptString: (value) => value.toString()
|
|
};
|
|
export const ipcMain = {
|
|
handle: (name, handler) => state.handlers.set(name, handler)
|
|
};
|
|
export const dialog = { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) };
|
|
export const shell = { openPath: async () => '' };
|
|
export const powerSaveBlocker = { start: () => 1, stop: () => undefined };
|
|
export class BrowserWindow {}
|
|
export class WebContentsView {}
|
|
`
|
|
}))
|
|
pluginBuild.onResolve({ filter: /^(?:\.\/|\.\.\/)omp$/ }, () => ({ path: 'omp', namespace: 'mock-omp' }))
|
|
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-omp' }, () => ({
|
|
loader: 'js',
|
|
contents: `
|
|
export class OmpKernel {
|
|
constructor() {}
|
|
prompt() { return Promise.resolve({ type: 'response', success: true }); }
|
|
abort() { return Promise.resolve({ type: 'response', success: true }); }
|
|
dispose() {}
|
|
}
|
|
export const detectOmpPath = () => null;
|
|
export const getOmpInfo = () => ({ available: false, exePath: null, profile: 'ycode', model: null });
|
|
`
|
|
}))
|
|
}
|
|
}
|
|
]
|
|
})
|
|
|
|
const invokeState = state as RegressionState & {
|
|
invoke: (channel: string, ...args: unknown[]) => Promise<unknown>
|
|
}
|
|
const bundleUrl = pathToFileURL(outfile).href
|
|
const module = (await import(bundleUrl)) as IpcModule
|
|
installHandlers(module, state)
|
|
const invoke = invokeState.invoke
|
|
const sessionsFile = join(state.userData, 'sessions.json')
|
|
|
|
assert.equal(await import('node:fs/promises').then(({ stat }) => stat(state.userData).then(() => true, () => false)), false,
|
|
'the test should begin without a user-data directory')
|
|
|
|
const created = await Promise.all(
|
|
Array.from({ length: 12 }, (_, index) => invoke('session:create', `Concurrent ${index}`) as Promise<SessionMeta>)
|
|
)
|
|
const listed = (await invoke('session:list')) as SessionMeta[]
|
|
assert.equal(created.length, 12)
|
|
assert.equal(new Set(created.map((item) => item.id)).size, 12)
|
|
assert.equal(listed.length, 12, 'concurrent first creates should all survive in memory')
|
|
assert.deepEqual(new Set(listed.map((item) => item.title)), new Set(created.map((item) => item.title)))
|
|
assert.ok(await import('node:fs/promises').then(({ stat }) => stat(state.userData).then(() => true, () => false)),
|
|
'the first persist should create the user-data directory')
|
|
assert.equal((await readStoredSessions(sessionsFile)).length, 12,
|
|
'concurrent first creates should all survive on disk')
|
|
|
|
const targetSession = created[0]
|
|
const messages = Array.from({ length: 10 }, (_, index) => makeMessage(index))
|
|
await Promise.all(messages.map((message) => module.sessions.appendMessage(targetSession.id, message)))
|
|
await Promise.all(messages.map((message, index) => {
|
|
const terminalStatus = index % 2 === 0 ? 'done' as const : 'cancelled' as const
|
|
const toolStatus = index % 2 === 0 ? 'ok' as const : 'cancelled' as const
|
|
return module.sessions.patchMessage(targetSession.id, message.id, {
|
|
content: `terminal-${index}`,
|
|
parts: [
|
|
{ type: 'text', text: `terminal-${index}` },
|
|
{ type: 'tool', toolId: `tool-${index}` }
|
|
],
|
|
tools: [tool(`tool-${index}`, toolStatus)],
|
|
status: terminalStatus
|
|
})
|
|
}))
|
|
|
|
const inMemory = (await invoke('session:messages', targetSession.id)) as ChatMessage[]
|
|
const diskSessions = await readStoredSessions(sessionsFile)
|
|
const diskMessages = diskSessions.find((item) => item.id === targetSession.id)?.messages
|
|
assert.ok(diskMessages)
|
|
assert.deepEqual(diskMessages, inMemory,
|
|
'the latest atomic JSON snapshot should match the session:messages IPC result')
|
|
assert.equal(inMemory.length, 10)
|
|
for (const [index, message] of inMemory.entries()) {
|
|
assert.equal(message.status, index % 2 === 0 ? 'done' : 'cancelled')
|
|
assert.equal(message.tools?.[0].status, index % 2 === 0 ? 'ok' : 'cancelled')
|
|
assert.equal(message.tools?.[0].output, `output-tool-${index}`)
|
|
}
|
|
|
|
const coldModule = (await import(`${bundleUrl}?cold=${Date.now()}`)) as IpcModule
|
|
installHandlers(coldModule, state)
|
|
const coldMessages = (await invoke('session:messages', targetSession.id)) as ChatMessage[]
|
|
const coldSessions = (await invoke('session:list')) as SessionMeta[]
|
|
assert.deepEqual(coldMessages, inMemory, 'a cold module import should recover saved messages from disk')
|
|
assert.equal(coldSessions.length, 12, 'a cold module import should recover all concurrent sessions')
|
|
|
|
await rm(sessionsFile)
|
|
await mkdir(sessionsFile)
|
|
await assert.rejects(coldModule.sessions.createSession('transient write failure'),
|
|
'a failed atomic rename must reject its caller')
|
|
const leftovers = (await readdir(state.userData)).filter((name) => /^\.sessions-.*\.tmp$/.test(name))
|
|
assert.deepEqual(leftovers, [], 'a failed write should remove its temporary file')
|
|
|
|
await rm(sessionsFile, { recursive: true })
|
|
const recovered = await coldModule.sessions.createSession('queue recovered')
|
|
assert.equal(recovered.title, 'queue recovered')
|
|
const recoveredDisk = await readStoredSessions(sessionsFile)
|
|
assert.ok(recoveredDisk.some((item) => item.id === recovered.id),
|
|
'a later write should recover after a transient failure')
|
|
const afterRecovery = (await invoke('session:messages', targetSession.id)) as ChatMessage[]
|
|
assert.deepEqual(afterRecovery, inMemory,
|
|
'session:messages should remain consistent after the write queue recovers')
|
|
|
|
console.log('session-store-regression: all checks passed')
|
|
} finally {
|
|
if (previous === undefined) delete globals[STATE_KEY]
|
|
else globals[STATE_KEY] = previous
|
|
await rm(tempRoot, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
void main().catch((error: unknown) => {
|
|
console.error('session-store-regression failed:', error)
|
|
process.exitCode = 1
|
|
})
|