// Real Windows ConPTY + real Electron owners; commands only inspect temporary test workspaces. const path = require('node:path') const fs = require('node:fs/promises') const assert = require('node:assert/strict') const directory = path.resolve('.tmp/terminal-host-regression') if (!process.versions.electron) { ;(async () => { await fs.mkdir(directory, { recursive: true }) await require('esbuild').build({ entryPoints: [path.resolve('src/main/terminal-host.ts')], outfile: path.join(directory, 'host.cjs'), bundle: true, platform: 'node', format: 'cjs', external: ['electron'], logLevel: 'silent', plugins: [{ name: 'native-pty-capture', setup(build) { build.onResolve({ filter: /^node-pty$/ }, () => ({ path: 'native-pty', namespace: 'capture' })) build.onLoad({ filter: /.*/, namespace: 'capture' }, () => ({ loader: 'js', contents: ` const real = globalThis.__YCODE_PTY_NATIVE_TEST__.pty; export function spawn(file,args,options){ const pty=real.spawn(file,args,options);globalThis.__YCODE_PTY_NATIVE_TEST__.children.push({pid:pty.pid,env:options.env,args});return pty } ` })) } }] }) const env = { ...process.env }; delete env.ELECTRON_RUN_AS_NODE const result = require('node:child_process').spawnSync(require('electron'), [__filename], { env, windowsHide: true, stdio: 'inherit', timeout: 60000 }) process.exitCode = result.status ?? 1 })().catch(error => { console.error(error); process.exitCode = 1 }) } else { const { app, BrowserWindow } = require('electron') app.setPath('userData', path.join(directory, 'user-data')) app.disableHardwareAcceleration() const state = { children: [], events: [], pty: require('node-pty') } globalThis.__YCODE_PTY_NATIVE_TEST__ = state const host = require(path.join(directory, 'host.cjs')) const pause = ms => new Promise(resolve => setTimeout(resolve, ms)) const plain = value => value.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '').replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '') const alive = pid => { try { process.kill(pid, 0); return true } catch { return false } } async function until(test, label, timeout = 12000) { const start = Date.now() while (Date.now() - start < timeout) { if (await test()) return; await pause(25) } throw Error('Timed out: ' + label) } const windows = [] const previousSecret = process.env.YCODE_MODEL_API_KEY process.env.YCODE_MODEL_API_KEY = 'native-test-host-secret' app.whenReady().then(async () => { const cwdA = path.join(directory, 'workspace A'), cwdB = path.join(directory, 'workspace B') await Promise.all([fs.mkdir(cwdA, { recursive: true }), fs.mkdir(cwdB, { recursive: true })]) const createOwner = async () => { const owner = new BrowserWindow({ show: false, webPreferences: { offscreen: true } }) await owner.loadURL('about:blank') const original = owner.webContents.send.bind(owner.webContents) owner.webContents.send = (channel, event) => { if (channel === 'terminal:event') state.events.push({ windowId: owner.id, ...event }); return original(channel, event) } windows.push(owner); return owner } const ownerA = await createOwner(), ownerB = await createOwner() assert.equal(process.platform, 'win32', 'this regression specifically checks Windows ConPTY') const first = await host.createTerminal(ownerA, cwdA, 'session-A', 90, 26) const second = await host.createTerminal(ownerB, cwdB, 'session-B', 80, 24) assert.match(first.shell, /powershell\.exe$/i) assert.deepEqual(state.children[0].args, ['-NoLogo', '-NoProfile']) for (const child of state.children) { assert.equal(Object.keys(child.env).some(name => /KEY|TOKEN|SECRET|AUTH|COOKIE|PASSWORD|CREDENTIAL/i.test(name)), false) assert.equal(child.env.ELECTRON_RUN_AS_NODE, undefined) assert.ok(child.env.SystemRoot || child.env.SYSTEMROOT || child.env.windir, 'normal Windows system environment remains available') } assert.throws(() => host.getTerminal(ownerB, first.id, 'session-A'), /不属于/) assert.throws(() => host.getTerminal(ownerA, first.id, 'session-B'), /不属于/) assert.throws(() => host.writeTerminal(ownerB, first.id, 'exit\r', 'session-A'), /不属于/) assert.throws(() => host.resizeTerminal(ownerA, first.id, 1, 30, 'session-A'), /尺寸/) await assert.rejects(() => host.createTerminal(ownerA, path.join(directory, 'missing'), 'session-A'), /不存在/) host.writeTerminal(ownerA, first.id, "Write-Output ('__READY_'+'A__');[Console]::WriteLine((Get-Location).Path)\r", 'session-A') host.writeTerminal(ownerB, second.id, "Write-Output ('__READY_'+'B__');[Console]::WriteLine((Get-Location).Path)\r", 'session-B') await until(() => plain(host.getTerminal(ownerA, first.id, 'session-A').buffer).includes('__READY_A__'), 'real PowerShell A output') await until(() => plain(host.getTerminal(ownerB, second.id, 'session-B').buffer).includes('__READY_B__'), 'real PowerShell B output') assert.ok(plain(host.getTerminal(ownerA, first.id, 'session-A').buffer).includes(cwdA)) assert.ok(plain(host.getTerminal(ownerB, second.id, 'session-B').buffer).includes(cwdB)) host.resizeTerminal(ownerA, first.id, 120, 40, 'session-A') host.writeTerminal(ownerA, first.id, "Write-Output ('__DIM__'+[Console]::WindowWidth);Write-Output ('__ENV__'+[string]::IsNullOrEmpty($env:YCODE_MODEL_API_KEY))\r", 'session-A') await until(() => plain(host.getTerminal(ownerA, first.id, 'session-A').buffer).includes('__DIM__120'), 'ConPTY terminal resize reaches Windows Console API') await until(() => plain(host.getTerminal(ownerA, first.id, 'session-A').buffer).includes('__ENV__True'), 'child process cannot read gateway key') host.writeTerminal(ownerA, first.id, "Write-Output ('__BUS'+'Y__');Start-Sleep -Seconds 30;Write-Output ('__MUST_NOT_'+'RUN__')\r", 'session-A') await until(() => plain(host.getTerminal(ownerA, first.id, 'session-A').buffer).includes('__BUSY__'), 'cancellable command entered') host.writeTerminal(ownerA, first.id, '\x03', 'session-A') await pause(150) host.writeTerminal(ownerA, first.id, "Write-Output ('__CANCEL'+'LED__')\r", 'session-A') await until(() => plain(host.getTerminal(ownerA, first.id, 'session-A').buffer).includes('__CANCELLED__'), 'Ctrl+C interrupts foreground PowerShell command') assert.equal(plain(host.getTerminal(ownerA, first.id, 'session-A').buffer).includes('__MUST_NOT_RUN__'), false) host.clearTerminal(ownerA, first.id, 'session-A') assert.equal(host.getTerminal(ownerA, first.id, 'session-A').buffer, '\x1b[2J\x1b[3J\x1b[H') host.writeTerminal(ownerA, first.id, "Write-Output ('__BUFFER_'+'BEGIN__');[Console]::Write('x' * 280000);Write-Output ('__BUFFER_'+'END__')\r", 'session-A') await until(() => plain(host.getTerminal(ownerA, first.id, 'session-A').buffer).includes('__BUFFER_END__'), 'large native output', 20000) const replay = host.getTerminal(ownerA, first.id, 'session-A') assert.ok(replay.buffer.length <= host.TERMINAL_HOST_LIMITS.bufferCharacters) assert.equal(replay.replay.map(chunk => chunk.data).join(''), replay.buffer) assert.equal(replay.buffer.includes('__BUFFER_BEGIN__'), false) const draft = await host.createTerminal(ownerA, cwdA) assert.throws(() => host.rebindTerminal(ownerA, draft.id, undefined, 'new-session', cwdB), /工作目录/) const bound = host.rebindTerminal(ownerA, draft.id, undefined, 'new-session', cwdA) assert.equal(bound.sessionId, 'new-session') assert.equal(bound.cwd, cwdA) assert.throws(() => host.getTerminal(ownerA, draft.id), /不属于/) host.writeTerminal(ownerA, draft.id, "Write-Output ('__BOUND_'+'LIVE__')\r", 'new-session') await until(() => plain(host.getTerminal(ownerA, draft.id, 'new-session').buffer).includes('__BOUND_LIVE__'), 'draft terminal keeps its process after binding') host.closeTerminal(ownerA, draft.id, 'new-session') assert.throws(() => host.getTerminal(ownerA, draft.id, 'new-session'), /不存在/) host.writeTerminal(ownerB, second.id, 'exit 7\r', 'session-B') await until(() => host.getTerminal(ownerB, second.id, 'session-B').exited, 'native process exit') assert.equal(host.getTerminal(ownerB, second.id, 'session-B').exitCode, 7) assert.throws(() => host.writeTerminal(ownerB, second.id, 'echo after-exit\r', 'session-B'), /已退出/) const firstEvents = state.events.filter(event => event.id === first.id) assert.ok(firstEvents.length > 0) assert.ok(firstEvents.every((event, index) => event.windowId === ownerA.id && event.sessionId === 'session-A' && (!index || event.sequence > firstEvents[index - 1].sequence))) assert.ok(state.events.some(event => event.id === second.id && event.type === 'exit' && event.exitCode === 7)) ownerA.destroy() await until(() => state.children.filter(child => child.pid !== state.children[1].pid).every(child => !alive(child.pid)), 'closing owner destroys all its native PTY processes') host.disposeTerminalHosts() const report = { result: 'PASS Windows native terminal host', platform: process.platform, electron: process.versions.electron, checks: ['ConPTY PowerShell -NoProfile', 'cwd isolation', 'credential env exclusion', 'native 120-column resize', 'Ctrl+C cancel', 'clear', 'bounded ANSI replay', 'owner/session access control', 'draft rebind', 'exit status', 'window-close process cleanup'], nativeProcesses: state.children.length } await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2)) console.log(JSON.stringify(report)) if (previousSecret === undefined) delete process.env.YCODE_MODEL_API_KEY; else process.env.YCODE_MODEL_API_KEY = previousSecret windows.forEach(owner => { if (!owner.isDestroyed()) owner.destroy() }) app.exit(0) }).catch(error => { console.error(error); host.disposeTerminalHosts(); windows.forEach(owner => { if (!owner.isDestroyed()) owner.destroy() }); app.exit(1) }) }