Files
kilocode/packages/opencode/test/effect/cross-spawn-spawner.test.ts
T
Johnny Eric Amancio 6cf72ef05c OpenCode v1.3.5 (#8806)
* tweak: use theme tokens for debug bar surface

* chore: update nix node_modules hashes

* feat(tui): add heap snapshot functionality for TUI and server (#19028)

* ci

* change model for changelog

* release: v1.3.2

* fix(opencode): skip typechecking generated models snapshot (#19018)

* Revert "fix(app): more startup efficiency (#18985)"

This reverts commit 98b3340cee.

* Revert "fix(app): startup efficiency (#18854)"

This reverts commit 546748a461.

* effectify Worktree service (#18679)

* fix: increase operations-per-run to 1000 and pin stale action to v10.2.0

The stale-issues workflow was hitting the default 30 operations limit,
preventing it from processing all 2900+ issues/PRs. Increased to 1000
to handle the full backlog. Also pinned to exact v10.2.0 for reproducibility.

* Add close-issues script and GitHub Action

- Create script/github/close-issues.ts to close stale issues after 60 days
- Add GitHub Action workflow to run daily at 2 AM
- Remove old stale-issues workflow to avoid conflicts

* Fix close-issues workflow permissions

- Add contents: read permission for checkout
- Use github.token instead of secrets.GITHUB_TOKEN

* Process issues sequentially to avoid rate limits

* Change issue close reason from not_planned to completed

* fix(opencode): avoid snapshotting files over 2MB (#19043)

* fix: provide merge context to beta conflict resolver (#19055)

* tweak: only spawn lsp servers for files in current instance (or cwd if instance is global) (#19058)

* fix: beta resolver typecheck + build smoke check (#19060)

* fix: unblock beta conflict recovery (#19068)

* electron: add createDirectory to open directory picker (#19071)

* electron: remove file extension from electron-store wrapper (#19082)

* app: pre-warm project globalSync state when navigate project via keybind (#19088)

* fix(app): move message navigation off cmd+arrow (#18728)

* Reapply "fix(app): startup efficiency (#18854)"

This reverts commit a379eb3867.

* Reapply "fix(app): more startup efficiency (#18985)"

This reverts commit cbe1337f24.

* fix(app): hash inline script for csp

* Revert "fix(app): startup efficiency"

* Reapply "fix(app): startup efficiency"

This reverts commit 898456a25c.

* fix(app): opencode web server url

* chore(app): markdown playground in storyboard

* chore(app): markdown playground in storyboard

* feat(core): initial implementation of syncing (#17814)

* chore: generate

* chore: bump modelcontextprotocol/sdk to 1.27.1 (#19064)

* chore: storybook tweaks

* feat: restore git-backed review modes with effectful git service (#18900)

* chore: generate

* chore: update nix node_modules hashes

* chore: cleanup

* chore: remove dead code for todoread tool (#19128)

* chore: storybook tweaks

* fix(opencode): classify ZlibError from Bun fetch as retryable instead of unknown (#19104)

Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>

* fix(task): respect agent permission config for todowrite tool (#19125)

* fix(app): agent normalization (#19169)

* fix: Windows e2e stability (CrossSpawnSpawner, snapshot isolation, session race guards) (#19163)

* fix+refactor(mcp): lifecycle tests, cancelPending fix, Effect migration (#19042)

* effectify Bus service: migrate to Effect PubSub + InstanceState (#18579)

* file: use Effect.cached for scan deduplication (#19164)

* ignore: update disavowed list (#19184)

* skill: use Effect.cached for load deduplication (#19165)

* chore: generate

* fix: bump gitlab-ai-provider to 5.3.3 for DWS tool approval support (#19185)

* test: restore 5 workers on Windows e2e (#19188)

* fix(opencode): image paste on Windows Terminal 1.25+ with kitty keyboard (#17674)

* chore: update nix node_modules hashes

* wip: zen

* wip: zen

* go: do not respect disabled zen models

* fix: ensure enterprise url is set properly during auth flow (#19212)

* revert: roll back git-backed review modes (#19295)

* chore: generate

* tui: bypass local SSE event streaming in worker (#19183)

* feat: embed WebUI in binary with proxy flags (#19299)

Co-authored-by: BlankParticle <blankparticle@gmail.com>

* release: v1.3.3

* chore: generate

* changelog ci tweaks

* refactor(lsp): effectify LSP service with InstanceState (#19150)

* chore: generate

* feat: add gpt prompt so non codex gpt models have their own system prompt modeled after codex cli (#19220)

* feat(core): remove workspace server, WorkspaceContext, start work towards better routing (#19316)

* effectify Config service (#19139)

* chore: generate

* refactor(config): use cachedInvalidateWithTTL, bump effect to beta.37 (#19322)

* fix(mcp): close transport on failed/timed-out connections (#19200)

* fix(app): more startup perf (#19288)

* chore: generate

* chore: update nix node_modules hashes

* fix(app): don't bundle fonts (#19329)

* chore: generate

* fix(app): default shell tool to collapsed

* fix(app): remove fork session button

* fix(ui): reduce markdown jank while responses stream (#19304)

* fix: web ui bundle build on windows (#19337)

* refactor(effect): yield services instead of promise facades (#19325)

* chore: generate

* refactor(vcs): replace async git() with ChildProcessSpawner (#19361)

* fix(opencode): ignore generated models snapshot files (#19362)

* fix(ui): keep partial markdown readable while responses stream (#19403)

* chore: update nix node_modules hashes

* fix(app): persist queued followups across project switches (#19421)

* refactor(tool-registry): yield Config/Plugin services, use Effect.forEach (#19363)

* chore: generate

* tui plugins (#19347)

* chore: generate

* effectify Skill service internals (#19364)

* chore: update nix node_modules hashes

* effectify Plugin service internals (#19365)

* refactor(core): split out instance and route through workspaces (#19335)

* chore(app): more spacing controls

* fix(ui): make streamed markdown feel more continuous (#19404)

* fix(app): resize layout viewport when mobile keyboard appears (#15841)

* fix(desktop-electron): match dev dock icon inset on macOS (#19429)

* fix(app): default file tree to closed with minimum width (#19426)

* fix flaky plugin tests (no mock.module for bun) (#19445)

* tweak: add additional overflow error patterns (#19446)

* no theme override in dev (#19456)

* feat: AI SDK v6 support (#18433)

* refactor(session): effectify Session service (#19449)

* refactor(core): move more responsibility to workspace routing (#19455)

* chore: update nix node_modules hashes

* refactor(format): use ChildProcessSpawner instead of Process.spawn (#19457)

* chore: generate

* Single target plugin entrypoints (#19467)

* refactor(session): effectify SessionCompaction service (#19459)

* feat(ci): use Azure Artifact Signing for Windows releases (#15201)

* fix(app): more startup efficiency (#19454)

* update effect to 4.0.0-beta.42 (#19484)

* chore: update nix node_modules hashes

* tweak: adjust bash tool description to increase cache hit rates between projects (#19487)

* refactor(session): move context into prompt footer (#19486)

* refactor(prompt): remove variant cycle display from footer (#19489)

* feat: add model variant selection dialog (#19488)

* fix: restore subagent footer and fix style guide violations (#19491)

* tweak(session): add top spacing and remove obsolete docs prompt

* upgrade opentui to 0.1.91 (#19440)

* refactor(file): use AppFileSystem instead of raw Filesystem (#19458)

* chore: generate

* chore: update nix node_modules hashes

* kv theme before default fallback (#19523)

* feat: open dialog for model variant selection instead of cycling (#19534)

* refactor(session): effectify session processor (#19485)

* feat: dialog variant menu and subagent improvements (#19537)

* use theme color for prompt placeholder (#19535)

* fix: update opencode-gitlab-auth to 2.0.1 (#19552)

* chore: update nix node_modules hashes

* prompt slot (#19563)

* fix: respect semver build identifiers for nix (#11915)

* fix: nix embedded web-ui support (#19561)

* ignore: kill todo (#19566)

* chore: update nix node_modules hashes

* wip: zen

* wip: zen

* zen: ZDR policy

* ci: cancel stale nix-hashes runs (#19571)

* release: v1.3.4

* tweak: adjust gpt prompt to be more minimal, fix file reference annoyances (#19585)

* core: fix plugin hooks to properly handle async operations ensuring plugins can execute async code without errors (#19586)

* release: v1.3.5

* refactor: kilo compat for v1.3.5

* fix: Fix typecheck issues

* style(kilo-vscode): adjust formatting in parts-util and PopupSelector

Reindent boolean expression in isCompletionResult to remove extra
indentation, and reformat PopupSelectorProps generic interface
extends clause across multiple lines.

---------

Co-authored-by: Jay V <air@live.ca>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Brendan Allan <brendonovich@outlook.com>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: André Cruz <acruz@cloudflare.com>
Co-authored-by: Ariane Emory <97994360+ariane-emory@users.noreply.github.com>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: BlankParticle <blankparticle@gmail.com>
Co-authored-by: Sebastian <hasta84@gmail.com>
Co-authored-by: Burak Yigit Kaya <byk@sentry.io>
Co-authored-by: Caleb Norton <n0603919@outlook.com>
Co-authored-by: Imanol Maiztegui <imanol.mzd@gmail.com>
2026-04-12 17:10:08 +02:00

403 lines
13 KiB
TypeScript

import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { describe, expect } from "bun:test"
import fs from "node:fs/promises"
import path from "node:path"
import { Effect, Exit, Layer, Stream } from "effect"
import type * as PlatformError from "effect/PlatformError"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { tmpdir } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const live = CrossSpawnSpawner.defaultLayer
const fx = testEffect(live)
function js(code: string, opts?: ChildProcess.CommandOptions) {
return ChildProcess.make("node", ["-e", code], opts)
}
function decodeByteStream(stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>) {
return Stream.runCollect(stream).pipe(
Effect.map((chunks) => {
const total = chunks.reduce((acc, x) => acc + x.length, 0)
const out = new Uint8Array(total)
let off = 0
for (const chunk of chunks) {
out.set(chunk, off)
off += chunk.length
}
return new TextDecoder("utf-8").decode(out).trim()
}),
)
}
function alive(pid: number) {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
async function gone(pid: number, timeout = 5_000) {
const end = Date.now() + timeout
while (Date.now() < end) {
if (!alive(pid)) return true
await Bun.sleep(50)
}
return !alive(pid)
}
describe("cross-spawn spawner", () => {
describe("basic spawning", () => {
fx.effect(
"captures stdout",
Effect.gen(function* () {
const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
svc.string(ChildProcess.make(process.execPath, ["-e", 'process.stdout.write("ok")'])),
)
expect(out).toBe("ok")
}),
)
fx.effect(
"captures multiple lines",
Effect.gen(function* () {
const handle = yield* js('console.log("line1"); console.log("line2"); console.log("line3")')
const out = yield* decodeByteStream(handle.stdout)
expect(out).toBe("line1\nline2\nline3")
}),
)
fx.effect(
"returns exit code",
Effect.gen(function* () {
const handle = yield* js("process.exit(0)")
const code = yield* handle.exitCode
expect(code).toBe(ChildProcessSpawner.ExitCode(0))
}),
)
fx.effect(
"returns non-zero exit code",
Effect.gen(function* () {
const handle = yield* js("process.exit(42)")
const code = yield* handle.exitCode
expect(code).toBe(ChildProcessSpawner.ExitCode(42))
}),
)
})
describe("cwd option", () => {
fx.effect(
"uses cwd when spawning commands",
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
svc.string(
ChildProcess.make(process.execPath, ["-e", "process.stdout.write(process.cwd())"], { cwd: tmp.path }),
),
)
expect(out).toBe(tmp.path)
}),
)
fx.effect(
"fails for invalid cwd",
Effect.gen(function* () {
const exit = yield* Effect.exit(
ChildProcess.make("echo", ["test"], { cwd: "/nonexistent/directory/path" }).asEffect(),
)
expect(Exit.isFailure(exit)).toBe(true)
}),
)
})
describe("env option", () => {
fx.effect(
"passes environment variables with extendEnv",
Effect.gen(function* () {
const handle = yield* js('process.stdout.write(process.env.TEST_VAR ?? "")', {
env: { TEST_VAR: "test_value" },
extendEnv: true,
})
const out = yield* decodeByteStream(handle.stdout)
expect(out).toBe("test_value")
}),
)
fx.effect(
"passes multiple environment variables",
Effect.gen(function* () {
const handle = yield* js(
"process.stdout.write(`${process.env.VAR1}-${process.env.VAR2}-${process.env.VAR3}`)",
{
env: { VAR1: "one", VAR2: "two", VAR3: "three" },
extendEnv: true,
},
)
const out = yield* decodeByteStream(handle.stdout)
expect(out).toBe("one-two-three")
}),
)
})
describe("stderr", () => {
fx.effect(
"captures stderr output",
Effect.gen(function* () {
const handle = yield* js('process.stderr.write("error message")')
const err = yield* decodeByteStream(handle.stderr)
expect(err).toBe("error message")
}),
)
fx.effect(
"captures both stdout and stderr",
Effect.gen(function* () {
const handle = yield* js('process.stdout.write("stdout\\n"); process.stderr.write("stderr\\n")')
const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)])
expect(stdout).toBe("stdout")
expect(stderr).toBe("stderr")
}),
)
})
describe("combined output (all)", () => {
fx.effect(
"captures stdout via .all when no stderr",
Effect.gen(function* () {
const handle = yield* ChildProcess.make("echo", ["hello from stdout"])
const all = yield* decodeByteStream(handle.all)
expect(all).toBe("hello from stdout")
}),
)
fx.effect(
"captures stderr via .all when no stdout",
Effect.gen(function* () {
const handle = yield* js('process.stderr.write("hello from stderr")')
const all = yield* decodeByteStream(handle.all)
expect(all).toBe("hello from stderr")
}),
)
})
describe("stdin", () => {
fx.effect(
"allows providing standard input to a command",
Effect.gen(function* () {
const input = "a b c"
const stdin = Stream.make(Buffer.from(input, "utf-8"))
const handle = yield* js(
'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))',
{ stdin },
)
const out = yield* decodeByteStream(handle.stdout)
yield* handle.exitCode
expect(out).toBe("a b c")
}),
)
})
describe("process control", () => {
fx.effect(
"kills a running process",
Effect.gen(function* () {
const exit = yield* Effect.exit(
Effect.gen(function* () {
const handle = yield* js("setTimeout(() => {}, 10_000)")
yield* handle.kill()
return yield* handle.exitCode
}),
)
expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true)
}),
)
fx.effect(
"kills a child when scope exits",
Effect.gen(function* () {
const pid = yield* Effect.scoped(
Effect.gen(function* () {
const handle = yield* js("setInterval(() => {}, 10_000)")
return Number(handle.pid)
}),
)
const done = yield* Effect.promise(() => gone(pid))
expect(done).toBe(true)
}),
)
fx.effect(
"forceKillAfter escalates for stubborn processes",
Effect.gen(function* () {
if (process.platform === "win32") return
const started = Date.now()
const exit = yield* Effect.exit(
Effect.gen(function* () {
const handle = yield* js('process.on("SIGTERM", () => {}); setInterval(() => {}, 10_000)')
yield* handle.kill({ forceKillAfter: 100 })
return yield* handle.exitCode
}),
)
expect(Date.now() - started).toBeLessThan(1_000)
expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true)
}),
)
fx.effect(
"isRunning reflects process state",
Effect.gen(function* () {
const handle = yield* js('process.stdout.write("done")')
yield* handle.exitCode
const running = yield* handle.isRunning
expect(running).toBe(false)
}),
)
})
describe("error handling", () => {
fx.effect(
"fails for invalid command",
Effect.gen(function* () {
const exit = yield* Effect.exit(
Effect.gen(function* () {
const handle = yield* ChildProcess.make("nonexistent-command-12345")
return yield* handle.exitCode
}),
)
expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true)
}),
)
})
describe("pipeline", () => {
fx.effect(
"pipes stdout of one command to stdin of another",
Effect.gen(function* () {
const handle = yield* js('process.stdout.write("hello world")').pipe(
ChildProcess.pipeTo(
js(
'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.toUpperCase()))',
),
),
)
const out = yield* decodeByteStream(handle.stdout)
yield* handle.exitCode
expect(out).toBe("HELLO WORLD")
}),
)
fx.effect(
"three-stage pipeline",
Effect.gen(function* () {
const handle = yield* js('process.stdout.write("hello world")').pipe(
ChildProcess.pipeTo(
js(
'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.toUpperCase()))',
),
),
ChildProcess.pipeTo(
js(
'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.replaceAll(" ", "-")))',
),
),
)
const out = yield* decodeByteStream(handle.stdout)
yield* handle.exitCode
expect(out).toBe("HELLO-WORLD")
}),
)
fx.effect(
"pipes stderr with { from: 'stderr' }",
Effect.gen(function* () {
const handle = yield* js('process.stderr.write("error")').pipe(
ChildProcess.pipeTo(
js(
'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))',
),
{ from: "stderr" },
),
)
const out = yield* decodeByteStream(handle.stdout)
yield* handle.exitCode
expect(out).toBe("error")
}),
)
fx.effect(
"pipes combined output with { from: 'all' }",
Effect.gen(function* () {
const handle = yield* js('process.stdout.write("stdout\\n"); process.stderr.write("stderr\\n")').pipe(
ChildProcess.pipeTo(
js(
'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))',
),
{ from: "all" },
),
)
const out = yield* decodeByteStream(handle.stdout)
yield* handle.exitCode
expect(out).toContain("stdout")
expect(out).toContain("stderr")
}),
)
})
describe("Windows-specific", () => {
fx.effect(
"uses shell routing on Windows",
Effect.gen(function* () {
if (process.platform !== "win32") return
const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
svc.string(
ChildProcess.make("set", ["KILO_TEST_SHELL"], {
shell: true,
extendEnv: true,
env: { KILO_TEST_SHELL: "ok" },
}),
),
)
expect(out).toContain("KILO_TEST_SHELL=ok")
}),
)
fx.effect(
"runs cmd scripts with spaces on Windows without shell",
Effect.gen(function* () {
if (process.platform !== "win32") return
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const dir = path.join(tmp.path, "with space")
const file = path.join(dir, "echo cmd.cmd")
yield* Effect.promise(() => fs.mkdir(dir, { recursive: true }))
yield* Effect.promise(() => Bun.write(file, "@echo off\r\nif %~1==--stdio exit /b 0\r\nexit /b 7\r\n"))
const code = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
svc.exitCode(
ChildProcess.make(file, ["--stdio"], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
}),
),
)
expect(code).toBe(ChildProcessSpawner.ExitCode(0))
}),
)
})
})