120 lines
9.8 KiB
JavaScript
120 lines
9.8 KiB
JavaScript
// 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) })
|
|
}
|