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" ;
34import { tmpdir , platform as osPlatform } from "node:os" ;
45import { randomUUID } from "node:crypto" ;
56
@@ -34,28 +35,65 @@ export interface CLIHarness {
3435/** A temporary directory that is removed when disposed. */
3536export 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
5494const WORKSPACE_ROOT = new URL ( "../../../" , import . meta. url ) . pathname . replace ( / \/ $ / , "" ) ;
5595
5696const 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
105155interface 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
115167export 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