mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-22 17:22:47 +00:00
* fix(daemon): keep lifecycle operations on the selected instance Restart reconstructed launch inputs and address fallback could redirect a home-selected shutdown to another daemon. Let supervisors publish bound endpoints, retain their worker restart contract, and share captured local lifecycle ownership between CLI and Desktop. * test(daemon): verify bounded desktop readiness and cancellable artifacts * test(cli): verify reload target hints and durable offline pairing * test(daemon): align lifecycle fixtures with ownership and targeting * fix(daemon): preserve reachability when status details time out Provider discovery can outlast a status request after authentication succeeds. Keep local connection facts separate from unavailable details, while explicit remote queries retain their error outcome. Migrate CLI and packaged Desktop fixtures to persistent managed configuration and explicit targets. Isolate generic Git observation from forge discovery and reuse disposable CLI homes on Windows. * test(daemon): observe restart through the worker status RPC The bounded CLI status observation may omit worker details while an authenticated connection remains healthy. Exercise the app-style restart through its full RPC and retain the replacement observation, with a real slow provider probe covering the distinction.
459 lines
16 KiB
TypeScript
459 lines
16 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { appendFile } from "node:fs/promises";
|
|
import { expect, type Page } from "@playwright/test";
|
|
import { openSettings } from "../../../app/e2e/support/helpers/app";
|
|
import { getE2EDaemonPort } from "../../../app/e2e/support/helpers/daemon-port";
|
|
import { escapeRegex } from "../../../app/e2e/support/helpers/regex";
|
|
import {
|
|
openSettingsHost,
|
|
openSettingsHostSection,
|
|
openSettingsSection,
|
|
} from "../../../app/e2e/support/helpers/settings";
|
|
|
|
interface DaemonApiStatus {
|
|
version: string;
|
|
serverId: string;
|
|
hostname: string;
|
|
}
|
|
|
|
interface PidFileContent {
|
|
pid: number;
|
|
desktopManaged: boolean;
|
|
}
|
|
|
|
export interface RealDaemonState {
|
|
version: string;
|
|
pid: number | null;
|
|
logPath: string;
|
|
}
|
|
|
|
/**
|
|
* Reads live state from the running E2E test daemon: version from the HTTP
|
|
* status endpoint, PID from the paseo.pid lock file, log path from the
|
|
* E2E_PASEO_HOME directory. Call this in Node test code (not in the browser).
|
|
*/
|
|
export async function loadRealDaemonState(): Promise<RealDaemonState> {
|
|
const port = getE2EDaemonPort();
|
|
const paseoHome = process.env.E2E_PASEO_HOME;
|
|
if (!paseoHome) throw new Error("E2E_PASEO_HOME not set — the worker fixture must run first");
|
|
|
|
const resp = await fetch(`http://127.0.0.1:${port}/api/status`);
|
|
const data: DaemonApiStatus = await resp.json();
|
|
|
|
let pid: number | null = null;
|
|
try {
|
|
const raw = readFileSync(`${paseoHome}/paseo.pid`, "utf8");
|
|
const pidContent: PidFileContent = JSON.parse(raw);
|
|
pid = pidContent.pid ?? null;
|
|
} catch (err) {
|
|
// PID file may not be present yet on a very fresh daemon start
|
|
console.warn("[desktop-updates] paseo.pid not found:", err);
|
|
}
|
|
|
|
return { version: data.version, pid, logPath: `${paseoHome}/daemon.log` };
|
|
}
|
|
|
|
export interface DesktopRuntimeConfig {
|
|
serverId: string;
|
|
updateAvailable?: boolean;
|
|
latestVersion?: string;
|
|
updateReadyToInstall?: boolean;
|
|
manualUpdateBypassesRollout?: boolean;
|
|
slowInstall?: boolean;
|
|
/** Initial PID reported by desktop_daemon_status. Defaults to null. */
|
|
daemonPid?: number | null;
|
|
daemonHome?: string;
|
|
/** Current-session ownership, independent of the legacy management flag. */
|
|
ownedByDesktop?: boolean;
|
|
daemonVersion?: string | null;
|
|
daemonLogPath?: string;
|
|
/** Initial manageBuiltInDaemon setting. Defaults to false. */
|
|
manageBuiltInDaemon?: boolean;
|
|
/** Daemon listen address reported by desktop_daemon_status. Defaults to 127.0.0.1:6767. */
|
|
daemonListen?: string;
|
|
/** Keep start_desktop_daemon pending to hold the desktop startup blocker open. */
|
|
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
|
|
* fires. True = confirm (proceed with the action), false = cancel. Defaults to
|
|
* false so tests that only assert copy don't inadvertently trigger state changes.
|
|
*/
|
|
confirmShouldAccept?: boolean;
|
|
dialogOpenResult?: string | string[] | null;
|
|
editorTargets?: DesktopEditorTargetConfig[];
|
|
editorRecordPath?: string;
|
|
}
|
|
|
|
interface DesktopEditorTargetConfig {
|
|
id: string;
|
|
label: string;
|
|
kind: "editor" | "file-manager";
|
|
icon: { kind: "image"; dataUrl: string } | { kind: "symbol"; name: "folder" | "terminal" };
|
|
}
|
|
|
|
interface DesktopEditorOpenRecord {
|
|
editorId: string;
|
|
workspacePath: string;
|
|
filePath?: string;
|
|
line?: number;
|
|
column?: number;
|
|
}
|
|
|
|
export interface ConfirmDialogCall {
|
|
message: string;
|
|
title: string | undefined;
|
|
}
|
|
|
|
declare global {
|
|
interface Window {
|
|
__capturedDialogCall: ConfirmDialogCall | undefined;
|
|
__capturedDialogOpenCalls: Array<Record<string, unknown> | undefined>;
|
|
__recordDesktopEditorOpen?: (input: DesktopEditorOpenRecord) => Promise<void>;
|
|
__desktopDaemonStartRequested?: boolean;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Injects window.paseoDesktop before app load so all Electron-gated code
|
|
* activates. The update-check IPC is mocked at the boundary so the real
|
|
* auto-updater never fires. Daemon start/stop commands are stateful: the mock
|
|
* tracks running state and assigns a fresh PID on each start, letting tests
|
|
* observe PID changes without touching the real E2E daemon process.
|
|
* dialog.ask captures call arguments on window.__capturedDialogCall so tests
|
|
* can assert dialog copy without depending on window.confirm concatenation.
|
|
*/
|
|
export async function installDesktopRuntime(
|
|
page: Page,
|
|
config: DesktopRuntimeConfig,
|
|
): Promise<void> {
|
|
if (config.editorRecordPath) {
|
|
await page.exposeFunction(
|
|
"__recordDesktopEditorOpen",
|
|
async (input: DesktopEditorOpenRecord) => {
|
|
await appendFile(config.editorRecordPath as string, `${JSON.stringify(input)}\n`, "utf8");
|
|
},
|
|
);
|
|
}
|
|
|
|
await page.addInitScript((cfg) => {
|
|
// Mutable state shared across IPC calls within this page
|
|
let manageDaemon = cfg.manageBuiltInDaemon ?? false;
|
|
let daemonRunning = true;
|
|
let currentPid: number | null = cfg.daemonPid ?? null;
|
|
let ownedByDesktop = cfg.ownedByDesktop ?? false;
|
|
let manualUpdateAdmitted = false;
|
|
window.__desktopDaemonStartRequested = false;
|
|
|
|
function buildDaemonStatus() {
|
|
return {
|
|
serverId: cfg.serverId,
|
|
status: daemonRunning ? "running" : "stopped",
|
|
listen: cfg.daemonListen ?? "127.0.0.1:6767",
|
|
hostname: null,
|
|
pid: currentPid,
|
|
home: cfg.daemonHome ?? "",
|
|
startedAt: currentPid === null ? null : "2026-09-01T00:00:00.000Z",
|
|
ownedByDesktop,
|
|
version: cfg.daemonVersion ?? null,
|
|
desktopManaged: manageDaemon,
|
|
error: null,
|
|
};
|
|
}
|
|
|
|
function startDesktopDaemon() {
|
|
window.__desktopDaemonStartRequested = true;
|
|
if (cfg.hangDaemonStart) {
|
|
return new Promise(() => undefined);
|
|
}
|
|
if (!daemonRunning) {
|
|
currentPid = (cfg.daemonPid ?? 10000) + 1000;
|
|
ownedByDesktop = true;
|
|
}
|
|
daemonRunning = true;
|
|
return buildDaemonStatus();
|
|
}
|
|
|
|
async function waitForDesktopSettingsResponse() {
|
|
const delayMs = cfg.desktopSettingsDelayMs ?? 0;
|
|
if (delayMs > 0) {
|
|
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
|
|
}
|
|
}
|
|
|
|
function buildAppUpdateCheckResult(hasUpdate: boolean, readyToInstall: boolean) {
|
|
return {
|
|
hasUpdate,
|
|
readyToInstall,
|
|
currentVersion: "1.0.0",
|
|
latestVersion: hasUpdate ? (cfg.latestVersion ?? "1.2.3") : null,
|
|
body: null,
|
|
date: null,
|
|
};
|
|
}
|
|
|
|
function checkAppUpdate(intent: unknown) {
|
|
if (!cfg.manualUpdateBypassesRollout) {
|
|
return buildAppUpdateCheckResult(
|
|
cfg.updateAvailable === true,
|
|
cfg.updateAvailable === true && (cfg.updateReadyToInstall ?? true),
|
|
);
|
|
}
|
|
|
|
if (intent === "manual") {
|
|
manualUpdateAdmitted = true;
|
|
return buildAppUpdateCheckResult(true, false);
|
|
}
|
|
|
|
return buildAppUpdateCheckResult(manualUpdateAdmitted, manualUpdateAdmitted);
|
|
}
|
|
|
|
const desktopBridge: {
|
|
platform: string;
|
|
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
|
dialog: {
|
|
ask: (message: string, options?: Record<string, unknown>) => Promise<boolean>;
|
|
open: (options?: Record<string, unknown>) => Promise<string | string[] | null>;
|
|
};
|
|
getPendingOpenProject: () => Promise<string | null>;
|
|
events: { on: () => Promise<() => void> };
|
|
editor?: {
|
|
listTargets: () => Promise<DesktopEditorTargetConfig[]>;
|
|
openTarget: (input: DesktopEditorOpenRecord) => Promise<void>;
|
|
};
|
|
} = {
|
|
platform: "darwin",
|
|
invoke: async (command: string, args?: Record<string, unknown>) => {
|
|
if (command === "check_app_update") {
|
|
return checkAppUpdate(args?.intent);
|
|
}
|
|
|
|
if (command === "install_app_update") {
|
|
if (cfg.slowInstall) {
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 3000));
|
|
}
|
|
return {
|
|
installed: true,
|
|
version: cfg.latestVersion ?? "1.2.3",
|
|
message: "App update installed. Restart required.",
|
|
};
|
|
}
|
|
|
|
if (command === "desktop_daemon_status") {
|
|
return buildDaemonStatus();
|
|
}
|
|
|
|
if (command === "desktop_daemon_logs") {
|
|
return { logPath: cfg.daemonLogPath ?? "", contents: "" };
|
|
}
|
|
|
|
if (command === "get_desktop_settings") {
|
|
await waitForDesktopSettingsResponse();
|
|
return {
|
|
releaseChannel: "stable",
|
|
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
|
|
};
|
|
}
|
|
|
|
if (command === "patch_desktop_settings") {
|
|
const daemon = args?.daemon;
|
|
if (
|
|
daemon !== null &&
|
|
typeof daemon === "object" &&
|
|
"manageBuiltInDaemon" in daemon &&
|
|
typeof daemon.manageBuiltInDaemon === "boolean"
|
|
) {
|
|
manageDaemon = daemon.manageBuiltInDaemon;
|
|
}
|
|
return {
|
|
releaseChannel: "stable",
|
|
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
|
|
};
|
|
}
|
|
|
|
if (command === "stop_desktop_daemon") {
|
|
ownedByDesktop = false;
|
|
daemonRunning = false;
|
|
currentPid = null;
|
|
return buildDaemonStatus();
|
|
}
|
|
|
|
if (command === "start_desktop_daemon") {
|
|
return startDesktopDaemon();
|
|
}
|
|
|
|
return null;
|
|
},
|
|
dialog: {
|
|
ask: async (message: string, options?: Record<string, unknown>) => {
|
|
window.__capturedDialogCall = {
|
|
message,
|
|
title: typeof options?.title === "string" ? options.title : undefined,
|
|
};
|
|
return cfg.confirmShouldAccept ?? false;
|
|
},
|
|
open: async (options?: Record<string, unknown>) => {
|
|
window.__capturedDialogOpenCalls.push(options);
|
|
return cfg.dialogOpenResult ?? null;
|
|
},
|
|
},
|
|
getPendingOpenProject: async () => null,
|
|
events: { on: async () => () => undefined },
|
|
};
|
|
|
|
if (cfg.editorTargets) {
|
|
desktopBridge.editor = {
|
|
listTargets: async () => cfg.editorTargets ?? [],
|
|
openTarget: async (input: DesktopEditorOpenRecord) => {
|
|
await window.__recordDesktopEditorOpen?.(input);
|
|
},
|
|
};
|
|
}
|
|
|
|
window.__capturedDialogOpenCalls = [];
|
|
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = desktopBridge;
|
|
}, 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(
|
|
page: Page,
|
|
): Promise<Record<string, unknown> | undefined> {
|
|
await expect.poll(() => page.evaluate(() => window.__capturedDialogOpenCalls.length)).toBe(1);
|
|
return page.evaluate(() => window.__capturedDialogOpenCalls[0]);
|
|
}
|
|
|
|
export async function openDesktopSettings(page: Page, serverId: string): Promise<void> {
|
|
await openSettings(page);
|
|
await openSettingsHost(page, serverId);
|
|
// The daemon-lifecycle card moved to the Host section in the flat-settings
|
|
// layout; navigate there before asserting it.
|
|
await openSettingsHostSection(page, serverId, "host");
|
|
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
}
|
|
|
|
export async function openDesktopAboutSettings(page: Page): Promise<void> {
|
|
await openSettings(page);
|
|
await openSettingsSection(page, "about");
|
|
await expect(page.getByText("App updates", { exact: true })).toBeVisible();
|
|
}
|
|
|
|
export async function expectUpdateBanner(page: Page, version: string): Promise<void> {
|
|
const callout = page.getByTestId("update-callout");
|
|
await expect(callout).toBeVisible({ timeout: 15_000 });
|
|
await expect(callout).toContainText(`v${version.replace(/^v/i, "")}`);
|
|
}
|
|
|
|
export async function clickCheckForUpdates(page: Page): Promise<void> {
|
|
await page.getByRole("button", { name: "Check" }).click();
|
|
}
|
|
|
|
export async function expectPendingUpdateCheckResult(page: Page, version: string): Promise<void> {
|
|
const normalizedVersion = `v${version.replace(/^v/i, "")}`;
|
|
await expect(
|
|
page.getByText(
|
|
new RegExp(`Update found: ${escapeRegex(normalizedVersion)}\\. Downloading\\.\\.\\.`),
|
|
),
|
|
).toBeVisible();
|
|
await expect(page.getByText(`Ready to install: ${normalizedVersion}`)).toHaveCount(0);
|
|
await expect(page.getByRole("button", { name: "Update" })).toBeDisabled();
|
|
}
|
|
|
|
export async function expectReadyUpdateCheckResult(page: Page, version: string): Promise<void> {
|
|
const normalizedVersion = `v${version.replace(/^v/i, "")}`;
|
|
await expect(page.getByText(`Ready to install: ${normalizedVersion}`)).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
await expect(page.getByRole("button", { name: `Update to ${normalizedVersion}` })).toBeEnabled();
|
|
}
|
|
|
|
export async function clickInstallUpdate(page: Page): Promise<void> {
|
|
await page.getByRole("button", { name: "Install & restart" }).click();
|
|
}
|
|
|
|
export async function expectInstallInProgress(page: Page): Promise<void> {
|
|
await expect(page.getByRole("button", { name: "Installing..." })).toBeVisible();
|
|
}
|
|
|
|
/**
|
|
* Clicks the daemon management switch and waits for dialog.ask to fire in the
|
|
* mock, then returns the captured call args (message + title). The mock auto-
|
|
* dismisses via confirmShouldAccept=false so callers can assert copy without
|
|
* worrying about state changes.
|
|
*/
|
|
export async function interceptDaemonManagementConfirmDialog(
|
|
page: Page,
|
|
): Promise<ConfirmDialogCall> {
|
|
await page.getByRole("switch", { name: "Manage built-in daemon" }).click();
|
|
await page.waitForFunction(() => !!window.__capturedDialogCall, { timeout: 5_000 });
|
|
return page.evaluate(() => window.__capturedDialogCall!);
|
|
}
|
|
|
|
export async function interceptDaemonStopConfirmDialog(page: Page): Promise<ConfirmDialogCall> {
|
|
await page.getByRole("button", { name: "Stop daemon", exact: true }).click();
|
|
await page.waitForFunction(() => !!window.__capturedDialogCall);
|
|
return page.evaluate(() => window.__capturedDialogCall!);
|
|
}
|
|
|
|
export async function toggleDaemonManagement(
|
|
page: Page,
|
|
_action: "enable" | "disable",
|
|
): Promise<void> {
|
|
await page.getByRole("switch", { name: "Manage built-in daemon" }).click();
|
|
}
|
|
|
|
export function expectDaemonManagementConfirmDialog(args: ConfirmDialogCall): void {
|
|
expect(args.title).toBe("Pause built-in daemon");
|
|
expect(args.message).toBe(
|
|
"This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.",
|
|
);
|
|
}
|
|
|
|
export async function expectDaemonManagementEnabled(page: Page): Promise<void> {
|
|
await expect(page.getByRole("switch", { name: "Manage built-in daemon" })).toBeChecked();
|
|
}
|
|
|
|
export async function expectDaemonManagementDisabled(page: Page): Promise<void> {
|
|
await expect(page.getByRole("switch", { name: "Manage built-in daemon" })).not.toBeChecked();
|
|
}
|
|
|
|
/**
|
|
* Asserts the daemon status card shows the given PID. Pass null to assert
|
|
* the cleared state (shown as "PID —" when the daemon is stopped).
|
|
*/
|
|
export async function expectDaemonStatusPid(page: Page, pid: number | null): Promise<void> {
|
|
const expected = pid !== null ? `PID ${pid}` : "PID —";
|
|
await expect(
|
|
page.getByTestId("host-page-daemon-lifecycle-card").getByText(expected),
|
|
).toBeVisible();
|
|
}
|
|
|
|
export async function expectDaemonStatusLogPath(page: Page, logPath: string): Promise<void> {
|
|
await expect(
|
|
page.getByTestId("host-page-daemon-lifecycle-card").getByText(logPath),
|
|
).toBeVisible();
|
|
}
|
|
|
|
/**
|
|
* Asserts the host page identity badge shows the given version string.
|
|
* The badge is populated from the live WebSocket session's serverInfo.version.
|
|
*/
|
|
export async function expectDaemonStatusVersion(page: Page, version: string): Promise<void> {
|
|
await expect(
|
|
page.getByTestId("host-page-identity").getByText(version, { exact: false }),
|
|
).toBeVisible({ timeout: 15_000 });
|
|
}
|