Test the TUI through ConPTY on Windows
This commit is contained in:
@@ -50,5 +50,8 @@
|
|||||||
"@deepseek-ai/dsh-web": "workspace:*",
|
"@deepseek-ai/dsh-web": "workspace:*",
|
||||||
"@deepseek-ai/dsh-web-fetch-local": "workspace:*",
|
"@deepseek-ai/dsh-web-fetch-local": "workspace:*",
|
||||||
"@deepseek-ai/dsh-workflow-workerthread": "workspace:*"
|
"@deepseek-ai/dsh-workflow-workerthread": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"node-pty": "1.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { spawn } from 'node:child_process'
|
|||||||
import { mkdtemp, rm } from 'node:fs/promises'
|
import { mkdtemp, rm } from 'node:fs/promises'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
|
import { resolveExampleLaunch, type ExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
|
||||||
|
|
||||||
const PTY_DRIVER = String.raw`
|
const POSIX_PTY_DRIVER = String.raw`
|
||||||
import errno, json, os, pty, select, signal, sys, time
|
import errno, json, os, pty, select, signal, sys, time
|
||||||
node, launch_args_json, launch_env_json, cwd, actions_json, expected_exit, timeout_seconds = sys.argv[1:]
|
node, launch_args_json, launch_env_json, cwd, actions_json, expected_exit, timeout_seconds = sys.argv[1:]
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
@@ -71,9 +71,103 @@ export interface TuiPtySmokeOptions {
|
|||||||
readonly timeoutMs?: number
|
readonly timeoutMs?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function definedEnv(env: NodeJS.ProcessEnv): Record<string, string> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPosixPtySmoke(
|
||||||
|
launch: ExampleLaunch,
|
||||||
|
cwd: string,
|
||||||
|
options: TuiPtySmokeOptions,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<string> {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const child = spawn('python3', [
|
||||||
|
'-c',
|
||||||
|
POSIX_PTY_DRIVER,
|
||||||
|
launch.command,
|
||||||
|
JSON.stringify(launch.args),
|
||||||
|
JSON.stringify(launch.env),
|
||||||
|
cwd,
|
||||||
|
JSON.stringify(options.actions ?? []),
|
||||||
|
String(options.expectedExitCode ?? 0),
|
||||||
|
String(timeoutMs / 1_000),
|
||||||
|
], { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||||
|
let stdout = ''
|
||||||
|
let stderr = ''
|
||||||
|
child.stdout.setEncoding('utf8')
|
||||||
|
child.stdout.on('data', (chunk: string) => { stdout += chunk })
|
||||||
|
child.stderr.setEncoding('utf8')
|
||||||
|
child.stderr.on('data', (chunk: string) => { stderr += chunk })
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
child.kill('SIGKILL')
|
||||||
|
reject(new Error(`${options.label} PTY driver did not exit. stdout:\n${stdout}\nstderr:\n${stderr}`))
|
||||||
|
}, timeoutMs + 5_000)
|
||||||
|
child.once('error', (error) => { clearTimeout(timer); reject(error) })
|
||||||
|
child.once('exit', (code) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
if (code === 0) resolve(stdout)
|
||||||
|
else reject(new Error(`${options.label} PTY driver exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runWindowsPtySmoke(
|
||||||
|
launch: ExampleLaunch,
|
||||||
|
cwd: string,
|
||||||
|
options: TuiPtySmokeOptions,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<string> {
|
||||||
|
const pty = await import('node-pty')
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const actions = options.actions ?? []
|
||||||
|
const expectedExitCode = options.expectedExitCode ?? 0
|
||||||
|
let output = ''
|
||||||
|
let actionIndex = 0
|
||||||
|
let timedOut = false
|
||||||
|
const terminal = pty.spawn(launch.command, launch.args, {
|
||||||
|
name: 'xterm-256color',
|
||||||
|
cols: 100,
|
||||||
|
rows: 30,
|
||||||
|
cwd,
|
||||||
|
env: definedEnv({
|
||||||
|
...process.env,
|
||||||
|
...launch.env,
|
||||||
|
COLUMNS: '100',
|
||||||
|
LINES: '30',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
timedOut = true
|
||||||
|
terminal.kill()
|
||||||
|
}, timeoutMs)
|
||||||
|
terminal.onData((chunk) => {
|
||||||
|
output += chunk
|
||||||
|
while (actionIndex < actions.length && output.includes(actions[actionIndex]!.waitFor)) {
|
||||||
|
terminal.write(actions[actionIndex]!.send)
|
||||||
|
actionIndex += 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
terminal.onExit(({ exitCode, signal }) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
if (timedOut) {
|
||||||
|
reject(new Error(`${options.label} PTY process did not exit before ${String(timeoutMs)}ms. output:\n${output}`))
|
||||||
|
} else if (actionIndex !== actions.length) {
|
||||||
|
reject(new Error(`${options.label} completed ${String(actionIndex)}/${String(actions.length)} PTY actions. output:\n${output}`))
|
||||||
|
} else if (exitCode !== expectedExitCode) {
|
||||||
|
reject(new Error(`${options.label} expected exit ${String(expectedExitCode)}, got ${String(exitCode)} (signal ${String(signal)}). output:\n${output}`))
|
||||||
|
} else {
|
||||||
|
resolve(output)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Boot an example in a real pseudo-terminal, drive marker-gated input, and
|
* Boot an example in a real pseudo-terminal (ConPTY on Windows), drive
|
||||||
* return the captured terminal bytes after the expected process exit.
|
* marker-gated input, and return captured bytes after the expected process exit.
|
||||||
* @param options - launch paths, environment, actions, and expected exit code.
|
* @param options - launch paths, environment, actions, and expected exit code.
|
||||||
* @returns complete pseudo-terminal output.
|
* @returns complete pseudo-terminal output.
|
||||||
*/
|
*/
|
||||||
@@ -92,35 +186,10 @@ export async function runTuiPtySmoke(options: TuiPtySmokeOptions): Promise<strin
|
|||||||
...options.env,
|
...options.env,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return await new Promise((resolve, reject) => {
|
if (process.platform === 'win32') {
|
||||||
const child = spawn('python3', [
|
return await runWindowsPtySmoke(launch, cwd, options, timeoutMs)
|
||||||
'-c',
|
}
|
||||||
PTY_DRIVER,
|
return await runPosixPtySmoke(launch, cwd, options, timeoutMs)
|
||||||
launch.command,
|
|
||||||
JSON.stringify(launch.args),
|
|
||||||
JSON.stringify(launch.env),
|
|
||||||
cwd,
|
|
||||||
JSON.stringify(options.actions ?? []),
|
|
||||||
String(options.expectedExitCode ?? 0),
|
|
||||||
String(timeoutMs / 1_000),
|
|
||||||
], { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
||||||
let stdout = ''
|
|
||||||
let stderr = ''
|
|
||||||
child.stdout.setEncoding('utf8')
|
|
||||||
child.stdout.on('data', (chunk: string) => { stdout += chunk })
|
|
||||||
child.stderr.setEncoding('utf8')
|
|
||||||
child.stderr.on('data', (chunk: string) => { stderr += chunk })
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
child.kill('SIGKILL')
|
|
||||||
reject(new Error(`${options.label} PTY driver did not exit. stdout:\n${stdout}\nstderr:\n${stderr}`))
|
|
||||||
}, timeoutMs + 5_000)
|
|
||||||
child.once('error', (error) => { clearTimeout(timer); reject(error) })
|
|
||||||
child.once('exit', (code) => {
|
|
||||||
clearTimeout(timer)
|
|
||||||
if (code === 0) resolve(stdout)
|
|
||||||
else reject(new Error(`${options.label} PTY driver exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
} finally {
|
} finally {
|
||||||
await rm(cwd, { recursive: true, force: true })
|
await rm(cwd, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
|
|||||||
const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis.yml', import.meta.url))
|
const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis.yml', import.meta.url))
|
||||||
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
|
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
|
||||||
|
|
||||||
// The Python PTY driver imports the POSIX-only pty and termios modules.
|
describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
|
||||||
describe.skipIf(process.platform === 'win32')('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
|
|
||||||
it('boots pi-tui, renders the configured banner, accepts /exit, and restores the terminal', async () => {
|
it('boots pi-tui, renders the configured banner, accepts /exit, and restores the terminal', async () => {
|
||||||
const output = await runTuiPtySmoke({
|
const output = await runTuiPtySmoke({
|
||||||
label: 'tui-agent boot',
|
label: 'tui-agent boot',
|
||||||
|
|||||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@@ -227,6 +227,10 @@ importers:
|
|||||||
'@deepseek-ai/dsh-workflow-workerthread':
|
'@deepseek-ai/dsh-workflow-workerthread':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../packages/workflow/workflow-workerthread
|
version: link:../packages/workflow/workflow-workerthread
|
||||||
|
devDependencies:
|
||||||
|
node-pty:
|
||||||
|
specifier: 1.1.0
|
||||||
|
version: 1.1.0
|
||||||
|
|
||||||
packages/bash/bash:
|
packages/bash/bash:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
@@ -6179,6 +6183,9 @@ packages:
|
|||||||
neo-async@2.6.2:
|
neo-async@2.6.2:
|
||||||
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
|
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
|
||||||
|
|
||||||
|
node-addon-api@7.1.1:
|
||||||
|
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
||||||
|
|
||||||
node-addon-landlock-run-linux-arm64@0.0.0-test.0:
|
node-addon-landlock-run-linux-arm64@0.0.0-test.0:
|
||||||
resolution: {integrity: sha512-oJsXcC33qKl9mWYx0n9YPJ2pUAoY39PoIX0Gx4lDrSCTEvENFrEaODAsQYNY+eEGpn9YMN7E+FOftvea3/1FqQ==}
|
resolution: {integrity: sha512-oJsXcC33qKl9mWYx0n9YPJ2pUAoY39PoIX0Gx4lDrSCTEvENFrEaODAsQYNY+eEGpn9YMN7E+FOftvea3/1FqQ==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
@@ -6256,6 +6263,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
|
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
|
||||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||||
|
|
||||||
|
node-pty@1.1.0:
|
||||||
|
resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==}
|
||||||
|
|
||||||
non-layered-tidy-tree-layout@2.0.2:
|
non-layered-tidy-tree-layout@2.0.2:
|
||||||
resolution: {integrity: sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==}
|
resolution: {integrity: sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==}
|
||||||
|
|
||||||
@@ -10605,6 +10615,8 @@ snapshots:
|
|||||||
|
|
||||||
neo-async@2.6.2: {}
|
neo-async@2.6.2: {}
|
||||||
|
|
||||||
|
node-addon-api@7.1.1: {}
|
||||||
|
|
||||||
node-addon-landlock-run-linux-arm64@0.0.0-test.0:
|
node-addon-landlock-run-linux-arm64@0.0.0-test.0:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -10673,6 +10685,10 @@ snapshots:
|
|||||||
fetch-blob: 3.2.0
|
fetch-blob: 3.2.0
|
||||||
formdata-polyfill: 4.0.10
|
formdata-polyfill: 4.0.10
|
||||||
|
|
||||||
|
node-pty@1.1.0:
|
||||||
|
dependencies:
|
||||||
|
node-addon-api: 7.1.1
|
||||||
|
|
||||||
non-layered-tidy-tree-layout@2.0.2:
|
non-layered-tidy-tree-layout@2.0.2:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ peerDependencyRules:
|
|||||||
allowBuilds:
|
allowBuilds:
|
||||||
esbuild: true
|
esbuild: true
|
||||||
lefthook: true
|
lefthook: true
|
||||||
|
# Cross-platform PTY boundary for the TUI process smoke, including ConPTY on Windows.
|
||||||
|
node-pty: true
|
||||||
# Pulled in by @earendil-works/pi-ai (optional LLM API backend). pnpm lists
|
# Pulled in by @earendil-works/pi-ai (optional LLM API backend). pnpm lists
|
||||||
# them only because they ship lifecycle scripts, but those are no-ops we don't
|
# them only because they ship lifecycle scripts, but those are no-ops we don't
|
||||||
# need, so we deny them — install still succeeds.
|
# need, so we deny them — install still succeeds.
|
||||||
|
|||||||
Reference in New Issue
Block a user