Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .oxlintrc.effect.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,19 @@
"!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/**",
"!apps/cli/src/commands/services/**",
"!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/**",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
33 changes: 18 additions & 15 deletions apps/cli/src/commands/feedback/add/add.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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, withTempHome } from "../../../../tests/helpers/cli.ts";

import { FEEDBACK_EMPTY_MESSAGE } from "./add.errors.ts";

Expand All @@ -13,19 +14,21 @@ 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("");
},
() =>
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,
);
});
14 changes: 5 additions & 9 deletions apps/cli/src/commands/feedback/add/add.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,7 @@ const readCappedPipedText = (pipe: Stream.Stream<Uint8Array, PlatformError>) =>
);
if (readFailed) return Option.none<string>();
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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down
50 changes: 31 additions & 19 deletions apps/cli/src/commands/feedback/add/add.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -47,16 +45,26 @@ const tempRoot = useTempWorkdir("supabase-feedback-add-int-");
// Seeds `<workdir>/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";

Expand Down Expand Up @@ -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);
Expand All @@ -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");
Expand All @@ -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") }),
);
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand All @@ -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({
Expand Down Expand Up @@ -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" }));
Expand Down Expand Up @@ -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));
});
Expand All @@ -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
Expand Down
118 changes: 64 additions & 54 deletions apps/cli/src/commands/feedback/add/add.live.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { expect } from "vitest";
import { Effect, Schema } from "effect";

import { test } from "../../../../tests/helpers/live.ts";

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
Expand All @@ -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 },
),
);
Loading