import assert from 'node:assert/strict' import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import { build } from 'esbuild' import { parse } from '@babel/parser' import { testTerminalHostPlugin } from './test-terminal-host-plugin' import type { WorkspaceAttachResult, YCodeApi } from '../src/shared/ipc' import type { useChatStore as ChatStore } from '../src/renderer/src/stores/chat' import type { useSettingsStore as SettingsStore } from '../src/renderer/src/stores/settings' type DialogResult = { canceled: boolean; filePaths: string[] } const key = '__YCODE_WORKSPACE_ATTACH_REGRESSION__' const globals = globalThis as typeof globalThis & Record const previous = globals[key], previousWindow = globals.window const temp = await mkdtemp(join(tmpdir(), 'ycode-workspace-attach-')) const state = { userData: join(temp, 'data'), home: join(temp, 'home'), handlers: new Map any>(), nextDialog: () => Promise.resolve({ canceled: true, filePaths: [] }), dialogCalls: 0, changes: 0, api: undefined as YCodeApi | undefined, invoke: async (channel: string, ...args: any[]): Promise => state.handlers.get(channel)!({}, ...args) } globals[key] = state try { const source = await readFile('src/renderer/src/components/ChatPane.tsx', 'utf8') const tree = parse(source, { sourceType: 'module', plugins: ['typescript', 'jsx'] }) const declarations = new Map() function visit(node: any): void { if (!node || typeof node !== 'object') return if (node.type === 'VariableDeclaration') for (const declaration of node.declarations) { if (['bindDraftWorkspace', 'attachDraftWorkspace'].includes(declaration.id?.name)) { declarations.set(declaration.id.name, source.slice(node.start, node.end)) } } for (const value of Object.values(node)) if (Array.isArray(value)) value.forEach(visit) else if (value && typeof value === 'object') visit(value) } visit(tree) assert.equal(declarations.size, 2, 'test must execute the production ChatPane callbacks') const outfile = join(temp, 'attach.mjs') const modulePath = (file: string): string => JSON.stringify(resolve(file)) await build({ stdin: { contents: `export { registerIpc } from ${modulePath('src/main/ipc.ts')}; import ${modulePath('src/preload/index.ts')}; import { useSettingsStore } from ${modulePath('src/renderer/src/stores/settings.ts')}; import { useChatStore } from ${modulePath('src/renderer/src/stores/chat.ts')}; ${declarations.get('bindDraftWorkspace')} ${declarations.get('attachDraftWorkspace')} export { useSettingsStore, useChatStore, attachDraftWorkspace };`, resolveDir: process.cwd(), sourcefile: 'workspace-attach-fixture.ts', loader: 'ts' }, outfile, bundle: true, platform: 'node', format: 'esm', external: ['node-pty'], logLevel: 'silent', plugins: [testTerminalHostPlugin, { name: 'workspace-attach-electron-boundary', setup(plugin) { plugin.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'attach-test' })) plugin.onLoad({ filter: /.*/, namespace: 'attach-test' }, () => ({ loader: 'js', contents: ` const state = globalThis.${key}; export const app = {getPath: name => name === 'home' ? state.home : 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:(channel,handler)=>state.handlers.set(channel,handler)}; export const ipcRenderer = {invoke:(...args)=>state.invoke(...args),on:()=>{},removeListener:()=>{}}; export const contextBridge = {exposeInMainWorld:(_name,api)=>state.api=api}; export const dialog = {showOpenDialog:async()=>{state.dialogCalls++;return state.nextDialog()}}; export const shell = {openPath:async()=>''}; export class BrowserWindow { static getAllWindows(){return [{webContents:{send:()=>state.changes++}}]} } export class WebContentsView {} ` })) plugin.onResolve({ filter: /^(?:\.\/|\.\.\/)omp$/ }, () => ({ path: 'omp', namespace: 'attach-omp' })) plugin.onLoad({ filter: /.*/, namespace: 'attach-omp' }, () => ({ loader: 'js', contents: ` export class OmpKernel {} export const detectOmpPath=()=>null,getOmpInfo=()=>({available:false,exePath:null,profile:'ycode',model:null}); ` })) } }] }) const fixture = await import(pathToFileURL(outfile).href) as { registerIpc(getWindow: () => null): void useSettingsStore: typeof SettingsStore useChatStore: typeof ChatStore attachDraftWorkspace(): Promise } fixture.registerIpc(() => null) globals.window = { ycode: state.api! } const api = state.api!, settings = fixture.useSettingsStore, chat = fixture.useChatStore const firstPath = join(temp, 'existing'), secondPath = join(temp, 'new') await Promise.all([mkdir(firstPath), mkdir(secondPath)]) const original = await api.workspace.list() assert.equal(original.workspaces.length, 0) const first = await api.workspace.attach(firstPath) assert.ok(first.attachedWorkspaceId) assert.equal(first.selectedWorkspaceId, first.attachedWorkspaceId) assert.equal(first.workspaces.at(-1)?.root, firstPath) assert.equal(state.dialogCalls, 0, 'programmatic attach must bypass the OS picker') const repeated = await api.workspace.attach(firstPath) assert.equal(repeated.attachedWorkspaceId, first.attachedWorkspaceId) assert.equal(repeated.workspaces.length, first.workspaces.length) await assert.rejects(() => api.workspace.attach(''), /路径无效/) const countBeforeCancel = state.changes const cancelled = await api.workspace.attach() assert.equal(cancelled.attachedWorkspaceId, undefined) assert.deepEqual(cancelled, { workspaces: repeated.workspaces, selectedWorkspaceId: repeated.selectedWorkspaceId }) assert.equal(state.changes, countBeforeCancel, 'cancellation must not signal a registry mutation') state.nextDialog = async () => ({ canceled: false, filePaths: [] }) assert.equal((await api.workspace.attach()).attachedWorkspaceId, undefined, 'empty picker result also cancels') const second = await settings.getState().attachWorkspace(secondPath) assert.ok(second) assert.equal(settings.getState().selectedWorkspaceId, second) assert.equal('attachedWorkspaceId' in settings.getState(), false, 'result metadata is not persisted in the settings store') state.nextDialog = async () => ({ canceled: false, filePaths: [firstPath] }) chat.getState().startTask({ cwd: 'old-draft-root', modelId: 'keep-model' }) await fixture.attachDraftWorkspace() assert.deepEqual(chat.getState().draftContext, { workspaceId: first.attachedWorkspaceId, cwd: firstPath, modelId: 'keep-model' }) assert.equal(settings.getState().workspaces.length, first.workspaces.length + 1) // Selecting the same globally selected directory must still bind an unrelated draft. chat.getState().startTask({ cwd: 'another-root' }) await fixture.attachDraftWorkspace() assert.equal(chat.getState().draftContext.workspaceId, first.attachedWorkspaceId) assert.equal(chat.getState().draftContext.cwd, firstPath) const realAttach = settings.getState().attachWorkspace settings.setState({ attachWorkspace: async () => { const attachedId = await realAttach() // A separate settings window may select another default before this callback binds. settings.setState({ selectedWorkspaceId: second }) return attachedId } }) chat.getState().startTask() await fixture.attachDraftWorkspace() assert.equal(chat.getState().draftContext.workspaceId, first.attachedWorkspaceId, 'bind the returned ID, not the later global default') settings.setState({ attachWorkspace: realAttach }) state.nextDialog = async () => ({ canceled: true, filePaths: [] }) chat.getState().startTask({ cwd: 'cancel-preserved', modelId: 'model' }) const before = chat.getState().draftContext, draftId = chat.getState().draftId await fixture.attachDraftWorkspace() assert.strictEqual(chat.getState().draftContext, before) assert.equal(chat.getState().draftId, draftId) assert.equal(chat.getState().currentSessionId, null) let complete!: (result: DialogResult) => void state.nextDialog = () => new Promise(resolve => { complete = resolve }) const pending = fixture.attachDraftWorkspace() chat.getState().startTask({ cwd: 'newer-draft', modelId: 'next' }) complete({ canceled: false, filePaths: [secondPath] }) await pending assert.deepEqual(chat.getState().draftContext, { cwd: 'newer-draft', modelId: 'next' }) const pendingSessionSwitch = fixture.attachDraftWorkspace() chat.setState({ currentSessionId: 'existing-session' }) complete({ canceled: false, filePaths: [firstPath] }) await pendingSessionSwitch assert.deepEqual(chat.getState().draftContext, { cwd: 'newer-draft', modelId: 'next' }) const callsBeforeSession = state.dialogCalls await fixture.attachDraftWorkspace() assert.equal(state.dialogCalls, callsBeforeSession, 'existing sessions cannot open the draft picker') assert.equal(chat.getState().sessions.length, 0, 'attach and cancel never create sessions') const thirdPath = join(temp, 'new-through-picker') await mkdir(thirdPath) state.nextDialog = async () => ({ canceled: false, filePaths: [thirdPath] }) const newFromPicker = await api.workspace.attach() assert.ok(newFromPicker.attachedWorkspaceId) assert.equal(newFromPicker.workspaces.at(-1)?.root, thirdPath) assert.equal(newFromPicker.workspaces.length, first.workspaces.length + 2) const unchangedApi = await api.workspace.select(null) assert.equal('attachedWorkspaceId' in unchangedApi, false, 'other workspace APIs preserve their registry-only contract') const disk = JSON.parse(await readFile(join(state.userData, 'workspaces.json'), 'utf8')) assert.equal('attachedWorkspaceId' in disk, false) console.log('workspace-attach-regression: cancel, repeated/new paths, preload/store, and draft/session races passed (Electron dialog stub; OS picker not driven)') } finally { if (previous === undefined) delete globals[key]; else globals[key] = previous if (previousWindow === undefined) delete globals.window; else globals.window = previousWindow await rm(temp, { recursive: true, force: true }) }