From 1458c4520f84a23aee4f26ad4c57ec21318c79b5 Mon Sep 17 00:00:00 2001 From: onlyfeng Date: Wed, 24 Jun 2026 15:03:57 +0800 Subject: [PATCH] fix(test): keep default fixtures outside checkout --- packages/opencode/test/fixture/fixture.ts | 29 ++++++-- packages/opencode/test/preload.ts | 72 +++++++++++++++++-- .../test/server/project-init-git.test.ts | 2 +- .../test/server/session-prompt-busy.test.ts | 4 +- .../test/server/workflows-route.test.ts | 4 +- 5 files changed, 96 insertions(+), 15 deletions(-) diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index 3b4e578af..89369851c 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -64,18 +64,29 @@ async function stop(dir: string) { await $`git fsmonitor--daemon stop`.cwd(dir).quiet().nothrow() } +// Unauthenticated HTTP route tests must use a fixture under cwd because the +// server rejects directories outside the checkout. node_modules is gitignored, +// and git:true fixtures create their own .git so VCS detection stops there. +const cwdFixtureRoot = () => path.join(process.cwd(), "node_modules", ".mimocode-cwd-fixtures") + +function tmpdirBase(root?: "cwd") { + if (root === "cwd") return cwdFixtureRoot() + return process.env["MIMOCODE_TEST_TMPDIR_ROOT"] ?? os.tmpdir() +} + type TmpDirOptions = { git?: boolean outsideGit?: boolean config?: Partial init?: (dir: string) => Promise dispose?: (dir: string) => Promise + root?: "cwd" } export async function tmpdir(options?: TmpDirOptions) { const prevRoot = options?.outsideGit ? process.env["MIMOCODE_TEST_TMPDIR_ROOT"] : undefined if (options?.outsideGit) process.env["MIMOCODE_TEST_TMPDIR_ROOT"] = outsideGitTmpRoot() const dirpath = sanitizePath( - path.join(process.env["MIMOCODE_TEST_TMPDIR_ROOT"] ?? os.tmpdir(), "mimocode-test-" + Math.random().toString(36).slice(2)), + path.join(tmpdirBase(options?.root), "mimocode-test-" + Math.random().toString(36).slice(2)), ) await fs.mkdir(dirpath, { recursive: true }) if (options?.git) { @@ -116,8 +127,15 @@ export async function tmpdir(options?: TmpDirOptions) { return result } +type ScopedTmpDirOptions = { + git?: boolean + config?: Partial + outsideGit?: boolean + root?: "cwd" +} + /** Effectful scoped tmpdir. Cleaned up when the scope closes. Make sure these stay in sync */ -export function tmpdirScoped(options?: { git?: boolean; config?: Partial; outsideGit?: boolean }) { +export function tmpdirScoped(options?: ScopedTmpDirOptions) { return Effect.gen(function* () { const prevRoot = options?.outsideGit ? process.env["MIMOCODE_TEST_TMPDIR_ROOT"] : undefined if (options?.outsideGit) process.env["MIMOCODE_TEST_TMPDIR_ROOT"] = outsideGitTmpRoot() @@ -132,7 +150,7 @@ export function tmpdirScoped(options?: { git?: boolean; config?: Partial fs.mkdir(dirpath, { recursive: true })) const dir = sanitizePath(yield* Effect.promise(() => fs.realpath(dirpath))) @@ -183,7 +201,7 @@ export const provideInstance = export function provideTmpdirInstance( self: (path: string) => Effect.Effect, - options?: { git?: boolean; config?: Partial; outsideGit?: boolean }, + options?: ScopedTmpDirOptions, ) { return Effect.gen(function* () { const path = yield* tmpdirScoped(options) @@ -207,7 +225,7 @@ export function provideTmpdirInstance( export function provideTmpdirServer( self: (input: { dir: string; llm: TestLLMServer["Service"] }) => Effect.Effect, - options?: { git?: boolean; config?: (url: string) => Partial }, + options?: { git?: boolean; config?: (url: string) => Partial; root?: "cwd" }, ): Effect.Effect< A, E | PlatformError.PlatformError, @@ -218,6 +236,7 @@ export function provideTmpdirServer( return yield* provideTmpdirInstance((dir) => self({ dir, llm }), { git: options?.git, config: options?.config?.(llm.url), + root: options?.root, }) }) } diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 6e175873f..8ae7c33de 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -2,17 +2,79 @@ // xdg-basedir reads env vars at import time, so we must set these first import os from "os" import path from "path" +import { constants as fsConstants } from "fs" import fs from "fs/promises" import { setTimeout as sleep } from "node:timers/promises" import { afterAll } from "bun:test" -// Set XDG env vars FIRST, before any src/ imports -const dir = path.join(os.tmpdir(), "mimocode-test-data-" + process.pid) +const forbiddenFixtureRoots = ["/etc", "/proc", "/sys", "/dev", "/boot", "/private/etc"] + +function containsPath(parent: string, child: string) { + const relative = path.relative(path.resolve(parent), path.resolve(child)) + return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative)) +} + +async function findGitRoot(directory: string): Promise { + const current = path.resolve(directory) + const hasGitDir = await fs + .stat(path.join(current, ".git")) + .then(() => true) + .catch(() => false) + if (hasGitDir) return current + const parent = path.dirname(current) + if (parent === current) return undefined + return findGitRoot(parent) +} + +async function gitFreeParent(directory: string): Promise { + const root = await findGitRoot(directory) + if (!root) return path.resolve(directory) + const parent = path.dirname(root) + if (parent === root) return parent + return gitFreeParent(parent) +} + +async function isWritableDirectory(directory: string) { + const isDirectory = await fs + .stat(directory) + .then((stat) => stat.isDirectory()) + .catch(() => false) + if (!isDirectory) return false + return fs + .access(directory, fsConstants.W_OK) + .then(() => true) + .catch(() => false) +} + +async function isFixtureBaseBlocked(candidate: string) { + const resolved = path.resolve(candidate) + if (resolved === path.parse(resolved).root) return true + if (await findGitRoot(resolved)) return true + if (forbiddenFixtureRoots.some((forbidden) => containsPath(forbidden, resolved))) return true + return !(await isWritableDirectory(resolved)) +} + +async function fixtureBase() { + const candidates = await Promise.all( + [os.tmpdir(), os.homedir(), await gitFreeParent(process.cwd())].map(async (candidate) => ({ + candidate, + blocked: await isFixtureBaseBlocked(candidate), + })), + ) + const selected = candidates.find((candidate) => !candidate.blocked) + return selected?.candidate ?? os.tmpdir() +} + +// Set XDG env vars FIRST, before any src/ imports. Keep the process-wide data +// root outside the repo checkout and unsafe system roots because worktree +// bootstrap creates real project instances under Global.Path.data. +const base = await fixtureBase() +const dir = path.join(base, "mimocode-test-data-" + process.pid) await fs.mkdir(dir, { recursive: true }) -// Route fixture tmpdirs under cwd so they pass the InstanceMiddleware cwd -// containment check (security: unauthenticated servers restrict directory to cwd subtree). -const fixtureRoot = path.join(process.cwd(), ".mimocode-test-fixtures-" + process.pid) +// Default fixture tmpdirs should not inherit the repository checkout's worktree. +// HTTP route tests that need cwd containment opt into root: "cwd". +const fixtureRoot = path.join(base, ".mimocode-test-fixtures-" + process.pid) await fs.mkdir(fixtureRoot, { recursive: true }) process.env["MIMOCODE_TEST_TMPDIR_ROOT"] = fixtureRoot afterAll(async () => { diff --git a/packages/opencode/test/server/project-init-git.test.ts b/packages/opencode/test/server/project-init-git.test.ts index a58a721bc..e3c339f9c 100644 --- a/packages/opencode/test/server/project-init-git.test.ts +++ b/packages/opencode/test/server/project-init-git.test.ts @@ -91,7 +91,7 @@ describe("project.initGit endpoint", () => { }) test("does not reload when the project is already git", async () => { - await using tmp = await tmpdir({ git: true }) + await using tmp = await tmpdir({ git: true, root: "cwd" }) const app = Server.Default().app const seen: { directory?: string; payload: { type: string } }[] = [] const fn = (evt: { directory?: string; payload: { type: string } }) => { diff --git a/packages/opencode/test/server/session-prompt-busy.test.ts b/packages/opencode/test/server/session-prompt-busy.test.ts index 14586a87b..e23701951 100644 --- a/packages/opencode/test/server/session-prompt-busy.test.ts +++ b/packages/opencode/test/server/session-prompt-busy.test.ts @@ -33,7 +33,7 @@ describe("ErrorMiddleware → BusyError mapping", () => { describe("POST /session/:sessionID/message busy-runner behavior", () => { test("returns 409 when session main runner is already busy", async () => { - await using tmp = await tmpdir({}) + await using tmp = await tmpdir({ git: true, root: "cwd" }) const status = await Instance.provide({ directory: tmp.path, @@ -84,7 +84,7 @@ describe("POST /session/:sessionID/message busy-runner behavior", () => { }) test("POST /:sessionID/abort frees runner; subsequent POST is no longer rejected with 409", async () => { - await using tmp = await tmpdir({}) + await using tmp = await tmpdir({ git: true, root: "cwd" }) const result = await Instance.provide({ directory: tmp.path, diff --git a/packages/opencode/test/server/workflows-route.test.ts b/packages/opencode/test/server/workflows-route.test.ts index d84742804..47e3daedc 100644 --- a/packages/opencode/test/server/workflows-route.test.ts +++ b/packages/opencode/test/server/workflows-route.test.ts @@ -225,7 +225,7 @@ describe("workflows routes — live runtime", () => { resumed: false, }) }), - { git: true, config: providerCfg }, + { git: true, config: providerCfg, root: "cwd" }, ), ) @@ -269,7 +269,7 @@ describe("workflows routes — live runtime", () => { expect(row!.status).toBe("completed") expect(row!.succeeded).toBeGreaterThanOrEqual(1) }), - { git: true, config: providerCfg }, + { git: true, config: providerCfg, root: "cwd" }, ), // Headroom over the default 5s: this exercises a full Instance bootstrap + // a real runtime run + an HTTP round-trip, and runs alongside the heavy