|
|
@@ -1,445 +1,422 @@
|
|
|
-import { describe, test, expect, afterEach } from "bun:test"
|
|
|
-import path from "path"
|
|
|
+import { afterEach, describe, expect } from "bun:test"
|
|
|
import fs from "fs/promises"
|
|
|
+import path from "path"
|
|
|
+import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
|
|
|
+import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
|
|
import { FileTime } from "../../src/file/time"
|
|
|
import { Instance } from "../../src/project/instance"
|
|
|
import { SessionID } from "../../src/session/schema"
|
|
|
import { Filesystem } from "../../src/util/filesystem"
|
|
|
-import { tmpdir } from "../fixture/fixture"
|
|
|
+import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
|
|
|
+import { testEffect } from "../lib/effect"
|
|
|
|
|
|
afterEach(async () => {
|
|
|
await Instance.disposeAll()
|
|
|
})
|
|
|
|
|
|
-async function touch(file: string, time: number) {
|
|
|
- const date = new Date(time)
|
|
|
- await fs.utimes(file, date, date)
|
|
|
-}
|
|
|
+const it = testEffect(Layer.mergeAll(FileTime.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
|
|
+
|
|
|
+const id = SessionID.make("ses_00000000000000000000000001")
|
|
|
|
|
|
-function gate() {
|
|
|
- let open!: () => void
|
|
|
- const wait = new Promise<void>((resolve) => {
|
|
|
- open = resolve
|
|
|
+const put = (file: string, text: string) => Effect.promise(() => fs.writeFile(file, text, "utf-8"))
|
|
|
+
|
|
|
+const touch = (file: string, time: number) =>
|
|
|
+ Effect.promise(() => {
|
|
|
+ const date = new Date(time)
|
|
|
+ return fs.utimes(file, date, date)
|
|
|
})
|
|
|
- return { open, wait }
|
|
|
-}
|
|
|
|
|
|
-describe("file/time", () => {
|
|
|
- const sessionID = SessionID.make("ses_00000000000000000000000001")
|
|
|
+const read = (id: SessionID, file: string) => FileTime.Service.use((svc) => svc.read(id, file))
|
|
|
+
|
|
|
+const get = (id: SessionID, file: string) => FileTime.Service.use((svc) => svc.get(id, file))
|
|
|
+
|
|
|
+const check = (id: SessionID, file: string) => FileTime.Service.use((svc) => svc.assert(id, file))
|
|
|
+
|
|
|
+const lock = <A>(file: string, fn: () => Effect.Effect<A>) => FileTime.Service.use((svc) => svc.withLock(file, fn))
|
|
|
+
|
|
|
+const fail = Effect.fn("FileTimeTest.fail")(function* <A, E, R>(self: Effect.Effect<A, E, R>) {
|
|
|
+ const exit = yield* self.pipe(Effect.exit)
|
|
|
+ if (Exit.isFailure(exit)) {
|
|
|
+ const err = Cause.squash(exit.cause)
|
|
|
+ return err instanceof Error ? err : new Error(String(err))
|
|
|
+ }
|
|
|
+ throw new Error("expected file time effect to fail")
|
|
|
+})
|
|
|
|
|
|
+describe("file/time", () => {
|
|
|
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")
|
|
|
-
|
|
|
- await Instance.provide({
|
|
|
- directory: tmp.path,
|
|
|
- fn: async () => {
|
|
|
- const before = await FileTime.get(sessionID, filepath)
|
|
|
+ it.live("stores read timestamp", () =>
|
|
|
+ provideTmpdirInstance((dir) =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const file = path.join(dir, "file.txt")
|
|
|
+ yield* put(file, "content")
|
|
|
+
|
|
|
+ const before = yield* get(id, file)
|
|
|
expect(before).toBeUndefined()
|
|
|
|
|
|
- await FileTime.read(sessionID, filepath)
|
|
|
+ yield* read(id, file)
|
|
|
|
|
|
- const after = await FileTime.get(sessionID, filepath)
|
|
|
+ const after = yield* get(id, file)
|
|
|
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 () => {
|
|
|
- await FileTime.read(SessionID.make("ses_00000000000000000000000002"), filepath)
|
|
|
- await FileTime.read(SessionID.make("ses_00000000000000000000000003"), filepath)
|
|
|
-
|
|
|
- const time1 = await FileTime.get(SessionID.make("ses_00000000000000000000000002"), filepath)
|
|
|
- const time2 = await FileTime.get(SessionID.make("ses_00000000000000000000000003"), 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 () => {
|
|
|
- await FileTime.read(sessionID, filepath)
|
|
|
- const first = await FileTime.get(sessionID, filepath)
|
|
|
-
|
|
|
- await FileTime.read(sessionID, filepath)
|
|
|
- const second = await FileTime.get(sessionID, filepath)
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.live("tracks separate timestamps per session", () =>
|
|
|
+ provideTmpdirInstance((dir) =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const file = path.join(dir, "file.txt")
|
|
|
+ yield* put(file, "content")
|
|
|
+
|
|
|
+ const one = SessionID.make("ses_00000000000000000000000002")
|
|
|
+ const two = SessionID.make("ses_00000000000000000000000003")
|
|
|
+ yield* read(one, file)
|
|
|
+ yield* read(two, file)
|
|
|
+
|
|
|
+ const first = yield* get(one, file)
|
|
|
+ const second = yield* get(two, file)
|
|
|
+
|
|
|
+ expect(first).toBeDefined()
|
|
|
+ expect(second).toBeDefined()
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.live("updates timestamp on subsequent reads", () =>
|
|
|
+ provideTmpdirInstance((dir) =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const file = path.join(dir, "file.txt")
|
|
|
+ yield* put(file, "content")
|
|
|
+
|
|
|
+ yield* read(id, file)
|
|
|
+ const first = yield* get(id, file)
|
|
|
+
|
|
|
+ yield* read(id, file)
|
|
|
+ const second = yield* get(id, file)
|
|
|
|
|
|
expect(second!.getTime()).toBeGreaterThanOrEqual(first!.getTime())
|
|
|
- },
|
|
|
- })
|
|
|
- })
|
|
|
-
|
|
|
- test("isolates reads by directory", async () => {
|
|
|
- await using one = await tmpdir()
|
|
|
- await using two = await tmpdir()
|
|
|
- await using shared = await tmpdir()
|
|
|
- const filepath = path.join(shared.path, "file.txt")
|
|
|
- await fs.writeFile(filepath, "content", "utf-8")
|
|
|
-
|
|
|
- await Instance.provide({
|
|
|
- directory: one.path,
|
|
|
- fn: async () => {
|
|
|
- await FileTime.read(sessionID, filepath)
|
|
|
- },
|
|
|
- })
|
|
|
-
|
|
|
- await Instance.provide({
|
|
|
- directory: two.path,
|
|
|
- fn: async () => {
|
|
|
- expect(await FileTime.get(sessionID, filepath)).toBeUndefined()
|
|
|
- },
|
|
|
- })
|
|
|
- })
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.live("isolates reads by directory", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const one = yield* tmpdirScoped()
|
|
|
+ const two = yield* tmpdirScoped()
|
|
|
+ const shared = yield* tmpdirScoped()
|
|
|
+ const file = path.join(shared, "file.txt")
|
|
|
+ yield* put(file, "content")
|
|
|
+
|
|
|
+ yield* provideInstance(one)(read(id, file))
|
|
|
+ const result = yield* provideInstance(two)(get(id, file))
|
|
|
+ expect(result).toBeUndefined()
|
|
|
+ }),
|
|
|
+ )
|
|
|
})
|
|
|
|
|
|
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 touch(filepath, 1_000)
|
|
|
-
|
|
|
- await Instance.provide({
|
|
|
- directory: tmp.path,
|
|
|
- fn: async () => {
|
|
|
- await FileTime.read(sessionID, filepath)
|
|
|
- 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 touch(filepath, 1_000)
|
|
|
-
|
|
|
- await Instance.provide({
|
|
|
- directory: tmp.path,
|
|
|
- fn: async () => {
|
|
|
- await FileTime.read(sessionID, filepath)
|
|
|
- await fs.writeFile(filepath, "modified content", "utf-8")
|
|
|
- await touch(filepath, 2_000)
|
|
|
- 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 touch(filepath, 1_000)
|
|
|
-
|
|
|
- await Instance.provide({
|
|
|
- directory: tmp.path,
|
|
|
- fn: async () => {
|
|
|
- await FileTime.read(sessionID, filepath)
|
|
|
- await fs.writeFile(filepath, "modified", "utf-8")
|
|
|
- await touch(filepath, 2_000)
|
|
|
-
|
|
|
- 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:")
|
|
|
- },
|
|
|
- })
|
|
|
- })
|
|
|
+ it.live("passes when file has not been modified", () =>
|
|
|
+ provideTmpdirInstance((dir) =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const file = path.join(dir, "file.txt")
|
|
|
+ yield* put(file, "content")
|
|
|
+ yield* touch(file, 1_000)
|
|
|
+
|
|
|
+ yield* read(id, file)
|
|
|
+ yield* check(id, file)
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.live("throws when file was not read first", () =>
|
|
|
+ provideTmpdirInstance((dir) =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const file = path.join(dir, "file.txt")
|
|
|
+ yield* put(file, "content")
|
|
|
+
|
|
|
+ const err = yield* fail(check(id, file))
|
|
|
+ expect(err.message).toContain("You must read file")
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.live("throws when file was modified after read", () =>
|
|
|
+ provideTmpdirInstance((dir) =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const file = path.join(dir, "file.txt")
|
|
|
+ yield* put(file, "content")
|
|
|
+ yield* touch(file, 1_000)
|
|
|
+
|
|
|
+ yield* read(id, file)
|
|
|
+ yield* put(file, "modified content")
|
|
|
+ yield* touch(file, 2_000)
|
|
|
+
|
|
|
+ const err = yield* fail(check(id, file))
|
|
|
+ expect(err.message).toContain("modified since it was last read")
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.live("includes timestamps in error message", () =>
|
|
|
+ provideTmpdirInstance((dir) =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const file = path.join(dir, "file.txt")
|
|
|
+ yield* put(file, "content")
|
|
|
+ yield* touch(file, 1_000)
|
|
|
+
|
|
|
+ yield* read(id, file)
|
|
|
+ yield* put(file, "modified")
|
|
|
+ yield* touch(file, 2_000)
|
|
|
+
|
|
|
+ const err = yield* fail(check(id, file))
|
|
|
+ expect(err.message).toContain("Last modification:")
|
|
|
+ expect(err.message).toContain("Last read:")
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ )
|
|
|
})
|
|
|
|
|
|
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"
|
|
|
- })
|
|
|
+ it.live("executes function within lock", () =>
|
|
|
+ provideTmpdirInstance((dir) =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const file = path.join(dir, "file.txt")
|
|
|
+ let hit = false
|
|
|
+
|
|
|
+ yield* lock(file, () =>
|
|
|
+ Effect.sync(() => {
|
|
|
+ hit = true
|
|
|
+ return "result"
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ expect(hit).toBe(true)
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.live("returns function result", () =>
|
|
|
+ provideTmpdirInstance((dir) =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const file = path.join(dir, "file.txt")
|
|
|
+ const result = yield* lock(file, () => Effect.succeed("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 () => {
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.live("serializes concurrent operations on same file", () =>
|
|
|
+ provideTmpdirInstance((dir) =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const file = path.join(dir, "file.txt")
|
|
|
const order: number[] = []
|
|
|
- const hold = gate()
|
|
|
- const ready = gate()
|
|
|
-
|
|
|
- const op1 = FileTime.withLock(filepath, async () => {
|
|
|
- order.push(1)
|
|
|
- ready.open()
|
|
|
- await hold.wait
|
|
|
- order.push(2)
|
|
|
- })
|
|
|
+ const hold = yield* Deferred.make<void>()
|
|
|
+ const ready = yield* Deferred.make<void>()
|
|
|
+
|
|
|
+ const one = yield* lock(file, () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ order.push(1)
|
|
|
+ yield* Deferred.succeed(ready, void 0)
|
|
|
+ yield* Deferred.await(hold)
|
|
|
+ order.push(2)
|
|
|
+ }),
|
|
|
+ ).pipe(Effect.forkScoped)
|
|
|
|
|
|
- await ready.wait
|
|
|
+ yield* Deferred.await(ready)
|
|
|
|
|
|
- const op2 = FileTime.withLock(filepath, async () => {
|
|
|
- order.push(3)
|
|
|
- order.push(4)
|
|
|
- })
|
|
|
+ const two = yield* lock(file, () =>
|
|
|
+ Effect.sync(() => {
|
|
|
+ order.push(3)
|
|
|
+ order.push(4)
|
|
|
+ }),
|
|
|
+ ).pipe(Effect.forkScoped)
|
|
|
|
|
|
- hold.open()
|
|
|
+ yield* Deferred.succeed(hold, void 0)
|
|
|
+ yield* Fiber.join(one)
|
|
|
+ yield* Fiber.join(two)
|
|
|
|
|
|
- await Promise.all([op1, op2])
|
|
|
expect(order).toEqual([1, 2, 3, 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 hold = gate()
|
|
|
- const ready = gate()
|
|
|
-
|
|
|
- const op1 = FileTime.withLock(filepath1, async () => {
|
|
|
- started1 = true
|
|
|
- ready.open()
|
|
|
- await hold.wait
|
|
|
- expect(started2).toBe(true)
|
|
|
- })
|
|
|
-
|
|
|
- await ready.wait
|
|
|
-
|
|
|
- const op2 = FileTime.withLock(filepath2, async () => {
|
|
|
- started2 = true
|
|
|
- hold.open()
|
|
|
- })
|
|
|
-
|
|
|
- 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")
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.live("allows concurrent operations on different files", () =>
|
|
|
+ provideTmpdirInstance((dir) =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const onefile = path.join(dir, "file1.txt")
|
|
|
+ const twofile = path.join(dir, "file2.txt")
|
|
|
+ let one = false
|
|
|
+ let two = false
|
|
|
+ const hold = yield* Deferred.make<void>()
|
|
|
+ const ready = yield* Deferred.make<void>()
|
|
|
+
|
|
|
+ const a = yield* lock(onefile, () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ one = true
|
|
|
+ yield* Deferred.succeed(ready, void 0)
|
|
|
+ yield* Deferred.await(hold)
|
|
|
+ expect(two).toBe(true)
|
|
|
}),
|
|
|
- ).rejects.toThrow("Test error")
|
|
|
-
|
|
|
- let executed = false
|
|
|
- await FileTime.withLock(filepath, async () => {
|
|
|
- executed = true
|
|
|
- })
|
|
|
- expect(executed).toBe(true)
|
|
|
- },
|
|
|
- })
|
|
|
- })
|
|
|
+ ).pipe(Effect.forkScoped)
|
|
|
+
|
|
|
+ yield* Deferred.await(ready)
|
|
|
+
|
|
|
+ const b = yield* lock(twofile, () =>
|
|
|
+ Effect.sync(() => {
|
|
|
+ two = true
|
|
|
+ }),
|
|
|
+ ).pipe(Effect.forkScoped)
|
|
|
+
|
|
|
+ yield* Fiber.join(b)
|
|
|
+ yield* Deferred.succeed(hold, void 0)
|
|
|
+ yield* Fiber.join(a)
|
|
|
+
|
|
|
+ expect(one).toBe(true)
|
|
|
+ expect(two).toBe(true)
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.live("releases lock even if function throws", () =>
|
|
|
+ provideTmpdirInstance((dir) =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const file = path.join(dir, "file.txt")
|
|
|
+ const err = yield* fail(lock(file, () => Effect.die(new Error("Test error"))))
|
|
|
+ expect(err.message).toContain("Test error")
|
|
|
+
|
|
|
+ let hit = false
|
|
|
+ yield* lock(file, () =>
|
|
|
+ Effect.sync(() => {
|
|
|
+ hit = true
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ expect(hit).toBe(true)
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ )
|
|
|
})
|
|
|
|
|
|
describe("path normalization", () => {
|
|
|
- test("read with forward slashes, assert with backslashes", async () => {
|
|
|
- await using tmp = await tmpdir()
|
|
|
- const filepath = path.join(tmp.path, "file.txt")
|
|
|
- await fs.writeFile(filepath, "content", "utf-8")
|
|
|
- await touch(filepath, 1_000)
|
|
|
-
|
|
|
- const forwardSlash = filepath.replaceAll("\\", "/")
|
|
|
-
|
|
|
- await Instance.provide({
|
|
|
- directory: tmp.path,
|
|
|
- fn: async () => {
|
|
|
- await FileTime.read(sessionID, forwardSlash)
|
|
|
- // assert with the native backslash path should still work
|
|
|
- await FileTime.assert(sessionID, filepath)
|
|
|
- },
|
|
|
- })
|
|
|
- })
|
|
|
-
|
|
|
- test("read with backslashes, assert with forward slashes", async () => {
|
|
|
- await using tmp = await tmpdir()
|
|
|
- const filepath = path.join(tmp.path, "file.txt")
|
|
|
- await fs.writeFile(filepath, "content", "utf-8")
|
|
|
- await touch(filepath, 1_000)
|
|
|
-
|
|
|
- const forwardSlash = filepath.replaceAll("\\", "/")
|
|
|
-
|
|
|
- await Instance.provide({
|
|
|
- directory: tmp.path,
|
|
|
- fn: async () => {
|
|
|
- await FileTime.read(sessionID, filepath)
|
|
|
- // assert with forward slashes should still work
|
|
|
- await FileTime.assert(sessionID, forwardSlash)
|
|
|
- },
|
|
|
- })
|
|
|
- })
|
|
|
-
|
|
|
- test("get returns timestamp regardless of slash direction", async () => {
|
|
|
- await using tmp = await tmpdir()
|
|
|
- const filepath = path.join(tmp.path, "file.txt")
|
|
|
- await fs.writeFile(filepath, "content", "utf-8")
|
|
|
-
|
|
|
- const forwardSlash = filepath.replaceAll("\\", "/")
|
|
|
-
|
|
|
- await Instance.provide({
|
|
|
- directory: tmp.path,
|
|
|
- fn: async () => {
|
|
|
- await FileTime.read(sessionID, forwardSlash)
|
|
|
- const result = await FileTime.get(sessionID, filepath)
|
|
|
+ it.live("read with forward slashes, assert with backslashes", () =>
|
|
|
+ provideTmpdirInstance((dir) =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const file = path.join(dir, "file.txt")
|
|
|
+ yield* put(file, "content")
|
|
|
+ yield* touch(file, 1_000)
|
|
|
+
|
|
|
+ const forward = file.replaceAll("\\", "/")
|
|
|
+ yield* read(id, forward)
|
|
|
+ yield* check(id, file)
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.live("read with backslashes, assert with forward slashes", () =>
|
|
|
+ provideTmpdirInstance((dir) =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const file = path.join(dir, "file.txt")
|
|
|
+ yield* put(file, "content")
|
|
|
+ yield* touch(file, 1_000)
|
|
|
+
|
|
|
+ const forward = file.replaceAll("\\", "/")
|
|
|
+ yield* read(id, file)
|
|
|
+ yield* check(id, forward)
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.live("get returns timestamp regardless of slash direction", () =>
|
|
|
+ provideTmpdirInstance((dir) =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const file = path.join(dir, "file.txt")
|
|
|
+ yield* put(file, "content")
|
|
|
+
|
|
|
+ const forward = file.replaceAll("\\", "/")
|
|
|
+ yield* read(id, forward)
|
|
|
+
|
|
|
+ const result = yield* get(id, file)
|
|
|
expect(result).toBeInstanceOf(Date)
|
|
|
- },
|
|
|
- })
|
|
|
- })
|
|
|
-
|
|
|
- test("withLock serializes regardless of slash direction", async () => {
|
|
|
- await using tmp = await tmpdir()
|
|
|
- const filepath = path.join(tmp.path, "file.txt")
|
|
|
-
|
|
|
- const forwardSlash = filepath.replaceAll("\\", "/")
|
|
|
-
|
|
|
- await Instance.provide({
|
|
|
- directory: tmp.path,
|
|
|
- fn: async () => {
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.live("withLock serializes regardless of slash direction", () =>
|
|
|
+ provideTmpdirInstance((dir) =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const file = path.join(dir, "file.txt")
|
|
|
+ const forward = file.replaceAll("\\", "/")
|
|
|
const order: number[] = []
|
|
|
- const hold = gate()
|
|
|
- const ready = gate()
|
|
|
-
|
|
|
- const op1 = FileTime.withLock(filepath, async () => {
|
|
|
- order.push(1)
|
|
|
- ready.open()
|
|
|
- await hold.wait
|
|
|
- order.push(2)
|
|
|
- })
|
|
|
+ const hold = yield* Deferred.make<void>()
|
|
|
+ const ready = yield* Deferred.make<void>()
|
|
|
+
|
|
|
+ const one = yield* lock(file, () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ order.push(1)
|
|
|
+ yield* Deferred.succeed(ready, void 0)
|
|
|
+ yield* Deferred.await(hold)
|
|
|
+ order.push(2)
|
|
|
+ }),
|
|
|
+ ).pipe(Effect.forkScoped)
|
|
|
|
|
|
- await ready.wait
|
|
|
+ yield* Deferred.await(ready)
|
|
|
|
|
|
- // Use forward-slash variant -- should still serialize against op1
|
|
|
- const op2 = FileTime.withLock(forwardSlash, async () => {
|
|
|
- order.push(3)
|
|
|
- order.push(4)
|
|
|
- })
|
|
|
+ const two = yield* lock(forward, () =>
|
|
|
+ Effect.sync(() => {
|
|
|
+ order.push(3)
|
|
|
+ order.push(4)
|
|
|
+ }),
|
|
|
+ ).pipe(Effect.forkScoped)
|
|
|
|
|
|
- hold.open()
|
|
|
+ yield* Deferred.succeed(hold, void 0)
|
|
|
+ yield* Fiber.join(one)
|
|
|
+ yield* Fiber.join(two)
|
|
|
|
|
|
- await Promise.all([op1, op2])
|
|
|
expect(order).toEqual([1, 2, 3, 4])
|
|
|
- },
|
|
|
- })
|
|
|
- })
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ )
|
|
|
})
|
|
|
|
|
|
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 touch(filepath, 1_000)
|
|
|
-
|
|
|
- await Instance.provide({
|
|
|
- directory: tmp.path,
|
|
|
- fn: async () => {
|
|
|
- await FileTime.read(sessionID, filepath)
|
|
|
-
|
|
|
- const stats = Filesystem.stat(filepath)
|
|
|
- expect(stats?.mtime).toBeInstanceOf(Date)
|
|
|
- expect(stats!.mtime.getTime()).toBeGreaterThan(0)
|
|
|
-
|
|
|
- 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 touch(filepath, 1_000)
|
|
|
-
|
|
|
- await Instance.provide({
|
|
|
- directory: tmp.path,
|
|
|
- fn: async () => {
|
|
|
- await FileTime.read(sessionID, filepath)
|
|
|
-
|
|
|
- const originalStat = Filesystem.stat(filepath)
|
|
|
-
|
|
|
- await fs.writeFile(filepath, "modified", "utf-8")
|
|
|
- await touch(filepath, 2_000)
|
|
|
-
|
|
|
- const newStat = Filesystem.stat(filepath)
|
|
|
- expect(newStat!.mtime.getTime()).toBeGreaterThan(originalStat!.mtime.getTime())
|
|
|
-
|
|
|
- await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow()
|
|
|
- },
|
|
|
- })
|
|
|
- })
|
|
|
+ it.live("reads file modification time via Filesystem.stat()", () =>
|
|
|
+ provideTmpdirInstance((dir) =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const file = path.join(dir, "file.txt")
|
|
|
+ yield* put(file, "content")
|
|
|
+ yield* touch(file, 1_000)
|
|
|
+
|
|
|
+ yield* read(id, file)
|
|
|
+
|
|
|
+ const stat = Filesystem.stat(file)
|
|
|
+ expect(stat?.mtime).toBeInstanceOf(Date)
|
|
|
+ expect(stat!.mtime.getTime()).toBeGreaterThan(0)
|
|
|
+
|
|
|
+ yield* check(id, file)
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.live("detects modification via stat mtime", () =>
|
|
|
+ provideTmpdirInstance((dir) =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const file = path.join(dir, "file.txt")
|
|
|
+ yield* put(file, "original")
|
|
|
+ yield* touch(file, 1_000)
|
|
|
+
|
|
|
+ yield* read(id, file)
|
|
|
+
|
|
|
+ const first = Filesystem.stat(file)
|
|
|
+
|
|
|
+ yield* put(file, "modified")
|
|
|
+ yield* touch(file, 2_000)
|
|
|
+
|
|
|
+ const second = Filesystem.stat(file)
|
|
|
+ expect(second!.mtime.getTime()).toBeGreaterThan(first!.mtime.getTime())
|
|
|
+
|
|
|
+ yield* fail(check(id, file))
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ )
|
|
|
})
|
|
|
})
|