diff --git a/apps/cli/README.md b/apps/cli/README.md index 9b5223a182..867aebd2a0 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -123,7 +123,9 @@ Important areas: The local stack commands use `@supabase/stack` for lifecycle, daemon transport, status, and logs. That stack layer now has an explicit preparation phase, so foreground and detached `start` flows -can surface `Downloading` before normal runtime states. +can surface `Downloading` before normal runtime states. CLI-managed stacks use lazy service startup: +direct listeners start with the stack, while HTTP and Realtime services activate on first proxied +use. The package API itself keeps eager startup as its backward-compatible default. Useful companion docs: diff --git a/apps/cli/src/next/commands/start/start.integration.test.ts b/apps/cli/src/next/commands/start/start.integration.test.ts index be7b06c555..6113aa2628 100644 --- a/apps/cli/src/next/commands/start/start.integration.test.ts +++ b/apps/cli/src/next/commands/start/start.integration.test.ts @@ -170,10 +170,17 @@ function setupInteractive( function setupNonInteractive( opts: { info?: Partial; - stateChanges?: Array<{ name: string; status: StackServiceStatus }>; + stateChanges?: Array<{ name: string; status: StackServiceStatus; dormant?: boolean }>; + startPending?: boolean; + liveStateChanges?: boolean; } = {}, ) { - const stack = mockStack({ info: opts.info, stateChanges: opts.stateChanges }); + const stack = mockStack({ + info: opts.info, + stateChanges: opts.stateChanges, + startPending: opts.startPending, + liveStateChanges: opts.liveStateChanges, + }); const analytics = mockAnalytics(); const out = mockOutput({ format: "text", interactive: false }); const ink = mockInk(); @@ -247,6 +254,39 @@ describe("start", () => { }).pipe(Effect.provide(layer)); }); + it.live("completes startup progress for healthy and dormant services", () => { + const { layer, stack, out } = setupNonInteractive({ + stateChanges: [ + { name: "postgres", status: "Pending" }, + { name: "studio", status: "Pending" }, + ], + startPending: true, + liveStateChanges: true, + }); + return Effect.gen(function* () { + const fiber = yield* start(backgroundFlags).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* waitFor(() => stack.started, "stack startup did not begin"); + + stack.emitStateChange({ name: "postgres", status: "Healthy" }); + stack.emitStateChange({ name: "studio", status: "Pending", dormant: true }); + stack.resolveStart(); + yield* Fiber.join(fiber); + + expect( + out.progressEvents + .filter((event) => event.type === "advance") + .reduce((sum, event) => sum + (event.step ?? 0), 0), + ).toBe(2); + expect(out.progressEvents).toContainEqual({ + type: "advance", + step: 1, + message: "studio is dormant", + }); + }).pipe(Effect.provide(layer)); + }); + it.live("accepts explicit native mode for detached start", () => { const { layer, stack } = setupNonInteractive(); return Effect.gen(function* () { diff --git a/apps/cli/src/next/commands/start/start.live.test.ts b/apps/cli/src/next/commands/start/start.live.test.ts new file mode 100644 index 0000000000..f9183d5f16 --- /dev/null +++ b/apps/cli/src/next/commands/start/start.live.test.ts @@ -0,0 +1,89 @@ +import { afterEach, expect, test } from "vitest"; +import { makeTempHome, makeTempStackProject } from "../../../../tests/helpers/cli.ts"; +import { describeLive, runSupabaseLive } from "../../../../tests/helpers/live.ts"; + +const START_TIMEOUT_MS = 180_000; +const COMMAND_OPTIONS = { entrypoint: "next" as const }; +const LIGHTWEIGHT_DOCKER_ARGS = [ + "start", + "--detach", + "--mode", + "docker", + "--exclude", + "realtime", + "--exclude", + "storage", + "--exclude", + "imgproxy", + "--exclude", + "mailpit", + "--exclude", + "pgmeta", + "--exclude", + "studio", + "--exclude", + "analytics", + "--exclude", + "vector", + "--exclude", + "pooler", +] as const; + +// Lazy service activation crosses the real proxy, daemon, Docker network, and +// container lifecycle boundaries, so keep one gated golden-path live test. +describeLive("supabase start lazy lifecycle (live)", () => { + let project: Awaited> | undefined; + let home: ReturnType | undefined; + + afterEach(async () => { + if (project !== undefined && home !== undefined) { + await runSupabaseLive(["stop", "--no-backup"], { + ...COMMAND_OPTIONS, + cwd: project.dir, + home: home.dir, + }).catch(() => undefined); + } + await project?.cleanup(); + home?.[Symbol.dispose](); + project = undefined; + home = undefined; + }); + + test( + "keeps an HTTP service dormant until its first proxied request", + { timeout: START_TIMEOUT_MS + 120_000 }, + async () => { + project = await makeTempStackProject("supabase-lazy-start-live-"); + home = makeTempHome(); + + const started = await runSupabaseLive([...LIGHTWEIGHT_DOCKER_ARGS], { + ...COMMAND_OPTIONS, + cwd: project.dir, + home: home.dir, + exitTimeoutMs: START_TIMEOUT_MS, + }); + expect(started.exitCode, `stdout:\n${started.stdout}\nstderr:\n${started.stderr}`).toBe(0); + + const before = await runSupabaseLive(["status"], { + ...COMMAND_OPTIONS, + cwd: project.dir, + home: home.dir, + }); + expect(before.exitCode, `stdout:\n${before.stdout}\nstderr:\n${before.stderr}`).toBe(0); + expect(before.stdout).toContain("auth: Pending"); + + const response = await fetch(`http://127.0.0.1:${project.ports.apiPort}/auth/v1/health`, { + signal: AbortSignal.timeout(60_000), + }); + expect(response.ok).toBe(true); + + const after = await runSupabaseLive(["status"], { + ...COMMAND_OPTIONS, + cwd: project.dir, + home: home.dir, + }); + expect(after.exitCode, `stdout:\n${after.stdout}\nstderr:\n${after.stderr}`).toBe(0); + expect(after.stdout).toContain("auth: Healthy"); + }, + ); +}); diff --git a/apps/cli/src/next/commands/start/ui/StartDashboard.tsx b/apps/cli/src/next/commands/start/ui/StartDashboard.tsx index 4f41f4d39d..9f8883bc0f 100644 --- a/apps/cli/src/next/commands/start/ui/StartDashboard.tsx +++ b/apps/cli/src/next/commands/start/ui/StartDashboard.tsx @@ -9,8 +9,7 @@ export function StartDashboard({ model }: { model: StartDashboardModel }) { const states = useAtomValue(model.displayStatesAtom); const info = useAtomValue(model.stackInfoAtom); const phase = useAtomValue(model.phaseAtom); - const showConnectionInfo = - useAtomValue(model.allHealthyAtom) && info !== null && phase !== "failed"; + const showConnectionInfo = useAtomValue(model.showConnectionInfoAtom); const statusLine = useAtomValue(model.statusLineAtom); return ( diff --git a/apps/cli/src/next/commands/start/ui/dashboard.model.ts b/apps/cli/src/next/commands/start/ui/dashboard.model.ts index 1e0c14fb54..9e01b35095 100644 --- a/apps/cli/src/next/commands/start/ui/dashboard.model.ts +++ b/apps/cli/src/next/commands/start/ui/dashboard.model.ts @@ -26,6 +26,7 @@ export interface StartDashboardModel { readonly errorAtom: Atom.Writable; readonly displayStatesAtom: Atom.Atom>; readonly allHealthyAtom: Atom.Atom; + readonly showConnectionInfoAtom: Atom.Atom; readonly statusLineAtom: Atom.Atom; } @@ -68,6 +69,12 @@ export function createStartDashboardModel( get(displayStatesAtom).length > 0 && get(displayStatesAtom).every((s) => s.status === "Healthy"), ); + // Lazy stacks intentionally leave proxy-backed services Pending. A + // successful start phase, rather than universal health, makes connection + // details safe to display. + const showConnectionInfoAtom = Atom.make( + (get) => get(phaseAtom) === "running" && get(stackInfoAtom) !== null, + ); const statusLineAtom = Atom.make((get) => { const phase = get(phaseAtom); const error = get(errorAtom); @@ -97,6 +104,7 @@ export function createStartDashboardModel( errorAtom, displayStatesAtom, allHealthyAtom, + showConnectionInfoAtom, statusLineAtom, }; } diff --git a/apps/cli/src/next/commands/start/ui/dashboard.model.unit.test.ts b/apps/cli/src/next/commands/start/ui/dashboard.model.unit.test.ts index 46f0fc21b1..2bcbcb6193 100644 --- a/apps/cli/src/next/commands/start/ui/dashboard.model.unit.test.ts +++ b/apps/cli/src/next/commands/start/ui/dashboard.model.unit.test.ts @@ -17,6 +17,15 @@ function state(name: string, status: StackServiceStatus) { } describe("createStartDashboardModel", () => { + const stackInfo: StackInfo = { + url: "http://127.0.0.1:54321", + dbUrl: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", + publishableKey: "pk", + secretKey: "sk", + anonJwt: "anon", + serviceRoleJwt: "service-role", + serviceEndpoints: {}, + }; const dashboardStateLayer = Layer.effect( StartDashboardState, Effect.gen(function* () { @@ -55,9 +64,12 @@ describe("createStartDashboardModel", () => { registry.get(model.displayStatesAtom).find((entry) => entry.name === "postgres")?.status, ).toBe("Initializing"); expect(registry.get(model.allHealthyAtom)).toBe(false); + registry.set(model.stackInfoAtom, stackInfo); + expect(registry.get(model.showConnectionInfoAtom)).toBe(false); registry.set(model.phaseAtom, "running"); expect(registry.get(model.statusLineAtom)).toContain("Interrupt to stop"); + expect(registry.get(model.showConnectionInfoAtom)).toBe(true); }); test("shows the foreground failure message when startup fails", async () => { diff --git a/apps/cli/src/next/commands/status/status.handler.ts b/apps/cli/src/next/commands/status/status.handler.ts index 3ba977decb..6caf2d3ac6 100644 --- a/apps/cli/src/next/commands/status/status.handler.ts +++ b/apps/cli/src/next/commands/status/status.handler.ts @@ -14,8 +14,6 @@ import { Output } from "../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; import type { StatusFlags } from "./status.command.ts"; -const READY_STATUSES = new Set(["Healthy", "Running"]); - function formatServiceStateLine(service: { readonly name: string; readonly status: string; @@ -148,7 +146,11 @@ export const status = Effect.fnUntraced(function* (_flags: StatusFlags) { : fillServiceVersionManifest(managedStack.state.services), ); const sortedServices = [...services].sort((a, b) => a.name.localeCompare(b.name)); - const allReady = sortedServices.every((service) => READY_STATUSES.has(service.status)); + const allReady = services.every( + (service) => + ["Running", "Healthy"].includes(service.status) || + (service.status === "Pending" && service.dormant === true), + ); const message = allReady ? "Local Supabase stack is running." : "Local Supabase stack is running, but some services are not ready."; @@ -175,6 +177,7 @@ export const status = Effect.fnUntraced(function* (_flags: StatusFlags) { restart_count: service.restartCount, started_at: service.startedAt, error: service.error, + dormant: service.dormant === true, })), }; diff --git a/apps/cli/src/next/commands/status/status.integration.test.ts b/apps/cli/src/next/commands/status/status.integration.test.ts index 06f21de9e7..951cad9ef9 100644 --- a/apps/cli/src/next/commands/status/status.integration.test.ts +++ b/apps/cli/src/next/commands/status/status.integration.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; import { unixHttpClientLayer } from "@supabase/stack"; +import { StackServiceState } from "@supabase/stack/effect"; import { Effect, Layer } from "effect"; import { status } from "./status.handler.ts"; import { @@ -161,6 +162,166 @@ describe("status handler", () => { }), ); + it.live("does not report dormant lazy services as unready", () => + Effect.gen(function* () { + const fixture = yield* Effect.acquireRelease( + Effect.promise(() => + makeRunningStackFixture({ + states: [ + new StackServiceState({ + name: "auth", + status: "Pending", + pid: null, + exitCode: null, + restartCount: 0, + startedAt: null, + error: null, + dormant: true, + }), + ], + }), + ), + (resource) => Effect.promise(() => resource.dispose()), + ); + const out = mockOutput(); + const layer = Layer.mergeAll( + fixture.baseLayer, + out.layer, + mockProjectLinkState(), + mockProjectLocalServiceVersions(), + ); + + yield* status({ stack: fixture.stackName }).pipe(Effect.provide(layer)); + + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "success", message: "Local Supabase stack is running." }), + ); + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "info", message: "auth: Pending" }), + ); + }), + ); + + it.live("reports an activating pending service as unready", () => + Effect.gen(function* () { + const fixture = yield* Effect.acquireRelease( + Effect.promise(() => + makeRunningStackFixture({ + states: [ + new StackServiceState({ + name: "auth", + status: "Pending", + pid: null, + exitCode: null, + restartCount: 0, + startedAt: null, + error: null, + }), + ], + }), + ), + (resource) => Effect.promise(() => resource.dispose()), + ); + const out = mockOutput(); + const layer = Layer.mergeAll( + fixture.baseLayer, + out.layer, + mockProjectLinkState(), + mockProjectLocalServiceVersions(), + ); + + yield* status({ stack: fixture.stackName }).pipe(Effect.provide(layer)); + + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "warn", + message: "Local Supabase stack is running, but some services are not ready.", + }), + ); + }), + ); + + it.live("reports a stopped service as unready", () => + Effect.gen(function* () { + const fixture = yield* Effect.acquireRelease( + Effect.promise(() => + makeRunningStackFixture({ + states: [ + new StackServiceState({ + name: "auth", + status: "Stopped", + pid: null, + exitCode: 0, + restartCount: 0, + startedAt: null, + error: null, + }), + ], + }), + ), + (resource) => Effect.promise(() => resource.dispose()), + ); + const out = mockOutput(); + const layer = Layer.mergeAll( + fixture.baseLayer, + out.layer, + mockProjectLinkState(), + mockProjectLocalServiceVersions(), + ); + + yield* status({ stack: fixture.stackName }).pipe(Effect.provide(layer)); + + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "warn", + message: "Local Supabase stack is running, but some services are not ready.", + }), + ); + }), + ); + + it.live("reports transitional states without waiting for readiness", () => + Effect.gen(function* () { + const starting = new StackServiceState({ + name: "auth", + status: "Starting", + pid: 123, + exitCode: null, + restartCount: 0, + startedAt: Date.now(), + error: null, + }); + const fixture = yield* Effect.acquireRelease( + Effect.promise(() => + makeRunningStackFixture({ + states: [starting], + waitAllReadyNever: true, + }), + ), + (resource) => Effect.promise(() => resource.dispose()), + ); + const out = mockOutput(); + const layer = Layer.mergeAll( + fixture.baseLayer, + out.layer, + mockProjectLinkState(), + mockProjectLocalServiceVersions(), + ); + + yield* status({ stack: fixture.stackName }).pipe(Effect.provide(layer)); + + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "warn", + message: "Local Supabase stack is running, but some services are not ready.", + }), + ); + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "info", message: "auth: Starting" }), + ); + }).pipe(Effect.timeout("2 seconds")), + ); + it.live("emits machine-readable available updates when the pinned stack is behind", () => Effect.gen(function* () { const fixture = yield* Effect.acquireRelease( diff --git a/apps/cli/src/next/config/stack-config.ts b/apps/cli/src/next/config/stack-config.ts index 15e5fd07c8..4b02f31ce3 100644 --- a/apps/cli/src/next/config/stack-config.ts +++ b/apps/cli/src/next/config/stack-config.ts @@ -25,6 +25,7 @@ export function toStartStackConfig( const excluded = new Set(exclude); return { mode, + startupMode: "lazy", realtime: excluded.has("realtime") ? false : {}, storage: excluded.has("storage") ? false : {}, imgproxy: excluded.has("imgproxy") || excluded.has("storage") ? false : {}, diff --git a/apps/cli/src/next/config/stack-config.unit.test.ts b/apps/cli/src/next/config/stack-config.unit.test.ts index 776e8750f6..d60e7d20fa 100644 --- a/apps/cli/src/next/config/stack-config.unit.test.ts +++ b/apps/cli/src/next/config/stack-config.unit.test.ts @@ -2,10 +2,19 @@ import { describe, expect, it } from "vitest"; import { toStartStackConfig, withServiceVersions } from "./stack-config.ts"; describe("toStartStackConfig", () => { - it("sets the requested startup mode", () => { - expect(toStartStackConfig([], "auto")).toMatchObject({ mode: "auto" }); - expect(toStartStackConfig([], "docker")).toMatchObject({ mode: "docker" }); - expect(toStartStackConfig([], "native")).toMatchObject({ mode: "native" }); + it("uses lazy service startup with the requested runtime mode", () => { + expect(toStartStackConfig([], "auto")).toMatchObject({ + mode: "auto", + startupMode: "lazy", + }); + expect(toStartStackConfig([], "docker")).toMatchObject({ + mode: "docker", + startupMode: "lazy", + }); + expect(toStartStackConfig([], "native")).toMatchObject({ + mode: "native", + startupMode: "lazy", + }); }); it("dedupes excluded services when building stack config", () => { diff --git a/apps/cli/src/next/stack/stack.shared.ts b/apps/cli/src/next/stack/stack.shared.ts index 1ecae74727..37e784a53e 100644 --- a/apps/cli/src/next/stack/stack.shared.ts +++ b/apps/cli/src/next/stack/stack.shared.ts @@ -9,17 +9,25 @@ export const startStackWithProgress = Effect.fnUntraced(function* () { const initialStates = yield* stack.getAllStates(); const stateNames = new Set(initialStates.map((state) => state.name)); const statesByName = new Map(initialStates.map((state) => [state.name, state] as const)); - const readyNames = new Set( - initialStates.filter((state) => state.status === "Healthy").map((state) => state.name), + const completedNames = new Set( + initialStates + .filter((state) => state.status === "Healthy" || state.dormant === true) + .map((state) => state.name), ); const prog = yield* output.progress({ max: initialStates.length }); yield* prog.start("Waiting for services..."); + if (completedNames.size > 0) { + yield* prog.advance(completedNames.size, "Already ready"); + } - const fiber = yield* Stream.runForEach(stack.allStateChanges(), (state) => + const updateProgress = (state: (typeof initialStates)[number]) => Effect.sync(() => { const previousState = statesByName.get(state.name); statesByName.set(state.name, state); - if (!stateNames.has(state.name) || previousState?.status === state.status) { + if ( + !stateNames.has(state.name) || + (previousState?.status === state.status && previousState.dormant === state.dormant) + ) { return []; } return [state]; @@ -28,13 +36,18 @@ export const startStackWithProgress = Effect.fnUntraced(function* () { Effect.forEach( changedStates, (serviceState) => { - if (serviceState.status === "Healthy") { - if (readyNames.has(serviceState.name)) { + if (serviceState.status === "Healthy" || serviceState.dormant === true) { + if (completedNames.has(serviceState.name)) { return Effect.void; } - readyNames.add(serviceState.name); - return prog.advance(1, `${serviceState.name} is ready`); + completedNames.add(serviceState.name); + return prog.advance( + 1, + serviceState.dormant === true + ? `${serviceState.name} is dormant` + : `${serviceState.name} is ready`, + ); } return prog.message(`${serviceState.name}: ${serviceState.status}`); @@ -42,13 +55,18 @@ export const startStackWithProgress = Effect.fnUntraced(function* () { { discard: true }, ), ), - ), - ).pipe( + ); + + const fiber = yield* Stream.runForEach(stack.allStateChanges(), updateProgress).pipe( Effect.catch(() => Effect.void), Effect.forkChild({ startImmediately: true }), ); - yield* stack.start().pipe(Effect.ensuring(Fiber.interrupt(fiber))); + yield* Effect.gen(function* () { + yield* stack.start(); + const finalStates = yield* stack.getAllStates(); + yield* Effect.forEach(finalStates, updateProgress, { discard: true }); + }).pipe(Effect.ensuring(Fiber.interrupt(fiber))); yield* prog.stop("All services started"); }); diff --git a/apps/cli/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index d9e1aca763..4aaaa33266 100644 --- a/apps/cli/tests/helpers/mocks.ts +++ b/apps/cli/tests/helpers/mocks.ts @@ -605,7 +605,11 @@ export function mockTelemetryRuntime( export function mockStack( opts: { info?: Partial; - stateChanges?: Array<{ name: string; status: StackServiceState["status"] }>; + stateChanges?: Array<{ + name: string; + status: StackServiceState["status"]; + dormant?: boolean; + }>; startError?: unknown; startPending?: boolean; stopPending?: boolean; @@ -633,6 +637,7 @@ export function mockStack( restartCount: 0, startedAt: null, error: null, + ...(change.dormant === undefined ? {} : { dormant: change.dormant }), }), ); } @@ -692,20 +697,24 @@ export function mockStack( }), ), getAllStates: () => { - const serviceNames = opts.stateChanges - ? [...new Set(opts.stateChanges.map((s) => s.name))] - : ["postgres"]; + const latestStates = new Map( + (stateHistory.length > 0 + ? stateHistory + : [{ name: "postgres", status: "Pending" as const }] + ).map((state) => [state.name, state] as const), + ); return Effect.succeed( - serviceNames.map( - (name) => + [...latestStates.values()].map( + (state) => new StackServiceState({ - name, - status: "Pending", + name: state.name, + status: state.status, pid: null, exitCode: null, restartCount: 0, startedAt: null, error: null, + ...(state.dormant === undefined ? {} : { dormant: state.dormant }), }), ), ); @@ -726,6 +735,7 @@ export function mockStack( restartCount: 0, startedAt: null, error: null, + ...(change.dormant === undefined ? {} : { dormant: change.dormant }), }), ), ) @@ -743,7 +753,11 @@ export function mockStack( get stopped() { return stopped; }, - emitStateChange(change: { name: string; status: StackServiceState["status"] }) { + emitStateChange(change: { + name: string; + status: StackServiceState["status"]; + dormant?: boolean; + }) { stateHistory.push(change); PubSub.publishUnsafe( statePubSub, @@ -755,6 +769,7 @@ export function mockStack( restartCount: 0, startedAt: null, error: null, + ...(change.dormant === undefined ? {} : { dormant: change.dormant }), }), ); }, @@ -940,6 +955,17 @@ export function mockStateManager( Effect.sync(() => { states.delete(name); }), + removeOwned: (expected: StackState) => + Effect.sync(() => { + const current = states.get(expected.name); + if ( + current?.pid === expected.pid && + current.startedAt === expected.startedAt && + current.socketPath === expected.socketPath + ) { + states.delete(expected.name); + } + }), deleteStack: (name: string) => Effect.sync(() => { states.delete(name); diff --git a/apps/cli/tests/helpers/running-stack.ts b/apps/cli/tests/helpers/running-stack.ts index 261c512eee..50283f76fa 100644 --- a/apps/cli/tests/helpers/running-stack.ts +++ b/apps/cli/tests/helpers/running-stack.ts @@ -125,6 +125,7 @@ function makeProjectHome(projectRoot: string) { function makeStackLayer(opts: { info: StackInfo; states: ReadonlyArray; + waitAllReadyNever?: boolean; history: ReadonlyArray; live: ReadonlyArray; onStop?: () => void; @@ -178,7 +179,7 @@ function makeStackLayer(opts: { opts.states.some((state) => state.name === name) ? Effect.void : Effect.fail(new ServiceNotFoundError({ name })), - waitAllReady: () => Effect.void, + waitAllReady: () => (opts.waitAllReadyNever ? Effect.never : Effect.void), subscribeLogs: (name: string) => Stream.fromIterable(opts.live.filter((entry) => entry.service === name)), subscribeAllLogs: (services?: ReadonlyArray) => @@ -238,6 +239,7 @@ export async function makeStackFixture( services?: PartialVersionManifest; metadata?: StackMetadata; states?: ReadonlyArray; + waitAllReadyNever?: boolean; history?: ReadonlyArray; live?: ReadonlyArray; } = {}, @@ -308,6 +310,7 @@ export async function makeStackFixture( makeStackLayer({ info, states, + waitAllReadyNever: opts.waitAllReadyNever, history, live, onStop: () => {