From 7e098ae03a87267036371d1fe89ff2e8c51666b2 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 26 May 2026 12:51:52 +0200 Subject: [PATCH 1/2] test(cli): stabilize functions dev watcher test Use a fake FileWatcher layer so the unit test no longer depends on native filesystem watcher timing. Co-authored-by: Cursor --- .../dev/functions-dev-runtime.unit.test.ts | 113 +++++++++++------- 1 file changed, 73 insertions(+), 40 deletions(-) diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.unit.test.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.unit.test.ts index 151b541d39..cdff1752b7 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.unit.test.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.unit.test.ts @@ -1,73 +1,106 @@ import { describe, expect, it } from "@effect/vitest"; -import { BunServices } from "@effect/platform-bun"; -import { Duration, Effect, Layer, Stream } from "effect"; -import { mkdtempSync } from "node:fs"; -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { Duration, Effect, Fiber, Layer, Queue, Stream } from "effect"; import { join } from "node:path"; -import { parcelFileWatcherLayer } from "../../../../shared/runtime/parcel-file-watcher.layer.ts"; +import { + FileWatcher, + type FileWatchEvent, +} from "../../../../shared/runtime/file-watcher.service.ts"; import { watchPaths } from "./functions-dev-runtime.ts"; -function makeTempProject(): string { - return mkdtempSync(join(tmpdir(), "supabase-functions-dev-watch-")); +function makeFakeFileWatcher() { + return Effect.gen(function* () { + const watchedPaths = yield* Queue.unbounded(); + const queues = new Map>>(); + + const layer = Layer.succeed( + FileWatcher, + FileWatcher.of({ + watch: (path) => + Stream.unwrap( + Effect.gen(function* () { + const queue = yield* Queue.unbounded>(); + queues.set(path, queue); + yield* Queue.offer(watchedPaths, path); + return Stream.fromQueue(queue); + }), + ), + }), + ); + + const awaitWatch = (expectedPath: string) => + Queue.take(watchedPaths).pipe( + Effect.tap((path) => + Effect.sync(() => { + expect(path).toBe(expectedPath); + }), + ), + ); + + const emit = (path: string, events: ReadonlyArray) => + Effect.gen(function* () { + const queue = queues.get(path); + if (queue === undefined) { + return yield* Effect.die(new Error(`No watcher registered for ${path}`)); + } + yield* Queue.offer(queue, events); + }); + + return { awaitWatch, emit, layer }; + }); } describe("functions dev runtime", () => { it.live("emits when the supabase functions directory appears after dev starts", () => { - const cwd = makeTempProject(); + const cwd = "/tmp/supabase-functions-dev-watch"; return Effect.gen(function* () { + const watcher = yield* makeFakeFileWatcher(); let emitted = false; - yield* Effect.forkChild( - Effect.gen(function* () { - yield* Effect.sleep(Duration.millis(50)); - yield* Effect.tryPromise(() => - mkdir(join(cwd, "supabase", "functions"), { recursive: true }), - ); - }), - ); - - yield* watchPaths([{ path: cwd, names: ["supabase"] }]).pipe( + const fiber = yield* watchPaths([{ path: cwd, names: ["supabase"] }]).pipe( Stream.take(1), Stream.runForEach(() => Effect.sync(() => { emitted = true; }), ), - Effect.timeout(Duration.seconds(5)), + Effect.timeout(Duration.seconds(1)), + Effect.provide(watcher.layer), + Effect.forkChild({ startImmediately: true }), ); + yield* watcher.awaitWatch(cwd); + yield* watcher.emit(cwd, [{ path: join(cwd, "supabase", "functions"), type: "create" }]); + yield* Fiber.join(fiber); + expect(emitted).toBe(true); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(cwd, { recursive: true, force: true }))), - Effect.provide(Layer.mergeAll(BunServices.layer, parcelFileWatcherLayer)), - ); + }); }); it.live("marks config json changes as project config changes", () => { - const cwd = makeTempProject(); + const cwd = "/tmp/supabase-functions-dev-watch"; + const supabaseDir = join(cwd, "supabase"); return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(cwd, "supabase"), { recursive: true })); + const watcher = yield* makeFakeFileWatcher(); - yield* Effect.forkChild( - Effect.gen(function* () { - yield* Effect.sleep(Duration.millis(50)); - yield* Effect.tryPromise(() => - writeFile(join(cwd, "supabase", "config.json"), JSON.stringify({ functions: {} })), - ); - }), + const fiber = yield* watchPaths([ + { path: supabaseDir, names: ["functions", "config.toml", "config.json"] }, + ]).pipe( + Stream.take(1), + Stream.runCollect, + Effect.timeout(Duration.seconds(1)), + Effect.provide(watcher.layer), + Effect.forkChild({ startImmediately: true }), ); - const changes = yield* watchPaths([ - { path: join(cwd, "supabase"), names: ["functions", "config.toml", "config.json"] }, - ]).pipe(Stream.take(1), Stream.runCollect, Effect.timeout(Duration.seconds(5))); + yield* watcher.awaitWatch(supabaseDir); + yield* watcher.emit(supabaseDir, [ + { path: join(supabaseDir, "config.json"), type: "create" }, + ]); + const changes = yield* Fiber.join(fiber); expect(changes.at(0)?.touchesProjectConfig).toBe(true); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(cwd, { recursive: true, force: true }))), - Effect.provide(Layer.mergeAll(BunServices.layer, parcelFileWatcherLayer)), - ); + }); }); }); From 6dfb7212f5d1d7a85547a0d0b288c100feacd060 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 26 May 2026 12:58:49 +0200 Subject: [PATCH 2/2] test(cli): simplify functions dev watcher fake Make the fake FileWatcher setup match the synchronous stateful layer pattern used by nearby tests. Co-authored-by: Cursor --- .../dev/functions-dev-runtime.unit.test.ts | 65 +++++++++---------- 1 file changed, 30 insertions(+), 35 deletions(-) diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.unit.test.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.unit.test.ts index cdff1752b7..ae53560c6b 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.unit.test.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.unit.test.ts @@ -8,45 +8,40 @@ import { import { watchPaths } from "./functions-dev-runtime.ts"; function makeFakeFileWatcher() { - return Effect.gen(function* () { - const watchedPaths = yield* Queue.unbounded(); - const queues = new Map>>(); + const queues = new Map>>(); - const layer = Layer.succeed( - FileWatcher, - FileWatcher.of({ - watch: (path) => - Stream.unwrap( - Effect.gen(function* () { - const queue = yield* Queue.unbounded>(); - queues.set(path, queue); - yield* Queue.offer(watchedPaths, path); - return Stream.fromQueue(queue); - }), - ), - }), - ); - - const awaitWatch = (expectedPath: string) => - Queue.take(watchedPaths).pipe( - Effect.tap((path) => + const layer = Layer.succeed( + FileWatcher, + FileWatcher.of({ + watch: (path) => + Stream.callback>((queue) => Effect.sync(() => { - expect(path).toBe(expectedPath); + queues.set(path, queue); }), ), - ); - - const emit = (path: string, events: ReadonlyArray) => - Effect.gen(function* () { - const queue = queues.get(path); - if (queue === undefined) { - return yield* Effect.die(new Error(`No watcher registered for ${path}`)); - } - yield* Queue.offer(queue, events); - }); + }), + ); - return { awaitWatch, emit, layer }; + const awaitWatch = Effect.fnUntraced(function* (expectedPath: string) { + for (let attempt = 0; attempt < 50; attempt++) { + if (queues.has(expectedPath)) { + return; + } + yield* Effect.sleep("1 millis"); + } + throw new Error(`No watcher registered for ${expectedPath}`); }); + + const emit = (path: string, events: ReadonlyArray) => + Effect.sync(() => { + const queue = queues.get(path); + if (queue === undefined) { + throw new Error(`No watcher registered for ${path}`); + } + Queue.offerUnsafe(queue, events); + }); + + return { awaitWatch, emit, layer }; } describe("functions dev runtime", () => { @@ -54,7 +49,7 @@ describe("functions dev runtime", () => { const cwd = "/tmp/supabase-functions-dev-watch"; return Effect.gen(function* () { - const watcher = yield* makeFakeFileWatcher(); + const watcher = makeFakeFileWatcher(); let emitted = false; const fiber = yield* watchPaths([{ path: cwd, names: ["supabase"] }]).pipe( @@ -82,7 +77,7 @@ describe("functions dev runtime", () => { const supabaseDir = join(cwd, "supabase"); return Effect.gen(function* () { - const watcher = yield* makeFakeFileWatcher(); + const watcher = makeFakeFileWatcher(); const fiber = yield* watchPaths([ { path: supabaseDir, names: ["functions", "config.toml", "config.json"] },