Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
27c3a72
fix: support windows script execution via fileURLToPath and explicit …
Hona Feb 23, 2026
743a05a
build(turbo): ensure dependencies build before typecheck
Hona Feb 23, 2026
26c8cc3
fix(win32): resolve various test flakes and pathing issues
Hona Feb 23, 2026
6204724
fix(win32): resolve various test flakes and pathing issues without fa…
Hona Feb 23, 2026
3b4d91c
lol
Hona Feb 23, 2026
0e2693f
fix: resolve foreign key constraint failures during concurrent test s…
Hona Feb 23, 2026
39fea33
fix(test): resolve win32 path separator and bounds testing flakes
Hona Feb 23, 2026
957378a
fix(e2e): resolve windows path issues in project switch and mention t…
Hona Feb 23, 2026
7702af2
fix(e2e): revert erroneous path normalization in layout and utils
Hona Feb 23, 2026
476ec50
chore(e2e): limit playwright workers on local windows environments
Hona Feb 23, 2026
5e654a2
parallel fixes
Hona Feb 23, 2026
06eaf99
fix: disable core.fsmonitor for snapshot git repo to prevent orphaned…
Hona Feb 23, 2026
310d05f
perf: optimize internal git snapshot performance on Windows
Hona Feb 23, 2026
01bd58d
use node compatible
Hona Feb 23, 2026
be9d107
fix(e2e): track README in git template for workspace tests
Hona Feb 23, 2026
3ba11dd
refactor: address code smells and flaky patterns
Hona Feb 23, 2026
e8e6b66
fix(e2e): revert prompt-mention mock to use README.md for stability
Hona Feb 23, 2026
d6d65f8
fix(e2e): actually apply local workers limit to 3 on windows
Hona Feb 23, 2026
9a0520e
stability
Hona Feb 23, 2026
8d34df1
fix(test): remove bun install mutex and apply longer timeouts for plu…
Hona Feb 23, 2026
1f5068b
what the slop
Hona Feb 23, 2026
21585eb
slopity slop
Hona Feb 23, 2026
e14e25a
fix: harden session teardown races and add missing longpaths flags
Hona Feb 23, 2026
35d9ceb
fix: restore throw to preserve loop return type for typecheck
Hona Feb 23, 2026
ae41183
fix: restore --no-cache on CI to prevent bun install cache contention
Hona Feb 23, 2026
5a940c8
fix: skip 3s install delay on CI to prevent test timeouts
Hona Feb 23, 2026
a9c56d4
fix: remove install mutex and 3s delay, rely on --no-cache for parall…
Hona Feb 23, 2026
65cd3f9
Merge remote-tracking branch 'origin/dev' into fix/tests-win32-flakey
Hona Feb 23, 2026
c996725
Merge remote-tracking branch 'origin/dev' into fix/tests-win32-flakey
Hona Feb 23, 2026
a407295
Merge remote-tracking branch 'upstream/dev' into fix/tests-win32-flakey
Hona Feb 24, 2026
6221530
revert: remove glob result normalization, keep native paths
Hona Feb 24, 2026
a5206c6
fix(test): use native paths in assertions after reverting glob normal…
Hona Feb 24, 2026
07a880b
Merge remote-tracking branch 'upstream/dev' into fix/tests-win32-flakey
Hona Feb 24, 2026
69993f8
remove Object.defineProperty hack from snapshot bootstrap, fwd() hand…
Hona Feb 24, 2026
1800ff4
fix(test): use path.sep instead of hardcoded separator check
Hona Feb 24, 2026
84af896
fix(test): normalize git excludesFile path for Windows
Hona Feb 24, 2026
3130f63
Merge branch 'dev' into fix/tests-win32-flakey
Hona Feb 24, 2026
57f9e36
Merge branch 'dev' into fix/tests-win32-flakey
Hona Feb 24, 2026
39b68b2
drop stray blank line in snapshot/index.ts, already merged via #14890
Hona Feb 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 44 additions & 7 deletions packages/app/e2e/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,17 +191,54 @@ export async function seedProjects(page: Page, input: { directory: string; extra
)
}

let gitTemplatePromise: Promise<string> | 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
}
Expand Down
44 changes: 28 additions & 16 deletions packages/app/e2e/prompt/prompt-mention.spec.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
14 changes: 8 additions & 6 deletions packages/app/e2e/session/session-composer-dock.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ type Sdk = Parameters<typeof clearSessionDockSeed>[0]
async function withDockSession<T>(sdk: Sdk, title: string, fn: (session: { id: string; title: string }) => Promise<T>) {
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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions packages/app/e2e/session/session-undo-redo.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand Down
1 change: 1 addition & 0 deletions packages/app/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]],
Expand Down
24 changes: 18 additions & 6 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -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) => {
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/file/ignore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
5 changes: 3 additions & 2 deletions packages/opencode/src/file/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,8 @@ export namespace File {
}

const set = new Set<string>()
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) {
Expand Down Expand Up @@ -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 })
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/file/time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`,
)
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/project/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand All @@ -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) {
Expand Down
10 changes: 6 additions & 4 deletions packages/opencode/src/project/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,9 +216,6 @@ export namespace Project {
updated: Date.now(),
},
}
if (data.id !== "global") {
await migrateFromGlobal(data.id, data.worktree)
}
return fresh
})

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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())
Expand Down
Loading
Loading