Przeglądaj źródła

Test the TUI through ConPTY on Windows

Tianyi Cui 2 miesięcy temu
rodzic
commit
29729f83cf

+ 3 - 0
examples/package.json

@@ -50,5 +50,8 @@
     "@deepseek-ai/dsh-web": "workspace:*",
     "@deepseek-ai/dsh-web-fetch-local": "workspace:*",
     "@deepseek-ai/dsh-workflow-workerthread": "workspace:*"
+  },
+  "devDependencies": {
+    "node-pty": "1.1.0"
   }
 }

+ 102 - 33
examples/tui-agent/tests/pty-harness.ts

@@ -2,9 +2,9 @@ import { spawn } from 'node:child_process'
 import { mkdtemp, rm } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 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
 node, launch_args_json, launch_env_json, cwd, actions_json, expected_exit, timeout_seconds = sys.argv[1:]
 env = os.environ.copy()
@@ -71,9 +71,103 @@ export interface TuiPtySmokeOptions {
   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
- * return the captured terminal bytes after the expected process exit.
+ * Boot an example in a real pseudo-terminal (ConPTY on Windows), drive
+ * marker-gated input, and return captured bytes after the expected process exit.
  * @param options - launch paths, environment, actions, and expected exit code.
  * @returns complete pseudo-terminal output.
  */
@@ -92,35 +186,10 @@ export async function runTuiPtySmoke(options: TuiPtySmokeOptions): Promise<strin
         ...options.env,
       },
     })
-    return await new Promise((resolve, reject) => {
-      const child = spawn('python3', [
-        '-c',
-        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}`))
-      })
-    })
+    if (process.platform === 'win32') {
+      return await runWindowsPtySmoke(launch, cwd, options, timeoutMs)
+    }
+    return await runPosixPtySmoke(launch, cwd, options, timeoutMs)
   } finally {
     await rm(cwd, { recursive: true, force: true })
   }

+ 1 - 2
examples/tui-agent/tests/tui-keyless-smoke.e2e.ts

@@ -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 tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
 
-// The Python PTY driver imports the POSIX-only pty and termios modules.
-describe.skipIf(process.platform === 'win32')('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
+describe('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 () => {
     const output = await runTuiPtySmoke({
       label: 'tui-agent boot',

+ 16 - 0
pnpm-lock.yaml

@@ -227,6 +227,10 @@ importers:
       '@deepseek-ai/dsh-workflow-workerthread':
         specifier: workspace:*
         version: link:../packages/workflow/workflow-workerthread
+    devDependencies:
+      node-pty:
+        specifier: 1.1.0
+        version: 1.1.0
 
   packages/bash/bash:
     devDependencies:
@@ -6179,6 +6183,9 @@ packages:
   neo-async@2.6.2:
     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:
     resolution: {integrity: sha512-oJsXcC33qKl9mWYx0n9YPJ2pUAoY39PoIX0Gx4lDrSCTEvENFrEaODAsQYNY+eEGpn9YMN7E+FOftvea3/1FqQ==}
     engines: {node: '>=20'}
@@ -6256,6 +6263,9 @@ packages:
     resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
     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:
     resolution: {integrity: sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==}
 
@@ -10605,6 +10615,8 @@ snapshots:
 
   neo-async@2.6.2: {}
 
+  node-addon-api@7.1.1: {}
+
   node-addon-landlock-run-linux-arm64@0.0.0-test.0:
     optional: true
 
@@ -10673,6 +10685,10 @@ snapshots:
       fetch-blob: 3.2.0
       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:
     optional: true
 

+ 2 - 0
pnpm-workspace.yaml

@@ -25,6 +25,8 @@ peerDependencyRules:
 allowBuilds:
   esbuild: 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
   # them only because they ship lifecycle scripts, but those are no-ops we don't
   # need, so we deny them — install still succeeds.