mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
test(cli): validate PTY across release targets
This commit is contained in:
@@ -211,10 +211,12 @@ jobs:
|
||||
export XDG_CACHE_HOME="$root/cache"
|
||||
export XDG_CONFIG_HOME="$root/config"
|
||||
export XDG_STATE_HOME="$root/state"
|
||||
export KILO_PTY_SMOKE=1 # kilocode_change
|
||||
export KILO_DISABLE_MODELS_FETCH=1
|
||||
export KILO_DISABLE_PROJECT_CONFIG=1
|
||||
export KILO_CONFIG_CONTENT='{"enabled_providers":["anthropic"]}'
|
||||
export ANTHROPIC_API_KEY=dummy
|
||||
"$binary" --pure __pty-smoke # kilocode_change
|
||||
"$binary" --pure models anthropic | grep -q '^anthropic/'
|
||||
)
|
||||
}
|
||||
@@ -247,10 +249,12 @@ jobs:
|
||||
export XDG_CACHE_HOME="$root/cache"
|
||||
export XDG_CONFIG_HOME="$root/config"
|
||||
export XDG_STATE_HOME="$root/state"
|
||||
export KILO_PTY_SMOKE=1 # kilocode_change
|
||||
export KILO_DISABLE_MODELS_FETCH=1
|
||||
export KILO_DISABLE_PROJECT_CONFIG=1
|
||||
export KILO_CONFIG_CONTENT='\''{"enabled_providers":["anthropic"]}'\''
|
||||
export ANTHROPIC_API_KEY=dummy
|
||||
"$binary" --pure __pty-smoke # kilocode_change
|
||||
"$binary" --pure models anthropic | grep -q "^anthropic/"
|
||||
'
|
||||
|
||||
@@ -307,10 +311,13 @@ jobs:
|
||||
$env:XDG_CACHE_HOME = Join-Path $root "cache"
|
||||
$env:XDG_CONFIG_HOME = Join-Path $root "config"
|
||||
$env:XDG_STATE_HOME = Join-Path $root "state"
|
||||
$env:KILO_PTY_SMOKE = "1" # kilocode_change
|
||||
$env:KILO_DISABLE_MODELS_FETCH = "1"
|
||||
$env:KILO_DISABLE_PROJECT_CONFIG = "1"
|
||||
$env:KILO_CONFIG_CONTENT = '{"enabled_providers":["anthropic"]}'
|
||||
$env:ANTHROPIC_API_KEY = "dummy"
|
||||
& $binary --pure __pty-smoke # kilocode_change
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } # kilocode_change
|
||||
$output = & $binary --pure models anthropic
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
if (-not ($output -match "(?m)^anthropic/")) {
|
||||
|
||||
@@ -61,14 +61,14 @@ jobs:
|
||||
run: |
|
||||
if [ "$GENERAL" != "true" ]; then
|
||||
echo 'general=false' >> "$GITHUB_OUTPUT"
|
||||
echo 'settings=[{"os":"linux","index":1,"total":1,"host":"blacksmith-4vcpu-ubuntu-2404","run":false,"packages":false}]' >> "$GITHUB_OUTPUT"
|
||||
echo 'settings=[{"os":"linux","index":1,"total":1,"host":"blacksmith-4vcpu-ubuntu-2404","run":false,"packages":false,"pty":false}]' >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo 'general=true' >> "$GITHUB_OUTPUT"
|
||||
# kilocode_change - Windows runs 6 shards at KILO_TEST_CONCURRENCY=2. Measured: a
|
||||
# 5-shard matrix was worse on both wall-clock (slowest job 9.3m vs 6.7m) and machine
|
||||
# minutes (43 vs 37) — with 2 workers per shard, packing more work per shard loses.
|
||||
echo 'settings=[{"os":"linux","index":1,"total":2,"host":"blacksmith-4vcpu-ubuntu-2404","run":true,"packages":true},{"os":"linux","index":2,"total":2,"host":"blacksmith-4vcpu-ubuntu-2404","run":true,"packages":false},{"os":"macos","index":1,"total":1,"host":"macos-15","run":true,"packages":true},{"os":"windows","index":1,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":true},{"os":"windows","index":2,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false},{"os":"windows","index":3,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false},{"os":"windows","index":4,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false},{"os":"windows","index":5,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false},{"os":"windows","index":6,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false}]' >> "$GITHUB_OUTPUT"
|
||||
echo 'settings=[{"os":"linux","index":1,"total":2,"host":"blacksmith-4vcpu-ubuntu-2404","run":true,"packages":true,"pty":true},{"os":"linux","index":2,"total":2,"host":"blacksmith-4vcpu-ubuntu-2404","run":true,"packages":false,"pty":false},{"os":"macos","index":1,"total":1,"host":"macos-15","run":true,"packages":true,"pty":true},{"os":"windows","index":1,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":true,"pty":true},{"os":"windows","index":2,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false,"pty":false},{"os":"windows","index":3,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false,"pty":false},{"os":"windows","index":4,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false,"pty":false},{"os":"windows","index":5,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false,"pty":false},{"os":"windows","index":6,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false,"pty":false}]' >> "$GITHUB_OUTPUT"
|
||||
# kilocode_change end
|
||||
unit:
|
||||
# kilocode_change start
|
||||
@@ -118,7 +118,6 @@ jobs:
|
||||
}
|
||||
Write-Host "Defender path exclusions applied: $applied"
|
||||
# kilocode_change end
|
||||
|
||||
- name: Checkout repository
|
||||
if: matrix.settings.run # kilocode_change
|
||||
uses: actions/checkout@v6 # kilocode_change
|
||||
@@ -187,8 +186,21 @@ jobs:
|
||||
run: bun turbo test:ci --output-logs=errors-only --log-order=grouped --log-prefix=task --filter='!@kilocode/cli' --filter='!@kilocode/kilo-jetbrains'
|
||||
env:
|
||||
KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: "true" # kilocode_change - non-CLI tests use the watcher-free unit-test profile
|
||||
# kilocode_change end
|
||||
|
||||
# Keep this outside the CLI shard and OS profiles so one real PTY contract
|
||||
# always runs on Linux, macOS, and Windows.
|
||||
- name: Run cross-platform PTY service tests
|
||||
if: matrix.settings.run && matrix.settings.pty
|
||||
working-directory: packages/core
|
||||
run: bun test test/kilocode/pty-platform.test.ts --timeout 60000
|
||||
|
||||
- name: Run cross-platform PTY route tests
|
||||
if: matrix.settings.run && matrix.settings.pty
|
||||
working-directory: packages/opencode
|
||||
run: |
|
||||
bun test test/server/httpapi-pty.test.ts --test-name-pattern "serves Agent Manager regular terminal" --timeout 60000
|
||||
bun test test/server/httpapi-v2-pty.test.ts --test-name-pattern "serves Agent Manager script terminal" --timeout 60000
|
||||
# kilocode_change end
|
||||
# kilocode_change start - ensure the Darwin profile cannot suppress its own validation
|
||||
- name: Validate Darwin CLI test profile
|
||||
if: matrix.settings.run && matrix.settings.os == 'macos'
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Shell } from "../../shell"
|
||||
import { KiloPtyTermination } from "./termination"
|
||||
import { spawn } from "#pty"
|
||||
|
||||
const TIMEOUT = 15_000
|
||||
|
||||
export async function smoke() {
|
||||
const proc = spawn(Shell.preferred(), [], {
|
||||
name: "xterm-256color",
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, TERM: "xterm-256color", KILO_TERMINAL: "1" } as Record<string, string>,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
})
|
||||
const state = { output: "", exited: false }
|
||||
const output = Promise.withResolvers<void>()
|
||||
const exited = Promise.withResolvers<number>()
|
||||
const data = proc.onData((chunk) => {
|
||||
state.output += chunk
|
||||
if (state.output.includes("KILO_PTY_READY")) output.resolve()
|
||||
})
|
||||
const exit = proc.onExit((event) => {
|
||||
state.exited = true
|
||||
exited.resolve(event.exitCode)
|
||||
})
|
||||
const timeout = AbortSignal.timeout(TIMEOUT)
|
||||
|
||||
try {
|
||||
proc.resize(100, 40)
|
||||
proc.write("echo KILO_PTY_READY\r")
|
||||
await Promise.race([
|
||||
output.promise,
|
||||
new Promise<never>((_, reject) =>
|
||||
timeout.addEventListener(
|
||||
"abort",
|
||||
() => reject(new Error(`PTY produced no output within ${TIMEOUT}ms: ${JSON.stringify(state.output)}`)),
|
||||
{ once: true },
|
||||
),
|
||||
),
|
||||
])
|
||||
proc.write("exit 7\r")
|
||||
const code = await Promise.race([
|
||||
exited.promise,
|
||||
new Promise<never>((_, reject) =>
|
||||
timeout.addEventListener("abort", () => reject(new Error(`PTY did not exit within ${TIMEOUT}ms`)), {
|
||||
once: true,
|
||||
}),
|
||||
),
|
||||
])
|
||||
if (code !== 7) throw new Error(`PTY exited ${code}, expected 7`)
|
||||
} finally {
|
||||
data.dispose()
|
||||
exit.dispose()
|
||||
if (!state.exited) proc.kill()
|
||||
}
|
||||
|
||||
const active = spawn(Shell.preferred(), [], {
|
||||
name: "xterm-256color",
|
||||
cwd: process.cwd(),
|
||||
env: process.env as Record<string, string>,
|
||||
})
|
||||
let stopped = false
|
||||
try {
|
||||
await KiloPtyTermination.terminate(active)
|
||||
stopped = true
|
||||
} finally {
|
||||
if (!stopped) active.kill()
|
||||
}
|
||||
}
|
||||
|
||||
export * as PtySmoke from "./smoke"
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Pty } from "@opencode-ai/core/pty"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Deferred, Effect, Layer, Queue } from "effect"
|
||||
import os from "node:os"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const directory = os.tmpdir()
|
||||
const layer = AppNodeBuilder.build(LayerNode.group([Pty.node, EventV2.node]), [
|
||||
[Config.node, Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })],
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
],
|
||||
])
|
||||
const it = testEffect(layer)
|
||||
|
||||
const attach = Effect.fn("PtyPlatformTest.attach")(function* (id: Pty.Info["id"]) {
|
||||
const pty = yield* Pty.Service
|
||||
const output = yield* Queue.unbounded<string>()
|
||||
const ended = yield* Deferred.make<{ exitCode?: number }>()
|
||||
const attachment = yield* pty.attach(id, {
|
||||
onData: (data) => Queue.offerUnsafe(output, data),
|
||||
onEnd: (event) => Deferred.doneUnsafe(ended, Effect.succeed(event)),
|
||||
})
|
||||
attachment.activate()
|
||||
return { attachment, output, ended }
|
||||
})
|
||||
|
||||
describe("cross-platform PTY", () => {
|
||||
it.live("starts the default shell and exits from input", () =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const info = yield* Effect.acquireRelease(pty.create({ cwd: directory }), (item) =>
|
||||
pty.remove(item.id).pipe(Effect.ignore),
|
||||
)
|
||||
const terminal = yield* attach(info.id)
|
||||
terminal.attachment.write("exit\r")
|
||||
expect(yield* Deferred.await(terminal.ended).pipe(Effect.timeout("15 seconds"))).toEqual({ exitCode: 0 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("terminates a spawned process tree", () =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const source = [
|
||||
'const child = Bun.spawn([process.execPath, "-e", "setInterval(() => {}, 1000)"], {',
|
||||
' stdin: "ignore", stdout: "ignore", stderr: "ignore", windowsHide: true,',
|
||||
"})",
|
||||
"process.stdout.write(`CHILD:${child.pid}\\n`)",
|
||||
"setInterval(() => {}, 1000)",
|
||||
].join("\n")
|
||||
const info = yield* Effect.acquireRelease(
|
||||
pty.create({ command: process.execPath, args: ["-e", source], cwd: directory }),
|
||||
(item) => pty.remove(item.id).pipe(Effect.ignore),
|
||||
)
|
||||
const terminal = yield* attach(info.id)
|
||||
const output = yield* Effect.gen(function* () {
|
||||
let text = ""
|
||||
while (!text.includes("CHILD:")) text += yield* Queue.take(terminal.output)
|
||||
return text
|
||||
}).pipe(Effect.timeout("15 seconds"))
|
||||
const pid = Number(output.match(/CHILD:(\d+)/)?.[1])
|
||||
expect(pid).toBeGreaterThan(0)
|
||||
|
||||
yield* pty.remove(info.id)
|
||||
expect(() => process.kill(pid, 0)).toThrow()
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import { cmd } from "@/cli/cmd/cmd"
|
||||
|
||||
export const PtySmokeCommand = cmd({
|
||||
command: "__pty-smoke",
|
||||
describe: false,
|
||||
async handler() {
|
||||
if (process.env.KILO_PTY_SMOKE !== "1") throw new Error("PTY smoke command is release-only")
|
||||
const { PtySmoke } = await import("@opencode-ai/core/kilocode/pty/smoke")
|
||||
await PtySmoke.smoke()
|
||||
},
|
||||
})
|
||||
@@ -12,6 +12,7 @@ import { DevSetupCommand, DevAliasCommand } from "@/kilocode/cli/dev-setup"
|
||||
import { RemoteCommand } from "@/cli/cmd/remote"
|
||||
import { ConfigCommand as ConfigCLICommand } from "@/cli/cmd/config"
|
||||
import { WorktreeCommand } from "@/kilocode/cli/cmd/worktree"
|
||||
import { PtySmokeCommand } from "@/kilocode/cli/cmd/pty-smoke"
|
||||
|
||||
const log = Log.create({ service: "kilocode.cli" })
|
||||
|
||||
@@ -58,6 +59,7 @@ export namespace KiloCli {
|
||||
.command(DaemonCommand)
|
||||
.command(ConfigCLICommand)
|
||||
.command(WorktreeCommand)
|
||||
if (process.env.KILO_PTY_SMOKE === "1") cli.command(PtySmokeCommand)
|
||||
if (InstallationBuildKind !== "release") cli.command(DevSetupCommand).command(DevAliasCommand)
|
||||
// Safe self-reference: `cli` is a typed parameter and yargs `.command()` returns the same
|
||||
// instance, so the help command can resolve the fully-built root at handler time. This also
|
||||
|
||||
@@ -269,48 +269,69 @@ describe("pty HttpApi bridge", () => {
|
||||
message: `PTY session not found: ${missingID}`,
|
||||
})
|
||||
})
|
||||
;(process.platform === "win32" ? effectIt.live.skip : effectIt.live)(
|
||||
"serves PTY websocket output and input through Effect routes",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true, config: { formatter: false, lsp: false } })
|
||||
const created = yield* HttpClientRequest.post(PtyPaths.create).pipe(
|
||||
directoryHeader(dir),
|
||||
HttpClientRequest.bodyJson({ command: "/bin/cat", title: "websocket" }),
|
||||
Effect.flatMap(HttpClient.execute),
|
||||
// kilocode_change start - portable coverage for the exact legacy routes used by regular Agent Manager terminals
|
||||
effectIt.live("serves Agent Manager regular terminal create, resize, input, output, and remove routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true, config: { formatter: false, lsp: false } })
|
||||
const child = [
|
||||
'let input = ""',
|
||||
"process.stdout.write(`READY:${process.stdout.isTTY}:${process.stdout.columns}x${process.stdout.rows}\\n`)",
|
||||
'process.stdin.setEncoding("utf8")',
|
||||
'process.stdin.on("data", (chunk) => {',
|
||||
" input += chunk",
|
||||
' if (input.includes("PING")) process.stdout.write(`PONG:${process.stdout.columns}x${process.stdout.rows}\\n`)',
|
||||
"})",
|
||||
].join("\n")
|
||||
const created = yield* HttpClientRequest.post(PtyPaths.create).pipe(
|
||||
directoryHeader(dir),
|
||||
HttpClientRequest.bodyJson({
|
||||
command: process.execPath,
|
||||
args: ["-e", child],
|
||||
title: "websocket",
|
||||
size: { cols: 80, rows: 24 },
|
||||
}),
|
||||
Effect.flatMap(HttpClient.execute),
|
||||
)
|
||||
expect(created.status).toBe(200)
|
||||
const info = yield* Schema.decodeUnknownEffect(Pty.Info)(yield* created.json)
|
||||
|
||||
const socket = yield* Socket.makeWebSocket(
|
||||
`${(yield* serverUrl()).replace(/^http/, "ws")}${PtyPaths.connect.replace(":ptyID", info.id)}?cursor=0&directory=${encodeURIComponent(dir)}`,
|
||||
{ closeCodeIsError: () => false },
|
||||
)
|
||||
const messages = yield* Queue.unbounded<string>()
|
||||
yield* socket
|
||||
.runRaw((message) =>
|
||||
Queue.offer(messages, typeof message === "string" ? message : new TextDecoder().decode(message)),
|
||||
)
|
||||
expect(created.status).toBe(200)
|
||||
const info = yield* Schema.decodeUnknownEffect(Pty.Info)(yield* created.json)
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
.pipe(Effect.forkScoped)
|
||||
const write = yield* socket.writer
|
||||
|
||||
const socket = yield* Socket.makeWebSocket(
|
||||
`${(yield* serverUrl()).replace(/^http/, "ws")}${PtyPaths.connect.replace(":ptyID", info.id)}?cursor=-1&directory=${encodeURIComponent(dir)}`,
|
||||
{ closeCodeIsError: () => false },
|
||||
)
|
||||
const messages = yield* Queue.unbounded<string>()
|
||||
yield* socket
|
||||
.runRaw((message) =>
|
||||
Queue.offer(messages, typeof message === "string" ? message : new TextDecoder().decode(message)),
|
||||
)
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
.pipe(Effect.forkScoped)
|
||||
const write = yield* socket.writer
|
||||
const takeUntil = (expected: string, seen = ""): Effect.Effect<string, unknown> =>
|
||||
Effect.gen(function* () {
|
||||
const next = seen + (yield* Queue.take(messages).pipe(Effect.timeout("5 seconds")))
|
||||
if (next.includes(expected)) return next
|
||||
return yield* takeUntil(expected, next)
|
||||
})
|
||||
|
||||
const takeUntil = (expected: string, seen = ""): Effect.Effect<string, unknown> =>
|
||||
Effect.gen(function* () {
|
||||
const next = seen + (yield* Queue.take(messages).pipe(Effect.timeout("5 seconds")))
|
||||
if (next.includes(expected)) return next
|
||||
return yield* takeUntil(expected, next)
|
||||
})
|
||||
expect(yield* takeUntil("READY:")).toContain("READY:true:80x24")
|
||||
const updated = yield* HttpClientRequest.put(PtyPaths.update.replace(":ptyID", info.id)).pipe(
|
||||
directoryHeader(dir),
|
||||
HttpClientRequest.bodyJson({ size: { cols: 100, rows: 40 } }),
|
||||
Effect.flatMap(HttpClient.execute),
|
||||
)
|
||||
expect(updated.status).toBe(200)
|
||||
|
||||
yield* write("ping-route\n")
|
||||
expect(yield* takeUntil("ping-route")).toContain("ping-route")
|
||||
yield* write(new Socket.CloseEvent(1000, "done")).pipe(Effect.catch(() => Effect.void))
|
||||
yield* write("PING\r")
|
||||
expect(yield* takeUntil("PONG:100x40")).toContain("PONG:100x40")
|
||||
|
||||
const removed = yield* HttpClientRequest.delete(PtyPaths.remove.replace(":ptyID", info.id)).pipe(
|
||||
directoryHeader(dir),
|
||||
HttpClient.execute,
|
||||
)
|
||||
expect(removed.status).toBe(200)
|
||||
}),
|
||||
const removed = yield* HttpClientRequest.delete(PtyPaths.remove.replace(":ptyID", info.id)).pipe(
|
||||
directoryHeader(dir),
|
||||
HttpClient.execute,
|
||||
)
|
||||
expect(removed.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
// kilocode_change end
|
||||
})
|
||||
|
||||
@@ -134,51 +134,102 @@ describe("v2 pty HttpApi", () => {
|
||||
await request(`/api/pty/${info.id}`, tmp.path, { method: "DELETE" })
|
||||
}
|
||||
})
|
||||
;(process.platform === "win32" ? effectIt.live.skip : effectIt.live)(
|
||||
"serves PTY websocket output and input through the canonical route",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true, config: { formatter: false, lsp: false } })
|
||||
const created = yield* HttpClientRequest.post("/api/pty").pipe(
|
||||
directoryHeader(dir),
|
||||
HttpClientRequest.bodyJson({ command: "/bin/cat", title: "v2-websocket" }),
|
||||
Effect.flatMap(HttpClient.execute),
|
||||
)
|
||||
expect(created.status).toBe(200)
|
||||
const body = yield* Schema.decodeUnknownEffect(Location.response(Pty.Info))(yield* created.json)
|
||||
const info = body.data
|
||||
// kilocode_change start - portable live PTY coverage on Linux, macOS, and Windows CI
|
||||
effectIt.live("serves Agent Manager script terminal create, resize, input, output, exit, and remove routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true, config: { formatter: false, lsp: false } })
|
||||
const child = [
|
||||
'const state = { input: "", pong: false }',
|
||||
"process.stdout.write(`READY:${process.stdout.isTTY}:${process.stdout.columns}x${process.stdout.rows}\\n`)",
|
||||
'process.stdin.setEncoding("utf8")',
|
||||
'process.stdin.on("data", (chunk) => {',
|
||||
" state.input += chunk",
|
||||
' if (!state.pong && state.input.includes("PING")) {',
|
||||
" state.pong = true",
|
||||
" process.stdout.write(`PONG:${process.stdout.columns}x${process.stdout.rows}\\n`)",
|
||||
" }",
|
||||
' if (state.input.includes("EXIT")) process.exit(7)',
|
||||
"})",
|
||||
].join("\n")
|
||||
const created = yield* HttpClientRequest.post("/api/pty").pipe(
|
||||
directoryHeader(dir),
|
||||
HttpClientRequest.bodyJson({
|
||||
command: process.execPath,
|
||||
args: ["-e", child],
|
||||
title: "v2-websocket",
|
||||
size: { cols: 80, rows: 24 },
|
||||
}),
|
||||
Effect.flatMap(HttpClient.execute),
|
||||
)
|
||||
expect(created.status).toBe(200)
|
||||
const body = yield* Schema.decodeUnknownEffect(Location.response(Pty.Info))(yield* created.json)
|
||||
const info = body.data
|
||||
|
||||
const socket = yield* Socket.makeWebSocket(
|
||||
`${(yield* serverUrl()).replace(/^http/, "ws")}/api/pty/${info.id}/connect?cursor=-1&location[directory]=${encodeURIComponent(dir)}`,
|
||||
{ closeCodeIsError: () => false },
|
||||
const socket = yield* Socket.makeWebSocket(
|
||||
`${(yield* serverUrl()).replace(/^http/, "ws")}/api/pty/${info.id}/connect?cursor=0&location[directory]=${encodeURIComponent(dir)}`,
|
||||
{ closeCodeIsError: () => false },
|
||||
)
|
||||
const messages = yield* Queue.unbounded<string>()
|
||||
yield* socket
|
||||
.runRaw((message) =>
|
||||
Queue.offer(messages, typeof message === "string" ? message : new TextDecoder().decode(message)),
|
||||
)
|
||||
const messages = yield* Queue.unbounded<string>()
|
||||
yield* socket
|
||||
.runRaw((message) =>
|
||||
Queue.offer(messages, typeof message === "string" ? message : new TextDecoder().decode(message)),
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
.pipe(Effect.forkScoped)
|
||||
const write = yield* socket.writer
|
||||
|
||||
const takeUntil = (expected: string, seen = ""): Effect.Effect<string, unknown> =>
|
||||
Effect.gen(function* () {
|
||||
const next =
|
||||
seen +
|
||||
(yield* Queue.take(messages).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "5 seconds",
|
||||
orElse: () =>
|
||||
Effect.fail(
|
||||
new Error(
|
||||
`PTY output did not contain ${JSON.stringify(expected)}, received ${JSON.stringify(seen)}`,
|
||||
),
|
||||
),
|
||||
}),
|
||||
))
|
||||
if (next.includes(expected)) return next
|
||||
return yield* takeUntil(expected, next)
|
||||
})
|
||||
|
||||
expect(yield* takeUntil("READY:")).toContain("READY:true:80x24")
|
||||
const resized = yield* HttpClientRequest.put(`/api/pty/${info.id}`).pipe(
|
||||
directoryHeader(dir),
|
||||
HttpClientRequest.bodyJson({ size: { cols: 100, rows: 40 } }),
|
||||
Effect.flatMap(HttpClient.execute),
|
||||
)
|
||||
expect(resized.status).toBe(200)
|
||||
|
||||
yield* write("PING\r")
|
||||
expect(yield* takeUntil("PONG:100x40")).toContain("PONG:100x40")
|
||||
yield* write("EXIT\r")
|
||||
const exit = yield* Effect.gen(function* () {
|
||||
while (true) {
|
||||
const response = yield* HttpClientRequest.get(`/api/pty/${info.id}`).pipe(
|
||||
directoryHeader(dir),
|
||||
HttpClient.execute,
|
||||
)
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
.pipe(Effect.forkScoped)
|
||||
const write = yield* socket.writer
|
||||
expect(response.status).toBe(200)
|
||||
const data = (yield* Schema.decodeUnknownEffect(Location.response(Pty.Info))(yield* response.json)).data
|
||||
if (data.status === "exited") return data
|
||||
yield* Effect.sleep("20 millis")
|
||||
}
|
||||
}).pipe(Effect.timeout("5 seconds"))
|
||||
expect(exit).toMatchObject({ status: "exited", exitCode: 7 })
|
||||
|
||||
const takeUntil = (expected: string, seen = ""): Effect.Effect<string, unknown> =>
|
||||
Effect.gen(function* () {
|
||||
const next = seen + (yield* Queue.take(messages).pipe(Effect.timeout("5 seconds")))
|
||||
if (next.includes(expected)) return next
|
||||
return yield* takeUntil(expected, next)
|
||||
})
|
||||
|
||||
yield* write("ping-v2\n")
|
||||
expect(yield* takeUntil("ping-v2")).toContain("ping-v2")
|
||||
yield* write(new Socket.CloseEvent(1000, "done")).pipe(Effect.catch(() => Effect.void))
|
||||
|
||||
const removed = yield* HttpClientRequest.delete(`/api/pty/${info.id}`).pipe(
|
||||
directoryHeader(dir),
|
||||
HttpClient.execute,
|
||||
)
|
||||
expect(removed.status).toBe(204)
|
||||
}),
|
||||
const removed = yield* HttpClientRequest.delete(`/api/pty/${info.id}`).pipe(
|
||||
directoryHeader(dir),
|
||||
HttpClient.execute,
|
||||
)
|
||||
expect(removed.status).toBe(204)
|
||||
}),
|
||||
)
|
||||
// kilocode_change end
|
||||
;(process.platform === "win32" ? effectIt.live.skip : effectIt.live)(
|
||||
"applies plugin shell environment before forced PTY values",
|
||||
() =>
|
||||
|
||||
Reference in New Issue
Block a user