diff --git a/packages/opencode/test/file/time.test.ts b/packages/opencode/test/file/time.test.ts index e46d5229b4..112afef6ab 100644 --- a/packages/opencode/test/file/time.test.ts +++ b/packages/opencode/test/file/time.test.ts @@ -1,361 +1,121 @@ -import { describe, test, expect, beforeEach } from "bun:test" -import path from "path" +import { afterEach, expect, test } from "bun:test" import fs from "fs/promises" -import { FileTime } from "../../src/file/time" -import { Instance } from "../../src/project/instance" -import { Filesystem } from "../../src/util/filesystem" +import path from "path" import { tmpdir } from "../fixture/fixture" +import { Instance } from "../../src/project/instance" +import { FileTime } from "../../src/file/time" -describe("file/time", () => { - const sessionID = "test-session-123" +afterEach(() => Instance.disposeAll()) - describe("read() and get()", () => { - test("stores read timestamp", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "content", "utf-8") +test("read records a timestamp for a file", async () => { + await using tmp = await tmpdir() + const file = path.join(tmp.path, "test.txt") + await fs.writeFile(file, "hello") - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const before = FileTime.get(sessionID, filepath) - expect(before).toBeUndefined() - - FileTime.read(sessionID, filepath) - - const after = FileTime.get(sessionID, filepath) - expect(after).toBeInstanceOf(Date) - expect(after!.getTime()).toBeGreaterThan(0) - }, - }) - }) - - test("tracks separate timestamps per session", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "content", "utf-8") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - FileTime.read("session1", filepath) - FileTime.read("session2", filepath) - - const time1 = FileTime.get("session1", filepath) - const time2 = FileTime.get("session2", filepath) - - expect(time1).toBeDefined() - expect(time2).toBeDefined() - }, - }) - }) - - test("updates timestamp on subsequent reads", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "content", "utf-8") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - FileTime.read(sessionID, filepath) - const first = FileTime.get(sessionID, filepath)! - - await new Promise((resolve) => setTimeout(resolve, 10)) - - FileTime.read(sessionID, filepath) - const second = FileTime.get(sessionID, filepath)! - - expect(second.getTime()).toBeGreaterThanOrEqual(first.getTime()) - }, - }) - }) - }) - - describe("assert()", () => { - test("passes when file has not been modified", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "content", "utf-8") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - FileTime.read(sessionID, filepath) - - // Should not throw - await FileTime.assert(sessionID, filepath) - }, - }) - }) - - test("throws when file was not read first", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "content", "utf-8") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("You must read file") - }, - }) - }) - - test("throws when file was modified after read", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "content", "utf-8") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - FileTime.read(sessionID, filepath) - - // Wait to ensure different timestamps - await new Promise((resolve) => setTimeout(resolve, 100)) - - // Modify file after reading - await fs.writeFile(filepath, "modified content", "utf-8") - - await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("modified since it was last read") - }, - }) - }) - - test("includes timestamps in error message", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "content", "utf-8") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - FileTime.read(sessionID, filepath) - await new Promise((resolve) => setTimeout(resolve, 100)) - await fs.writeFile(filepath, "modified", "utf-8") - - let error: Error | undefined - try { - await FileTime.assert(sessionID, filepath) - } catch (e) { - error = e as Error - } - expect(error).toBeDefined() - expect(error!.message).toContain("Last modification:") - expect(error!.message).toContain("Last read:") - }, - }) - }) - - test("skips check when OPENCODE_DISABLE_FILETIME_CHECK is true", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "content", "utf-8") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const { Flag } = await import("../../src/flag/flag") - const original = Flag.OPENCODE_DISABLE_FILETIME_CHECK - ;(Flag as { OPENCODE_DISABLE_FILETIME_CHECK: boolean }).OPENCODE_DISABLE_FILETIME_CHECK = true - - try { - // Should not throw even though file wasn't read - await FileTime.assert(sessionID, filepath) - } finally { - ;(Flag as { OPENCODE_DISABLE_FILETIME_CHECK: boolean }).OPENCODE_DISABLE_FILETIME_CHECK = original - } - }, - }) - }) - }) - - describe("withLock()", () => { - test("executes function within lock", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - let executed = false - await FileTime.withLock(filepath, async () => { - executed = true - return "result" - }) - expect(executed).toBe(true) - }, - }) - }) - - test("returns function result", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const result = await FileTime.withLock(filepath, async () => { - return "success" - }) - expect(result).toBe("success") - }, - }) - }) - - test("serializes concurrent operations on same file", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const order: number[] = [] - - const op1 = FileTime.withLock(filepath, async () => { - order.push(1) - await new Promise((resolve) => setTimeout(resolve, 10)) - order.push(2) - }) - - const op2 = FileTime.withLock(filepath, async () => { - order.push(3) - order.push(4) - }) - - await Promise.all([op1, op2]) - - // Operations should be serialized - expect(order).toContain(1) - expect(order).toContain(2) - expect(order).toContain(3) - expect(order).toContain(4) - }, - }) - }) - - test("allows concurrent operations on different files", async () => { - await using tmp = await tmpdir() - const filepath1 = path.join(tmp.path, "file1.txt") - const filepath2 = path.join(tmp.path, "file2.txt") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - let started1 = false - let started2 = false - - const op1 = FileTime.withLock(filepath1, async () => { - started1 = true - await new Promise((resolve) => setTimeout(resolve, 50)) - expect(started2).toBe(true) // op2 should have started while op1 is running - }) - - const op2 = FileTime.withLock(filepath2, async () => { - started2 = true - }) - - await Promise.all([op1, op2]) - - expect(started1).toBe(true) - expect(started2).toBe(true) - }, - }) - }) - - test("releases lock even if function throws", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await expect( - FileTime.withLock(filepath, async () => { - throw new Error("Test error") - }), - ).rejects.toThrow("Test error") - - // Lock should be released, subsequent operations should work - let executed = false - await FileTime.withLock(filepath, async () => { - executed = true - }) - expect(executed).toBe(true) - }, - }) - }) - - test("deadlocks on nested locks (expected behavior)", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - // Nested locks on same file cause deadlock - this is expected - // The outer lock waits for inner to complete, but inner waits for outer to release - const timeout = new Promise((_, reject) => - setTimeout(() => reject(new Error("Deadlock detected")), 100), - ) - - const nestedLock = FileTime.withLock(filepath, async () => { - return FileTime.withLock(filepath, async () => { - return "inner" - }) - }) - - // Should timeout due to deadlock - await expect(Promise.race([nestedLock, timeout])).rejects.toThrow("Deadlock detected") - }, - }) - }) - }) - - describe("stat() Filesystem.stat pattern", () => { - test("reads file modification time via Filesystem.stat()", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "content", "utf-8") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - FileTime.read(sessionID, filepath) - - const stats = Filesystem.stat(filepath) - expect(stats?.mtime).toBeInstanceOf(Date) - expect(stats!.mtime.getTime()).toBeGreaterThan(0) - - // FileTime.assert uses this stat internally - await FileTime.assert(sessionID, filepath) - }, - }) - }) - - test("detects modification via stat mtime", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "original", "utf-8") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - FileTime.read(sessionID, filepath) - - const originalStat = Filesystem.stat(filepath) - - // Wait and modify - await new Promise((resolve) => setTimeout(resolve, 100)) - await fs.writeFile(filepath, "modified", "utf-8") - - const newStat = Filesystem.stat(filepath) - expect(newStat!.mtime.getTime()).toBeGreaterThan(originalStat!.mtime.getTime()) - - await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow() - }, - }) - }) + await Instance.provide({ + directory: tmp.path, + fn: () => { + const before = new Date() + FileTime.read("session-1", file) + const time = FileTime.get("session-1", file) + expect(time).toBeDefined() + expect(time!.getTime()).toBeGreaterThanOrEqual(before.getTime()) + }, + }) +}) + +test("get returns undefined for unread files", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: () => { + expect(FileTime.get("session-1", "/nonexistent")).toBeUndefined() + }, + }) +}) + +test("read times are scoped per session", async () => { + await using tmp = await tmpdir() + const file = path.join(tmp.path, "test.txt") + await fs.writeFile(file, "hello") + + await Instance.provide({ + directory: tmp.path, + fn: () => { + FileTime.read("session-a", file) + expect(FileTime.get("session-a", file)).toBeDefined() + expect(FileTime.get("session-b", file)).toBeUndefined() + }, + }) +}) + +test("assert throws if file was not read first", async () => { + await using tmp = await tmpdir() + const file = path.join(tmp.path, "test.txt") + await fs.writeFile(file, "hello") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + expect(FileTime.assert("session-1", file)).rejects.toThrow("must read file") + }, + }) +}) + +test("assert passes if file unchanged since read", async () => { + await using tmp = await tmpdir() + const file = path.join(tmp.path, "test.txt") + await fs.writeFile(file, "hello") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + FileTime.read("session-1", file) + await FileTime.assert("session-1", file) + }, + }) +}) + +test("assert throws if file modified after read", async () => { + await using tmp = await tmpdir() + const file = path.join(tmp.path, "test.txt") + await fs.writeFile(file, "hello") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + FileTime.read("session-1", file) + await Bun.sleep(100) + await fs.writeFile(file, "modified") + expect(FileTime.assert("session-1", file)).rejects.toThrow("has been modified") + }, + }) +}) + +test("withLock serializes concurrent writes to same file", async () => { + await using tmp = await tmpdir() + const file = path.join(tmp.path, "locked.txt") + await fs.writeFile(file, "") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const order: number[] = [] + + const a = FileTime.withLock(file, async () => { + order.push(1) + await Bun.sleep(50) + order.push(2) + }) + + const b = FileTime.withLock(file, async () => { + order.push(3) + await Bun.sleep(10) + order.push(4) + }) + + await Promise.all([a, b]) + expect(order).toEqual([1, 2, 3, 4]) + }, }) })