Skip to content
Merged
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
51 changes: 37 additions & 14 deletions apps/cli/src/legacy/commands/functions/deploy/deploy.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,20 @@ import { join } from "node:path";
import { describe, expect, test } from "vitest";
import { makeTempHome, runSupabase } from "../../../../../tests/helpers/cli.ts";

// Argument-validation negatives for `functions deploy`. This validation lives in
// the Go CLI today (the legacy TS command proxies to it); a black-box subprocess
// test keeps these assertions valid through the eventual native TS port — it
// guards behavior, not implementation. Asserting the SPECIFIC error text also
// avoids a false pass from an unrelated non-zero exit (e.g. a missing Go binary).
// Argument-validation negatives for `functions deploy`. This validation is native TS
// (`shared/functions/deploy.ts`'s mutual-exclusivity and `--jobs` guards) ported from
// Go's cobra flag-group validation — a black-box subprocess test keeps these
// assertions valid across the shell boundary. Asserting the SPECIFIC error text also
// avoids a false pass from an unrelated non-zero exit.
//
// All cases fail before any network call (cobra flag parsing / pre-resolution),
// so no auth or linked project is required.
// All cases fail before any network call (the guards run before project-ref/config
// resolution), so no auth or linked project is required.

const E2E_TIMEOUT_MS = 30_000;
const SLUG = "deploy-e2e-basic";
// Valid-format token + ref to clear the auth and project-ref gates (both checked
// before the Go bundler-flag validation under test). These cases all fail before
// any network call (cobra flag-group validation / the jobs check at the top of
// RunE), so neither value is ever used against a real API.
// before the bundler-flag validation under test). These cases all fail before any
// network call, so neither value is ever used against a real API.
const FAKE_TOKEN = `sbp_${"0".repeat(40)}`;
const FAKE_REF = "a".repeat(20);

Expand Down Expand Up @@ -56,12 +55,36 @@ describe("supabase functions deploy (legacy) — argument validation", () => {
},
);
expect(exitCode).not.toBe(0);
// The Go CLI phrases this as either "must be used together with --use-api"
// or "cannot be used with local bundling" depending on version — both mean
// --jobs is rejected without server-side (--use-api) bundling.
expect(stderr).toMatch(/--jobs\b.*(--use-api|local bundling)/i);
expect(stderr).toContain("--jobs must be used together with --use-api");
});

test(
"rejects --jobs without --use-api even with --use-docker=false (Go parity gap)",
{ timeout: E2E_TIMEOUT_MS },
async () => {
using home = makeTempHome();
const { exitCode, stderr } = await runSupabase(
[
"functions",
"deploy",
SLUG,
"--project-ref",
FAKE_REF,
"--use-docker=false",
"--jobs",
"2",
],
{
entrypoint: "legacy",
home: home.dir,
env: { HOME: home.dir, SUPABASE_ACCESS_TOKEN: FAKE_TOKEN },
},
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("--jobs must be used together with --use-api");
},
);

test("fails without a linked project or --project-ref", { timeout: E2E_TIMEOUT_MS }, async () => {
using home = makeTempHome();
const workdir = mkdtempSync(join(tmpdir(), "fn-deploy-nolink-"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
useLegacyTempWorkdir,
} from "../../../../../tests/helpers/legacy-mocks.ts";
import { mockOutput, mockRuntimeInfo } from "../../../../../tests/helpers/mocks.ts";
import { mockChildProcessSpawner } from "../../../../../../../packages/process-compose/tests/helpers/mocks.ts";
import { legacyFunctionsDeploy } from "./deploy.handler.ts";
import type { LegacyFunctionsDeployFlags } from "./deploy.command.ts";

Expand Down Expand Up @@ -482,4 +483,253 @@ describe("legacy functions deploy", () => {
),
);
});

describe("--jobs validation (Go parity: cmd/functions.go:79-82)", () => {
function setupJobsTest(rawArgs: ReadonlyArray<string>) {
const out = mockOutput({ format: "text" });
const api = mockLegacyPlatformApi({
handler: (request) => Effect.succeed(legacyJsonResponse(request, 200, [])),
});
const layer = Layer.mergeAll(
buildLegacyTestRuntime({
out,
api,
cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }),
runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }),
}),
Layer.succeed(LegacyYesFlag, false),
Stdio.layerTest({ args: Effect.succeed(rawArgs) }),
);
return { out, api, layer };
}

it.live("rejects --jobs > 1 without --use-api, even with default --use-docker", () => {
const { layer } = setupJobsTest(["functions", "deploy", "hello-world", "--jobs", "2"]);

return Effect.gen(function* () {
const error = yield* legacyFunctionsDeploy({
...baseFlags,
useApi: false,
useDocker: true,
jobs: Option.some(2),
}).pipe(Effect.provide(layer), Effect.flip);

expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe("--jobs must be used together with --use-api");
});
});

it.live("rejects --jobs > 1 with --use-docker=false and no --use-api (Go parity gap)", () => {
// Divergence this test guards: previously the guard only fired when local
// bundling (Docker/legacy-bundle) was active, so `--use-docker=false --jobs 2`
// (no --use-api) silently passed in TS while Go rejected it.
const { layer } = setupJobsTest([
"functions",
"deploy",
"hello-world",
"--use-docker=false",
"--jobs",
"2",
]);

return Effect.gen(function* () {
const error = yield* legacyFunctionsDeploy({
...baseFlags,
useApi: false,
useDocker: false,
jobs: Option.some(2),
}).pipe(Effect.provide(layer), Effect.flip);

expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe("--jobs must be used together with --use-api");
});
});

it.live("allows --jobs > 1 together with --use-api", () => {
const out = mockOutput({ format: "text" });
const api = mockLegacyPlatformApi({
handler: (request) => {
if (request.method === "GET") {
return Effect.succeed(legacyJsonResponse(request, 200, []));
}
return Effect.succeed(
legacyJsonResponse(request, 201, {
id: "function-id",
slug: "hello-world",
name: "hello-world",
status: "ACTIVE",
version: 2,
created_at: 1_687_423_025_152,
updated_at: 1_687_423_025_152,
verify_jwt: true,
import_map: true,
entrypoint_path: "functions/hello-world/index.ts",
import_map_path: "functions/hello-world/deno.json",
}),
);
},
});
const layer = Layer.mergeAll(
buildLegacyTestRuntime({
out,
api,
cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }),
runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }),
}),
Layer.succeed(LegacyYesFlag, false),
Stdio.layerTest({
args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api", "--jobs", "2"]),
}),
);

return Effect.gen(function* () {
yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current));
yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world"));

yield* legacyFunctionsDeploy({
...baseFlags,
useApi: true,
jobs: Option.some(2),
});

expect(out.stdoutText).toContain(
"Deployed Functions on project abcdefghijklmnopqrst: hello-world\n",
);
}).pipe(
Effect.provide(layer),
Effect.ensuring(
Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })),
),
);
});

it.live("treats --jobs 0 as 1 and does not require --use-api", () => {
const out = mockOutput({ format: "text" });
const api = mockLegacyPlatformApi({
handler: (request) => {
if (request.method === "GET") {
return Effect.succeed(legacyJsonResponse(request, 200, []));
}
return Effect.succeed(
legacyJsonResponse(request, 201, {
id: "function-id",
slug: "hello-world",
name: "hello-world",
status: "ACTIVE",
version: 2,
created_at: 1_687_423_025_152,
updated_at: 1_687_423_025_152,
verify_jwt: true,
import_map: true,
entrypoint_path: "functions/hello-world/index.ts",
import_map_path: "functions/hello-world/deno.json",
}),
);
},
});
const layer = Layer.mergeAll(
buildLegacyTestRuntime({
out,
api,
cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }),
runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }),
}),
Layer.succeed(LegacyYesFlag, false),
Stdio.layerTest({
args: Effect.succeed(["functions", "deploy", "hello-world", "--jobs", "0"]),
}),
);

return Effect.gen(function* () {
yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current));
yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world"));

yield* legacyFunctionsDeploy({
...baseFlags,
useApi: false,
jobs: Option.some(0),
});

expect(out.stdoutText).toContain(
"Deployed Functions on project abcdefghijklmnopqrst: hello-world\n",
);
}).pipe(
Effect.provide(layer),
Effect.ensuring(
Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })),
),
);
});
});

describe("bundler routing with --use-api=false (Go parity: cmd/functions.go:79-80)", () => {
it.live("falls through to Docker bundling, not the API path, when --use-api=false", () => {
// Divergence this test guards: Go's `if useApi { useDocker = false }` only forces
// the API path when the RESOLVED value is true. `--use-api=false` alone must leave
// `useDocker`'s own value (default true) in effect, routing to Docker — previously
// `useLocalBundler` keyed off flag *presence* (`explicitUseApi`), so typing
// `--use-api=false` silently forced the API path instead.
const out = mockOutput({ format: "text" });
const child = mockChildProcessSpawner({ exitCode: 1 });
const api = mockLegacyPlatformApi({
handler: (request) => {
if (request.method === "GET") {
return Effect.succeed(legacyJsonResponse(request, 200, []));
}
return Effect.succeed(
legacyJsonResponse(request, 201, {
id: "function-id",
slug: "hello-world",
name: "hello-world",
status: "ACTIVE",
version: 2,
created_at: 1_687_423_025_152,
updated_at: 1_687_423_025_152,
verify_jwt: true,
import_map: true,
entrypoint_path: "functions/hello-world/index.ts",
import_map_path: "functions/hello-world/deno.json",
}),
);
},
});
const layer = Layer.mergeAll(
buildLegacyTestRuntime({
out,
api,
cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }),
runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }),
}),
Layer.succeed(LegacyYesFlag, false),
child.layer,
Stdio.layerTest({
args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api=false"]),
}),
);

return Effect.gen(function* () {
yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current));
yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world"));

yield* legacyFunctionsDeploy({
...baseFlags,
useApi: false,
useDocker: true,
});

// Docker was actually attempted (proves useLocalBundler resolved to true);
// it wasn't running, so the command fell back to the API and still succeeded.
expect(child.spawned).toEqual([{ command: "docker", args: ["info"] }]);
expect(out.stderrText).toContain("WARNING: Docker is not running\n");
expect(out.stdoutText).toContain(
"Deployed Functions on project abcdefghijklmnopqrst: hello-world\n",
);
}).pipe(
Effect.provide(layer),
Effect.ensuring(
Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })),
),
);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2175,4 +2175,48 @@ describe("functions deploy", () => {
expect(error.message).toContain("--use-docker");
}).pipe(Effect.ensuring(cleanupTempDir(tempDir)));
});

describe("--jobs validation (Go parity: cmd/functions.go:79-82)", () => {
it.live("rejects --jobs > 1 without --use-api", () => {
const tempDir = makeTempDir();

return Effect.gen(function* () {
yield* Effect.promise(() => writeProjectConfig(tempDir));
const { layer } = setup(tempDir, {
rawArgs: ["functions", "deploy", "--jobs", "2"],
});

const error = yield* functionsDeploy({
...BASE_FLAGS,
useApi: false,
jobs: Option.some(2),
}).pipe(Effect.provide(layer), Effect.flip);

expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe("--jobs must be used together with --use-api");
}).pipe(Effect.ensuring(cleanupTempDir(tempDir)));
});

it.live("allows --jobs > 1 together with --use-api", () => {
const tempDir = makeTempDir();

return Effect.gen(function* () {
yield* Effect.promise(() => writeProjectConfig(tempDir));
yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world"));

const { out, layer } = setup(tempDir, {
rawArgs: ["functions", "deploy", "hello-world", "--use-api", "--jobs", "2"],
});

yield* functionsDeploy({
...BASE_FLAGS,
functionNames: ["hello-world"],
useApi: true,
jobs: Option.some(2),
}).pipe(Effect.provide(layer));

expect(out.stdoutText).toContain(`Deployed Functions on project ${PROJECT_REF}`);
}).pipe(Effect.ensuring(cleanupTempDir(tempDir)));
});
});
});
Loading
Loading