mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
fix(app): keep reload splash continuous
Treat desktop settings evaluation as part of daemon startup so restored app chrome cannot render between bootstrap phases.
This commit is contained in:
@@ -65,6 +65,8 @@ export interface DesktopBridgeConfig {
|
|||||||
daemonListen?: string;
|
daemonListen?: string;
|
||||||
/** Keep start_desktop_daemon pending to hold the desktop startup blocker open. */
|
/** Keep start_desktop_daemon pending to hold the desktop startup blocker open. */
|
||||||
hangDaemonStart?: boolean;
|
hangDaemonStart?: boolean;
|
||||||
|
/** Delay the desktop settings IPC response to exercise startup ordering. */
|
||||||
|
desktopSettingsDelayMs?: number;
|
||||||
/**
|
/**
|
||||||
* Controls what dialog.ask returns when the daemon management confirm dialog
|
* Controls what dialog.ask returns when the daemon management confirm dialog
|
||||||
* fires. True = confirm (proceed with the action), false = cancel. Defaults to
|
* fires. True = confirm (proceed with the action), false = cancel. Defaults to
|
||||||
@@ -101,6 +103,7 @@ declare global {
|
|||||||
__capturedDialogCall: ConfirmDialogCall | undefined;
|
__capturedDialogCall: ConfirmDialogCall | undefined;
|
||||||
__capturedDialogOpenCalls: Array<Record<string, unknown> | undefined>;
|
__capturedDialogOpenCalls: Array<Record<string, unknown> | undefined>;
|
||||||
__recordDesktopEditorOpen?: (input: DesktopEditorOpenRecord) => Promise<void>;
|
__recordDesktopEditorOpen?: (input: DesktopEditorOpenRecord) => Promise<void>;
|
||||||
|
__desktopDaemonStartRequested?: boolean;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +132,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
|||||||
let daemonRunning = true;
|
let daemonRunning = true;
|
||||||
let currentPid: number | null = cfg.daemonPid ?? null;
|
let currentPid: number | null = cfg.daemonPid ?? null;
|
||||||
let startCount = 0;
|
let startCount = 0;
|
||||||
|
window.__desktopDaemonStartRequested = false;
|
||||||
|
|
||||||
function buildDaemonStatus() {
|
function buildDaemonStatus() {
|
||||||
return {
|
return {
|
||||||
@@ -145,6 +149,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startDesktopDaemon() {
|
function startDesktopDaemon() {
|
||||||
|
window.__desktopDaemonStartRequested = true;
|
||||||
if (cfg.hangDaemonStart) {
|
if (cfg.hangDaemonStart) {
|
||||||
return new Promise(() => undefined);
|
return new Promise(() => undefined);
|
||||||
}
|
}
|
||||||
@@ -156,6 +161,13 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
|||||||
return buildDaemonStatus();
|
return buildDaemonStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForDesktopSettingsResponse() {
|
||||||
|
const delayMs = cfg.desktopSettingsDelayMs ?? 0;
|
||||||
|
if (delayMs > 0) {
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const desktopBridge: {
|
const desktopBridge: {
|
||||||
platform: string;
|
platform: string;
|
||||||
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||||
@@ -212,6 +224,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (command === "get_desktop_settings") {
|
if (command === "get_desktop_settings") {
|
||||||
|
await waitForDesktopSettingsResponse();
|
||||||
return {
|
return {
|
||||||
releaseChannel: "stable",
|
releaseChannel: "stable",
|
||||||
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
|
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
|
||||||
@@ -277,6 +290,17 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
|||||||
}, config);
|
}, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function waitForDesktopDaemonStartRequest(page: Page): Promise<void> {
|
||||||
|
await page.waitForFunction(() => window.__desktopDaemonStartRequested === true);
|
||||||
|
// Give the startup state two paints to expose any app → splash regression.
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) =>
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function waitForDirectoryDialog(
|
export async function waitForDirectoryDialog(
|
||||||
page: Page,
|
page: Page,
|
||||||
): Promise<Record<string, unknown> | undefined> {
|
): Promise<Record<string, unknown> | undefined> {
|
||||||
|
|||||||
@@ -31,11 +31,52 @@ import {
|
|||||||
} from "./helpers/workspace-ui";
|
} from "./helpers/workspace-ui";
|
||||||
import { clickSettingsBackToWorkspace } from "./helpers/settings";
|
import { clickSettingsBackToWorkspace } from "./helpers/settings";
|
||||||
import { getServerId } from "./helpers/server-id";
|
import { getServerId } from "./helpers/server-id";
|
||||||
import { injectDesktopBridge } from "./helpers/desktop-updates";
|
import { injectDesktopBridge, waitForDesktopDaemonStartRequest } from "./helpers/desktop-updates";
|
||||||
import { expectAppRoute } from "./helpers/route-assertions";
|
import { expectAppRoute } from "./helpers/route-assertions";
|
||||||
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
||||||
|
|
||||||
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
|
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
|
||||||
|
type StartupPresentation = "splash" | "app";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__paseoStartupPresentationTrace?: StartupPresentation[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function observeStartupPresentation(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const trace: StartupPresentation[] = [];
|
||||||
|
window.__paseoStartupPresentationTrace = trace;
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const recordPresentation = () => {
|
||||||
|
let presentation: StartupPresentation | null = null;
|
||||||
|
if (document.querySelector('[data-testid="startup-splash"]')) {
|
||||||
|
presentation = "splash";
|
||||||
|
} else if (
|
||||||
|
document.querySelector(
|
||||||
|
'[data-testid="workspace-header-title"], [data-testid="sidebar-settings"]',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
presentation = "app";
|
||||||
|
}
|
||||||
|
if (presentation && trace.at(-1) !== presentation) {
|
||||||
|
trace.push(presentation);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
new MutationObserver(recordPresentation).observe(document.documentElement, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
recordPresentation();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStartupPresentation(page: Page): Promise<StartupPresentation[]> {
|
||||||
|
return page.evaluate(() => window.__paseoStartupPresentationTrace?.slice() ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
async function expectNoLoadingWorkspacePane(
|
async function expectNoLoadingWorkspacePane(
|
||||||
page: Page,
|
page: Page,
|
||||||
@@ -239,7 +280,9 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("refresh keeps the user on the same workspace route", async ({ page }) => {
|
test("refresh keeps one continuous splash before restoring the workspace route", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
const serverId = getServerId();
|
const serverId = getServerId();
|
||||||
const daemonGate = await installDaemonWebSocketGate(page);
|
const daemonGate = await installDaemonWebSocketGate(page);
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-refresh-route-" });
|
const workspace = await seedWorkspace({ repoPrefix: "workspace-refresh-route-" });
|
||||||
@@ -254,6 +297,7 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
serverId,
|
serverId,
|
||||||
manageBuiltInDaemon: true,
|
manageBuiltInDaemon: true,
|
||||||
hangDaemonStart: true,
|
hangDaemonStart: true,
|
||||||
|
desktopSettingsDelayMs: 250,
|
||||||
daemonListen: `127.0.0.1:${getE2EDaemonPort()}`,
|
daemonListen: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||||
});
|
});
|
||||||
await openWorkspaceThroughApp(page, { serverId, workspace });
|
await openWorkspaceThroughApp(page, { serverId, workspace });
|
||||||
@@ -261,14 +305,16 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
await expectWorkspaceTabVisible(page, agent.id);
|
await expectWorkspaceTabVisible(page, agent.id);
|
||||||
await expectWorkspaceLocation(page, { serverId, workspace });
|
await expectWorkspaceLocation(page, { serverId, workspace });
|
||||||
|
|
||||||
|
await observeStartupPresentation(page);
|
||||||
await daemonGate.drop();
|
await daemonGate.drop();
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page.getByTestId("startup-splash")).toBeVisible({ timeout: 30_000 });
|
await waitForDesktopDaemonStartRequest(page);
|
||||||
daemonGate.restore();
|
daemonGate.restore();
|
||||||
await waitForSidebarHydration(page);
|
await waitForSidebarHydration(page);
|
||||||
|
|
||||||
await expectWorkspaceLocation(page, { serverId, workspace });
|
await expectWorkspaceLocation(page, { serverId, workspace });
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
expect(await getStartupPresentation(page)).toEqual(["splash", "app"]);
|
||||||
} finally {
|
} finally {
|
||||||
daemonGate.restore();
|
daemonGate.restore();
|
||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ import {
|
|||||||
resolveStartupBlocker,
|
resolveStartupBlocker,
|
||||||
resolveStartupNavigationReady,
|
resolveStartupNavigationReady,
|
||||||
shouldRunStartupGiveUpTimer,
|
shouldRunStartupGiveUpTimer,
|
||||||
startDaemonIfGateAllows,
|
|
||||||
startHostRuntimeBootstrap,
|
startHostRuntimeBootstrap,
|
||||||
type StartupBlocker,
|
type StartupBlocker,
|
||||||
} from "@/navigation/host-runtime-bootstrap";
|
} from "@/navigation/host-runtime-bootstrap";
|
||||||
@@ -337,7 +336,6 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
|||||||
store,
|
store,
|
||||||
daemonStartService,
|
daemonStartService,
|
||||||
shouldStartDaemon: shouldStartBuiltInDaemon,
|
shouldStartDaemon: shouldStartBuiltInDaemon,
|
||||||
onGateError: (message) => daemonStartService.recordError(message),
|
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -376,11 +374,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const retry = useCallback(() => {
|
const retry = useCallback(() => {
|
||||||
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
|
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
|
||||||
startDaemonIfGateAllows({
|
void daemonStartService.startIfEnabled({ shouldStart: shouldStartBuiltInDaemon });
|
||||||
daemonStartService,
|
|
||||||
shouldStartDaemon: shouldStartBuiltInDaemon,
|
|
||||||
onGateError: (message) => daemonStartService.recordError(message),
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const splashError =
|
const splashError =
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
resolveStartupBlocker,
|
resolveStartupBlocker,
|
||||||
resolveStartupNavigationReady,
|
resolveStartupNavigationReady,
|
||||||
@@ -7,115 +7,37 @@ import {
|
|||||||
shouldRunStartupGiveUpTimer,
|
shouldRunStartupGiveUpTimer,
|
||||||
startHostRuntimeBootstrap,
|
startHostRuntimeBootstrap,
|
||||||
} from "./host-runtime-bootstrap";
|
} from "./host-runtime-bootstrap";
|
||||||
|
import type {
|
||||||
function createFakeStore() {
|
DaemonStartCondition,
|
||||||
return { boot: vi.fn() };
|
StartDaemonIfEnabledInput,
|
||||||
}
|
} from "@/runtime/daemon-start-service";
|
||||||
|
|
||||||
function createFakeDaemonStartService() {
|
|
||||||
return {
|
|
||||||
start: vi.fn(async () => ({ ok: true as const })),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("startHostRuntimeBootstrap", () => {
|
describe("startHostRuntimeBootstrap", () => {
|
||||||
it("fires boot and daemon-start without awaiting the daemon-start promise", () => {
|
it("boots the host registry and starts the managed-daemon decision as one operation", () => {
|
||||||
const events: string[] = [];
|
const events: string[] = [];
|
||||||
|
const shouldStartDaemon = async () => true;
|
||||||
const store = {
|
const store = {
|
||||||
boot: vi.fn(() => {
|
boot: () => {
|
||||||
events.push("boot");
|
events.push("boot");
|
||||||
}),
|
|
||||||
};
|
|
||||||
const daemonStartService = {
|
|
||||||
start: vi.fn(async () => {
|
|
||||||
events.push("daemon-start");
|
|
||||||
return { ok: true as const };
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
startHostRuntimeBootstrap({
|
|
||||||
store,
|
|
||||||
daemonStartService,
|
|
||||||
shouldStartDaemon: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(store.boot).toHaveBeenCalledTimes(1);
|
|
||||||
expect(daemonStartService.start).toHaveBeenCalledTimes(1);
|
|
||||||
expect(events).toEqual(["boot", "daemon-start"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips daemon-start when shouldStartDaemon is false", () => {
|
|
||||||
const store = createFakeStore();
|
|
||||||
const daemonStartService = createFakeDaemonStartService();
|
|
||||||
|
|
||||||
startHostRuntimeBootstrap({
|
|
||||||
store,
|
|
||||||
daemonStartService,
|
|
||||||
shouldStartDaemon: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(store.boot).toHaveBeenCalledTimes(1);
|
|
||||||
expect(daemonStartService.start).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips daemon-start when the startup gate resolves false", async () => {
|
|
||||||
const store = createFakeStore();
|
|
||||||
const daemonStartService = createFakeDaemonStartService();
|
|
||||||
|
|
||||||
startHostRuntimeBootstrap({
|
|
||||||
store,
|
|
||||||
daemonStartService,
|
|
||||||
shouldStartDaemon: async () => false,
|
|
||||||
});
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(store.boot).toHaveBeenCalledTimes(1);
|
|
||||||
expect(daemonStartService.start).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("surfaces gate rejection to onGateError without starting the daemon", async () => {
|
|
||||||
const store = createFakeStore();
|
|
||||||
const daemonStartService = createFakeDaemonStartService();
|
|
||||||
const onGateError = vi.fn();
|
|
||||||
|
|
||||||
startHostRuntimeBootstrap({
|
|
||||||
store,
|
|
||||||
daemonStartService,
|
|
||||||
shouldStartDaemon: async () => {
|
|
||||||
throw new Error("settings file unreadable");
|
|
||||||
},
|
},
|
||||||
onGateError,
|
};
|
||||||
});
|
let receivedCondition: DaemonStartCondition | null = null;
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(onGateError).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(daemonStartService.start).not.toHaveBeenCalled();
|
|
||||||
expect(onGateError).toHaveBeenCalledWith(expect.stringContaining("settings file unreadable"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not await the daemon-start promise", () => {
|
|
||||||
const store = createFakeStore();
|
|
||||||
let resolveStart: ((value: { ok: true }) => void) | undefined;
|
|
||||||
const daemonStartService = {
|
const daemonStartService = {
|
||||||
start: vi.fn(
|
startIfEnabled: async (input: StartDaemonIfEnabledInput) => {
|
||||||
() =>
|
receivedCondition = input.shouldStart;
|
||||||
new Promise<{ ok: true }>((resolve) => {
|
events.push("daemon-start-decision");
|
||||||
resolveStart = resolve;
|
return { ok: true as const };
|
||||||
}),
|
},
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
startHostRuntimeBootstrap({
|
startHostRuntimeBootstrap({
|
||||||
store,
|
store,
|
||||||
daemonStartService,
|
daemonStartService,
|
||||||
shouldStartDaemon: true,
|
shouldStartDaemon,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(store.boot).toHaveBeenCalledTimes(1);
|
expect(events).toEqual(["boot", "daemon-start-decision"]);
|
||||||
expect(daemonStartService.start).toHaveBeenCalledTimes(1);
|
expect(receivedCondition).toBe(shouldStartDaemon);
|
||||||
|
|
||||||
resolveStart?.({ ok: true });
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
import type { DaemonStartResult } from "@/runtime/daemon-start-service";
|
import type {
|
||||||
|
DaemonStartCondition,
|
||||||
|
DaemonStartResult,
|
||||||
|
StartDaemonIfEnabledInput,
|
||||||
|
} from "@/runtime/daemon-start-service";
|
||||||
import type { Href } from "expo-router";
|
import type { Href } from "expo-router";
|
||||||
import {
|
import {
|
||||||
buildHostRootRoute,
|
buildHostRootRoute,
|
||||||
@@ -12,54 +16,22 @@ export interface HostRuntimeBootstrapStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface HostRuntimeBootstrapDaemonStartService {
|
export interface HostRuntimeBootstrapDaemonStartService {
|
||||||
start: () => Promise<DaemonStartResult>;
|
startIfEnabled: (input: StartDaemonIfEnabledInput) => Promise<DaemonStartResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type HostRuntimeBootstrapStartGate = boolean | (() => boolean | Promise<boolean>);
|
|
||||||
|
|
||||||
export interface StartHostRuntimeBootstrapInput {
|
export interface StartHostRuntimeBootstrapInput {
|
||||||
store: HostRuntimeBootstrapStore;
|
store: HostRuntimeBootstrapStore;
|
||||||
daemonStartService: HostRuntimeBootstrapDaemonStartService;
|
daemonStartService: HostRuntimeBootstrapDaemonStartService;
|
||||||
shouldStartDaemon: HostRuntimeBootstrapStartGate;
|
shouldStartDaemon: DaemonStartCondition;
|
||||||
onGateError?: (message: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startHostRuntimeBootstrap(input: StartHostRuntimeBootstrapInput): void {
|
export function startHostRuntimeBootstrap(input: StartHostRuntimeBootstrapInput): void {
|
||||||
input.store.boot();
|
input.store.boot();
|
||||||
startDaemonIfGateAllows({
|
void input.daemonStartService.startIfEnabled({
|
||||||
daemonStartService: input.daemonStartService,
|
shouldStart: input.shouldStartDaemon,
|
||||||
shouldStartDaemon: input.shouldStartDaemon,
|
|
||||||
onGateError: input.onGateError,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startDaemonIfGateAllows(input: {
|
|
||||||
daemonStartService: HostRuntimeBootstrapDaemonStartService;
|
|
||||||
shouldStartDaemon: HostRuntimeBootstrapStartGate;
|
|
||||||
onGateError?: (message: string) => void;
|
|
||||||
}): void {
|
|
||||||
const gate = input.shouldStartDaemon;
|
|
||||||
if (typeof gate === "boolean") {
|
|
||||||
if (gate) {
|
|
||||||
void input.daemonStartService.start();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void Promise.resolve()
|
|
||||||
.then(() => gate())
|
|
||||||
.then((shouldStartDaemon) => {
|
|
||||||
if (shouldStartDaemon) {
|
|
||||||
void input.daemonStartService.start();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
input.onGateError?.(`Failed to evaluate desktop daemon settings: ${message}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const WELCOME_ROUTE: Href = "/welcome";
|
const WELCOME_ROUTE: Href = "/welcome";
|
||||||
|
|
||||||
export type StartupBlocker =
|
export type StartupBlocker =
|
||||||
|
|||||||
@@ -164,6 +164,53 @@ describe("DaemonStartService", () => {
|
|||||||
expect(runningSnapshots).toEqual([true, false]);
|
expect(runningSnapshots).toEqual([true, false]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("stays running while deciding whether the managed daemon should start", async () => {
|
||||||
|
const fake = createFakeStore();
|
||||||
|
let resolveCondition: ((value: boolean) => void) | undefined;
|
||||||
|
let daemonStartCount = 0;
|
||||||
|
const service = new DaemonStartService({
|
||||||
|
store: fake.store,
|
||||||
|
startDesktopDaemon: async () => {
|
||||||
|
daemonStartCount += 1;
|
||||||
|
return makeStatus();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startPromise = service.startIfEnabled({
|
||||||
|
shouldStart: () =>
|
||||||
|
new Promise<boolean>((resolve) => {
|
||||||
|
resolveCondition = resolve;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.isRunning()).toBe(true);
|
||||||
|
expect(daemonStartCount).toBe(0);
|
||||||
|
|
||||||
|
resolveCondition?.(true);
|
||||||
|
await startPromise;
|
||||||
|
|
||||||
|
expect(service.isRunning()).toBe(false);
|
||||||
|
expect(daemonStartCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finishes without starting the daemon when management is disabled", async () => {
|
||||||
|
const fake = createFakeStore();
|
||||||
|
let daemonStartCount = 0;
|
||||||
|
const service = new DaemonStartService({
|
||||||
|
store: fake.store,
|
||||||
|
startDesktopDaemon: async () => {
|
||||||
|
daemonStartCount += 1;
|
||||||
|
return makeStatus();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.startIfEnabled({ shouldStart: false });
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true });
|
||||||
|
expect(service.isRunning()).toBe(false);
|
||||||
|
expect(daemonStartCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("clears the error and notifies subscribers when retry begins", async () => {
|
it("clears the error and notifies subscribers when retry begins", async () => {
|
||||||
const fake = createFakeStore();
|
const fake = createFakeStore();
|
||||||
const startMock = vi
|
const startMock = vi
|
||||||
@@ -188,19 +235,27 @@ describe("DaemonStartService", () => {
|
|||||||
expect(service.getLastError()).toBeNull();
|
expect(service.getLastError()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("recordError surfaces an external error and notifies subscribers", () => {
|
it("surfaces settings errors through the daemon startup state", async () => {
|
||||||
const fake = createFakeStore();
|
const fake = createFakeStore();
|
||||||
const service = new DaemonStartService({
|
const service = new DaemonStartService({
|
||||||
store: fake.store,
|
store: fake.store,
|
||||||
startDesktopDaemon: async () => makeStatus(),
|
startDesktopDaemon: async () => makeStatus(),
|
||||||
});
|
});
|
||||||
const notifications = vi.fn();
|
|
||||||
service.subscribe(notifications);
|
|
||||||
|
|
||||||
service.recordError("settings file unreadable");
|
const result = await service.startIfEnabled({
|
||||||
|
shouldStart: async () => {
|
||||||
|
throw new Error("settings file unreadable");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
expect(service.getLastError()).toBe("settings file unreadable");
|
expect(result).toEqual({
|
||||||
expect(notifications).toHaveBeenCalledTimes(1);
|
ok: false,
|
||||||
|
error: "Failed to evaluate desktop daemon settings: settings file unreadable",
|
||||||
|
});
|
||||||
|
expect(service.getLastError()).toBe(
|
||||||
|
"Failed to evaluate desktop daemon settings: settings file unreadable",
|
||||||
|
);
|
||||||
|
expect(service.isRunning()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stops notifying after a subscriber unsubscribes", async () => {
|
it("stops notifying after a subscriber unsubscribes", async () => {
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import { connectionFromListen } from "@/types/host-connection";
|
|||||||
import type { HostRuntimeStore } from "@/runtime/host-runtime";
|
import type { HostRuntimeStore } from "@/runtime/host-runtime";
|
||||||
|
|
||||||
export type DaemonStartResult = { ok: true } | { ok: false; error: string };
|
export type DaemonStartResult = { ok: true } | { ok: false; error: string };
|
||||||
|
export type DaemonStartCondition = boolean | (() => boolean | Promise<boolean>);
|
||||||
|
|
||||||
|
export interface StartDaemonIfEnabledInput {
|
||||||
|
shouldStart: DaemonStartCondition;
|
||||||
|
}
|
||||||
|
|
||||||
type DaemonConnectionStore = Pick<HostRuntimeStore, "upsertConnectionFromListen">;
|
type DaemonConnectionStore = Pick<HostRuntimeStore, "upsertConnectionFromListen">;
|
||||||
|
|
||||||
@@ -50,8 +55,27 @@ export class DaemonStartService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async start(): Promise<DaemonStartResult> {
|
async start(): Promise<DaemonStartResult> {
|
||||||
|
return this.startIfEnabled({ shouldStart: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async startIfEnabled(input: StartDaemonIfEnabledInput): Promise<DaemonStartResult> {
|
||||||
|
// Settings evaluation is part of startup. Publish the running state before
|
||||||
|
// its first await so restored app chrome cannot appear between these phases.
|
||||||
this.beginRequest();
|
this.beginRequest();
|
||||||
try {
|
try {
|
||||||
|
let shouldStart: boolean;
|
||||||
|
try {
|
||||||
|
shouldStart =
|
||||||
|
typeof input.shouldStart === "boolean" ? input.shouldStart : await input.shouldStart();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return this.fail(`Failed to evaluate desktop daemon settings: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldStart) {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
const daemon = await this.invokeStartDesktopDaemon();
|
const daemon = await this.invokeStartDesktopDaemon();
|
||||||
const result = await upsertDesktopDaemonConnection(this.store, daemon);
|
const result = await upsertDesktopDaemonConnection(this.store, daemon);
|
||||||
return result.ok ? result : this.fail(result.error);
|
return result.ok ? result : this.fail(result.error);
|
||||||
@@ -66,10 +90,6 @@ export class DaemonStartService {
|
|||||||
return this.lastError;
|
return this.lastError;
|
||||||
}
|
}
|
||||||
|
|
||||||
recordError(message: string): void {
|
|
||||||
this.setLastError(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
isRunning(): boolean {
|
isRunning(): boolean {
|
||||||
return this.inFlightCount > 0;
|
return this.inFlightCount > 0;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user