From dcba50476b9d95b60c8293ac3698af854110fdb2 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:29:16 +0530 Subject: [PATCH 1/2] refactor(cli): cover `functions delete`, `list`, `new` with effect lint (CLI-2459) --- .oxlintrc.effect.json | 4 + .../functions/delete/delete.live.test.ts | 120 +++++----- .../commands/functions/list/list.format.ts | 18 +- .../functions/list/list.format.unit.test.ts | 5 + .../functions/list/list.integration.test.ts | 16 +- .../commands/functions/list/list.live.test.ts | 25 ++- .../src/commands/functions/new/new.handler.ts | 22 +- .../functions/new/new.integration.test.ts | 209 +++++++++--------- 8 files changed, 218 insertions(+), 201 deletions(-) diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index 62be7614b2..ad01e20bd0 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -15,6 +15,10 @@ "!apps/cli/src/commands/encryption/**", "!apps/cli/src/commands/experimental/**", "!apps/cli/src/commands/feedback/**", + "!apps/cli/src/commands/functions/functions.command.ts", + "!apps/cli/src/commands/functions/delete/**", + "!apps/cli/src/commands/functions/list/**", + "!apps/cli/src/commands/functions/new/**", "!apps/cli/src/commands/init/**", "!apps/cli/src/commands/issue/**", "!apps/cli/src/commands/link/**", diff --git a/apps/cli/src/commands/functions/delete/delete.live.test.ts b/apps/cli/src/commands/functions/delete/delete.live.test.ts index fe7068293e..5c18fac981 100644 --- a/apps/cli/src/commands/functions/delete/delete.live.test.ts +++ b/apps/cli/src/commands/functions/delete/delete.live.test.ts @@ -1,58 +1,76 @@ import { randomUUID } from "node:crypto"; -import { mkdir, writeFile } from "node:fs/promises"; + +import { BunServices } from "@effect/platform-bun"; +import { Cause, Data, Effect, Exit, FileSystem, Path } from "effect"; import { expect } from "vitest"; -import { test, throwWithCleanup } from "../../../../tests/helpers/live.ts"; - -async function cleanupFunction( - cli: (args: string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>, - slug: string, - ref: string, -): Promise { - const deleted = await cli(["functions", "delete", slug, "--project-ref", ref]); - if ( - deleted.exitCode !== 0 && - !/not found|does not exist/i.test(`${deleted.stdout}\n${deleted.stderr}`) - ) { - throw new Error(`functions delete cleanup failed:\n${deleted.stdout}\n${deleted.stderr}`); - } +import { type LiveFixtures, test, throwWithCleanup } from "../../../../tests/helpers/live.ts"; + +type LiveCliEffect = LiveFixtures["cliEffect"]; + +class FunctionsDeleteLiveError extends Data.TaggedError("FunctionsDeleteLiveError")<{ + readonly message: string; +}> {} + +function cleanupFunction(cliEffect: LiveCliEffect, slug: string, ref: string) { + return Effect.gen(function* () { + const deleted = yield* cliEffect(["functions", "delete", slug, "--project-ref", ref]); + if ( + deleted.exitCode !== 0 && + !/not found|does not exist/i.test(`${deleted.stdout}\n${deleted.stderr}`) + ) { + return yield* new FunctionsDeleteLiveError({ + message: `functions delete cleanup failed:\n${deleted.stdout}\n${deleted.stderr}`, + }); + } + }); } -test("deletes a deployed function", async ({ cli, project, workspace }) => { - const slug = `cli-e2e-delete-${randomUUID().slice(0, 8)}`; - const directory = `${workspace.path}/supabase/functions/${slug}`; - await mkdir(directory, { recursive: true }); - await writeFile(`${directory}/index.ts`, "Deno.serve(() => Response.json({ ok: true }));\n"); - await writeFile(`${directory}/deno.json`, '{\n "imports": {}\n}\n'); - - let targetError: unknown; - let cleanupError: unknown; - try { - const deployed = await cli([ - "functions", - "deploy", - slug, - "--project-ref", - project.ref, - "--use-api", - ]); - if (deployed.exitCode !== 0) { - throw new Error( - `functions deploy setup failed (exit ${deployed.exitCode})\nstdout:\n${deployed.stdout}\nstderr:\n${deployed.stderr}`, +test("deletes a deployed function", ({ cliEffect, project, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const slug = `cli-e2e-delete-${randomUUID().slice(0, 8)}`; + const directory = path.join(workspace.path, "supabase", "functions", slug); + yield* fs.makeDirectory(directory, { recursive: true }); + yield* fs.writeFileString( + path.join(directory, "index.ts"), + "Deno.serve(() => Response.json({ ok: true }));\n", ); - } + yield* fs.writeFileString(path.join(directory, "deno.json"), '{\n "imports": {}\n}\n'); - const result = await cli(["functions", "delete", slug, "--project-ref", project.ref]); - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).toContain("Deleted Function"); - } catch (error) { - targetError = error; - } finally { - try { - await cleanupFunction(cli, slug, project.ref); - } catch (error) { - cleanupError = error; - } - } - throwWithCleanup(targetError, cleanupError === undefined ? [] : [cleanupError]); -}); + const target = Effect.gen(function* () { + const deployed = yield* cliEffect([ + "functions", + "deploy", + slug, + "--project-ref", + project.ref, + "--use-api", + ]); + if (deployed.exitCode !== 0) { + return yield* new FunctionsDeleteLiveError({ + message: `functions deploy setup failed (exit ${deployed.exitCode})\nstdout:\n${deployed.stdout}\nstderr:\n${deployed.stderr}`, + }); + } + + const result = yield* cliEffect([ + "functions", + "delete", + slug, + "--project-ref", + project.ref, + ]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("Deleted Function"); + }); + + const targetExit = yield* Effect.exit(target); + const cleanupExit = yield* Effect.exit(cleanupFunction(cliEffect, slug, project.ref)); + return { + targetError: Exit.isFailure(targetExit) ? Cause.squash(targetExit.cause) : undefined, + cleanupErrors: Exit.isFailure(cleanupExit) ? [Cause.squash(cleanupExit.cause)] : [], + }; + }).pipe(Effect.provide(BunServices.layer)), + ).then(({ targetError, cleanupErrors }) => throwWithCleanup(targetError, cleanupErrors))); diff --git a/apps/cli/src/commands/functions/list/list.format.ts b/apps/cli/src/commands/functions/list/list.format.ts index 893eefe30b..41b5998611 100644 --- a/apps/cli/src/commands/functions/list/list.format.ts +++ b/apps/cli/src/commands/functions/list/list.format.ts @@ -1,16 +1,16 @@ +import { DateTime, Option } from "effect"; + import { renderGlamourTable } from "../../../output/glamour-table.ts"; import type { Functions } from "./list.encoders.ts"; export function formatUnixMilliTimestamp(value: number): string { - const date = new Date(value); - const parts = [ - date.getUTCFullYear(), - date.getUTCMonth() + 1, - date.getUTCDate(), - date.getUTCHours(), - date.getUTCMinutes(), - date.getUTCSeconds(), - ]; + const parts = DateTime.make(value).pipe( + Option.map((dateTime) => { + const utc = DateTime.toPartsUtc(dateTime); + return [utc.year, utc.month, utc.day, utc.hour, utc.minute, utc.second]; + }), + Option.getOrElse(() => [NaN, NaN, NaN, NaN, NaN, NaN]), + ); const [year, ...rest] = parts.map((part) => part.toString().padStart(2, "0")); return `${year}-${rest[0]}-${rest[1]} ${rest[2]}:${rest[3]}:${rest[4]}`; } diff --git a/apps/cli/src/commands/functions/list/list.format.unit.test.ts b/apps/cli/src/commands/functions/list/list.format.unit.test.ts index fc76722b38..1e6ee00155 100644 --- a/apps/cli/src/commands/functions/list/list.format.unit.test.ts +++ b/apps/cli/src/commands/functions/list/list.format.unit.test.ts @@ -10,4 +10,9 @@ describe("formatUnixMilliTimestamp", () => { it("pads single-digit UTC components", () => { expect(formatUnixMilliTimestamp(Date.UTC(2024, 0, 2, 3, 4, 5))).toBe("2024-01-02 03:04:05"); }); + + it("renders invalid millis the way native Date getters do", () => { + expect(formatUnixMilliTimestamp(Number.NaN)).toBe("NaN-NaN-NaN NaN:NaN:NaN"); + expect(formatUnixMilliTimestamp(1e20)).toBe("NaN-NaN-NaN NaN:NaN:NaN"); + }); }); diff --git a/apps/cli/src/commands/functions/list/list.integration.test.ts b/apps/cli/src/commands/functions/list/list.integration.test.ts index b1a35a2ca0..861028dc9f 100644 --- a/apps/cli/src/commands/functions/list/list.integration.test.ts +++ b/apps/cli/src/commands/functions/list/list.integration.test.ts @@ -1,6 +1,6 @@ import type { V1ListAllFunctionsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer, Option } from "effect"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { @@ -204,7 +204,7 @@ describe("functions list integration", () => { const exit = yield* Effect.exit(functionsList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Cause.pretty(exit.cause); expect(json).toContain("FunctionsEnvNotSupportedError"); expect(json).toContain("--output env flag is not supported"); } @@ -270,7 +270,7 @@ describe("functions list integration", () => { const exit = yield* Effect.exit(functionsList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Cause.pretty(exit.cause); expect(json).toContain("FunctionsListUnexpectedStatusError"); expect(json).toContain("unexpected list functions status 503"); } @@ -283,7 +283,7 @@ describe("functions list integration", () => { const exit = yield* Effect.exit(functionsList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Cause.pretty(exit.cause); expect(json).toContain("FunctionsListNetworkError"); expect(json).toContain("failed to list functions"); } @@ -310,7 +310,7 @@ describe("functions list integration", () => { const exit = yield* Effect.exit(functionsList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Cause.pretty(exit.cause); expect(json).toContain("FunctionsListNetworkError"); expect(json).toContain("failed to list functions:"); } @@ -337,7 +337,7 @@ describe("functions list integration", () => { const exit = yield* Effect.exit(functionsList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Cause.pretty(exit.cause); expect(json).toContain("FunctionsListUnexpectedStatusError"); expect(json).toContain("unexpected list functions status 200"); expect(json).toContain("Hello World"); @@ -351,7 +351,7 @@ describe("functions list integration", () => { const exit = yield* Effect.exit(functionsList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Cause.pretty(exit.cause); expect(json).toContain("FunctionsListNetworkError"); expect(json).toContain("failed to list functions"); } @@ -364,7 +364,7 @@ describe("functions list integration", () => { const exit = yield* Effect.exit(functionsList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Cause.pretty(exit.cause); expect(json).toContain("FunctionsListNetworkError"); expect(json).toContain("failed to list functions"); } diff --git a/apps/cli/src/commands/functions/list/list.live.test.ts b/apps/cli/src/commands/functions/list/list.live.test.ts index fed0af3fb4..b6f683bc6a 100644 --- a/apps/cli/src/commands/functions/list/list.live.test.ts +++ b/apps/cli/src/commands/functions/list/list.live.test.ts @@ -1,3 +1,4 @@ +import { Effect } from "effect"; import { describe, expect } from "vitest"; import { test } from "../../../../tests/helpers/live.ts"; @@ -10,15 +11,19 @@ describe("supabase functions list (live)", () => { test( "lists edge functions for the project", { timeout: LIVE_TIMEOUT_MS }, - async ({ cli, project }) => { - const { exitCode, stdout, stderr } = await cli([ - "functions", - "list", - "--project-ref", - project.ref, - ]); - expect(exitCode, stderr).toBe(0); - expect(stdout, stderr).toMatch(/ID\s+\|\s+NAME\s+\|\s+SLUG\s+\|\s+STATUS/); - }, + ({ cliEffect, project, signal }) => + Effect.runPromise( + Effect.gen(function* () { + const { exitCode, stdout, stderr } = yield* cliEffect([ + "functions", + "list", + "--project-ref", + project.ref, + ]); + expect(exitCode, stderr).toBe(0); + expect(stdout, stderr).toMatch(/ID\s+\|\s+NAME\s+\|\s+SLUG\s+\|\s+STATUS/); + }), + { signal }, + ), ); }); diff --git a/apps/cli/src/commands/functions/new/new.handler.ts b/apps/cli/src/commands/functions/new/new.handler.ts index ebaa08b690..54616be9dc 100644 --- a/apps/cli/src/commands/functions/new/new.handler.ts +++ b/apps/cli/src/commands/functions/new/new.handler.ts @@ -174,12 +174,10 @@ export const functionsNew = Effect.fn("functions.new")(function* (flags: Functio const invalidSlugMessage = validateFunctionSlugMessage(flags.functionName); if (invalidSlugMessage !== undefined) { - return yield* Effect.fail( - new FunctionsNewInvalidSlugError({ - message: invalidSlugMessage, - detail: invalidFunctionSlugDetail, - }), - ); + return yield* new FunctionsNewInvalidSlugError({ + message: invalidSlugMessage, + detail: invalidFunctionSlugDetail, + }); } const existingSlugs = yield* listExistingFunctionSlugs(cliSettings.workdir); @@ -205,13 +203,11 @@ export const functionsNew = Effect.fn("functions.new")(function* (flags: Functio .exists(entrypointPath) .pipe(Effect.orElseSucceed(() => false)); if (entrypointExists) { - return yield* Effect.fail( - new FunctionsNewFileExistsError({ - path: relEntrypoint, - message: "failed to create entrypoint: file already exists", - suggestion: `Remove ${relEntrypoint} or use a different Function name.`, - }), - ); + return yield* new FunctionsNewFileExistsError({ + path: relEntrypoint, + message: "failed to create entrypoint: file already exists", + suggestion: `Remove ${relEntrypoint} or use a different Function name.`, + }); } const templateInputs = yield* resolveTemplateInputs(cliSettings, flags.functionName); diff --git a/apps/cli/src/commands/functions/new/new.integration.test.ts b/apps/cli/src/commands/functions/new/new.integration.test.ts index 92bc7a1dc9..f3ae141ead 100644 --- a/apps/cli/src/commands/functions/new/new.integration.test.ts +++ b/apps/cli/src/commands/functions/new/new.integration.test.ts @@ -1,15 +1,12 @@ -import { existsSync, readFileSync } from "node:fs"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; - import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Path } from "effect"; import { mockCommandSettings, mockTelemetryStateTracked, useTempWorkdir, + withEnvVar, } from "../../../../tests/helpers/command-mocks.ts"; import { mockOutput, mockStdin, mockTty } from "../../../../tests/helpers/mocks.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; @@ -78,15 +75,13 @@ describe("functions new integration", () => { it.live("creates the default apikey scaffold, config snippet, and optional files", () => { const { layer, out, telemetry, workdir } = setup(); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; yield* functionsNew({ functionName: "hello-world", auth: "apikey" }); - const functionDir = join(workdir, "supabase", "functions", "hello-world"); - const entrypoint = yield* Effect.tryPromise(() => - readFile(join(functionDir, "index.ts"), "utf8"), - ); - const config = yield* Effect.tryPromise(() => - readFile(join(workdir, "supabase", "config.toml"), "utf8"), - ); + const functionDir = path.join(workdir, "supabase", "functions", "hello-world"); + const entrypoint = yield* fs.readFileString(path.join(functionDir, "index.ts")); + const config = yield* fs.readFileString(path.join(workdir, "supabase", "config.toml")); expect(entrypoint).toContain('withSupabase({ auth: ["publishable", "secret"] }'); expect(entrypoint).toContain("--header 'apiKey: sb_publishable_"); @@ -94,12 +89,14 @@ describe("functions new integration", () => { expect(config).toContain("[functions.hello-world]"); expect(config).toContain("verify_jwt = false"); expect(config).toContain('import_map = "./functions/hello-world/deno.json"'); - expect(readFileSync(join(functionDir, "deno.json"), "utf8")).toBe(FUNCTIONS_NEW_DENO_JSON); - expect(readFileSync(join(functionDir, ".npmrc"), "utf8")).toBe(FUNCTIONS_NEW_NPMRC); + expect(yield* fs.readFileString(path.join(functionDir, "deno.json"))).toBe( + FUNCTIONS_NEW_DENO_JSON, + ); + expect(yield* fs.readFileString(path.join(functionDir, ".npmrc"))).toBe(FUNCTIONS_NEW_NPMRC); expect(out.stdoutText).toContain("Created new Function at "); - expect(out.stdoutText).toContain(join("supabase", "functions", "hello-world")); + expect(out.stdoutText).toContain(path.join("supabase", "functions", "hello-world")); expect(out.stderrText).toContain("Generate VS Code settings for Deno? [Y/n]"); - expect(existsSync(join(workdir, ".vscode", "settings.json"))).toBe(true); + expect(yield* fs.exists(path.join(workdir, ".vscode", "settings.json"))).toBe(true); expect(telemetry.flushed).toBe(true); }).pipe(Effect.provide(layer)); }); @@ -107,13 +104,13 @@ describe("functions new integration", () => { it.live("uses the none-auth scaffold and keeps verify_jwt disabled", () => { const { layer, workdir } = setup(); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; yield* functionsNew({ functionName: "public-fn", auth: "none" }); - const entrypoint = yield* Effect.tryPromise(() => - readFile(join(workdir, "supabase", "functions", "public-fn", "index.ts"), "utf8"), - ); - const config = yield* Effect.tryPromise(() => - readFile(join(workdir, "supabase", "config.toml"), "utf8"), + const entrypoint = yield* fs.readFileString( + path.join(workdir, "supabase", "functions", "public-fn", "index.ts"), ); + const config = yield* fs.readFileString(path.join(workdir, "supabase", "config.toml")); expect(entrypoint).toContain('withSupabase({ auth: "none" }'); expect(entrypoint).toContain("--header 'Content-Type: application/json'"); expect(config).toContain("verify_jwt = false"); @@ -123,13 +120,13 @@ describe("functions new integration", () => { it.live("uses the user-auth scaffold and enables verify_jwt", () => { const { layer, workdir } = setup(); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; yield* functionsNew({ functionName: "user-fn", auth: "user" }); - const entrypoint = yield* Effect.tryPromise(() => - readFile(join(workdir, "supabase", "functions", "user-fn", "index.ts"), "utf8"), - ); - const config = yield* Effect.tryPromise(() => - readFile(join(workdir, "supabase", "config.toml"), "utf8"), + const entrypoint = yield* fs.readFileString( + path.join(workdir, "supabase", "functions", "user-fn", "index.ts"), ); + const config = yield* fs.readFileString(path.join(workdir, "supabase", "config.toml")); expect(entrypoint).toContain('withSupabase({ auth: "user" }'); expect(entrypoint).toContain("--header 'Authorization: Bearer '"); expect(config).toContain("verify_jwt = true"); @@ -139,27 +136,26 @@ describe("functions new integration", () => { it.live("uses api.port and auth.publishable_key from config.toml when present", () => { const { layer, workdir } = setup(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - mkdir(join(workdir, "supabase"), { recursive: true }).then(() => - writeFile( - join(workdir, "supabase", "config.toml"), - [ - 'project_id = "test-project"', - "", - "[api]", - "port = 54310", - "", - "[auth]", - 'publishable_key = "sb_publishable_custom"', - "", - ].join("\n"), - ), - ), + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(workdir, "supabase", "config.toml"), + [ + 'project_id = "test-project"', + "", + "[api]", + "port = 54310", + "", + "[auth]", + 'publishable_key = "sb_publishable_custom"', + "", + ].join("\n"), ); yield* functionsNew({ functionName: "customized", auth: "apikey" }); - const entrypoint = yield* Effect.tryPromise(() => - readFile(join(workdir, "supabase", "functions", "customized", "index.ts"), "utf8"), + const entrypoint = yield* fs.readFileString( + path.join(workdir, "supabase", "functions", "customized", "index.ts"), ); expect(entrypoint).toContain("http://127.0.0.1:54310/functions/v1/customized"); expect(entrypoint).toContain("--header 'apiKey: sb_publishable_custom'"); @@ -169,16 +165,13 @@ describe("functions new integration", () => { it.live("appends config even when the existing config.toml is malformed", () => { const { layer, workdir } = setup(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - mkdir(join(workdir, "supabase"), { recursive: true }).then(() => - writeFile(join(workdir, "supabase", "config.toml"), "not valid toml ]["), - ), - ); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(workdir, "supabase", "config.toml"), "not valid toml ]["); yield* functionsNew({ functionName: "after-bad-config", auth: "none" }); - const config = yield* Effect.tryPromise(() => - readFile(join(workdir, "supabase", "config.toml"), "utf8"), - ); + const config = yield* fs.readFileString(path.join(workdir, "supabase", "config.toml")); expect(config).toContain("not valid toml ]["); expect(config).toContain("[functions.after-bad-config]"); }).pipe(Effect.provide(layer)); @@ -187,19 +180,16 @@ describe("functions new integration", () => { it.live("warns and skips the config append when the function is already declared", () => { const { layer, out, workdir } = setup(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - mkdir(join(workdir, "supabase"), { recursive: true }).then(() => - writeFile( - join(workdir, "supabase", "config.toml"), - ["[functions.hello-world]", "enabled = true", ""].join("\n"), - ), - ), + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(workdir, "supabase", "config.toml"), + ["[functions.hello-world]", "enabled = true", ""].join("\n"), ); yield* functionsNew({ functionName: "hello-world", auth: "apikey" }); - const config = yield* Effect.tryPromise(() => - readFile(join(workdir, "supabase", "config.toml"), "utf8"), - ); + const config = yield* fs.readFileString(path.join(workdir, "supabase", "config.toml")); expect(config.match(/\[functions\.hello-world\]/g) ?? []).toHaveLength(1); expect(out.stderrText).toContain("[functions.hello-world] is already declared in "); }).pipe(Effect.provide(layer)); @@ -208,47 +198,38 @@ describe("functions new integration", () => { it.live("does not auto-generate IDE files when another function already exists", () => { const { layer, workdir } = setup(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - mkdir(join(workdir, "supabase", "functions", "existing"), { recursive: true }).then(() => - writeFile( - join(workdir, "supabase", "functions", "existing", "index.ts"), - "// existing\n", - ), - ), - ); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const existingDir = path.join(workdir, "supabase", "functions", "existing"); + yield* fs.makeDirectory(existingDir, { recursive: true }); + yield* fs.writeFileString(path.join(existingDir, "index.ts"), "// existing\n"); yield* functionsNew({ functionName: "second-fn", auth: "apikey" }); - expect(existsSync(join(workdir, ".vscode", "settings.json"))).toBe(false); - expect(existsSync(join(workdir, ".idea", "deno.xml"))).toBe(false); + expect(yield* fs.exists(path.join(workdir, ".vscode", "settings.json"))).toBe(false); + expect(yield* fs.exists(path.join(workdir, ".idea", "deno.xml"))).toBe(false); }).pipe(Effect.provide(layer)); }); it.live("supports --yes by echoing the VS Code prompt and generating settings", () => { const { layer, out, workdir } = setup({ yes: true }); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; yield* functionsNew({ functionName: "with-yes", auth: "apikey" }); expect(out.stderrText).toContain("Generate VS Code settings for Deno? [Y/n] y"); - expect(existsSync(join(workdir, ".vscode", "settings.json"))).toBe(true); + expect(yield* fs.exists(path.join(workdir, ".vscode", "settings.json"))).toBe(true); }).pipe(Effect.provide(layer)); }); it.live("SUPABASE_YES=1 in the environment echoes the VS Code prompt and writes settings", () => { - const prev = process.env["SUPABASE_YES"]; - process.env["SUPABASE_YES"] = "1"; const { layer, out, workdir } = setup({ yes: false }); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; yield* functionsNew({ functionName: "with-env-yes", auth: "apikey" }); expect(out.stderrText).toContain("Generate VS Code settings for Deno? [Y/n] y"); - expect(existsSync(join(workdir, ".vscode", "settings.json"))).toBe(true); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_YES"]; - else process.env["SUPABASE_YES"] = prev; - }), - ), - Effect.provide(layer), - ); + expect(yield* fs.exists(path.join(workdir, ".vscode", "settings.json"))).toBe(true); + }).pipe((effect) => withEnvVar("SUPABASE_YES", "1", effect), Effect.provide(layer)); }); it.live("piped `n` then `y` declines VS Code and writes IntelliJ settings (Go parity)", () => { @@ -256,11 +237,13 @@ describe("functions new integration", () => { // IntelliJ=yes. const { layer, out, workdir } = setup({ stdinIsTty: false, stdinInput: "n\ny\n" }); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; yield* functionsNew({ functionName: "piped-idea", auth: "apikey" }); expect(out.stderrText).toContain("Generate VS Code settings for Deno? [Y/n] n"); expect(out.stderrText).toContain("Generate IntelliJ IDEA settings for Deno? [y/N] y"); - expect(existsSync(join(workdir, ".vscode", "settings.json"))).toBe(false); - expect(existsSync(join(workdir, ".idea", "deno.xml"))).toBe(true); + expect(yield* fs.exists(path.join(workdir, ".vscode", "settings.json"))).toBe(false); + expect(yield* fs.exists(path.join(workdir, ".idea", "deno.xml"))).toBe(true); }).pipe(Effect.provide(layer)); }); @@ -271,9 +254,11 @@ describe("functions new integration", () => { promptConfirmResponses: [false, true], }); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; yield* functionsNew({ functionName: "idea-fn", auth: "apikey" }); - expect(existsSync(join(workdir, ".vscode", "settings.json"))).toBe(false); - expect(existsSync(join(workdir, ".idea", "deno.xml"))).toBe(true); + expect(yield* fs.exists(path.join(workdir, ".vscode", "settings.json"))).toBe(false); + expect(yield* fs.exists(path.join(workdir, ".idea", "deno.xml"))).toBe(true); expect(out.stdoutText).toContain("Generated IntelliJ settings in .idea/deno.xml."); }).pipe(Effect.provide(layer)); }); @@ -281,27 +266,30 @@ describe("functions new integration", () => { it.live("stays payload-only in json mode without writing IDE files", () => { const { layer, out, workdir } = setup({ format: "json" }); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; yield* functionsNew({ functionName: "json-fn", auth: "apikey" }); const success = out.messages.find((message) => message.type === "success"); expect(success?.data).toMatchObject({ - path: join("supabase", "functions", "json-fn"), + path: path.join("supabase", "functions", "json-fn"), function_name: "json-fn", auth: "apikey", }); expect(out.stdoutText).toBe(""); expect(out.stderrText).not.toContain("Generate VS Code settings"); - expect(existsSync(join(workdir, ".vscode", "settings.json"))).toBe(false); - expect(existsSync(join(workdir, ".idea", "deno.xml"))).toBe(false); + expect(yield* fs.exists(path.join(workdir, ".vscode", "settings.json"))).toBe(false); + expect(yield* fs.exists(path.join(workdir, ".idea", "deno.xml"))).toBe(false); }).pipe(Effect.provide(layer)); }); it.live("emits structured success in stream-json mode", () => { const { layer, out } = setup({ format: "stream-json" }); return Effect.gen(function* () { + const path = yield* Path.Path; yield* functionsNew({ functionName: "stream-fn", auth: "user" }); const success = out.messages.find((message) => message.type === "success"); expect(success?.data).toMatchObject({ - path: join("supabase", "functions", "stream-fn"), + path: path.join("supabase", "functions", "stream-fn"), auth: "user", }); }).pipe(Effect.provide(layer)); @@ -319,11 +307,11 @@ describe("functions new integration", () => { it.live("fails when the entrypoint already exists", () => { const { layer, workdir } = setup(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - mkdir(join(workdir, "supabase", "functions", "dupe"), { recursive: true }).then(() => - writeFile(join(workdir, "supabase", "functions", "dupe", "index.ts"), "// existing\n"), - ), - ); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dupeDir = path.join(workdir, "supabase", "functions", "dupe"); + yield* fs.makeDirectory(dupeDir, { recursive: true }); + yield* fs.writeFileString(path.join(dupeDir, "index.ts"), "// existing\n"); const exit = yield* Effect.exit(functionsNew({ functionName: "dupe", auth: "apikey" })); expect(exitTag(exit)).toBe("FunctionsNewFileExistsError"); }).pipe(Effect.provide(layer)); @@ -332,10 +320,10 @@ describe("functions new integration", () => { it.live("fails with a write error when config.toml cannot be appended", () => { const { layer, telemetry, workdir } = setup(); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; // A directory at the config.toml path makes the append write fail (EISDIR). - yield* Effect.tryPromise(() => - mkdir(join(workdir, "supabase", "config.toml"), { recursive: true }), - ); + yield* fs.makeDirectory(path.join(workdir, "supabase", "config.toml"), { recursive: true }); const exit = yield* Effect.exit(functionsNew({ functionName: "write-fail", auth: "apikey" })); expect(exitTag(exit)).toBe("FunctionsNewWriteError"); expect(telemetry.flushed).toBe(true); @@ -344,20 +332,21 @@ describe("functions new integration", () => { it.live( "fails without scaffolding anything when --workdir names a directory that does not exist at all", - () => { - const badWorkdir = join(tempRoot.current, "does-not-exist"); - const { layer, telemetry } = setup({ workdir: badWorkdir, explicitWorkdir: true }); - return Effect.gen(function* () { + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const badWorkdir = path.join(tempRoot.current, "does-not-exist"); + const { layer, telemetry } = setup({ workdir: badWorkdir, explicitWorkdir: true }); const exit = yield* Effect.exit( functionsNew({ functionName: "hello-world", auth: "apikey" }), - ); + ).pipe(Effect.provide(layer)); expect(exitTag(exit)).toBe("FunctionsNewWorkdirError"); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit)).toContain("failed to change workdir: chdir"); + expect(Cause.pretty(exit.cause)).toContain("failed to change workdir: chdir"); } - expect(existsSync(join(badWorkdir, "supabase"))).toBe(false); + expect(yield* fs.exists(path.join(badWorkdir, "supabase"))).toBe(false); expect(telemetry.flushed).toBe(true); - }).pipe(Effect.provide(layer)); - }, + }).pipe(Effect.provide(BunServices.layer)), ); }); From c1fbb8dd6a438a3aeaa01a95fbcd43367bfbfe57 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Tue, 22 Sep 2026 01:52:35 +0530 Subject: [PATCH 2/2] test(cli): apply functions lint coverage review nits --- .../commands/functions/list/list.format.ts | 10 ++--- .../functions/list/list.integration.test.ts | 44 +++++++++---------- .../functions/new/new.integration.test.ts | 2 +- 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/apps/cli/src/commands/functions/list/list.format.ts b/apps/cli/src/commands/functions/list/list.format.ts index 41b5998611..235807f979 100644 --- a/apps/cli/src/commands/functions/list/list.format.ts +++ b/apps/cli/src/commands/functions/list/list.format.ts @@ -3,16 +3,16 @@ import { DateTime, Option } from "effect"; import { renderGlamourTable } from "../../../output/glamour-table.ts"; import type { Functions } from "./list.encoders.ts"; +const pad2 = (value: number): string => value.toString().padStart(2, "0"); + export function formatUnixMilliTimestamp(value: number): string { - const parts = DateTime.make(value).pipe( + return DateTime.make(value).pipe( Option.map((dateTime) => { const utc = DateTime.toPartsUtc(dateTime); - return [utc.year, utc.month, utc.day, utc.hour, utc.minute, utc.second]; + return `${pad2(utc.year)}-${pad2(utc.month)}-${pad2(utc.day)} ${pad2(utc.hour)}:${pad2(utc.minute)}:${pad2(utc.second)}`; }), - Option.getOrElse(() => [NaN, NaN, NaN, NaN, NaN, NaN]), + Option.getOrElse(() => "NaN-NaN-NaN NaN:NaN:NaN"), ); - const [year, ...rest] = parts.map((part) => part.toString().padStart(2, "0")); - return `${year}-${rest[0]}-${rest[1]} ${rest[2]}:${rest[3]}:${rest[4]}`; } export function renderFunctionsTable(functions: Functions): string { diff --git a/apps/cli/src/commands/functions/list/list.integration.test.ts b/apps/cli/src/commands/functions/list/list.integration.test.ts index 861028dc9f..6b9e887992 100644 --- a/apps/cli/src/commands/functions/list/list.integration.test.ts +++ b/apps/cli/src/commands/functions/list/list.integration.test.ts @@ -204,9 +204,9 @@ describe("functions list integration", () => { const exit = yield* Effect.exit(functionsList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = Cause.pretty(exit.cause); - expect(json).toContain("FunctionsEnvNotSupportedError"); - expect(json).toContain("--output env flag is not supported"); + const causeText = Cause.pretty(exit.cause); + expect(causeText).toContain("FunctionsEnvNotSupportedError"); + expect(causeText).toContain("--output env flag is not supported"); } }).pipe(Effect.provide(layer)); }); @@ -270,9 +270,9 @@ describe("functions list integration", () => { const exit = yield* Effect.exit(functionsList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = Cause.pretty(exit.cause); - expect(json).toContain("FunctionsListUnexpectedStatusError"); - expect(json).toContain("unexpected list functions status 503"); + const causeText = Cause.pretty(exit.cause); + expect(causeText).toContain("FunctionsListUnexpectedStatusError"); + expect(causeText).toContain("unexpected list functions status 503"); } }).pipe(Effect.provide(layer)); }); @@ -283,9 +283,9 @@ describe("functions list integration", () => { const exit = yield* Effect.exit(functionsList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = Cause.pretty(exit.cause); - expect(json).toContain("FunctionsListNetworkError"); - expect(json).toContain("failed to list functions"); + const causeText = Cause.pretty(exit.cause); + expect(causeText).toContain("FunctionsListNetworkError"); + expect(causeText).toContain("failed to list functions"); } }).pipe(Effect.provide(layer)); }); @@ -310,9 +310,9 @@ describe("functions list integration", () => { const exit = yield* Effect.exit(functionsList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = Cause.pretty(exit.cause); - expect(json).toContain("FunctionsListNetworkError"); - expect(json).toContain("failed to list functions:"); + const causeText = Cause.pretty(exit.cause); + expect(causeText).toContain("FunctionsListNetworkError"); + expect(causeText).toContain("failed to list functions:"); } }).pipe(Effect.provide(layer)); }); @@ -337,10 +337,10 @@ describe("functions list integration", () => { const exit = yield* Effect.exit(functionsList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = Cause.pretty(exit.cause); - expect(json).toContain("FunctionsListUnexpectedStatusError"); - expect(json).toContain("unexpected list functions status 200"); - expect(json).toContain("Hello World"); + const causeText = Cause.pretty(exit.cause); + expect(causeText).toContain("FunctionsListUnexpectedStatusError"); + expect(causeText).toContain("unexpected list functions status 200"); + expect(causeText).toContain("Hello World"); } }).pipe(Effect.provide(layer)); }); @@ -351,9 +351,9 @@ describe("functions list integration", () => { const exit = yield* Effect.exit(functionsList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = Cause.pretty(exit.cause); - expect(json).toContain("FunctionsListNetworkError"); - expect(json).toContain("failed to list functions"); + const causeText = Cause.pretty(exit.cause); + expect(causeText).toContain("FunctionsListNetworkError"); + expect(causeText).toContain("failed to list functions"); } }).pipe(Effect.provide(layer)); }); @@ -364,9 +364,9 @@ describe("functions list integration", () => { const exit = yield* Effect.exit(functionsList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = Cause.pretty(exit.cause); - expect(json).toContain("FunctionsListNetworkError"); - expect(json).toContain("failed to list functions"); + const causeText = Cause.pretty(exit.cause); + expect(causeText).toContain("FunctionsListNetworkError"); + expect(causeText).toContain("failed to list functions"); } }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/functions/new/new.integration.test.ts b/apps/cli/src/commands/functions/new/new.integration.test.ts index f3ae141ead..af997d744b 100644 --- a/apps/cli/src/commands/functions/new/new.integration.test.ts +++ b/apps/cli/src/commands/functions/new/new.integration.test.ts @@ -229,7 +229,7 @@ describe("functions new integration", () => { yield* functionsNew({ functionName: "with-env-yes", auth: "apikey" }); expect(out.stderrText).toContain("Generate VS Code settings for Deno? [Y/n] y"); expect(yield* fs.exists(path.join(workdir, ".vscode", "settings.json"))).toBe(true); - }).pipe((effect) => withEnvVar("SUPABASE_YES", "1", effect), Effect.provide(layer)); + }).pipe(Effect.provide(layer), (body) => withEnvVar("SUPABASE_YES", "1", body)); }); it.live("piped `n` then `y` declines VS Code and writes IntelliJ settings (Go parity)", () => {