Files
ycode/scripts/workspace-attach-regression.ts
T

171 lines
10 KiB
TypeScript

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<string, any>
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<string, (...args: any[]) => any>(),
nextDialog: () => Promise.resolve<DialogResult>({ canceled: true, filePaths: [] }),
dialogCalls: 0, changes: 0, api: undefined as YCodeApi | undefined,
invoke: async (channel: string, ...args: any[]): Promise<any> => 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<string, string>()
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<void>
}
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 })
}