diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index a7ccba61752b..497fc1815e2b 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -191,17 +191,54 @@ export async function seedProjects(page: Page, input: { directory: string; extra ) } +let gitTemplatePromise: Promise | undefined +async function getGitTemplate() { + if (gitTemplatePromise) return gitTemplatePromise + gitTemplatePromise = (async () => { + const templatePath = path.join( + os.tmpdir(), + "opencode-e2e-git-template-" + process.pid + "-" + Math.random().toString(36).slice(2), + ) + await fs.mkdir(templatePath, { recursive: true }) + + for (let attempt = 1; attempt <= 5; attempt++) { + try { + await fs.writeFile(path.join(templatePath, "README.md"), "# e2e\n") + + // Add a nested file to explicitly test nested path matching and slash normalization in E2E tests + await fs.mkdir(path.join(templatePath, "packages", "app"), { recursive: true }) + await fs.writeFile(path.join(templatePath, "packages", "app", "package.json"), "{}") + + execSync("git init", { cwd: templatePath, stdio: "ignore" }) + execSync("git config core.longpaths true", { cwd: templatePath, stdio: "ignore" }) + execSync("git add -A", { cwd: templatePath, stdio: "ignore" }) + execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', { + cwd: templatePath, + stdio: "ignore", + }) + break + } catch (err) { + if (attempt === 5) throw err + await new Promise((r) => setTimeout(r, 1000 + Math.random() * 2000)) + } + } + + return templatePath + })() + return gitTemplatePromise +} + export async function createTestProject() { const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-")) - await fs.writeFile(path.join(root, "README.md"), "# e2e\n") + const templatePath = await getGitTemplate() + await fs.cp(templatePath, root, { recursive: true }) - execSync("git init", { cwd: root, stdio: "ignore" }) - execSync("git add -A", { cwd: root, stdio: "ignore" }) - execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', { - cwd: root, - stdio: "ignore", - }) + const gitIdPath = path.join(root, ".git", "opencode") + if (await fs.stat(path.join(root, ".git")).catch(() => false)) { + // Generate a uniquely identifiable string for this specific test project instance + await fs.writeFile(gitIdPath, Math.random().toString(36).slice(2) + "-" + Date.now().toString()) + } return root } diff --git a/packages/app/e2e/prompt/prompt-mention.spec.ts b/packages/app/e2e/prompt/prompt-mention.spec.ts index 5cc9f6e68505..a38ce7fcddeb 100644 --- a/packages/app/e2e/prompt/prompt-mention.spec.ts +++ b/packages/app/e2e/prompt/prompt-mention.spec.ts @@ -1,26 +1,38 @@ +import fs from "node:fs/promises" +import path from "node:path" import { test, expect } from "../fixtures" import { promptSelector } from "../selectors" -test("smoke @mention inserts file pill token", async ({ page, gotoSession }) => { - await gotoSession() +test("smoke @mention inserts file pill token", async ({ page, withProject }) => { + await withProject(async ({ directory, gotoSession }) => { + // Scaffold nested file to test slashes and subdirectories + await fs.mkdir(path.join(directory, "packages", "app"), { recursive: true }) + await fs.writeFile(path.join(directory, "packages", "app", "package.json"), "{}") - await page.locator(promptSelector).click() - const sep = process.platform === "win32" ? "\\" : "/" - const file = ["packages", "app", "package.json"].join(sep) - const filePattern = /packages[\\/]+app[\\/]+\s*package\.json/ + await gotoSession() - await page.keyboard.type(`@${file}`) + const file = "packages/app/package.json" + const filePattern = /packages[\\/]+app[\\/]+\s*package\.json/ - const suggestion = page.getByRole("button", { name: filePattern }).first() - await expect(suggestion).toBeVisible() - await suggestion.hover() + const suggestion = page.getByRole("button", { name: filePattern }).first() - await page.keyboard.press("Tab") + await expect(async () => { + await page.locator(promptSelector).click() + await page.keyboard.press("Control+A") + await page.keyboard.press("Backspace") + await page.keyboard.type(`@${file}`) + await expect(suggestion).toBeVisible({ timeout: 500 }) + }).toPass({ timeout: 10_000 }) - const pill = page.locator(`${promptSelector} [data-type="file"]`).first() - await expect(pill).toBeVisible() - await expect(pill).toHaveAttribute("data-path", filePattern) + await suggestion.hover() - await page.keyboard.type(" ok") - await expect(page.locator(promptSelector)).toContainText("ok") + await page.keyboard.press("Tab") + + const pill = page.locator(`${promptSelector} [data-type="file"]`).first() + await expect(pill).toBeVisible() + await expect(pill).toHaveAttribute("data-path", filePattern) + + await page.keyboard.type(" ok") + await expect(page.locator(promptSelector)).toContainText("ok") + }) }) diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts index 6bf7714a66d1..1874f7cdfd33 100644 --- a/packages/app/e2e/session/session-composer-dock.spec.ts +++ b/packages/app/e2e/session/session-composer-dock.spec.ts @@ -15,7 +15,11 @@ type Sdk = Parameters[0] async function withDockSession(sdk: Sdk, title: string, fn: (session: { id: string; title: string }) => Promise) { const session = await sdk.session.create({ title }).then((r) => r.data) if (!session?.id) throw new Error("Session create did not return an id") - return fn(session) + try { + return await fn(session) + } finally { + await sdk.session.delete({ sessionID: session.id }).catch(() => undefined) + } } test.setTimeout(120_000) @@ -82,8 +86,7 @@ test("blocked permission flow supports allow once", async ({ page, sdk, gotoSess await seedSessionPermission(sdk, { sessionID: session.id, permission: "bash", - patterns: ["README.md"], - description: "Need permission for command", + patterns: [`REJECT_${Date.now()}.md`], }) await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1) @@ -107,7 +110,7 @@ test("blocked permission flow supports reject", async ({ page, sdk, gotoSession await seedSessionPermission(sdk, { sessionID: session.id, permission: "bash", - patterns: ["REJECT.md"], + patterns: [`REJECT_${Date.now()}.md`], }) await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1) @@ -128,8 +131,7 @@ test("blocked permission flow supports allow always", async ({ page, sdk, gotoSe await seedSessionPermission(sdk, { sessionID: session.id, permission: "bash", - patterns: ["README.md"], - description: "Need permission for command", + patterns: [`REJECT_${Date.now()}.md`], }) await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1) diff --git a/packages/app/e2e/session/session-undo-redo.spec.ts b/packages/app/e2e/session/session-undo-redo.spec.ts index c6ea2aea0aca..5e1a8d093eb3 100644 --- a/packages/app/e2e/session/session-undo-redo.spec.ts +++ b/packages/app/e2e/session/session-undo-redo.spec.ts @@ -107,6 +107,7 @@ test("slash redo clears revert and restores latest state", async ({ page, withPr }) .toBe(seeded.userMessageID) + await expect(seeded.prompt).toContainText(token) await seeded.prompt.click() await page.keyboard.press(`${modKey}+A`) await page.keyboard.press("Backspace") @@ -179,6 +180,7 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro await expect(firstMessage.first()).toBeVisible() await expect(secondMessage).toHaveCount(0) + await expect(second.prompt).toContainText(secondToken) await second.prompt.click() await page.keyboard.press(`${modKey}+A`) await page.keyboard.press("Backspace") @@ -195,6 +197,7 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro await expect(firstMessage).toHaveCount(0) await expect(secondMessage).toHaveCount(0) + await expect(second.prompt).toContainText(firstToken) await second.prompt.click() await page.keyboard.press(`${modKey}+A`) await page.keyboard.press("Backspace") diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index a97c8265144e..a7c07dd397bf 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ timeout: 10_000, }, fullyParallel: process.env.PLAYWRIGHT_FULLY_PARALLEL === "1", + workers: process.env.CI ? undefined : process.platform === "win32" ? 3 : undefined, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]], diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index aad0fd76c4be..708454d6395b 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1,6 +1,7 @@ import { Log } from "../util/log" import path from "path" -import { pathToFileURL } from "url" +import { pathToFileURL, fileURLToPath } from "url" +import { createRequire } from "module" import os from "os" import z from "zod" import { Filesystem } from "../util/filesystem" @@ -276,7 +277,6 @@ export namespace Config { "@opencode-ai/plugin": targetVersion, } await Filesystem.writeJson(pkg, json) - await new Promise((resolve) => setTimeout(resolve, 3000)) const gitignore = path.join(dir, ".gitignore") const hasGitIgnore = await Filesystem.exists(gitignore) @@ -289,7 +289,9 @@ export namespace Config { [ "install", // TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936) - ...(proxied() ? ["--no-cache"] : []), + // Bypass global cache on CI and E2E to prevent concurrent lock contention + // when multiple processes install simultaneously. + ...(proxied() || !!process.env.CI || !!process.env.OPENCODE_E2E_PROJECT_DIR ? ["--no-cache"] : []), ], { cwd: dir }, ).catch((err) => { @@ -342,10 +344,11 @@ export namespace Config { } function rel(item: string, patterns: string[]) { + const normalizedItem = item.replaceAll("\\", "/") for (const pattern of patterns) { - const index = item.indexOf(pattern) + const index = normalizedItem.indexOf(pattern) if (index === -1) continue - return item.slice(index + pattern.length) + return normalizedItem.slice(index + pattern.length) } } @@ -1332,7 +1335,16 @@ export namespace Config { const plugin = data.plugin[i] try { data.plugin[i] = import.meta.resolve!(plugin, options.path) - } catch (err) {} + } catch (e) { + try { + // import.meta.resolve sometimes fails with newly created node_modules + const require = createRequire(options.path) + const resolvedPath = require.resolve(plugin) + data.plugin[i] = pathToFileURL(resolvedPath).href + } catch { + // Ignore, plugin might be a generic string identifier like "mcp-server" + } + } } } return data diff --git a/packages/opencode/src/file/ignore.ts b/packages/opencode/src/file/ignore.ts index 94ffaf5ce049..b9731040c7d7 100644 --- a/packages/opencode/src/file/ignore.ts +++ b/packages/opencode/src/file/ignore.ts @@ -67,7 +67,7 @@ export namespace FileIgnore { if (Glob.match(pattern, filepath)) return false } - const parts = filepath.split(sep) + const parts = filepath.split(/[/\\]/) for (let i = 0; i < parts.length; i++) { if (FOLDERS.has(parts[i])) return true } diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index b7daddc5fb8e..7a84751e9320 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -379,7 +379,8 @@ export namespace File { } const set = new Set() - for await (const file of Ripgrep.files({ cwd: Instance.directory })) { + for await (let file of Ripgrep.files({ cwd: Instance.directory })) { + if (process.platform === "win32") file = file.replaceAll("\\", "/") result.files.push(file) let current = file while (true) { @@ -605,7 +606,7 @@ export namespace File { } export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) { - const query = input.query.trim() + const query = input.query.trim().replaceAll("\\", "/") const limit = input.limit ?? 100 const kind = input.type ?? (input.dirs === false ? "file" : "all") log.info("search", { query, kind }) diff --git a/packages/opencode/src/file/time.ts b/packages/opencode/src/file/time.ts index c85781eb4116..efb1c437647f 100644 --- a/packages/opencode/src/file/time.ts +++ b/packages/opencode/src/file/time.ts @@ -61,7 +61,8 @@ export namespace FileTime { const time = get(sessionID, filepath) if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`) const mtime = Filesystem.stat(filepath)?.mtime - if (mtime && mtime.getTime() > time.getTime()) { + // Allow a 50ms tolerance for Windows NTFS timestamp fuzziness / async flushing + if (mtime && mtime.getTime() > time.getTime() + 50) { throw new Error( `File ${filepath} has been modified since it was last read.\nLast modification: ${mtime.toISOString()}\nLast read: ${time.toISOString()}\n\nPlease read the file again before modifying it.`, ) diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index a2be3733f853..d32dcf286eca 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -12,6 +12,7 @@ import { Log } from "@/util/log" import { ShareNext } from "@/share/share-next" import { Snapshot } from "../snapshot" import { Truncate } from "../tool/truncation" +import { SessionPrompt } from "../session/prompt" export async function InstanceBootstrap() { Log.Default.info("bootstrapping", { directory: Instance.directory }) @@ -24,6 +25,7 @@ export async function InstanceBootstrap() { Vcs.init() Snapshot.init() Truncate.init() + SessionPrompt.init() Bus.subscribe(Command.Event.Executed, async (payload) => { if (payload.properties.name === Command.Default.INIT) { diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index adbe2b9fb159..6dd89c0a335e 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -216,9 +216,6 @@ export namespace Project { updated: Date.now(), }, } - if (data.id !== "global") { - await migrateFromGlobal(data.id, data.worktree) - } return fresh }) @@ -263,6 +260,11 @@ export namespace Project { Database.use((db) => db.insert(ProjectTable).values(insert).onConflictDoUpdate({ target: ProjectTable.id, set: updateSet }).run(), ) + + if (!row && data.id !== "global") { + await migrateFromGlobal(data.id, data.worktree) + } + GlobalBus.emit("event", { payload: { type: Event.Updated.type, @@ -309,7 +311,7 @@ export namespace Project { await work(10, sessions, async (row) => { // Skip sessions that belong to a different directory - if (row.directory && row.directory !== worktree) return + if (row.directory && path.relative(row.directory, worktree) !== "") return log.info("migrating session", { sessionID: row.id, from: "global", to: id }) Database.use((db) => db.update(SessionTable).set({ project_id: id }).where(eq(SessionTable.id, row.id)).run()) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 8454a9c3e975..7e7bf3420d1a 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -670,22 +670,30 @@ export namespace Session { export const updateMessage = fn(MessageV2.Info, async (msg) => { const time_created = msg.time.created const { id, sessionID, ...data } = msg - Database.use((db) => { - db.insert(MessageTable) - .values({ - id, - session_id: sessionID, - time_created, - data, - }) - .onConflictDoUpdate({ target: MessageTable.id, set: { data } }) - .run() - Database.effect(() => - Bus.publish(MessageV2.Event.Updated, { - info: msg, - }), - ) - }) + try { + Database.use((db) => { + db.insert(MessageTable) + .values({ + id, + session_id: sessionID, + time_created, + data, + }) + .onConflictDoUpdate({ target: MessageTable.id, set: { data } }) + .run() + Database.effect(() => + Bus.publish(MessageV2.Event.Updated, { + info: msg, + }), + ) + }) + } catch (e) { + if (e instanceof Error && e.message.includes("FOREIGN KEY constraint failed")) { + log.warn("session deleted while updating message", { sessionID, messageID: id }) + return msg + } + throw e + } return msg }) @@ -735,23 +743,31 @@ export namespace Session { export const updatePart = fn(UpdatePartInput, async (part) => { const { id, messageID, sessionID, ...data } = part const time = Date.now() - Database.use((db) => { - db.insert(PartTable) - .values({ - id, - message_id: messageID, - session_id: sessionID, - time_created: time, - data, - }) - .onConflictDoUpdate({ target: PartTable.id, set: { data } }) - .run() - Database.effect(() => - Bus.publish(MessageV2.Event.PartUpdated, { - part, - }), - ) - }) + try { + Database.use((db) => { + db.insert(PartTable) + .values({ + id, + message_id: messageID, + session_id: sessionID, + time_created: time, + data, + }) + .onConflictDoUpdate({ target: PartTable.id, set: { data } }) + .run() + Database.effect(() => + Bus.publish(MessageV2.Event.PartUpdated, { + part, + }), + ) + }) + } catch (e) { + if (e instanceof Error && e.message?.includes("FOREIGN KEY constraint failed")) { + log.warn("session deleted while updating part", { sessionID, messageID, partID: id }) + return part + } + throw e + } return part }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 75bd3c9dfaca..f4458c327538 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -83,6 +83,12 @@ export namespace SessionPrompt { }, ) + export function init() { + Bus.subscribe(Session.Event.Deleted, async (payload) => { + cancel(payload.properties.info.id) + }) + } + export function assertNotBusy(sessionID: string) { const match = state()[sessionID] if (match) throw new Session.BusyError(sessionID) @@ -331,7 +337,7 @@ export namespace SessionPrompt { modelID: lastUser.model.modelID, providerID: lastUser.model.providerID, history: msgs, - }) + }).catch(() => {}) const model = await Provider.getModel(lastUser.model.providerID, lastUser.model.modelID).catch((e) => { if (Provider.ModelNotFoundError.isInstance(e)) { @@ -720,7 +726,7 @@ export namespace SessionPrompt { } return item } - throw new Error("Impossible") + throw new Error("no assistant message found after loop") }) async function lastModel(sessionID: string) { diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 349336ba788f..710421319de8 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -72,10 +72,11 @@ export namespace SessionSummary { messageID: z.string(), }), async (input) => { - const all = await Session.messages({ sessionID: input.sessionID }) + const all = await Session.messages({ sessionID: input.sessionID }).catch(() => [] as MessageV2.WithParts[]) + if (!all.length) return await Promise.all([ - summarizeSession({ sessionID: input.sessionID, messages: all }), - summarizeMessage({ messageID: input.messageID, messages: all }), + summarizeSession({ sessionID: input.sessionID, messages: all }).catch(() => {}), + summarizeMessage({ messageID: input.messageID, messages: all }).catch(() => {}), ]) }, ) @@ -101,7 +102,8 @@ export namespace SessionSummary { const messages = input.messages.filter( (m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID), ) - const msgWithParts = messages.find((m) => m.info.id === input.messageID)! + const msgWithParts = messages.find((m) => m.info.id === input.messageID) + if (!msgWithParts) return const userMsg = msgWithParts.info as MessageV2.User const diffs = await computeDiff({ messages }) userMsg.summary = { diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 56773570af57..0a70b89b8799 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -646,7 +646,7 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => { if (prev === undefined) delete process.env.OPENCODE_CONFIG_DIR else process.env.OPENCODE_CONFIG_DIR = prev } -}) +}, 30_000) test("resolves scoped npm plugins in config", async () => { await using tmp = await tmpdir({ @@ -689,7 +689,16 @@ test("resolves scoped npm plugins in config", async () => { const pluginEntries = config.plugin ?? [] const baseUrl = pathToFileURL(path.join(tmp.path, "opencode.json")).href - const expected = import.meta.resolve("@scope/plugin", baseUrl) + let expected: string + try { + expected = import.meta.resolve("@scope/plugin", baseUrl) + } catch (e) { + // Fallback for Windows where dynamically created node_modules aren't immediately available to import.meta.resolve + const { createRequire } = await import("module") + const require = createRequire(tmp.path + "/") + const resolvedPath = require.resolve("@scope/plugin") + expected = pathToFileURL(resolvedPath).href + } expect(pluginEntries.includes(expected)).toBe(true) diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index ed8c5e344a81..c2b92976a049 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -9,6 +9,36 @@ function sanitizePath(p: string): string { return p.replace(/\0/g, "") } +let gitTemplatePromise: Promise | undefined +async function getGitTemplate() { + if (gitTemplatePromise) return gitTemplatePromise + gitTemplatePromise = (async () => { + const templatePath = path.join( + os.tmpdir(), + "opencode-git-template-" + process.pid + "-" + Math.random().toString(36).slice(2), + ) + await fs.mkdir(templatePath, { recursive: true }) + + // Retry logic to handle Windows CI resource exhaustion + for (let attempt = 1; attempt <= 5; attempt++) { + try { + await $`git init`.cwd(templatePath).quiet() + await $`git config core.longpaths true`.cwd(templatePath).quiet() + await $`git config core.symlinks true`.cwd(templatePath).quiet() + await $`git commit --allow-empty -m "root commit"`.cwd(templatePath).quiet() + break // Success + } catch (err) { + if (attempt === 5) throw err + // Wait before retrying to let other processes finish + await new Promise((r) => setTimeout(r, 1000 + Math.random() * 2000)) + } + } + + return templatePath + })() + return gitTemplatePromise +} + type TmpDirOptions = { git?: boolean config?: Partial @@ -19,8 +49,12 @@ export async function tmpdir(options?: TmpDirOptions) { const dirpath = sanitizePath(path.join(os.tmpdir(), "opencode-test-" + Math.random().toString(36).slice(2))) await fs.mkdir(dirpath, { recursive: true }) if (options?.git) { - await $`git init`.cwd(dirpath).quiet() - await $`git commit --allow-empty -m "root commit ${dirpath}"`.cwd(dirpath).quiet() + const templatePath = await getGitTemplate() + await fs.cp(templatePath, dirpath, { recursive: true }) + + // Write a unique project ID to .git/opencode so that projects sharing the exact same + // git template commit hash don't incorrectly collide in the test Database. + await fs.writeFile(path.join(dirpath, ".git", "opencode"), "test-id-" + Math.random().toString(36).slice(2)) } if (options?.config) { await Bun.write( diff --git a/packages/opencode/test/ide/ide.test.ts b/packages/opencode/test/ide/ide.test.ts index 4d70140197fe..e10700e80ffe 100644 --- a/packages/opencode/test/ide/ide.test.ts +++ b/packages/opencode/test/ide/ide.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test, afterEach } from "bun:test" import { Ide } from "../../src/ide" describe("ide", () => { - const original = structuredClone(process.env) + const original = { ...process.env } afterEach(() => { Object.keys(process.env).forEach((key) => { diff --git a/packages/opencode/test/skill/discovery.test.ts b/packages/opencode/test/skill/discovery.test.ts index d1963f697b98..5664fa32b8ad 100644 --- a/packages/opencode/test/skill/discovery.test.ts +++ b/packages/opencode/test/skill/discovery.test.ts @@ -77,7 +77,7 @@ describe("Discovery.pull", () => { test("downloads reference files alongside SKILL.md", async () => { const dirs = await Discovery.pull(CLOUDFLARE_SKILLS_URL) // find a skill dir that should have reference files (e.g. agents-sdk) - const agentsSdk = dirs.find((d) => d.endsWith("/agents-sdk")) + const agentsSdk = dirs.find((d) => d.endsWith(path.sep + "agents-sdk")) expect(agentsSdk).toBeDefined() if (agentsSdk) { const refs = path.join(agentsSdk, "references") diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index db05f8f623f6..ac93016927ac 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import os from "os" import path from "path" import { BashTool } from "../../src/tool/bash" import { Instance } from "../../src/project/instance" @@ -138,14 +139,14 @@ describe("tool.bash permissions", () => { await bash.execute( { command: "ls", - workdir: "/tmp", - description: "List /tmp", + workdir: os.tmpdir(), + description: "List temp dir", }, testCtx, ) const extDirReq = requests.find((r) => r.permission === "external_directory") expect(extDirReq).toBeDefined() - expect(extDirReq!.patterns).toContain("/tmp/*") + expect(extDirReq!.patterns).toContain(path.join(os.tmpdir(), "*")) }, }) }) @@ -366,7 +367,8 @@ describe("tool.bash truncation", () => { ctx, ) expect((result.metadata as any).truncated).toBe(false) - expect(result.output).toBe("hello\n") + const eol = process.platform === "win32" ? "\r\n" : "\n" + expect(result.output).toBe(`hello${eol}`) }, }) }) diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index 33c5e2c7397f..a75f767b3b6c 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -65,7 +65,7 @@ describe("tool.assertExternalDirectory", () => { const directory = "/tmp/project" const target = "/tmp/outside/file.txt" - const expected = path.join(path.dirname(target), "*") + const expected = path.join(path.dirname(target), "*").replaceAll("\\", "/") await Instance.provide({ directory, @@ -91,7 +91,7 @@ describe("tool.assertExternalDirectory", () => { const directory = "/tmp/project" const target = "/tmp/outside" - const expected = path.join(target, "*") + const expected = path.join(target, "*").replaceAll("\\", "/") await Instance.provide({ directory, diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 706a9e12caf9..52557d319cc4 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -38,7 +38,7 @@ describe("tool.registry", () => { expect(ids).toContain("hello") }, }) - }) + }, 30_000) test("loads tools from .opencode/tools (plural)", async () => { await using tmp = await tmpdir({ @@ -72,7 +72,7 @@ describe("tool.registry", () => { expect(ids).toContain("hello") }, }) - }) + }, 30_000) test("loads tools with external dependencies without crashing", async () => { await using tmp = await tmpdir({ @@ -99,10 +99,10 @@ describe("tool.registry", () => { [ "import { say } from 'cowsay'", "export default {", - " description: 'tool that imports cowsay at top level',", - " args: { text: { type: 'string' } },", - " execute: async ({ text }: { text: string }) => {", - " return say({ text })", + " description: 'says hello',", + " args: {},", + " execute: async () => {", + " return say({ text: 'hello' })", " },", "}", "", @@ -118,5 +118,5 @@ describe("tool.registry", () => { expect(ids).toContain("cowsay") }, }) - }) + }, 30_000) }) diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index 4f1a7d28e8cf..294bebdb0b77 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -293,19 +293,26 @@ describe("tool.write", () => { }) describe("error handling", () => { - test("throws error for paths outside project", async () => { + test("throws error when OS denies write access", async () => { await using tmp = await tmpdir() - const outsidePath = "/etc/passwd" + const readonlyPath = path.join(tmp.path, "readonly.txt") + + // Create a read-only file + await fs.writeFile(readonlyPath, "test", "utf-8") + await fs.chmod(readonlyPath, 0o444) await Instance.provide({ directory: tmp.path, fn: async () => { + const { FileTime } = await import("../../src/file/time") + FileTime.read(ctx.sessionID, readonlyPath) + const write = await WriteTool.init() await expect( write.execute( { - filePath: outsidePath, - content: "test", + filePath: readonlyPath, + content: "new content", }, ctx, ), @@ -313,6 +320,37 @@ describe("tool.write", () => { }, }) }) + + test("throws error when user denies permission for path outside project", async () => { + await using tmp = await tmpdir() + const outsidePath = path.join(require("os").tmpdir(), "outside.txt") + + // Mock a context that rejects the external_directory permission + const denyingCtx = { + ...ctx, + ask: async (req: any) => { + if (req.permission === "external_directory") { + throw new Error("Permission denied") + } + }, + } + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const write = await WriteTool.init() + await expect( + write.execute( + { + filePath: outsidePath, + content: "test", + }, + denyingCtx, + ), + ).rejects.toThrow("Permission denied") + }, + }) + }) }) describe("title generation", () => {