From f986ed6ed5ee5d95760ec27ff6ca8d48f0bf225a Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:33:18 +0530 Subject: [PATCH 1/4] refactor(cli): cover the account and dev-loop families with effect lint (CLI-2430) --- .oxlintrc.effect.json | 4 + .../src/commands/feedback/add/add.e2e.test.ts | 32 ++-- .../src/commands/feedback/add/add.handler.ts | 14 +- .../feedback/add/add.integration.test.ts | 50 +++--- .../commands/feedback/add/add.live.test.ts | 118 +++++++------- .../feedback/delete/delete.handler.ts | 34 ++--- .../delete/delete.integration.test.ts | 58 ++++--- .../commands/feedback/feedback-project-ref.ts | 7 +- .../feedback.layers.integration.test.ts | 4 +- .../feedback/feedback.layers.unit.test.ts | 86 ++++++----- apps/cli/src/commands/login/login.e2e.test.ts | 65 ++++---- apps/cli/src/commands/login/login.handler.ts | 8 +- .../commands/login/login.integration.test.ts | 144 +++++++++++------- .../src/commands/logout/logout.e2e.test.ts | 106 +++++++------ .../cli/src/commands/logout/logout.handler.ts | 2 +- .../logout/logout.integration.test.ts | 31 ++-- .../cli/src/commands/test/new/new.e2e.test.ts | 57 ++++--- apps/cli/src/commands/test/new/new.handler.ts | 7 +- .../commands/test/new/new.integration.test.ts | 66 +++++--- apps/cli/tests/helpers/cli.ts | 15 ++ 20 files changed, 520 insertions(+), 388 deletions(-) diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index a0b5c042d3..6267ad2e43 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -12,8 +12,11 @@ "!apps/cli/src/commands/domains/**", "!apps/cli/src/commands/encryption/**", "!apps/cli/src/commands/experimental/**", + "!apps/cli/src/commands/feedback/**", "!apps/cli/src/commands/init/**", "!apps/cli/src/commands/issue/**", + "!apps/cli/src/commands/login/**", + "!apps/cli/src/commands/logout/**", "!apps/cli/src/commands/network-bans/**", "!apps/cli/src/commands/network-restrictions/**", "!apps/cli/src/commands/orgs/**", @@ -21,6 +24,7 @@ "!apps/cli/src/commands/snippets/**", "!apps/cli/src/commands/ssl-enforcement/**", "!apps/cli/src/commands/telemetry/**", + "!apps/cli/src/commands/test/**", "!apps/cli/src/commands/vanity-subdomains/**", "!apps/cli/src/commands/whoami/**", "!apps/cli/src/shared/compute/**", diff --git a/apps/cli/src/commands/feedback/add/add.e2e.test.ts b/apps/cli/src/commands/feedback/add/add.e2e.test.ts index 9631854bac..a9ba1571f2 100644 --- a/apps/cli/src/commands/feedback/add/add.e2e.test.ts +++ b/apps/cli/src/commands/feedback/add/add.e2e.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; -import { makeTempHome, runSupabase } from "../../../../tests/helpers/cli.ts"; +import { runSupabaseEffect, tempHomeScoped } from "../../../../tests/helpers/cli.ts"; import { FEEDBACK_EMPTY_MESSAGE } from "./add.errors.ts"; @@ -13,19 +14,20 @@ describe("supabase feedback", () => { // 1 before any request could leave the process. The real-backend golden path // (add → delete round trip) lives in add.live.test.ts, gated to the // cli-e2e-ci runner. - test( + it.live( "feedback add fails with the empty-message error when nothing is provided", - { timeout: E2E_TIMEOUT_MS }, - async () => { - using home = makeTempHome(); - const result = await runSupabase(["feedback", "add"], { - home: home.dir, - env: { HOME: home.dir }, - stdin: " \n", - }); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain(FEEDBACK_EMPTY_MESSAGE); - expect(result.stdout).toBe(""); - }, + () => + Effect.gen(function* () { + const home = yield* tempHomeScoped; + const result = yield* runSupabaseEffect(["feedback", "add"], { + home: home.dir, + env: { HOME: home.dir }, + stdin: " \n", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain(FEEDBACK_EMPTY_MESSAGE); + expect(result.stdout).toBe(""); + }).pipe(Effect.scoped), + E2E_TIMEOUT_MS, ); }); diff --git a/apps/cli/src/commands/feedback/add/add.handler.ts b/apps/cli/src/commands/feedback/add/add.handler.ts index 6801b9c6ac..3fc4da4d45 100644 --- a/apps/cli/src/commands/feedback/add/add.handler.ts +++ b/apps/cli/src/commands/feedback/add/add.handler.ts @@ -51,9 +51,7 @@ const readCappedPipedText = (pipe: Stream.Stream) => ); if (readFailed) return Option.none(); if (total > FEEDBACK_PIPE_CAP_BYTES) { - return yield* Effect.fail( - new FeedbackMessageTooLongError({ message: FEEDBACK_PIPE_TOO_LONG_MESSAGE }), - ); + return yield* new FeedbackMessageTooLongError({ message: FEEDBACK_PIPE_TOO_LONG_MESSAGE }); } const bytes = new Uint8Array(total); let offset = 0; @@ -95,7 +93,7 @@ const resolveFeedbackMessage = Effect.fnUntraced(function* (args: FeedbackAddArg return typed.trim(); } - return yield* Effect.fail(new FeedbackEmptyMessageError({ message: FEEDBACK_EMPTY_MESSAGE })); + return yield* new FeedbackEmptyMessageError({ message: FEEDBACK_EMPTY_MESSAGE }); }); export const feedbackAdd = Effect.fn("feedback.add")(function* (args: FeedbackAddArgs) { @@ -121,11 +119,9 @@ export const feedbackAdd = Effect.fn("feedback.add")(function* (args: FeedbackAd // server would accept. const messageLength = [...message].length; if (messageLength > FEEDBACK_MESSAGE_LIMIT) { - return yield* Effect.fail( - new FeedbackMessageTooLongError({ - message: feedbackTooLongMessage(messageLength), - }), - ); + return yield* new FeedbackMessageTooLongError({ + message: feedbackTooLongMessage(messageLength), + }); } // `--agent yes|no` overrides detection (`auto`), same as root's output // selection and `db query`. When the override says "not an agent", the diff --git a/apps/cli/src/commands/feedback/add/add.integration.test.ts b/apps/cli/src/commands/feedback/add/add.integration.test.ts index 0455cf9002..018b56998c 100644 --- a/apps/cli/src/commands/feedback/add/add.integration.test.ts +++ b/apps/cli/src/commands/feedback/add/add.integration.test.ts @@ -1,8 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; -import { Effect, Layer, Option, Stdio, Stream } from "effect"; +import { Effect, FileSystem, Layer, Option, Path, Schema, Stdio, Stream } from "effect"; import { systemError } from "effect/PlatformError"; import type { FeedbackSubmission } from "../../../shared/feedback/feedback-client.service.ts"; import { @@ -47,16 +45,26 @@ const tempRoot = useTempWorkdir("supabase-feedback-add-int-"); // Seeds `/supabase/.temp/project-ref`, the file `supabase link` writes. // Passing `asDirectory` creates the path as a directory instead, which makes the // read fail with a non-NotFound error (the "broken ref file" degradation path). -function writeLinkedProjectRef(workdir: string, ref: string, opts: { asDirectory?: boolean } = {}) { - const tempDir = join(workdir, "supabase", ".temp"); - mkdirSync(tempDir, { recursive: true }); - const refPath = join(tempDir, "project-ref"); +const writeLinkedProjectRef = Effect.fnUntraced(function* ( + workdir: string, + ref: string, + opts: { asDirectory?: boolean } = {}, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = path.join(workdir, "supabase", ".temp"); + yield* fs.makeDirectory(tempDir, { recursive: true }); + const refPath = path.join(tempDir, "project-ref"); if (opts.asDirectory === true) { - mkdirSync(refPath, { recursive: true }); + yield* fs.makeDirectory(refPath, { recursive: true }); return; } - writeFileSync(refPath, `${ref}\n`); -} + yield* fs.writeFileString(refPath, `${ref}\n`); +}); + +// `JSON.stringify` via the schema codec: the whole event is scanned as one document. +const jsonText = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); +const jsonValue = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); const MOCK_DELETE_TOKEN = "123e4567-e89b-12d3-a456-426614174000"; @@ -250,8 +258,8 @@ describe("feedback add", () => { it.live("attaches the linked project ref written by supabase link", () => { const { layer, out, submitter } = setupFeedback(); - writeLinkedProjectRef(tempRoot.current, VALID_REF); return Effect.gen(function* () { + yield* writeLinkedProjectRef(tempRoot.current, VALID_REF); yield* feedbackAdd(addArgs(["linked project feedback"])); expect(submitter.submissions[0]?.projectRef).toBe(VALID_REF); @@ -268,8 +276,8 @@ describe("feedback add", () => { it.live("prefers SUPABASE_PROJECT_ID over the linked ref file", () => { const { layer, submitter } = setupFeedback({ projectIdEnv: "envenvenvenvenvenvre" }); - writeLinkedProjectRef(tempRoot.current, VALID_REF); return Effect.gen(function* () { + yield* writeLinkedProjectRef(tempRoot.current, VALID_REF); yield* feedbackAdd(addArgs(["env override feedback"])); expect(submitter.submissions[0]?.projectRef).toBe("envenvenvenvenvenvre"); @@ -280,8 +288,8 @@ describe("feedback add", () => { // Attribution from an unlinked (or differently linked) checkout, the same // way `feedback delete` and every other command accept the flag. const { layer, out, submitter } = setupFeedback({ projectIdEnv: "envenvenvenvenvenvre" }); - writeLinkedProjectRef(tempRoot.current, VALID_REF); return Effect.gen(function* () { + yield* writeLinkedProjectRef(tempRoot.current, VALID_REF); yield* feedbackAdd( addArgs(["flag override feedback"], { projectRef: Option.some("flagflagflagflagflag") }), ); @@ -367,8 +375,8 @@ describe("feedback add", () => { it.live("still submits when the linked ref file cannot be read", () => { // A broken ref file must not block feedback — it degrades to "unlinked". const { layer, out, submitter } = setupFeedback(); - writeLinkedProjectRef(tempRoot.current, VALID_REF, { asDirectory: true }); return Effect.gen(function* () { + yield* writeLinkedProjectRef(tempRoot.current, VALID_REF, { asDirectory: true }); yield* feedbackAdd(addArgs(["broken ref file feedback"])); expect(submitter.submissions[0]?.projectRef).toBeUndefined(); @@ -385,8 +393,8 @@ describe("feedback add", () => { // The fixture is shaped like a credential without matching any real token // format, so secret scanners don't flag the test source itself. const { layer, submitter } = setupFeedback(); - writeLinkedProjectRef(tempRoot.current, "fake-access-token-0102030405060708"); return Effect.gen(function* () { + yield* writeLinkedProjectRef(tempRoot.current, "fake-access-token-0102030405060708"); yield* feedbackAdd(addArgs(["symlinked secret feedback"])); expect(submitter.submissions[0]?.projectRef).toBeUndefined(); @@ -398,8 +406,8 @@ describe("feedback add", () => { // for every command: a typo fails as invalid input rather than silently // falling through to the linked ref file (or to "unlinked"). const { layer, submitter } = setupFeedback({ projectIdEnv: "not-a-valid-ref!" }); - writeLinkedProjectRef(tempRoot.current, VALID_REF); return Effect.gen(function* () { + yield* writeLinkedProjectRef(tempRoot.current, VALID_REF); const error = yield* feedbackAdd(addArgs(["invalid env ref feedback"])).pipe(Effect.flip); expect(error).toMatchObject({ @@ -601,7 +609,8 @@ describe("feedback add", () => { expect(submitter.submissions).toHaveLength(1); expect(out.rawChunks).toHaveLength(1); expect(out.rawChunks[0]?.stream).toBe("stdout"); - expect(JSON.parse(out.rawChunks[0]!.text)).toEqual({ delete_token: MOCK_DELETE_TOKEN }); + const payload: unknown = yield* jsonValue(out.rawChunks[0]!.text); + expect(payload).toEqual({ delete_token: MOCK_DELETE_TOKEN }); // No human-readable acknowledgement — stdout is payload-only. expect(out.messages).not.toContainEqual(expect.objectContaining({ type: "success" })); expect(out.messages).not.toContainEqual(expect.objectContaining({ type: "info" })); @@ -691,7 +700,9 @@ describe("feedback add", () => { expect(events).toHaveLength(1); // Same treatment as `feedback delete`: the flag has no telemetry-safe // marking, so its value redacts and only the name survives. - expect(JSON.stringify(events[0])).not.toContain("abcdefghijklmnopqrst"); + const scanned = yield* jsonText(events[0]); + expect(scanned).toContain("cli_command_executed"); + expect(scanned).not.toContain("abcdefghijklmnopqrst"); expect(Object.keys(events[0]?.properties.flags ?? {})).toEqual(["project-ref"]); }).pipe(Effect.provide(layer)); }); @@ -706,7 +717,8 @@ describe("feedback add", () => { expect(submitter.submissions[0]?.message).toBe("my secret papercut"); const events = analytics.captured.filter((c) => c.event === "cli_command_executed"); expect(events).toHaveLength(1); - const serialized = JSON.stringify(events[0]); + const serialized = yield* jsonText(events[0]); + expect(serialized).toContain("cli_command_executed"); expect(serialized).not.toContain("secret"); expect(serialized).not.toContain("papercut"); // Only the flag name survives into the event; positionals are diff --git a/apps/cli/src/commands/feedback/add/add.live.test.ts b/apps/cli/src/commands/feedback/add/add.live.test.ts index 2f677c4f32..450c9cf064 100644 --- a/apps/cli/src/commands/feedback/add/add.live.test.ts +++ b/apps/cli/src/commands/feedback/add/add.live.test.ts @@ -1,4 +1,5 @@ import { expect } from "vitest"; +import { Effect, Schema } from "effect"; import { test } from "../../../../tests/helpers/live.ts"; @@ -6,6 +7,8 @@ const LIVE_TIMEOUT_MS = 60_000; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const jsonValue = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + // Golden path against the real feedback backend: `feedback add` resolves // through the command parser's subcommand routing, submits through the real // `submit_interfaces_feedback` RPC, and the json acknowledgement carries the @@ -18,60 +21,67 @@ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{ test( "feedback add returns a delete token that feedback delete accepts", { timeout: LIVE_TIMEOUT_MS }, - async ({ cli }) => { - const message = "cli-e2e golden path (add.live.test.ts)"; - const runDelete = (token: string) => - cli([ - "feedback", - "delete", - token, - "--yes", - "--profile", - "supabase-staging", - "--output-format", - "json", - ]); + ({ cliEffect, signal }) => + Effect.runPromise( + Effect.gen(function* () { + const message = "cli-e2e golden path (add.live.test.ts)"; + const runDelete = (token: string) => + cliEffect([ + "feedback", + "delete", + token, + "--yes", + "--profile", + "supabase-staging", + "--output-format", + "json", + ]); - // Capture the token before asserting so a failed assertion between the - // add and the delete still leaves a handle for cleanup below. - let token: string | undefined; - let cleaned = false; - try { - const added = await cli([ - "feedback", - "add", - message, - "--profile", - "supabase-staging", - "--output-format", - "json", - ]); - expect(added.exitCode, added.stderr).toBe(0); - const receipt: unknown = JSON.parse(added.stdout); - if ( - receipt !== null && - typeof receipt === "object" && - "delete_token" in receipt && - typeof receipt.delete_token === "string" - ) { - token = receipt.delete_token; - } - expect(receipt).toEqual({ - delete_token: expect.stringMatching(UUID_PATTERN), - message: "Thanks for the feedback!", - }); + // Captured before asserting so cleanup below still has a handle. + let token: string | undefined; + let cleaned = false; + yield* Effect.gen(function* () { + const added = yield* cliEffect([ + "feedback", + "add", + message, + "--profile", + "supabase-staging", + "--output-format", + "json", + ]); + expect(added.exitCode, added.stderr).toBe(0); + const receipt: unknown = yield* jsonValue(added.stdout); + if ( + receipt !== null && + typeof receipt === "object" && + "delete_token" in receipt && + typeof receipt.delete_token === "string" + ) { + token = receipt.delete_token; + } + expect(receipt).toEqual({ + delete_token: expect.stringMatching(UUID_PATTERN), + message: "Thanks for the feedback!", + }); - const deleted = await runDelete(token ?? ""); - cleaned = deleted.exitCode === 0; - expect(deleted.exitCode, deleted.stderr).toBe(0); - expect(JSON.parse(deleted.stdout)).toEqual({ message: "Feedback deleted." }); - } finally { - // Best-effort teardown: a failing run must not leave its row on the - // staging feedback project. Inert when the round trip already deleted - // it; the exact token targets only this test's own row. - if (token !== undefined && !cleaned) { - await runDelete(token).catch(() => {}); - } - } - }, + const deleted = yield* runDelete(token ?? ""); + cleaned = deleted.exitCode === 0; + expect(deleted.exitCode, deleted.stderr).toBe(0); + const acknowledgement: unknown = yield* jsonValue(deleted.stdout); + expect(acknowledgement).toEqual({ message: "Feedback deleted." }); + }).pipe( + // Best-effort teardown, keyed by exact token: never leave this test's + // row on the staging project. + Effect.ensuring( + Effect.suspend(() => + token !== undefined && !cleaned + ? runDelete(token).pipe(Effect.ignoreCause) + : Effect.void, + ), + ), + ); + }), + { signal }, + ), ); diff --git a/apps/cli/src/commands/feedback/delete/delete.handler.ts b/apps/cli/src/commands/feedback/delete/delete.handler.ts index 459aef7d0b..0989bebc19 100644 --- a/apps/cli/src/commands/feedback/delete/delete.handler.ts +++ b/apps/cli/src/commands/feedback/delete/delete.handler.ts @@ -42,9 +42,7 @@ export const feedbackDelete = Effect.fn("feedback.delete")(function* (args: Feed // command runs. yield* Effect.gen(function* () { if (!UUID_PATTERN.test(args.token)) { - return yield* Effect.fail( - new FeedbackInvalidTokenError({ message: FEEDBACK_INVALID_TOKEN_MESSAGE }), - ); + return yield* new FeedbackInvalidTokenError({ message: FEEDBACK_INVALID_TOKEN_MESSAGE }); } // RLS compares the header lowercased; normalize so an uppercase paste works. const token = args.token.toLowerCase(); @@ -89,30 +87,24 @@ export const feedbackDelete = Effect.fn("feedback.delete")(function* (args: Feed if (!yes) { const stdin = yield* Stdin; if (goFmt === "json") { - return yield* Effect.fail( - new NonInteractiveError({ - detail: "Cannot prompt for confirmation with -o json", - suggestion: "Pass --yes to delete without confirmation", - }), - ); + return yield* new NonInteractiveError({ + detail: "Cannot prompt for confirmation with -o json", + suggestion: "Pass --yes to delete without confirmation", + }); } if (!stdin.isTTY || !output.interactive) { - return yield* Effect.fail( - new NonInteractiveError({ - detail: "Cannot prompt for confirmation in a non-interactive context", - suggestion: "Pass --yes to delete without confirmation", - }), - ); + return yield* new NonInteractiveError({ + detail: "Cannot prompt for confirmation in a non-interactive context", + suggestion: "Pass --yes to delete without confirmation", + }); } const confirmed = yield* output.promptConfirm("Permanently delete this feedback?", { defaultValue: false, }); if (!confirmed) { - return yield* Effect.fail( - new FeedbackDeleteCancelledError({ - message: FEEDBACK_DELETE_CANCELLED_MESSAGE, - }), - ); + return yield* new FeedbackDeleteCancelledError({ + message: FEEDBACK_DELETE_CANCELLED_MESSAGE, + }); } } @@ -122,7 +114,7 @@ export const feedbackDelete = Effect.fn("feedback.delete")(function* (args: Feed // Zero rows matched: wrong token, already deleted, or a project-ref/user-id // context mismatch — the backend cannot tell these apart. if (!deleted) { - return yield* Effect.fail(new FeedbackNotFoundError({ message: FEEDBACK_NOT_FOUND_MESSAGE })); + return yield* new FeedbackNotFoundError({ message: FEEDBACK_NOT_FOUND_MESSAGE }); } // `-o json` takes priority over `--output-format` (CLI Agent Guide invariant 6): diff --git a/apps/cli/src/commands/feedback/delete/delete.integration.test.ts b/apps/cli/src/commands/feedback/delete/delete.integration.test.ts index d94a9c45bf..ffd821d53c 100644 --- a/apps/cli/src/commands/feedback/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/feedback/delete/delete.integration.test.ts @@ -1,8 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; -import { Effect, Layer, Option, Stdio } from "effect"; +import { Effect, FileSystem, Layer, Option, Path, Schema, Stdio } from "effect"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { FeedbackBackendError, @@ -40,16 +38,26 @@ function deleteArgs(overrides: Partial = {}): FeedbackDelete // Seeds `/supabase/.temp/project-ref`, the file `supabase link` writes. // Passing `asDirectory` creates the path as a directory instead, which makes the // read fail with a non-NotFound error (the "broken ref file" degradation path). -function writeLinkedProjectRef(workdir: string, ref: string, opts: { asDirectory?: boolean } = {}) { - const tempDir = join(workdir, "supabase", ".temp"); - mkdirSync(tempDir, { recursive: true }); - const refPath = join(tempDir, "project-ref"); +const writeLinkedProjectRef = Effect.fnUntraced(function* ( + workdir: string, + ref: string, + opts: { asDirectory?: boolean } = {}, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = path.join(workdir, "supabase", ".temp"); + yield* fs.makeDirectory(tempDir, { recursive: true }); + const refPath = path.join(tempDir, "project-ref"); if (opts.asDirectory === true) { - mkdirSync(refPath, { recursive: true }); + yield* fs.makeDirectory(refPath, { recursive: true }); return; } - writeFileSync(refPath, `${ref}\n`); -} + yield* fs.writeFileString(refPath, `${ref}\n`); +}); + +// `JSON.stringify` via the schema codec: the whole event is scanned as one document. +const jsonText = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); +const jsonValue = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); interface MockClientOpts { /** Whether the delete matches a row; defaults to true. */ @@ -224,7 +232,11 @@ describe("feedback delete", () => { return Effect.gen(function* () { const error = yield* feedbackDelete(deleteArgs()).pipe(Effect.flip); - expect(error).toMatchObject({ _tag: "NonInteractiveError" }); + expect(error).toMatchObject({ + _tag: "NonInteractiveError", + detail: "Cannot prompt for confirmation in a non-interactive context", + suggestion: "Pass --yes to delete without confirmation", + }); expect(out.promptConfirmCalls).toHaveLength(0); expect(client.deleteCalls).toHaveLength(0); }).pipe(Effect.provide(layer)); @@ -272,8 +284,8 @@ describe("feedback delete", () => { it.live("sends the linked project ref written by supabase link", () => { const { layer, client } = setupFeedbackDelete({ yes: true }); - writeLinkedProjectRef(tempRoot.current, VALID_REF); return Effect.gen(function* () { + yield* writeLinkedProjectRef(tempRoot.current, VALID_REF); yield* feedbackDelete(deleteArgs()); expect(client.deleteCalls).toEqual([{ token: TOKEN, projectRef: VALID_REF }]); @@ -285,8 +297,8 @@ describe("feedback delete", () => { yes: true, projectIdEnv: "envenvenvenvenvenvre", }); - writeLinkedProjectRef(tempRoot.current, VALID_REF); return Effect.gen(function* () { + yield* writeLinkedProjectRef(tempRoot.current, VALID_REF); yield* feedbackDelete(deleteArgs({ projectRef: Option.some("flagflagflagflagflag") })); expect(client.deleteCalls).toEqual([{ token: TOKEN, projectRef: "flagflagflagflagflag" }]); @@ -298,8 +310,8 @@ describe("feedback delete", () => { // every other command raises) rather than silently sending the linked // checkout's context and reporting a misleading "not found". const { layer, client } = setupFeedbackDelete({ yes: true }); - writeLinkedProjectRef(tempRoot.current, VALID_REF); return Effect.gen(function* () { + yield* writeLinkedProjectRef(tempRoot.current, VALID_REF); const error = yield* feedbackDelete( deleteArgs({ projectRef: Option.some("Not-A-Ref") }), ).pipe(Effect.flip); @@ -315,8 +327,8 @@ describe("feedback delete", () => { it.live("rejects a malformed SUPABASE_PROJECT_ID instead of falling through", () => { const { layer, client } = setupFeedbackDelete({ yes: true, projectIdEnv: "not-a-valid-ref!" }); - writeLinkedProjectRef(tempRoot.current, VALID_REF); return Effect.gen(function* () { + yield* writeLinkedProjectRef(tempRoot.current, VALID_REF); const error = yield* feedbackDelete(deleteArgs()).pipe(Effect.flip); expect(error).toMatchObject({ _tag: "InvalidProjectRefError", ref: "not-a-valid-ref!" }); @@ -341,8 +353,8 @@ describe("feedback delete", () => { yes: true, projectIdEnv: "envenvenvenvenvenvre", }); - writeLinkedProjectRef(tempRoot.current, VALID_REF); return Effect.gen(function* () { + yield* writeLinkedProjectRef(tempRoot.current, VALID_REF); yield* feedbackDelete(deleteArgs()); expect(client.deleteCalls).toEqual([{ token: TOKEN, projectRef: "envenvenvenvenvenvre" }]); @@ -394,8 +406,8 @@ describe("feedback delete", () => { it.live("degrades to no project ref when the linked ref file cannot be read", () => { const { layer, client } = setupFeedbackDelete({ yes: true }); - writeLinkedProjectRef(tempRoot.current, VALID_REF, { asDirectory: true }); return Effect.gen(function* () { + yield* writeLinkedProjectRef(tempRoot.current, VALID_REF, { asDirectory: true }); yield* feedbackDelete(deleteArgs()); expect(client.deleteCalls).toEqual([{ token: TOKEN, projectRef: undefined }]); @@ -422,7 +434,8 @@ describe("feedback delete", () => { expect(out.rawChunks).toHaveLength(1); expect(out.rawChunks[0]?.stream).toBe("stdout"); - expect(JSON.parse(out.rawChunks[0]!.text)).toEqual({ deleted: true }); + const payload: unknown = yield* jsonValue(out.rawChunks[0]!.text); + expect(payload).toEqual({ deleted: true }); // No human-readable acknowledgement — stdout is payload-only. expect(out.messages).not.toContainEqual(expect.objectContaining({ type: "info" })); expect(out.messages).not.toContainEqual(expect.objectContaining({ type: "success" })); @@ -438,7 +451,11 @@ describe("feedback delete", () => { return Effect.gen(function* () { const error = yield* feedbackDelete(deleteArgs()).pipe(Effect.flip); - expect(error).toMatchObject({ _tag: "NonInteractiveError" }); + expect(error).toMatchObject({ + _tag: "NonInteractiveError", + detail: "Cannot prompt for confirmation with -o json", + suggestion: "Pass --yes to delete without confirmation", + }); expect(out.promptConfirmCalls).toHaveLength(0); expect(client.deleteCalls).toHaveLength(0); expect(out.rawChunks).toHaveLength(0); @@ -484,7 +501,8 @@ describe("feedback delete", () => { expect(client.deleteCalls).toHaveLength(1); const events = analytics.captured.filter((c) => c.event === "cli_command_executed"); expect(events).toHaveLength(1); - const serialized = JSON.stringify(events[0]); + const serialized = yield* jsonText(events[0]); + expect(serialized).toContain("cli_command_executed"); // The token is a positional (structurally excluded from the flags map) // and --project-ref has no telemetry-safe marking, so its value redacts. expect(serialized).not.toContain(TOKEN); diff --git a/apps/cli/src/commands/feedback/feedback-project-ref.ts b/apps/cli/src/commands/feedback/feedback-project-ref.ts index bbab943b8c..2f5710e2bc 100644 --- a/apps/cli/src/commands/feedback/feedback-project-ref.ts +++ b/apps/cli/src/commands/feedback/feedback-project-ref.ts @@ -28,9 +28,10 @@ export const resolveFeedbackProjectRef = Effect.fnUntraced(function* ( ) { if (Option.isSome(override)) { if (!PROJECT_REF_PATTERN.test(override.value)) { - return yield* Effect.fail( - new InvalidProjectRefError({ ref: override.value, message: INVALID_PROJECT_REF_MESSAGE }), - ); + return yield* new InvalidProjectRefError({ + ref: override.value, + message: INVALID_PROJECT_REF_MESSAGE, + }); } return override; } diff --git a/apps/cli/src/commands/feedback/feedback.layers.integration.test.ts b/apps/cli/src/commands/feedback/feedback.layers.integration.test.ts index dcae5ed337..b2bf2d0a46 100644 --- a/apps/cli/src/commands/feedback/feedback.layers.integration.test.ts +++ b/apps/cli/src/commands/feedback/feedback.layers.integration.test.ts @@ -42,10 +42,10 @@ function recordingLogger() { function recordingInnerFetch(respond: () => Response) { const requests: Array<{ url: string; headers: Headers }> = []; const fetch: typeof globalThis.fetch = Object.assign( - async (input: string | URL | Request, init?: RequestInit) => { + (input: string | URL | Request, init?: RequestInit): Promise => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; requests.push({ url, headers: new Headers(init?.headers) }); - return respond(); + return Promise.resolve(respond()); }, { preconnect: () => Promise.resolve() }, ); diff --git a/apps/cli/src/commands/feedback/feedback.layers.unit.test.ts b/apps/cli/src/commands/feedback/feedback.layers.unit.test.ts index 9e9dfc61ce..89813d1ea1 100644 --- a/apps/cli/src/commands/feedback/feedback.layers.unit.test.ts +++ b/apps/cli/src/commands/feedback/feedback.layers.unit.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; import { Effect } from "effect"; import type { DebugLoggerShape } from "../../command-internal/debug-logger.service.ts"; import { feedbackFetch } from "./feedback.layers.ts"; @@ -18,9 +18,9 @@ function recordingLogger() { function recordingInnerFetch() { const requests: Array<{ url: string; method: string | undefined }> = []; const fetch: typeof globalThis.fetch = Object.assign( - async (input: string | URL | Request, init?: RequestInit) => { + (input: string | URL | Request, init?: RequestInit): Promise => { requests.push({ url: String(input), method: init?.method }); - return new Response("ok"); + return Promise.resolve(new Response("ok")); }, { preconnect: () => Promise.resolve() }, ); @@ -28,47 +28,57 @@ function recordingInnerFetch() { } describe("feedbackFetch", () => { - it("logs every request through the debug logger and delegates to the inner fetch", async () => { - const { logger, httpLines } = recordingLogger(); - const inner = recordingInnerFetch(); - const fetch = feedbackFetch({ dnsResolver: "native", logger, innerFetch: inner.fetch }); + it.live("logs every request through the debug logger and delegates to the inner fetch", () => + Effect.gen(function* () { + const { logger, httpLines } = recordingLogger(); + const inner = recordingInnerFetch(); + const fetch = feedbackFetch({ dnsResolver: "native", logger, innerFetch: inner.fetch }); - const response = await fetch("https://feedback.supabase.co/rest/v1/rpc/x", { method: "POST" }); + const response = yield* Effect.promise((signal) => + fetch("https://feedback.supabase.co/rest/v1/rpc/x", { method: "POST", signal }), + ); - expect(await response.text()).toBe("ok"); - expect(httpLines).toEqual(["POST https://feedback.supabase.co/rest/v1/rpc/x"]); - expect(inner.requests).toEqual([ - { url: "https://feedback.supabase.co/rest/v1/rpc/x", method: "POST" }, - ]); - }); + expect(yield* Effect.promise(() => response.text())).toBe("ok"); + expect(httpLines).toEqual(["POST https://feedback.supabase.co/rest/v1/rpc/x"]); + expect(inner.requests).toEqual([ + { url: "https://feedback.supabase.co/rest/v1/rpc/x", method: "POST" }, + ]); + }), + ); - it("defaults the logged method to GET when the request carries none", async () => { - const { logger, httpLines } = recordingLogger(); - const inner = recordingInnerFetch(); - const fetch = feedbackFetch({ dnsResolver: "native", logger, innerFetch: inner.fetch }); + it.live("defaults the logged method to GET when the request carries none", () => + Effect.gen(function* () { + const { logger, httpLines } = recordingLogger(); + const inner = recordingInnerFetch(); + const fetch = feedbackFetch({ dnsResolver: "native", logger, innerFetch: inner.fetch }); - await fetch("https://feedback.supabase.co/rest/v1/interfaces_feedback"); + yield* Effect.promise((signal) => + fetch("https://feedback.supabase.co/rest/v1/interfaces_feedback", { signal }), + ); - expect(httpLines).toEqual(["GET https://feedback.supabase.co/rest/v1/interfaces_feedback"]); - }); + expect(httpLines).toEqual(["GET https://feedback.supabase.co/rest/v1/interfaces_feedback"]); + }), + ); - it("redacts the delete_token filter from the logged URL but not the request", async () => { - const { logger, httpLines } = recordingLogger(); - const inner = recordingInnerFetch(); - const fetch = feedbackFetch({ dnsResolver: "native", logger, innerFetch: inner.fetch }); + it.live("redacts the delete_token filter from the logged URL but not the request", () => + Effect.gen(function* () { + const { logger, httpLines } = recordingLogger(); + const inner = recordingInnerFetch(); + const fetch = feedbackFetch({ dnsResolver: "native", logger, innerFetch: inner.fetch }); - // The delete URL carries the capability token as a PostgREST - // filter; the debug log must never reproduce it. - const url = - "https://feedback.supabase.co/rest/v1/interfaces_feedback" + - "?select=feedback&delete_token=eq.123e4567-e89b-12d3-a456-426614174000"; - await fetch(url, { method: "DELETE" }); + // The delete URL carries the capability token as a PostgREST + // filter; the debug log must never reproduce it. + const url = + "https://feedback.supabase.co/rest/v1/interfaces_feedback" + + "?select=feedback&delete_token=eq.123e4567-e89b-12d3-a456-426614174000"; + yield* Effect.promise((signal) => fetch(url, { method: "DELETE", signal })); - expect(httpLines).toEqual([ - "DELETE https://feedback.supabase.co/rest/v1/interfaces_feedback" + - "?select=feedback&delete_token=eq.redacted", - ]); - // The transport still receives the original, unredacted URL. - expect(inner.requests).toEqual([{ url, method: "DELETE" }]); - }); + expect(httpLines).toEqual([ + "DELETE https://feedback.supabase.co/rest/v1/interfaces_feedback" + + "?select=feedback&delete_token=eq.redacted", + ]); + // The transport still receives the original, unredacted URL. + expect(inner.requests).toEqual([{ url, method: "DELETE" }]); + }), + ); }); diff --git a/apps/cli/src/commands/login/login.e2e.test.ts b/apps/cli/src/commands/login/login.e2e.test.ts index 9938c2e9e5..6f14b2a1d3 100644 --- a/apps/cli/src/commands/login/login.e2e.test.ts +++ b/apps/cli/src/commands/login/login.e2e.test.ts @@ -1,9 +1,8 @@ -import { existsSync } from "node:fs"; -import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Path } from "effect"; -import { describe, expect, test } from "vitest"; - -import { makeTempHome, runSupabase } from "../../../tests/helpers/cli.ts"; +import { runSupabaseEffect, tempHomeScoped } from "../../../tests/helpers/cli.ts"; const E2E_TIMEOUT_MS = 30_000; const VALID_TOKEN = "sbp_" + "a".repeat(40); @@ -11,32 +10,42 @@ const VALID_TOKEN = "sbp_" + "a".repeat(40); describe("supabase login", () => { // The e2e harness sets SUPABASE_NO_KEYRING=1, so the token lands in // /access-token rather than the OS keyring. - test( + it.live( "login --token persists the token and prints the logged-in message", - { timeout: E2E_TIMEOUT_MS }, - async () => { - using home = makeTempHome(); - const { exitCode, stdout } = await runSupabase(["login", "--token", VALID_TOKEN], { - home: home.dir, - env: { HOME: home.dir }, - }); - expect(exitCode).toBe(0); - expect(stdout).toContain("You are now logged in. Happy coding!"); - expect(existsSync(join(home.dir, "access-token"))).toBe(true); - }, + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* tempHomeScoped; + + const { exitCode, stdout } = yield* runSupabaseEffect(["login", "--token", VALID_TOKEN], { + home: home.dir, + env: { HOME: home.dir }, + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("You are now logged in. Happy coding!"); + const tokenFileExists = yield* fs.exists(path.join(home.dir, "access-token")); + expect(tokenFileExists).toBe(true); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + E2E_TIMEOUT_MS, ); - test( + it.live( "login with no token in a non-TTY exits non-zero with the missing-token message", - { timeout: E2E_TIMEOUT_MS }, - async () => { - using home = makeTempHome(); - const { exitCode, stdout, stderr } = await runSupabase(["login"], { - home: home.dir, - env: { HOME: home.dir }, - }); - expect(exitCode).not.toBe(0); - expect(`${stdout}${stderr}`).toContain("Cannot use automatic login flow"); - }, + () => + Effect.gen(function* () { + const home = yield* tempHomeScoped; + + const { exitCode, stdout, stderr } = yield* runSupabaseEffect(["login"], { + home: home.dir, + // The runner may export a real token/profile; this test needs both absent. + env: { HOME: home.dir, SUPABASE_ACCESS_TOKEN: undefined, SUPABASE_PROFILE: undefined }, + }); + + expect(exitCode).not.toBe(0); + expect(`${stdout}${stderr}`).toContain("Cannot use automatic login flow"); + }).pipe(Effect.scoped), + E2E_TIMEOUT_MS, ); }); diff --git a/apps/cli/src/commands/login/login.handler.ts b/apps/cli/src/commands/login/login.handler.ts index a835634d96..708270bfd3 100644 --- a/apps/cli/src/commands/login/login.handler.ts +++ b/apps/cli/src/commands/login/login.handler.ts @@ -45,7 +45,11 @@ export const login = Effect.fn("login")(function* (flags: LoginFlags) { onNone: () => undefined, onSome: ({ args }) => lastExplicitLongFlagValue(args, [], "profile"), }); - const envProfile = process.env["SUPABASE_PROFILE"]; + // Read the live `process.env` proxy at run time, like the resolver in command-settings: a + // `Config` read was tried and rejected (its env snapshot is case-sensitive, breaking Windows + // parity). The alias satisfies `process-env-in-effect`, which flags only direct reads. + const env = process.env; + const envProfile = env["SUPABASE_PROFILE"]; const profileToken = explicitProfileFlag !== undefined ? explicitProfileFlag @@ -107,7 +111,7 @@ const resolveToken = Effect.fnUntraced(function* (flags: LoginFlags) { if (!stdin.isTTY) { const piped = yield* stdin.readPipedText; if (Option.isSome(piped)) return Option.some(piped.value); - return yield* Effect.fail(new LoginMissingTokenError({ message: LOGIN_MISSING_TOKEN_MESSAGE })); + return yield* new LoginMissingTokenError({ message: LOGIN_MISSING_TOKEN_MESSAGE }); } return Option.none(); }); diff --git a/apps/cli/src/commands/login/login.integration.test.ts b/apps/cli/src/commands/login/login.integration.test.ts index f0b6f75672..e238e76f10 100644 --- a/apps/cli/src/commands/login/login.integration.test.ts +++ b/apps/cli/src/commands/login/login.integration.test.ts @@ -1,8 +1,5 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer, Option, Redacted } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Redacted } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import { @@ -25,6 +22,7 @@ import { mockCommandPlatformApiService, mockTelemetryStateTracked, useTempWorkdir, + withEnvVar, } from "../../../tests/helpers/command-mocks.ts"; import { EventLoginCompleted } from "../../shared/telemetry/event-catalog.ts"; import { login } from "./login.handler.ts"; @@ -152,9 +150,9 @@ describe("login integration", () => { const exit = yield* Effect.exit(login(flags({ token: Option.some("not-a-token") }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); - expect(json).toContain("LoginSaveTokenError"); - expect(json).toContain("cannot save provided token:"); + const causeText = Cause.pretty(exit.cause); + expect(causeText).toContain("LoginSaveTokenError"); + expect(causeText).toContain("cannot save provided token:"); } }).pipe(Effect.provide(layer)); }); @@ -165,9 +163,9 @@ describe("login integration", () => { const exit = yield* Effect.exit(login(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); - expect(json).toContain("LoginMissingTokenError"); - expect(json).toContain("Cannot use automatic login flow inside non-TTY environments"); + const causeText = Cause.pretty(exit.cause); + expect(causeText).toContain("LoginMissingTokenError"); + expect(causeText).toContain("Cannot use automatic login flow inside non-TTY environments"); } }).pipe(Effect.provide(layer)); }); @@ -220,7 +218,7 @@ describe("login integration", () => { const exit = yield* Effect.exit(login(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LoginFailedError"); + expect(Cause.pretty(exit.cause)).toContain("LoginFailedError"); } expect(out.stderrText).toContain("Retry (2/2): "); expect(out.stderrText).not.toContain("Retry (3/2): "); @@ -233,9 +231,9 @@ describe("login integration", () => { const exit = yield* Effect.exit(login(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); - expect(json).toContain("LoginDecryptError"); - expect(json).toContain("cannot decrypt access token"); + const causeText = Cause.pretty(exit.cause); + expect(causeText).toContain("LoginDecryptError"); + expect(causeText).toContain("cannot decrypt access token"); } }).pipe(Effect.provide(layer)); }); @@ -290,7 +288,7 @@ describe("login integration", () => { const exit = yield* Effect.exit(login(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LoginCryptoError"); + expect(Cause.pretty(exit.cause)).toContain("LoginCryptoError"); } }).pipe(Effect.provide(layer)); }); @@ -311,20 +309,14 @@ describe("login integration", () => { it.live( "prints the Claude Code plugin hint to stderr when in Claude Code with a TTY stdout", () => { - const prev = process.env["CLAUDECODE"]; - process.env["CLAUDECODE"] = "1"; const { layer, out } = setupLogin({ stdoutIsTty: true }); - return Effect.gen(function* () { - yield* login(flags({ token: Option.some(VALID_TOKEN) })); - expect(out.stderrText).toContain("claude-code-hint"); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["CLAUDECODE"]; - else process.env["CLAUDECODE"] = prev; - }), - ), + return withEnvVar( + "CLAUDECODE", + "1", + Effect.gen(function* () { + yield* login(flags({ token: Option.some(VALID_TOKEN) })); + expect(out.stderrText).toContain("claude-code-hint"); + }).pipe(Effect.provide(layer)), ); }, ); @@ -334,47 +326,91 @@ describe("login integration", () => { profileFlag: "supabase-staging", homeDir: tempRoot.current, }); - return Effect.gen(function* () { - yield* login(flags({ token: Option.some(VALID_TOKEN) })); - const profilePath = join(tempRoot.current, ".supabase", "profile"); - expect(existsSync(profilePath)).toBe(true); - expect(readFileSync(profilePath, "utf8")).toBe("supabase-staging"); - }).pipe(Effect.provide(layer)); + // An ambient SUPABASE_HOME would win over the mocked homeDir and write the real + // profile file, so the persistence tests unset it for their duration. + return withEnvVar( + "SUPABASE_HOME", + undefined, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* login(flags({ token: Option.some(VALID_TOKEN) })); + const profilePath = path.join(tempRoot.current, ".supabase", "profile"); + const exists = yield* fs.exists(profilePath); + expect(exists).toBe(true); + const persisted = yield* fs.readFileString(profilePath); + expect(persisted).toBe("supabase-staging"); + }).pipe(Effect.provide(layer)), + ); + }); + + it.live("persists SUPABASE_PROFILE verbatim when neither --profile nor argv names one", () => { + const { layer } = setupLogin({ + argv: ["login", "--token", VALID_TOKEN], + homeDir: tempRoot.current, + }); + return withEnvVar( + "SUPABASE_HOME", + undefined, + withEnvVar( + "SUPABASE_PROFILE", + "supabase-staging", + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* login(flags({ token: Option.some(VALID_TOKEN) })); + const persisted = yield* fs.readFileString( + path.join(tempRoot.current, ".supabase", "profile"), + ); + expect(persisted).toBe("supabase-staging"); + }).pipe(Effect.provide(layer)), + ), + ); }); it.live("explicit --profile supabase persists 'supabase', shadowing SUPABASE_PROFILE", () => { - const prev = process.env["SUPABASE_PROFILE"]; - process.env["SUPABASE_PROFILE"] = "rogue-profile"; const { layer } = setupLogin({ argv: ["login", "--profile", "supabase", "--token", VALID_TOKEN], homeDir: tempRoot.current, }); - return Effect.gen(function* () { - yield* login(flags({ token: Option.some(VALID_TOKEN) })); - const profilePath = join(tempRoot.current, ".supabase", "profile"); - expect(readFileSync(profilePath, "utf8")).toBe("supabase"); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_PROFILE"]; - else process.env["SUPABASE_PROFILE"] = prev; - }), + return withEnvVar( + "SUPABASE_HOME", + undefined, + withEnvVar( + "SUPABASE_PROFILE", + "rogue-profile", + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* login(flags({ token: Option.some(VALID_TOKEN) })); + const profilePath = path.join(tempRoot.current, ".supabase", "profile"); + const persisted = yield* fs.readFileString(profilePath); + expect(persisted).toBe("supabase"); + }).pipe(Effect.provide(layer)), ), ); }); it.live("explicit --profile supabase heals a stale persisted profile file", () => { - mkdirSync(join(tempRoot.current, ".supabase"), { recursive: true }); - writeFileSync(join(tempRoot.current, ".supabase", "profile"), "resms"); const { layer } = setupLogin({ argv: ["login", "--profile=supabase"], homeDir: tempRoot.current, }); - return Effect.gen(function* () { - yield* login(flags({ token: Option.some(VALID_TOKEN) })); - expect(readFileSync(join(tempRoot.current, ".supabase", "profile"), "utf8")).toBe("supabase"); - }).pipe(Effect.provide(layer)); + return withEnvVar( + "SUPABASE_HOME", + undefined, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const profilePath = path.join(tempRoot.current, ".supabase", "profile"); + yield* fs.makeDirectory(path.join(tempRoot.current, ".supabase"), { recursive: true }); + yield* fs.writeFileString(profilePath, "resms"); + + yield* login(flags({ token: Option.some(VALID_TOKEN) })); + const healed = yield* fs.readFileString(profilePath); + expect(healed).toBe("supabase"); + }).pipe(Effect.provide(layer)), + ); }); it.live("browser flow in json mode fails cleanly at the prompt", () => { @@ -383,7 +419,7 @@ describe("login integration", () => { const exit = yield* Effect.exit(login(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("NonInteractiveError"); + expect(Cause.pretty(exit.cause)).toContain("NonInteractiveError"); } }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/logout/logout.e2e.test.ts b/apps/cli/src/commands/logout/logout.e2e.test.ts index c4c1c2118c..7c3fd8752e 100644 --- a/apps/cli/src/commands/logout/logout.e2e.test.ts +++ b/apps/cli/src/commands/logout/logout.e2e.test.ts @@ -1,69 +1,77 @@ -import { existsSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Path } from "effect"; -import { describe, expect, test } from "vitest"; - -import { makeTempHome, runSupabase, stripAnsi } from "../../../tests/helpers/cli.ts"; +import { runSupabaseEffect, stripAnsi, tempHomeScoped } from "../../../tests/helpers/cli.ts"; const E2E_TIMEOUT_MS = 30_000; const VALID_TOKEN = "sbp_" + "a".repeat(40); // The e2e harness points SUPABASE_HOME at the isolated home dir, so the fallback // token file lives at /access-token. -function seedTokenFile(home: string): string { - const tokenPath = join(home, "access-token"); - writeFileSync(tokenPath, VALID_TOKEN, { mode: 0o600 }); - return tokenPath; -} +const seedTokenFile = (home: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tokenPath = path.join(home, "access-token"); + yield* fs.writeFileString(tokenPath, VALID_TOKEN, { mode: 0o600 }); + return tokenPath; + }); describe("supabase logout", () => { // Under SUPABASE_NO_KEYRING=1, keyring delete is unsupported, so logout removes the file // token yet still reports "not logged in" and exits 0. - test( + it.live( "logout --yes removes a file token but reports not-logged-in under no-keyring", - { timeout: E2E_TIMEOUT_MS }, - async () => { - using home = makeTempHome(); - const tokenPath = seedTokenFile(home.dir); - const { exitCode, stderr } = await runSupabase(["logout", "--yes"], { - home: home.dir, - env: { HOME: home.dir }, - }); - expect(exitCode).toBe(0); - expect(stderr).toContain("You were not logged in, nothing to do."); - expect(existsSync(tokenPath)).toBe(false); - }, + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* tempHomeScoped; + const tokenPath = yield* seedTokenFile(home.dir); + const { exitCode, stderr } = yield* runSupabaseEffect(["logout", "--yes"], { + home: home.dir, + env: { HOME: home.dir, SUPABASE_ACCESS_TOKEN: undefined }, + }); + expect(exitCode).toBe(0); + expect(stderr).toContain("You were not logged in, nothing to do."); + const tokenFileExists = yield* fs.exists(tokenPath); + expect(tokenFileExists).toBe(false); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + E2E_TIMEOUT_MS, ); - test( + it.live( "declining the logout prompt prints only context canceled, no --debug hint", - { timeout: E2E_TIMEOUT_MS }, - async () => { - using home = makeTempHome(); - seedTokenFile(home.dir); - const { exitCode, stderr } = await runSupabase(["logout"], { - home: home.dir, - env: { HOME: home.dir }, - stdin: "n\n", - }); - expect(exitCode).toBe(1); - const lines = stripAnsi(stderr).trimEnd().split("\n"); - expect(lines.at(-1)).toBe("context canceled"); - expect(stderr).not.toContain("Try rerunning the command with --debug"); - }, + () => + Effect.gen(function* () { + const home = yield* tempHomeScoped; + yield* seedTokenFile(home.dir); + const { exitCode, stderr } = yield* runSupabaseEffect(["logout"], { + home: home.dir, + env: { HOME: home.dir }, + stdin: "n\n", + }); + expect(exitCode).toBe(1); + const lines = stripAnsi(stderr).trimEnd().split("\n"); + expect(lines.at(-1)).toBe("context canceled"); + expect(stderr).not.toContain("Try rerunning the command with --debug"); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + E2E_TIMEOUT_MS, ); - test( + it.live( "logout --yes with no token reports not-logged-in and exits 0", - { timeout: E2E_TIMEOUT_MS }, - async () => { - using home = makeTempHome(); - const { exitCode, stderr } = await runSupabase(["logout", "--yes"], { - home: home.dir, - env: { HOME: home.dir }, - }); - expect(exitCode).toBe(0); - expect(stderr).toContain("You were not logged in, nothing to do."); - }, + () => + Effect.gen(function* () { + const home = yield* tempHomeScoped; + const { exitCode, stderr } = yield* runSupabaseEffect(["logout", "--yes"], { + home: home.dir, + // The runner may export a real token; not-logged-in needs it absent. + env: { HOME: home.dir, SUPABASE_ACCESS_TOKEN: undefined }, + }); + expect(exitCode).toBe(0); + expect(stderr).toContain("You were not logged in, nothing to do."); + }).pipe(Effect.scoped), + E2E_TIMEOUT_MS, ); }); diff --git a/apps/cli/src/commands/logout/logout.handler.ts b/apps/cli/src/commands/logout/logout.handler.ts index 07536224ac..efdd8d2875 100644 --- a/apps/cli/src/commands/logout/logout.handler.ts +++ b/apps/cli/src/commands/logout/logout.handler.ts @@ -35,7 +35,7 @@ export const logout = Effect.fn("logout")(function* () { return yield* promptYesNo(output, yes, confirmLabel, false); }); if (!confirmed) { - return yield* Effect.fail(new LogoutCancelledError({ message: CONTEXT_CANCELED_MESSAGE })); + return yield* new LogoutCancelledError({ message: CONTEXT_CANCELED_MESSAGE }); } // `NotLoggedInError` prints to stderr and exits 0 without sweeping project credentials; diff --git a/apps/cli/src/commands/logout/logout.integration.test.ts b/apps/cli/src/commands/logout/logout.integration.test.ts index c3e1c225a9..af4d86087c 100644 --- a/apps/cli/src/commands/logout/logout.integration.test.ts +++ b/apps/cli/src/commands/logout/logout.integration.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer } from "effect"; +import { Cause, Effect, Exit, Layer } from "effect"; import { mockOutput, mockStdin, mockTty } from "../../../tests/helpers/mocks.ts"; import { CliArgs } from "../../shared/cli/cli-args.service.ts"; import { mockCommandCredentialsTracked, mockTelemetryStateTracked, + withEnvVar, } from "../../../tests/helpers/command-mocks.ts"; import { YesFlag } from "../../command-internal/global-flags.ts"; import { logout } from "./logout.handler.ts"; @@ -77,7 +78,7 @@ describe("logout integration", () => { const exit = yield* Effect.exit(logout()); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LogoutCancelledError"); + expect(Cause.pretty(exit.cause)).toContain("LogoutCancelledError"); } expect(credentials.deletedAll).toBe(false); }).pipe(Effect.provide(layer)); @@ -89,7 +90,7 @@ describe("logout integration", () => { const exit = yield* Effect.exit(logout()); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LogoutCancelledError"); + expect(Cause.pretty(exit.cause)).toContain("LogoutCancelledError"); } expect(credentials.deletedAll).toBe(false); }).pipe(Effect.provide(layer)); @@ -104,20 +105,14 @@ describe("logout integration", () => { }); it.live("honors SUPABASE_YES and logs out even when a piped 'n' is present", () => { - const prev = process.env["SUPABASE_YES"]; - process.env["SUPABASE_YES"] = "1"; const { layer, credentials } = setupLogout({ stdinIsTty: false, pipedAnswers: ["n"] }); - return Effect.gen(function* () { - yield* logout(); - expect(credentials.deletedAll).toBe(true); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_YES"]; - else process.env["SUPABASE_YES"] = prev; - }), - ), - Effect.provide(layer), + return withEnvVar( + "SUPABASE_YES", + "1", + Effect.gen(function* () { + yield* logout(); + expect(credentials.deletedAll).toBe(true); + }).pipe(Effect.provide(layer)), ); }); @@ -140,7 +135,7 @@ describe("logout integration", () => { const exit = yield* Effect.exit(logout()); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("DeleteTokenError"); + expect(Cause.pretty(exit.cause)).toContain("DeleteTokenError"); } expect(credentials.deletedAll).toBe(false); }).pipe(Effect.provide(layer)); @@ -212,7 +207,7 @@ describe("logout integration", () => { const exit = yield* Effect.exit(logout()); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("NonInteractiveError"); + expect(Cause.pretty(exit.cause)).toContain("NonInteractiveError"); } }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/test/new/new.e2e.test.ts b/apps/cli/src/commands/test/new/new.e2e.test.ts index 0b420dd27a..089a4abc9a 100644 --- a/apps/cli/src/commands/test/new/new.e2e.test.ts +++ b/apps/cli/src/commands/test/new/new.e2e.test.ts @@ -1,9 +1,8 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Path } from "effect"; -import { runSupabase } from "../../../../tests/helpers/cli.ts"; +import { runSupabaseEffect } from "../../../../tests/helpers/cli.ts"; const E2E_TIMEOUT_MS = 30_000; @@ -14,30 +13,30 @@ const E2E_TIMEOUT_MS = 30_000; * integration suite. */ describe("supabase test new", () => { - let projectDir: string; - - beforeAll(() => { - projectDir = mkdtempSync(join(tmpdir(), "supabase-test-new-e2e-")); - mkdirSync(join(projectDir, "supabase"), { recursive: true }); - writeFileSync(join(projectDir, "supabase", "config.toml"), 'project_id = "test-new-e2e"\n'); - }); - - afterAll(() => { - rmSync(projectDir, { recursive: true, force: true }); - }); - - test( + it.live( "scaffolds supabase/tests/_test.sql and prints the created path", - { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode, stdout } = await runSupabase(["test", "new", "pet"], { - cwd: projectDir, - }); - expect(exitCode).toBe(0); - expect(stdout).toContain("Created new pgtap test at"); - const target = join(projectDir, "supabase", "tests", "pet_test.sql"); - expect(existsSync(target)).toBe(true); - expect(readFileSync(target, "utf8")).toContain("SELECT plan(1);"); - }, + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const projectDir = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-test-new-e2e-", + }); + yield* fs.makeDirectory(path.join(projectDir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(projectDir, "supabase", "config.toml"), + 'project_id = "test-new-e2e"\n', + ); + + const { exitCode, stdout } = yield* runSupabaseEffect(["test", "new", "pet"], { + cwd: projectDir, + }); + expect(exitCode).toBe(0); + expect(stdout).toContain("Created new pgtap test at"); + const target = path.join(projectDir, "supabase", "tests", "pet_test.sql"); + expect(yield* fs.exists(target)).toBe(true); + expect(yield* fs.readFileString(target)).toContain("SELECT plan(1);"); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + E2E_TIMEOUT_MS, ); }); diff --git a/apps/cli/src/commands/test/new/new.handler.ts b/apps/cli/src/commands/test/new/new.handler.ts index 8129e43591..05cc51eae8 100644 --- a/apps/cli/src/commands/test/new/new.handler.ts +++ b/apps/cli/src/commands/test/new/new.handler.ts @@ -29,9 +29,10 @@ export const testNew = Effect.fn("test.new")(function* (flags: TestNewFlags) { const exists = yield* fs.exists(target).pipe(Effect.orElseSucceed(() => false)); if (exists) { - return yield* Effect.fail( - new TestNewFileExistsError({ path: relPath, message: `${relPath} already exists.` }), - ); + return yield* new TestNewFileExistsError({ + path: relPath, + message: `${relPath} already exists.`, + }); } yield* fs diff --git a/apps/cli/src/commands/test/new/new.integration.test.ts b/apps/cli/src/commands/test/new/new.integration.test.ts index b23cd95697..fba10382e2 100644 --- a/apps/cli/src/commands/test/new/new.integration.test.ts +++ b/apps/cli/src/commands/test/new/new.integration.test.ts @@ -1,9 +1,6 @@ -import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, FileSystem, Layer, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; import { badArgument } from "effect/PlatformError"; import { mockOutput } from "../../../../tests/helpers/mocks.ts"; @@ -74,10 +71,12 @@ describe("test new integration", () => { it.live("creates a pgtap test file and prints the created path", () => { const { layer, out, workdir } = setup(); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; yield* testNew(flags("pet")); - const target = join(workdir, "supabase", "tests", "pet_test.sql"); - expect(existsSync(target)).toBe(true); - expect(readFileSync(target, "utf8")).toBe(PGTAP_TEMPLATE); + const target = path.join(workdir, "supabase", "tests", "pet_test.sql"); + expect(yield* fs.exists(target)).toBe(true); + expect(yield* fs.readFileString(target)).toBe(PGTAP_TEMPLATE); expect(out.stdoutText).toContain("Created new pgtap test at "); expect(out.stdoutText).toContain("supabase/tests/pet_test.sql"); }).pipe(Effect.provide(layer)); @@ -85,28 +84,44 @@ describe("test new integration", () => { it.live("pins the created test file to Go's exact 0644 mode under a permissive umask", () => { const { layer, workdir } = setup(); - const prevUmask = process.umask(0); - return Effect.gen(function* () { - yield* testNew(flags("modepin")); - const target = join(workdir, "supabase", "tests", "modepin_test.sql"); - expect(statSync(target).mode & 0o777).toBe(0o644); - }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(() => process.umask(prevUmask)))); + return Effect.acquireUseRelease( + Effect.sync(() => process.umask(0)), + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* testNew(flags("modepin")); + const target = path.join(workdir, "supabase", "tests", "modepin_test.sql"); + const info = yield* fs.stat(target); + expect(info.mode & 0o777).toBe(0o644); + }).pipe(Effect.provide(layer)), + (prevUmask) => + Effect.sync(() => { + process.umask(prevUmask); + }), + ); }); it.live("defaults the template to pgtap when --template is omitted", () => { const { layer, workdir } = setup(); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; yield* testNew(flags("nodbtemplate")); - const target = join(workdir, "supabase", "tests", "nodbtemplate_test.sql"); - expect(readFileSync(target, "utf8")).toBe(PGTAP_TEMPLATE); + const target = path.join(workdir, "supabase", "tests", "nodbtemplate_test.sql"); + expect(yield* fs.readFileString(target)).toBe(PGTAP_TEMPLATE); }).pipe(Effect.provide(layer)); }); it.live("honors an explicit --template pgtap", () => { const { layer, workdir } = setup(); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; yield* testNew(flags("explicit", "pgtap")); - expect(existsSync(join(workdir, "supabase", "tests", "explicit_test.sql"))).toBe(true); + expect(yield* fs.exists(path.join(workdir, "supabase", "tests", "explicit_test.sql"))).toBe( + true, + ); }).pipe(Effect.provide(layer)); }); @@ -134,15 +149,20 @@ describe("test new integration", () => { it.live("fails with TestNewFileExistsError when the file already exists", () => { const { layer, workdir } = setup(); - mkdirSync(join(workdir, "supabase", "tests"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "tests", "dupe_test.sql"), "-- existing\n"); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(workdir, "supabase", "tests"), { recursive: true }); + yield* fs.writeFileString( + path.join(workdir, "supabase", "tests", "dupe_test.sql"), + "-- existing\n", + ); const exit = yield* Effect.exit(testNew(flags("dupe"))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); - expect(json).toContain("TestNewFileExistsError"); - expect(json).toContain("supabase/tests/dupe_test.sql already exists."); + const causeText = Cause.pretty(exit.cause); + expect(causeText).toContain("TestNewFileExistsError"); + expect(causeText).toContain("supabase/tests/dupe_test.sql already exists."); } }).pipe(Effect.provide(layer)); }); @@ -153,7 +173,7 @@ describe("test new integration", () => { const exit = yield* Effect.exit(testNew(flags("nowrite"))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("TestNewWriteError"); + expect(Cause.pretty(exit.cause)).toContain("TestNewWriteError"); } }).pipe(Effect.provide(layer)); }); @@ -164,7 +184,7 @@ describe("test new integration", () => { const exit = yield* Effect.exit(testNew(flags("nomkdir"))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("TestNewWriteError"); + expect(Cause.pretty(exit.cause)).toContain("TestNewWriteError"); } }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/tests/helpers/cli.ts b/apps/cli/tests/helpers/cli.ts index bf4fa04be2..d0bf6ded09 100644 --- a/apps/cli/tests/helpers/cli.ts +++ b/apps/cli/tests/helpers/cli.ts @@ -146,6 +146,21 @@ export function makeTempHome() { return home; } +/** The run's owned temp `SUPABASE_HOME` could not be created. */ +export class TempHomeSetupError extends Data.TaggedError("TempHomeSetupError")<{ + readonly message: string; + readonly cause: unknown; +}> {} + +/** Scoped temp home for Effect-native e2e tests; the scope owns disposal. */ +export const tempHomeScoped = Effect.acquireRelease( + Effect.try({ + try: () => makeTempHome(), + catch: (cause) => new TempHomeSetupError({ message: "temp home setup failed", cause }), + }), + (owned) => Effect.sync(() => owned[Symbol.dispose]()), +); + function pickFreePort(): Promise { return new Promise((resolve, reject) => { const server = createServer(); From 524f9de89356b9b3cf0c65699b3e8197c6cb8873 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:45:05 +0530 Subject: [PATCH 2/4] test: align logout e2e hermeticity and pin the bare fetch passthrough --- .../feedback/feedback.layers.unit.test.ts | 20 +++++++++++++------ .../src/commands/logout/logout.e2e.test.ts | 14 +++++++++---- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/apps/cli/src/commands/feedback/feedback.layers.unit.test.ts b/apps/cli/src/commands/feedback/feedback.layers.unit.test.ts index 89813d1ea1..1d18948076 100644 --- a/apps/cli/src/commands/feedback/feedback.layers.unit.test.ts +++ b/apps/cli/src/commands/feedback/feedback.layers.unit.test.ts @@ -16,10 +16,10 @@ function recordingLogger() { } function recordingInnerFetch() { - const requests: Array<{ url: string; method: string | undefined }> = []; + const requests: Array<{ url: string; method: string | undefined; hasInit: boolean }> = []; const fetch: typeof globalThis.fetch = Object.assign( (input: string | URL | Request, init?: RequestInit): Promise => { - requests.push({ url: String(input), method: init?.method }); + requests.push({ url: String(input), method: init?.method, hasInit: init !== undefined }); return Promise.resolve(new Response("ok")); }, { preconnect: () => Promise.resolve() }, @@ -41,7 +41,7 @@ describe("feedbackFetch", () => { expect(yield* Effect.promise(() => response.text())).toBe("ok"); expect(httpLines).toEqual(["POST https://feedback.supabase.co/rest/v1/rpc/x"]); expect(inner.requests).toEqual([ - { url: "https://feedback.supabase.co/rest/v1/rpc/x", method: "POST" }, + { url: "https://feedback.supabase.co/rest/v1/rpc/x", method: "POST", hasInit: true }, ]); }), ); @@ -52,11 +52,19 @@ describe("feedbackFetch", () => { const inner = recordingInnerFetch(); const fetch = feedbackFetch({ dnsResolver: "native", logger, innerFetch: inner.fetch }); - yield* Effect.promise((signal) => - fetch("https://feedback.supabase.co/rest/v1/interfaces_feedback", { signal }), + // Deliberately no init at all: pins the undefined-init passthrough to the transport. + yield* Effect.promise(() => + fetch("https://feedback.supabase.co/rest/v1/interfaces_feedback"), ); expect(httpLines).toEqual(["GET https://feedback.supabase.co/rest/v1/interfaces_feedback"]); + expect(inner.requests).toEqual([ + { + url: "https://feedback.supabase.co/rest/v1/interfaces_feedback", + method: undefined, + hasInit: false, + }, + ]); }), ); @@ -78,7 +86,7 @@ describe("feedbackFetch", () => { "?select=feedback&delete_token=eq.redacted", ]); // The transport still receives the original, unredacted URL. - expect(inner.requests).toEqual([{ url, method: "DELETE" }]); + expect(inner.requests).toEqual([{ url, method: "DELETE", hasInit: true }]); }), ); }); diff --git a/apps/cli/src/commands/logout/logout.e2e.test.ts b/apps/cli/src/commands/logout/logout.e2e.test.ts index 7c3fd8752e..41cd439d6d 100644 --- a/apps/cli/src/commands/logout/logout.e2e.test.ts +++ b/apps/cli/src/commands/logout/logout.e2e.test.ts @@ -30,7 +30,9 @@ describe("supabase logout", () => { const tokenPath = yield* seedTokenFile(home.dir); const { exitCode, stderr } = yield* runSupabaseEffect(["logout", "--yes"], { home: home.dir, - env: { HOME: home.dir, SUPABASE_ACCESS_TOKEN: undefined }, + // Pin ambient runner state out of the child; the harness's SUPABASE_NO_KEYRING=1 + // is load-bearing safety here (without it `logout --yes` sweeps the real keychain). + env: { HOME: home.dir, SUPABASE_ACCESS_TOKEN: undefined, SUPABASE_PROFILE: undefined }, }); expect(exitCode).toBe(0); expect(stderr).toContain("You were not logged in, nothing to do."); @@ -48,7 +50,12 @@ describe("supabase logout", () => { yield* seedTokenFile(home.dir); const { exitCode, stderr } = yield* runSupabaseEffect(["logout"], { home: home.dir, - env: { HOME: home.dir }, + env: { + HOME: home.dir, + SUPABASE_ACCESS_TOKEN: undefined, + SUPABASE_PROFILE: undefined, + SUPABASE_YES: undefined, + }, stdin: "n\n", }); expect(exitCode).toBe(1); @@ -66,8 +73,7 @@ describe("supabase logout", () => { const home = yield* tempHomeScoped; const { exitCode, stderr } = yield* runSupabaseEffect(["logout", "--yes"], { home: home.dir, - // The runner may export a real token; not-logged-in needs it absent. - env: { HOME: home.dir, SUPABASE_ACCESS_TOKEN: undefined }, + env: { HOME: home.dir, SUPABASE_ACCESS_TOKEN: undefined, SUPABASE_PROFILE: undefined }, }); expect(exitCode).toBe(0); expect(stderr).toContain("You were not logged in, nothing to do."); From d8df9717e202a047cb9738ca9cb4a1bc3ee8386b Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Tue, 15 Sep 2026 01:51:25 +0530 Subject: [PATCH 3/4] refactor(cli): route the profile env read through command settings --- .../command-platform-api.layer.unit.test.ts | 1 + ...e-runtime-script.layer.integration.test.ts | 1 + apps/cli/src/commands/login/login.handler.ts | 9 ++-- .../commands/login/login.integration.test.ts | 47 +++++++++---------- .../services/services.integration.test.ts | 1 + apps/cli/src/config/command-settings.layer.ts | 8 +++- .../command-settings.layer.unit.test.ts | 11 +++++ .../src/config/command-settings.service.ts | 6 +++ .../src/config/project-ref.layer.unit.test.ts | 1 + apps/cli/tests/helpers/command-mocks.ts | 2 + apps/cli/tests/helpers/compute.ts | 1 + 11 files changed, 56 insertions(+), 32 deletions(-) diff --git a/apps/cli/src/auth/command-platform-api.layer.unit.test.ts b/apps/cli/src/auth/command-platform-api.layer.unit.test.ts index 362c72b9a1..edaf0eddfe 100644 --- a/apps/cli/src/auth/command-platform-api.layer.unit.test.ts +++ b/apps/cli/src/auth/command-platform-api.layer.unit.test.ts @@ -33,6 +33,7 @@ function mockCliSettings(opts: { projectHost?: string; }) { return Layer.succeed(CommandSettings, { + profileEnvValue: undefined, profile: opts.profile ?? "supabase", apiUrl: opts.apiUrl ?? "https://api.supabase.com", projectHost: opts.projectHost ?? "supabase.co", diff --git a/apps/cli/src/command-internal/edge-runtime-script.layer.integration.test.ts b/apps/cli/src/command-internal/edge-runtime-script.layer.integration.test.ts index 8757f24e13..606b541c6b 100644 --- a/apps/cli/src/command-internal/edge-runtime-script.layer.integration.test.ts +++ b/apps/cli/src/command-internal/edge-runtime-script.layer.integration.test.ts @@ -42,6 +42,7 @@ function fakeDocker(result: { exitCode: number; stdout?: string; stderr?: string // falls back to the default tag. function makeCliSettings(workdir = "/nonexistent-workdir") { return Layer.succeed(CommandSettings, { + profileEnvValue: undefined, profile: "supabase", apiUrl: "https://api.supabase.com", projectHost: "supabase.co", diff --git a/apps/cli/src/commands/login/login.handler.ts b/apps/cli/src/commands/login/login.handler.ts index 708270bfd3..46dcfb7ce2 100644 --- a/apps/cli/src/commands/login/login.handler.ts +++ b/apps/cli/src/commands/login/login.handler.ts @@ -33,6 +33,7 @@ export const login = Effect.fn("login")(function* (flags: LoginFlags) { const path = yield* Path.Path; const runtimeInfo = yield* RuntimeInfo; const profileFlag = yield* ProfileFlag; + const cliSettings = yield* CommandSettings; const claudeHint = suggestClaudePlugin({ stdoutIsTty: tty.stdoutIsTty }); @@ -45,17 +46,13 @@ export const login = Effect.fn("login")(function* (flags: LoginFlags) { onNone: () => undefined, onSome: ({ args }) => lastExplicitLongFlagValue(args, [], "profile"), }); - // Read the live `process.env` proxy at run time, like the resolver in command-settings: a - // `Config` read was tried and rejected (its env snapshot is case-sensitive, breaking Windows - // parity). The alias satisfies `process-env-in-effect`, which flags only direct reads. - const env = process.env; - const envProfile = env["SUPABASE_PROFILE"]; + const envProfile = cliSettings.profileEnvValue; const profileToken = explicitProfileFlag !== undefined ? explicitProfileFlag : profileFlag !== "supabase" ? profileFlag - : envProfile !== undefined && envProfile.length > 0 + : envProfile !== undefined ? envProfile : undefined; const persistProfileName = diff --git a/apps/cli/src/commands/login/login.integration.test.ts b/apps/cli/src/commands/login/login.integration.test.ts index e238e76f10..66d7e75e52 100644 --- a/apps/cli/src/commands/login/login.integration.test.ts +++ b/apps/cli/src/commands/login/login.integration.test.ts @@ -53,6 +53,8 @@ interface SetupOpts { readonly homeDir?: string; /** Raw argv for explicit `--profile` detection. */ readonly argv?: ReadonlyArray; + /** Raw `SUPABASE_PROFILE` value the settings layer captured. */ + readonly profileEnvValue?: string; } function flags(overrides: Partial = {}): LoginFlags { @@ -82,6 +84,7 @@ function setupLogin(opts: SetupOpts = {}) { const analytics = mockAnalytics(); const cliSettings = mockCommandSettings({ workdir: tempRoot.current, + profileEnvValue: opts.profileEnvValue, accessToken: opts.accessTokenEnv !== undefined ? Option.some(Redacted.make(opts.accessTokenEnv)) @@ -348,23 +351,20 @@ describe("login integration", () => { const { layer } = setupLogin({ argv: ["login", "--token", VALID_TOKEN], homeDir: tempRoot.current, + profileEnvValue: "supabase-staging", }); return withEnvVar( "SUPABASE_HOME", undefined, - withEnvVar( - "SUPABASE_PROFILE", - "supabase-staging", - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* login(flags({ token: Option.some(VALID_TOKEN) })); - const persisted = yield* fs.readFileString( - path.join(tempRoot.current, ".supabase", "profile"), - ); - expect(persisted).toBe("supabase-staging"); - }).pipe(Effect.provide(layer)), - ), + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* login(flags({ token: Option.some(VALID_TOKEN) })); + const persisted = yield* fs.readFileString( + path.join(tempRoot.current, ".supabase", "profile"), + ); + expect(persisted).toBe("supabase-staging"); + }).pipe(Effect.provide(layer)), ); }); @@ -372,22 +372,19 @@ describe("login integration", () => { const { layer } = setupLogin({ argv: ["login", "--profile", "supabase", "--token", VALID_TOKEN], homeDir: tempRoot.current, + profileEnvValue: "rogue-profile", }); return withEnvVar( "SUPABASE_HOME", undefined, - withEnvVar( - "SUPABASE_PROFILE", - "rogue-profile", - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* login(flags({ token: Option.some(VALID_TOKEN) })); - const profilePath = path.join(tempRoot.current, ".supabase", "profile"); - const persisted = yield* fs.readFileString(profilePath); - expect(persisted).toBe("supabase"); - }).pipe(Effect.provide(layer)), - ), + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* login(flags({ token: Option.some(VALID_TOKEN) })); + const profilePath = path.join(tempRoot.current, ".supabase", "profile"); + const persisted = yield* fs.readFileString(profilePath); + expect(persisted).toBe("supabase"); + }).pipe(Effect.provide(layer)), ); }); diff --git a/apps/cli/src/commands/services/services.integration.test.ts b/apps/cli/src/commands/services/services.integration.test.ts index d197b88473..15d5466724 100644 --- a/apps/cli/src/commands/services/services.integration.test.ts +++ b/apps/cli/src/commands/services/services.integration.test.ts @@ -85,6 +85,7 @@ function setup( Layer.succeed( CommandSettings, CommandSettings.of({ + profileEnvValue: undefined, profile: "supabase", apiUrl: opts.apiUrl ?? "https://api.supabase.com", projectHost: "supabase.co", diff --git a/apps/cli/src/config/command-settings.layer.ts b/apps/cli/src/config/command-settings.layer.ts index 3fa74c6d06..de2f3a6071 100644 --- a/apps/cli/src/config/command-settings.layer.ts +++ b/apps/cli/src/config/command-settings.layer.ts @@ -113,6 +113,8 @@ export const commandSettingsLayer = Layer.unwrap( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const runtimeInfo = yield* RuntimeInfo; + // The live `process.env` proxy, not a `Config` snapshot: the snapshot is + // case-sensitive and breaks Windows env lookup parity. const env = process.env; // Optional service: tests without argv default to "not explicit". An empty command @@ -123,6 +125,9 @@ export const commandSettingsLayer = Layer.unwrap( onSome: ({ args }) => lastExplicitLongFlagValue(args, [], "profile"), }); + const rawProfileEnv = env["SUPABASE_PROFILE"]; + const profileEnvValue = + rawProfileEnv === undefined || rawProfileEnv.length === 0 ? undefined : rawProfileEnv; const { name: profile, apiUrl, @@ -132,7 +137,7 @@ export const commandSettingsLayer = Layer.unwrap( } = yield* resolveProfile( profileFlag, explicitProfileFlag, - env["SUPABASE_PROFILE"], + profileEnvValue, fs, path, runtimeInfo.homeDir, @@ -167,6 +172,7 @@ export const commandSettingsLayer = Layer.unwrap( projectHost, poolerHost, dashboardUrl, + profileEnvValue, accessToken, projectId, workdir, diff --git a/apps/cli/src/config/command-settings.layer.unit.test.ts b/apps/cli/src/config/command-settings.layer.unit.test.ts index 358b548f5a..cf695aa465 100644 --- a/apps/cli/src/config/command-settings.layer.unit.test.ts +++ b/apps/cli/src/config/command-settings.layer.unit.test.ts @@ -81,6 +81,7 @@ describe("commandSettingsLayer", () => { Effect.gen(function* () { const config = yield* CommandSettings; expect(config.profile).toBe("supabase"); + expect(config.profileEnvValue).toBeUndefined(); expect(config.apiUrl).toBe("https://api.supabase.com"); expect(config.projectHost).toBe("supabase.co"); expect(config.poolerHost).toBe("supabase.com"); @@ -92,6 +93,7 @@ describe("commandSettingsLayer", () => { Effect.gen(function* () { const config = yield* CommandSettings; expect(config.profile).toBe("supabase-staging"); + expect(config.profileEnvValue).toBe("supabase-staging"); expect(config.apiUrl).toBe("https://api.supabase.green"); expect(config.projectHost).toBe("supabase.red"); expect(config.poolerHost).toBe("supabase.green"); @@ -100,6 +102,14 @@ describe("commandSettingsLayer", () => { ), ); + it.effect("captures an empty SUPABASE_PROFILE as undefined", () => + Effect.gen(function* () { + const config = yield* CommandSettings; + expect(config.profile).toBe("supabase"); + expect(config.profileEnvValue).toBeUndefined(); + }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_PROFILE: "" }, cwd: tempRoot }))), + ); + it.effect("uses supabase-local profile and localhost API URL", () => Effect.gen(function* () { const config = yield* CommandSettings; @@ -270,6 +280,7 @@ describe("commandSettingsLayer", () => { return Effect.gen(function* () { const config = yield* CommandSettings; expect(config.profile).toBe("cli-e2e"); + expect(config.profileEnvValue).toBe(profilePath); expect(config.apiUrl).toBe("http://127.0.0.1:9999"); expect(config.projectHost).toBe("localhost"); expect(config.poolerHost).toBe("staging.example.com"); diff --git a/apps/cli/src/config/command-settings.service.ts b/apps/cli/src/config/command-settings.service.ts index a893c2a278..80e8f6178f 100644 --- a/apps/cli/src/config/command-settings.service.ts +++ b/apps/cli/src/config/command-settings.service.ts @@ -27,6 +27,12 @@ interface CommandSettingsShape { readonly poolerHost: string; /** Dashboard base URL for the active profile, used by the connect-failure network-restrictions hint. */ readonly dashboardUrl: string; + /** + * Raw `SUPABASE_PROFILE` value captured at settings resolution (name or YAML path, + * unvalidated; empty captures as `undefined`). `login` persists it verbatim when no + * explicit flag names a profile. + */ + readonly profileEnvValue: string | undefined; readonly accessToken: Option.Option>; readonly projectId: Option.Option; readonly workdir: string; diff --git a/apps/cli/src/config/project-ref.layer.unit.test.ts b/apps/cli/src/config/project-ref.layer.unit.test.ts index 69aad60053..47747c6332 100644 --- a/apps/cli/src/config/project-ref.layer.unit.test.ts +++ b/apps/cli/src/config/project-ref.layer.unit.test.ts @@ -20,6 +20,7 @@ const ANOTHER_REF = "qrstuvwxyzabcdefghij"; function mockCliSettings(opts: { workdir: string; projectId?: string }) { return Layer.succeed(CommandSettings, { + profileEnvValue: undefined, profile: "supabase", apiUrl: "https://api.supabase.com", projectHost: "supabase.co", diff --git a/apps/cli/tests/helpers/command-mocks.ts b/apps/cli/tests/helpers/command-mocks.ts index 5b2bfe35fd..8b4bf0511d 100644 --- a/apps/cli/tests/helpers/command-mocks.ts +++ b/apps/cli/tests/helpers/command-mocks.ts @@ -397,8 +397,10 @@ export function mockCommandSettings(opts: { readonly accessToken?: Option.Option>; readonly projectId?: Option.Option; readonly userAgent?: string; + readonly profileEnvValue?: string; }): Layer.Layer { return Layer.succeed(CommandSettings, { + profileEnvValue: opts.profileEnvValue, profile: opts.profile ?? "supabase", apiUrl: opts.apiUrl ?? DEFAULT_API_URL, projectHost: opts.projectHost ?? "supabase.co", diff --git a/apps/cli/tests/helpers/compute.ts b/apps/cli/tests/helpers/compute.ts index 9605fdd9fc..f92a88c76a 100644 --- a/apps/cli/tests/helpers/compute.ts +++ b/apps/cli/tests/helpers/compute.ts @@ -349,6 +349,7 @@ export function makeComputeProject(files: Readonly> = {}) */ const testCliConfigLayer = (workdir: string, explicitWorkdir: boolean) => Layer.succeed(CommandSettings, { + profileEnvValue: undefined, profile: "supabase", apiUrl: "https://api.supabase.com", projectHost: "supabase.co", From b8e6d2d5c9f85d0faba8fa4694648e52af863bd6 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Tue, 15 Sep 2026 01:51:25 +0530 Subject: [PATCH 4/4] test: bracket the e2e temp home with acquireUseRelease --- .../src/commands/feedback/add/add.e2e.test.ts | 25 +++--- apps/cli/src/commands/login/login.e2e.test.ts | 57 ++++++------ .../src/commands/logout/logout.e2e.test.ts | 89 ++++++++++--------- apps/cli/tests/helpers/cli.ts | 29 ++++-- 4 files changed, 108 insertions(+), 92 deletions(-) diff --git a/apps/cli/src/commands/feedback/add/add.e2e.test.ts b/apps/cli/src/commands/feedback/add/add.e2e.test.ts index a9ba1571f2..1781cc4b47 100644 --- a/apps/cli/src/commands/feedback/add/add.e2e.test.ts +++ b/apps/cli/src/commands/feedback/add/add.e2e.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect } from "effect"; -import { runSupabaseEffect, tempHomeScoped } from "../../../../tests/helpers/cli.ts"; +import { runSupabaseEffect, withTempHome } from "../../../../tests/helpers/cli.ts"; import { FEEDBACK_EMPTY_MESSAGE } from "./add.errors.ts"; @@ -17,17 +17,18 @@ describe("supabase feedback", () => { it.live( "feedback add fails with the empty-message error when nothing is provided", () => - Effect.gen(function* () { - const home = yield* tempHomeScoped; - const result = yield* runSupabaseEffect(["feedback", "add"], { - home: home.dir, - env: { HOME: home.dir }, - stdin: " \n", - }); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain(FEEDBACK_EMPTY_MESSAGE); - expect(result.stdout).toBe(""); - }).pipe(Effect.scoped), + withTempHome((home) => + Effect.gen(function* () { + const result = yield* runSupabaseEffect(["feedback", "add"], { + home: home.dir, + env: { HOME: home.dir }, + stdin: " \n", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain(FEEDBACK_EMPTY_MESSAGE); + expect(result.stdout).toBe(""); + }), + ), E2E_TIMEOUT_MS, ); }); diff --git a/apps/cli/src/commands/login/login.e2e.test.ts b/apps/cli/src/commands/login/login.e2e.test.ts index 6f14b2a1d3..3aac4dbd36 100644 --- a/apps/cli/src/commands/login/login.e2e.test.ts +++ b/apps/cli/src/commands/login/login.e2e.test.ts @@ -2,7 +2,7 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, FileSystem, Path } from "effect"; -import { runSupabaseEffect, tempHomeScoped } from "../../../tests/helpers/cli.ts"; +import { runSupabaseEffect, withTempHome } from "../../../tests/helpers/cli.ts"; const E2E_TIMEOUT_MS = 30_000; const VALID_TOKEN = "sbp_" + "a".repeat(40); @@ -13,39 +13,40 @@ describe("supabase login", () => { it.live( "login --token persists the token and prints the logged-in message", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const home = yield* tempHomeScoped; - - const { exitCode, stdout } = yield* runSupabaseEffect(["login", "--token", VALID_TOKEN], { - home: home.dir, - env: { HOME: home.dir }, - }); - - expect(exitCode).toBe(0); - expect(stdout).toContain("You are now logged in. Happy coding!"); - const tokenFileExists = yield* fs.exists(path.join(home.dir, "access-token")); - expect(tokenFileExists).toBe(true); - }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + withTempHome((home) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const { exitCode, stdout } = yield* runSupabaseEffect(["login", "--token", VALID_TOKEN], { + home: home.dir, + env: { HOME: home.dir }, + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("You are now logged in. Happy coding!"); + const tokenFileExists = yield* fs.exists(path.join(home.dir, "access-token")); + expect(tokenFileExists).toBe(true); + }), + ).pipe(Effect.provide(BunServices.layer)), E2E_TIMEOUT_MS, ); it.live( "login with no token in a non-TTY exits non-zero with the missing-token message", () => - Effect.gen(function* () { - const home = yield* tempHomeScoped; - - const { exitCode, stdout, stderr } = yield* runSupabaseEffect(["login"], { - home: home.dir, - // The runner may export a real token/profile; this test needs both absent. - env: { HOME: home.dir, SUPABASE_ACCESS_TOKEN: undefined, SUPABASE_PROFILE: undefined }, - }); - - expect(exitCode).not.toBe(0); - expect(`${stdout}${stderr}`).toContain("Cannot use automatic login flow"); - }).pipe(Effect.scoped), + withTempHome((home) => + Effect.gen(function* () { + const { exitCode, stdout, stderr } = yield* runSupabaseEffect(["login"], { + home: home.dir, + // The runner may export a real token/profile; this test needs both absent. + env: { HOME: home.dir, SUPABASE_ACCESS_TOKEN: undefined, SUPABASE_PROFILE: undefined }, + }); + + expect(exitCode).not.toBe(0); + expect(`${stdout}${stderr}`).toContain("Cannot use automatic login flow"); + }), + ), E2E_TIMEOUT_MS, ); }); diff --git a/apps/cli/src/commands/logout/logout.e2e.test.ts b/apps/cli/src/commands/logout/logout.e2e.test.ts index 41cd439d6d..a149ca196e 100644 --- a/apps/cli/src/commands/logout/logout.e2e.test.ts +++ b/apps/cli/src/commands/logout/logout.e2e.test.ts @@ -2,7 +2,7 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, FileSystem, Path } from "effect"; -import { runSupabaseEffect, stripAnsi, tempHomeScoped } from "../../../tests/helpers/cli.ts"; +import { runSupabaseEffect, stripAnsi, withTempHome } from "../../../tests/helpers/cli.ts"; const E2E_TIMEOUT_MS = 30_000; const VALID_TOKEN = "sbp_" + "a".repeat(40); @@ -24,60 +24,63 @@ describe("supabase logout", () => { it.live( "logout --yes removes a file token but reports not-logged-in under no-keyring", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const home = yield* tempHomeScoped; - const tokenPath = yield* seedTokenFile(home.dir); - const { exitCode, stderr } = yield* runSupabaseEffect(["logout", "--yes"], { - home: home.dir, - // Pin ambient runner state out of the child; the harness's SUPABASE_NO_KEYRING=1 - // is load-bearing safety here (without it `logout --yes` sweeps the real keychain). - env: { HOME: home.dir, SUPABASE_ACCESS_TOKEN: undefined, SUPABASE_PROFILE: undefined }, - }); - expect(exitCode).toBe(0); - expect(stderr).toContain("You were not logged in, nothing to do."); - const tokenFileExists = yield* fs.exists(tokenPath); - expect(tokenFileExists).toBe(false); - }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + withTempHome((home) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tokenPath = yield* seedTokenFile(home.dir); + const { exitCode, stderr } = yield* runSupabaseEffect(["logout", "--yes"], { + home: home.dir, + // Pin ambient runner state out of the child; the harness's SUPABASE_NO_KEYRING=1 + // is load-bearing safety here (without it `logout --yes` sweeps the real keychain). + env: { HOME: home.dir, SUPABASE_ACCESS_TOKEN: undefined, SUPABASE_PROFILE: undefined }, + }); + expect(exitCode).toBe(0); + expect(stderr).toContain("You were not logged in, nothing to do."); + const tokenFileExists = yield* fs.exists(tokenPath); + expect(tokenFileExists).toBe(false); + }), + ).pipe(Effect.provide(BunServices.layer)), E2E_TIMEOUT_MS, ); it.live( "declining the logout prompt prints only context canceled, no --debug hint", () => - Effect.gen(function* () { - const home = yield* tempHomeScoped; - yield* seedTokenFile(home.dir); - const { exitCode, stderr } = yield* runSupabaseEffect(["logout"], { - home: home.dir, - env: { - HOME: home.dir, - SUPABASE_ACCESS_TOKEN: undefined, - SUPABASE_PROFILE: undefined, - SUPABASE_YES: undefined, - }, - stdin: "n\n", - }); - expect(exitCode).toBe(1); - const lines = stripAnsi(stderr).trimEnd().split("\n"); - expect(lines.at(-1)).toBe("context canceled"); - expect(stderr).not.toContain("Try rerunning the command with --debug"); - }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + withTempHome((home) => + Effect.gen(function* () { + yield* seedTokenFile(home.dir); + const { exitCode, stderr } = yield* runSupabaseEffect(["logout"], { + home: home.dir, + env: { + HOME: home.dir, + SUPABASE_ACCESS_TOKEN: undefined, + SUPABASE_PROFILE: undefined, + SUPABASE_YES: undefined, + }, + stdin: "n\n", + }); + expect(exitCode).toBe(1); + const lines = stripAnsi(stderr).trimEnd().split("\n"); + expect(lines.at(-1)).toBe("context canceled"); + expect(stderr).not.toContain("Try rerunning the command with --debug"); + }), + ).pipe(Effect.provide(BunServices.layer)), E2E_TIMEOUT_MS, ); it.live( "logout --yes with no token reports not-logged-in and exits 0", () => - Effect.gen(function* () { - const home = yield* tempHomeScoped; - const { exitCode, stderr } = yield* runSupabaseEffect(["logout", "--yes"], { - home: home.dir, - env: { HOME: home.dir, SUPABASE_ACCESS_TOKEN: undefined, SUPABASE_PROFILE: undefined }, - }); - expect(exitCode).toBe(0); - expect(stderr).toContain("You were not logged in, nothing to do."); - }).pipe(Effect.scoped), + withTempHome((home) => + Effect.gen(function* () { + const { exitCode, stderr } = yield* runSupabaseEffect(["logout", "--yes"], { + home: home.dir, + env: { HOME: home.dir, SUPABASE_ACCESS_TOKEN: undefined, SUPABASE_PROFILE: undefined }, + }); + expect(exitCode).toBe(0); + expect(stderr).toContain("You were not logged in, nothing to do."); + }), + ), E2E_TIMEOUT_MS, ); }); diff --git a/apps/cli/tests/helpers/cli.ts b/apps/cli/tests/helpers/cli.ts index d0bf6ded09..a8fa281c30 100644 --- a/apps/cli/tests/helpers/cli.ts +++ b/apps/cli/tests/helpers/cli.ts @@ -91,7 +91,7 @@ export class CliSpawnError extends Data.TaggedError("CliSpawnError")<{ } } -/** Disposing the run's owned temp `SUPABASE_HOME` failed after the CLI exited. */ +/** Disposing a run-owned or test-owned temp `SUPABASE_HOME` failed. */ export class CliHomeDisposeError extends Data.TaggedError("CliHomeDisposeError")<{ readonly cause: unknown; }> { @@ -152,14 +152,25 @@ export class TempHomeSetupError extends Data.TaggedError("TempHomeSetupError")<{ readonly cause: unknown; }> {} -/** Scoped temp home for Effect-native e2e tests; the scope owns disposal. */ -export const tempHomeScoped = Effect.acquireRelease( - Effect.try({ - try: () => makeTempHome(), - catch: (cause) => new TempHomeSetupError({ message: "temp home setup failed", cause }), - }), - (owned) => Effect.sync(() => owned[Symbol.dispose]()), -); +/** + * Runs `use` with an owned temp `SUPABASE_HOME`. Setup and disposal failures both stay in + * the typed channel (`rmSync` can throw), which a scoped release could not express. + */ +export const withTempHome = ( + use: (home: ReturnType) => Effect.Effect, +): Effect.Effect => + Effect.acquireUseRelease( + Effect.try({ + try: () => makeTempHome(), + catch: (cause) => new TempHomeSetupError({ message: "temp home setup failed", cause }), + }), + use, + (owned) => + Effect.try({ + try: () => owned[Symbol.dispose](), + catch: (cause) => new CliHomeDisposeError({ cause }), + }), + ); function pickFreePort(): Promise { return new Promise((resolve, reject) => {