diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 07f9107b61..384b6fc4ff 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -71,6 +71,12 @@ async function input(value?: string) { return piped + "\n" + value } +export function resolveThreadDirectory(project?: string, envPWD = process.env.PWD, cwd = process.cwd()) { + const root = Filesystem.resolve(envPWD ?? cwd) + if (project) return Filesystem.resolve(path.isAbsolute(project) ? project : path.join(root, project)) + return Filesystem.resolve(cwd) +} + export const TuiThreadCommand = cmd({ command: "$0 [project]", describe: "start opencode tui", @@ -124,10 +130,7 @@ export const TuiThreadCommand = cmd({ // Resolve relative --project paths from PWD, then use the real cwd after // chdir so the thread and worker share the same directory key. - const root = Filesystem.resolve(process.env.PWD ?? process.cwd()) - const next = args.project - ? Filesystem.resolve(path.isAbsolute(args.project) ? args.project : path.join(root, args.project)) - : Filesystem.resolve(process.cwd()) + const next = resolveThreadDirectory(args.project) const file = await target() try { process.chdir(next) diff --git a/packages/opencode/test/cli/tui/thread.test.ts b/packages/opencode/test/cli/tui/thread.test.ts index b743556556..53b7488c26 100644 --- a/packages/opencode/test/cli/tui/thread.test.ts +++ b/packages/opencode/test/cli/tui/thread.test.ts @@ -1,141 +1,28 @@ -import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" +import { describe, expect, test } from "bun:test" import fs from "fs/promises" import path from "path" import { tmpdir } from "../../fixture/fixture" -import * as App from "../../../src/cli/cmd/tui/app" -import { UI } from "../../../src/cli/ui" -import * as Timeout from "../../../src/util/timeout" -import * as Network from "../../../src/cli/network" -import * as Win32 from "../../../src/cli/cmd/tui/win32" - -const stop = new Error("stop") -const packageRoot = path.resolve(import.meta.dir, "../../..") -const seen = { - tui: [] as string[], -} - -class TestWorker extends EventTarget { - onerror: Worker["onerror"] = null - onmessage: Worker["onmessage"] = null - onmessageerror: Worker["onmessageerror"] = null - - postMessage(data: string) { - const parsed = JSON.parse(data) - if (!parsed || typeof parsed !== "object" || !("method" in parsed) || !("id" in parsed)) return - if (typeof parsed.method !== "string" || typeof parsed.id !== "number") return - const result = - parsed.method === "fetch" - ? { status: 200, headers: {}, body: "" } - : parsed.method === "server" - ? { url: "http://127.0.0.1" } - : parsed.method === "snapshot" - ? "" - : undefined - queueMicrotask(() => { - this.onmessage?.( - new MessageEvent("message", { data: JSON.stringify({ type: "rpc.result", result, id: parsed.id }) }), - ) - }) - } - - terminate() {} -} - -function setup() { - // Intentionally avoid mock.module() here: Bun keeps module overrides in cache - // and mock.restore() does not reset mock.module values. If this switches back - // to module mocks, later suites can see mocked @/config/tui and fail (e.g. - // plugin-loader tests expecting real TuiConfig.waitForDependencies). See: - // https://github.com/oven-sh/bun/issues/7823 and #12823. - spyOn(App, "tui").mockImplementation(async (input) => { - if (input.directory) seen.tui.push(input.directory) - throw stop - }) - spyOn(UI, "error").mockImplementation(() => {}) - spyOn(Timeout, "withTimeout").mockImplementation((input) => input) - spyOn(Network, "resolveNetworkOptions").mockResolvedValue({ - mdns: false, - port: 0, - hostname: "127.0.0.1", - mdnsDomain: "opencode.local", - cors: [], - }) - spyOn(Win32, "win32DisableProcessedInput").mockImplementation(() => {}) - spyOn(Win32, "win32InstallCtrlCGuard").mockReturnValue(undefined) -} +import { resolveThreadDirectory } from "../../../src/cli/cmd/tui/thread" describe("tui thread", () => { - afterEach(() => { - mock.restore() - }) - - async function call(project?: string) { - const { TuiThreadCommand } = await import("../../../src/cli/cmd/tui/thread") - const args: Parameters>[0] = { - _: [], - $0: "opencode", - project, - prompt: "hi", - model: undefined, - agent: undefined, - session: undefined, - continue: false, - fork: false, - port: 0, - hostname: "127.0.0.1", - mdns: false, - "mdns-domain": "opencode.local", - mdnsDomain: "opencode.local", - cors: [], - } - return TuiThreadCommand.handler(args) - } - async function check(project?: string) { - setup() - const pwd = process.env.PWD - const worker = globalThis.Worker - const tty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY") await using tmp = await tmpdir({ git: true }) const link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link") const type = process.platform === "win32" ? "junction" : "dir" - seen.tui.length = 0 - await fs.symlink(tmp.path, link, type) - - Object.defineProperty(process.stdin, "isTTY", { - configurable: true, - value: true, - }) - Object.defineProperty(globalThis, "Worker", { configurable: true, value: TestWorker }) try { - process.chdir(tmp.path) - process.env.PWD = link - let error: unknown - try { - await call(project) - } catch (caught) { - error = caught - } - expect(error).toBe(stop) - expect(seen.tui[0]).toBe(tmp.path) + await fs.symlink(tmp.path, link, type) + expect(resolveThreadDirectory(project, link, tmp.path)).toBe(tmp.path) } finally { - process.chdir(packageRoot) - if (pwd === undefined) delete process.env.PWD - else process.env.PWD = pwd - if (tty) Object.defineProperty(process.stdin, "isTTY", tty) - else delete (process.stdin as { isTTY?: boolean }).isTTY - Object.defineProperty(globalThis, "Worker", { configurable: true, value: worker }) await fs.rm(link, { recursive: true, force: true }).catch(() => undefined) } } - // serial because both modify real env vars - test.serial("uses the real cwd when PWD points at a symlink", async () => { + test("uses the real cwd when PWD points at a symlink", async () => { await check() }) - test.serial("uses the real cwd after resolving a relative project from PWD", async () => { + test("uses the real cwd after resolving a relative project from PWD", async () => { await check(".") }) })