Skip to content

Commit 730dec8

Browse files
authored
refactor(repo): cover tools, apps/docs and cli-test-helpers with effect lint (CLI-2881) (#7348)
## TL;DR brings `tools`, `apps/docs` and `packages/cli-test-helpers` under the effect lint ## whats introduced? - `apps/docs/**`, `packages/cli-test-helpers/**` and `tools/**` join the allow list - the release and config tools run as effect programs on `FileSystem` and `HttpClient`, with the same requests, console output, files written and exit codes, so the root gains `effect` and `@effect/platform-bun` dev dependencies - the test harness runs on effect too, so `cli-test-helpers` gains the same two as dependencies, and `makeTempDir` returns a promise of an async disposable dir, so its two `apps/cli-e2e` call sites await it - the docs page swaps its `async` functions for promise chains and renders the same pages - `local-registry.ts` adds one `oxlint-disable` for the `openSync` log descriptor Verdaccio shares across stdout and stderr, which effect's `ChildProcess` cannot hand over - a failed run prints the same error, with effect frames in the stack ## ref - closes: CLI-2881
1 parent 99b4540 commit 730dec8

13 files changed

Lines changed: 1063 additions & 586 deletions

File tree

‎.oxlintrc.effect.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
"!packages/config/**",
1111
"!packages/stack/**",
1212
"!apps/cli/**",
13+
"!apps/docs/**",
14+
"!packages/cli-test-helpers/**",
15+
"!tools/**",
1316
// Last match wins across the whole list: keep bare re-exclusions after every
1417
// `!` entry that would otherwise re-include them.
1518
"apps/cli/src/shared/cli/bin.ts",

‎apps/cli-e2e/src/tests/project-lifecycle.e2e.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,8 @@ describe("link", () => {
6767
// requires the --project-ref flag.
6868
test("exits non-zero without --project-ref in non-TTY", async () => {
6969
const serverUrl = inject("replayServerUrl") as string;
70-
const dir = makeTempDir("cli-e2e-link-no-ref-");
71-
using _ = dir;
70+
const dir = await makeTempDir("cli-e2e-link-no-ref-");
71+
await using _ = dir;
7272
const harness = createHarness({
7373
apiUrl: serverUrl,
7474
accessToken: ACCESS_TOKEN,

‎apps/cli-e2e/src/tests/test-context.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,22 +59,22 @@ export const testBehaviour = test.extend<BehaviourFixtures>({
5959
const serverUrl = inject("replayServerUrl");
6060
const slug = scenarioSlug(task);
6161
// Truncate to 40 chars to keep temp dir names manageable.
62-
const dir = makeTempDir(`cli-e2e-${slug.slice(0, 40)}-`);
62+
const dir = await makeTempDir(`cli-e2e-${slug.slice(0, 40)}-`);
6363

6464
const res = await fetch(`${serverUrl}/_ctrl/scenario`, {
6565
method: "POST",
6666
headers: { "Content-Type": "application/json" },
6767
body: JSON.stringify({ name: slug }),
6868
});
6969
if (!res.ok) {
70-
dir[Symbol.dispose]();
70+
await dir[Symbol.asyncDispose]();
7171
const { message } = (await res.json()) as { message: string };
7272
throw new Error(message);
7373
}
7474

7575
await use(dir);
7676

77-
dir[Symbol.dispose]();
77+
await dir[Symbol.asyncDispose]();
7878
await Promise.all([
7979
fetch(`${serverUrl}/_ctrl/requests`, { method: "DELETE" }),
8080
fetch(`${serverUrl}/_ctrl/overrides`, { method: "DELETE" }),

‎apps/docs/app/docs/[[...slug]]/page.tsx‎

Lines changed: 26 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,37 +4,39 @@ import { notFound } from "next/navigation";
44
import { source } from "@/lib/source";
55
import type { Metadata } from "next";
66

7-
export default async function Page(props: { params: Promise<{ slug?: string[] }> }) {
8-
const params = await props.params;
9-
const page = source.getPage(params.slug);
10-
if (!page) notFound();
7+
export default function Page(props: { params: Promise<{ slug?: string[] }> }) {
8+
return props.params.then((params) => {
9+
const page = source.getPage(params.slug);
10+
if (!page) notFound();
1111

12-
const MDX = page.data.body;
12+
const MDX = page.data.body;
1313

14-
return (
15-
<DocsPage toc={page.data.toc}>
16-
<DocsTitle>{page.data.title}</DocsTitle>
17-
<DocsDescription>{page.data.description}</DocsDescription>
18-
<DocsBody>
19-
<MDX components={{ ...defaultMdxComponents }} />
20-
</DocsBody>
21-
</DocsPage>
22-
);
14+
return (
15+
<DocsPage toc={page.data.toc}>
16+
<DocsTitle>{page.data.title}</DocsTitle>
17+
<DocsDescription>{page.data.description}</DocsDescription>
18+
<DocsBody>
19+
<MDX components={{ ...defaultMdxComponents }} />
20+
</DocsBody>
21+
</DocsPage>
22+
);
23+
});
2324
}
2425

25-
export async function generateStaticParams() {
26-
return source.generateParams();
26+
export function generateStaticParams() {
27+
return Promise.resolve(source.generateParams());
2728
}
2829

29-
export async function generateMetadata(props: {
30+
export function generateMetadata(props: {
3031
params: Promise<{ slug?: string[] }>;
3132
}): Promise<Metadata> {
32-
const params = await props.params;
33-
const page = source.getPage(params.slug);
34-
if (!page) notFound();
33+
return props.params.then((params) => {
34+
const page = source.getPage(params.slug);
35+
if (!page) notFound();
3536

36-
return {
37-
title: page.data.title,
38-
description: page.data.description,
39-
};
37+
return {
38+
title: page.data.title,
39+
description: page.data.description,
40+
};
41+
});
4042
}

‎package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,11 @@
3535
"devDependencies": {
3636
"@commitlint/cli": "^21.2.2",
3737
"@commitlint/config-conventional": "^21.2.2",
38+
"@effect/platform-bun": "catalog:",
3839
"@effect/tsgo": "catalog:",
3940
"@tsconfig/bun": "catalog:",
4041
"@types/bun": "catalog:",
42+
"effect": "catalog:",
4143
"husky": "^9.1.7",
4244
"knip": "catalog:",
4345
"oxfmt": "catalog:",

‎packages/cli-test-helpers/package.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212
"test:unit:run": "bun --bun vitest run --project unit --coverage.reportsDirectory=coverage/unit",
1313
"types:check": "tsc --noEmit"
1414
},
15+
"dependencies": {
16+
"@effect/platform-bun": "catalog:",
17+
"effect": "catalog:"
18+
},
1519
"devDependencies": {
1620
"@tsconfig/bun": "catalog:",
1721
"@types/bun": "catalog:",

‎packages/cli-test-helpers/src/harness.ts‎

Lines changed: 124 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2-
import { join } from "node:path";
1+
import { BunFileSystem, BunPath } from "@effect/platform-bun";
2+
import { Effect, FileSystem, Layer, Path, Schema } from "effect";
3+
import type { PlatformError } from "effect/PlatformError";
34
import { tmpdir, platform as osPlatform } from "node:os";
45
import { randomUUID } from "node:crypto";
56

@@ -34,28 +35,65 @@ export interface CLIHarness {
3435
/** A temporary directory that is removed when disposed. */
3536
export interface TempDir {
3637
readonly path: string;
37-
[Symbol.dispose](): void;
38+
[Symbol.asyncDispose](): Promise<void>;
39+
}
40+
41+
class MissingCliBuildError extends Schema.TaggedError<MissingCliBuildError>()(
42+
"MissingCliBuildError",
43+
{ message: Schema.String },
44+
) {}
45+
46+
class SubprocessError extends Schema.TaggedError<SubprocessError>()("SubprocessError", {
47+
cause: Schema.Defect(),
48+
}) {}
49+
50+
/* Rejects with each failure's underlying cause when it has one, and a plain Error for the build check, instead of the tagged Effect error. */
51+
function runHarness<A>(
52+
effect: Effect.Effect<
53+
A,
54+
MissingCliBuildError | SubprocessError | PlatformError,
55+
FileSystem.FileSystem | Path.Path
56+
>,
57+
): Promise<A> {
58+
return Effect.runPromise(
59+
effect.pipe(
60+
Effect.catchTags({
61+
MissingCliBuildError: (error) => Effect.die(new Error(error.message)),
62+
SubprocessError: (error) => Effect.die(error.cause),
63+
PlatformError: (error) => Effect.die(error.cause ?? error),
64+
}),
65+
Effect.provide(Layer.mergeAll(BunFileSystem.layer, BunPath.layer)),
66+
),
67+
);
3868
}
3969

4070
/** Create a unique temporary directory under os.tmpdir() for use as a CLI
4171
* working directory. Dispose it after the test to clean up. */
42-
export function makeTempDir(prefix = "cli-e2e-"): TempDir {
43-
const path = mkdtempSync(join(tmpdir(), prefix));
44-
return {
45-
path,
46-
[Symbol.dispose]() {
47-
rmSync(path, { recursive: true, force: true });
48-
},
49-
};
72+
export function makeTempDir(prefix = "cli-e2e-"): Promise<TempDir> {
73+
return runHarness(
74+
Effect.gen(function* () {
75+
const fs = yield* FileSystem.FileSystem;
76+
const { basename, dirname, join } = yield* Path.Path;
77+
/* Prefixes tmpdir's own name, so every prefix, empty included, maps to mkdtemp(join(tmpdir(), prefix)). */
78+
const path = yield* fs.makeTempDirectory({
79+
directory: dirname(tmpdir()),
80+
prefix: join(basename(tmpdir()), prefix),
81+
});
82+
return {
83+
path,
84+
[Symbol.asyncDispose]() {
85+
return runHarness(fs.remove(path, { recursive: true, force: true }));
86+
},
87+
};
88+
}),
89+
);
5090
}
5191

5292
// Resolve the monorepo root from this file's location:
5393
// packages/cli-test-helpers/src/harness.ts -> ../../../ = repo root
5494
const WORKSPACE_ROOT = new URL("../../../", import.meta.url).pathname.replace(/\/$/, "");
5595

5696
const BINARY_EXT = osPlatform() === "win32" ? ".exe" : "";
57-
const TS_CLI_SHIM = join(WORKSPACE_ROOT, "apps/cli/dist/supabase.js");
58-
const TS_CLI_BINARY = join(WORKSPACE_ROOT, `apps/cli/dist/supabase${BINARY_EXT}`);
5997

6098
// E2E subprocesses should only enter agent output mode when a test explicitly
6199
// opts in via `opts.env`. Keep this list aligned with @vercel/detect-agent env
@@ -92,37 +130,57 @@ export function createSubprocessBaseEnv(
92130
return env;
93131
}
94132

95-
function assertTsCliBuilt(binaryPath: string): void {
96-
if (!existsSync(TS_CLI_SHIM) || !existsSync(binaryPath)) {
97-
throw new Error(
98-
`Missing CLI build artifacts. Run \`pnpm --filter supabase build\` before running e2e tests.\n` +
99-
` expected shim: ${TS_CLI_SHIM}\n` +
100-
` expected binary: ${binaryPath}`,
101-
);
102-
}
133+
/* Treats any access failure, not only ENOENT, as missing. */
134+
function exists(fs: FileSystem.FileSystem, path: string): Effect.Effect<boolean> {
135+
return fs.exists(path).pipe(Effect.orElseSucceed(() => false));
136+
}
137+
138+
function assertTsCliBuilt(
139+
fs: FileSystem.FileSystem,
140+
shimPath: string,
141+
binaryPath: string,
142+
): Effect.Effect<void, MissingCliBuildError> {
143+
return Effect.gen(function* () {
144+
if (!(yield* exists(fs, shimPath)) || !(yield* exists(fs, binaryPath))) {
145+
return yield* new MissingCliBuildError({
146+
message:
147+
`Missing CLI build artifacts. Run \`pnpm --filter supabase build\` before running e2e tests.\n` +
148+
` expected shim: ${shimPath}\n` +
149+
` expected binary: ${binaryPath}`,
150+
});
151+
}
152+
});
103153
}
104154

105155
interface BuiltCommand {
106156
cmd: string[];
107157
binaryOverride?: string;
108158
}
109159

110-
function buildCommand(): BuiltCommand {
111-
assertTsCliBuilt(TS_CLI_BINARY);
112-
return { cmd: ["node", TS_CLI_SHIM], binaryOverride: TS_CLI_BINARY };
160+
function readText(stream: ReadableStream<Uint8Array>): Effect.Effect<string, SubprocessError> {
161+
return Effect.tryPromise({
162+
try: () => new Response(stream).text(),
163+
catch: (cause) => new SubprocessError({ cause }),
164+
});
113165
}
114166

115167
export function createHarness(options: HarnessOptions): CLIHarness {
116168
return { options };
117169
}
118170

119-
export async function exec(
171+
const execEffect = Effect.fnUntraced(function* (
120172
harness: CLIHarness,
121173
args: string[],
122174
opts?: { env?: Record<string, string> },
123-
): Promise<CLIResult> {
175+
) {
176+
const fs = yield* FileSystem.FileSystem;
177+
const path = yield* Path.Path;
178+
/* Builds the whole invocation before the first asynchronous step, in call order, so the caller
179+
can mutate its inputs once exec returns. */
124180
const start = performance.now();
125-
const built = buildCommand();
181+
const tsCliShim = path.join(WORKSPACE_ROOT, "apps/cli/dist/supabase.js");
182+
const tsCliBinary = path.join(WORKSPACE_ROOT, `apps/cli/dist/supabase${BINARY_EXT}`);
183+
const built: BuiltCommand = { cmd: ["node", tsCliShim], binaryOverride: tsCliBinary };
126184

127185
const env: Record<string, string> = {
128186
...createSubprocessBaseEnv(),
@@ -146,37 +204,53 @@ export async function exec(
146204
// file path, and the still-proxied Go binary reads the same file via
147205
// SUPABASE_PROFILE. Write a temp profile file pointing at the replay server so
148206
// both paths reach it.
149-
const profilePath = join(tmpdir(), `cli-e2e-profile-${randomUUID()}.yaml`);
207+
const profilePath = path.join(tmpdir(), `cli-e2e-profile-${randomUUID()}.yaml`);
150208
const url = harness.options.apiUrl;
151-
writeFileSync(
152-
profilePath,
153-
[
154-
`name: test`,
155-
`api_url: "${url}"`,
156-
`dashboard_url: "${url}"`,
157-
`project_host: ${harness.options.projectHost ?? "localhost"}`,
158-
].join("\n"),
159-
);
209+
const profile = [
210+
`name: test`,
211+
`api_url: "${url}"`,
212+
`dashboard_url: "${url}"`,
213+
`project_host: ${harness.options.projectHost ?? "localhost"}`,
214+
].join("\n");
215+
const command = [...built.cmd, ...args];
216+
// Default to os.tmpdir() so subprocess file writes never land in the repo
217+
const cwd = harness.options.cwd ?? tmpdir();
218+
219+
yield* assertTsCliBuilt(fs, tsCliShim, tsCliBinary);
220+
yield* fs.writeFileString(profilePath, profile);
160221
env["SUPABASE_PROFILE"] = profilePath;
161222

162-
const proc = Bun.spawn([...built.cmd, ...args], {
163-
env,
164-
// Default to os.tmpdir() so subprocess file writes never land in the repo
165-
cwd: harness.options.cwd ?? tmpdir(),
166-
stdout: "pipe",
167-
stderr: "pipe",
168-
stdin: "ignore",
223+
const proc = yield* Effect.try({
224+
try: () =>
225+
Bun.spawn(command, {
226+
env,
227+
cwd,
228+
stdout: "pipe",
229+
stderr: "pipe",
230+
stdin: "ignore",
231+
}),
232+
catch: (cause) => new SubprocessError({ cause }),
169233
});
170234

171-
const [stdout, stderr] = await Promise.all([
172-
new Response(proc.stdout).text(),
173-
new Response(proc.stderr).text(),
174-
]);
235+
const [stdout, stderr] = yield* Effect.all([readText(proc.stdout), readText(proc.stderr)], {
236+
concurrency: "unbounded",
237+
});
175238

176-
const exitCode = await proc.exited;
239+
const exitCode = yield* Effect.tryPromise({
240+
try: () => proc.exited,
241+
catch: (cause) => new SubprocessError({ cause }),
242+
});
177243
const durationMs = performance.now() - start;
178244

179-
if (profilePath) rmSync(profilePath, { force: true });
245+
if (profilePath) yield* fs.remove(profilePath, { force: true });
180246

181247
return { stdout, stderr, exitCode, durationMs };
248+
});
249+
250+
export function exec(
251+
harness: CLIHarness,
252+
args: string[],
253+
opts?: { env?: Record<string, string> },
254+
): Promise<CLIResult> {
255+
return runHarness(execEffect(harness, args, opts));
182256
}

0 commit comments

Comments
 (0)