mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
* feat(auth): authenticate daemon clients through hello Give local CLI and desktop clients a per-run credential while remote clients send the host password inside the protocol. Keep relay passwordless admission for this mobile release so existing saved hosts keep working until the follow-up enforcement release. Move password recovery for saved hosts into host settings and keep pairing credentials in the add flow. Include isolated web QA evidence for both flows. * fix(auth): preserve Hub bootstrap and local credential expectations Hub-owned sockets already carry admission; keep their hello handling synchronous through session bootstrap while resolving credentials asynchronously for ordinary sockets. Keep the resolver out of lightweight configuration imports. Update CLI expectations for same-home credentials, let a rejected connection yield to another usable connection, and clear pairing secrets when add-flow targets change. * fix(pairing): retain passwords across edits to the same target Track the last valid pairing host while a URI is edited. This preserves an entered password for whitespace and partial edits but clears it when a different host becomes the target. * test(auth): align local credential checks across platforms Expect the CLI to authenticate through the credential in its selected home, and verify POSIX file modes only where Node exposes them. * feat(app): move host password into a settings row with a modal The host page rendered an always-visible password form above Name on every host. Replace it with a Password row under a Security section below Appearance; tapping it opens a modal with Save and, when a password is saved, Clear. The connection-error callout keeps its guidance and gains a Set password button that opens the same modal. Drop the unused pairing.hostPassword body/reopen/saveError strings. * chore: remove committed QA screenshots QA evidence stays out of the repository; the PR body describes it in words. * feat(app): keep the host password out of the host page The saved password is connection data, entered only when a host is added. A Password setting on the host page reads as the daemon's own password, so the row, modal, and callout button are gone. A rejected host shows the daemon's reason with guidance to remove the host and add it again with the password. useHostMutations no longer exposes setHostPassword; relay imports still set it inside the runtime. * feat(app): show host connection errors in an error alert HostConnectionError renders the existing Alert with the connection error as its title. Auth rejections add the re-add guidance as the description; other errors show the title only. The Overview and Connections pages share it. * style(app): soften alert borders to half-opacity accent * feat(app): size alerts on the button scale Alert takes a size prop (xs/sm/md/lg, default md) whose geometry lives next to the button's in control-geometry: one font size for title and description, the button icon size centered on the first text line, button padding and radius, and a one-line alert as tall as the button of the same size. Consumers pick sm among settings rows, cards, panels, and modals, and md when the alert is the whole page or sheet state. * style(app): roomier, rounder alerts with a tight title block Alert sm padding returns to the original 12/16 and every size is at least borderRadius.xl. Title and description sit a small gap apart with no line height override. The icon slot carries a zero-width text strut in the alert's font so the icon centers on the first line for titled, title-only, and description-only alerts. * refactor(app): align the alert icon with a centered lead row Drop the zero-width strut. The icon and the alert's first text (title, else description) share one row centered on each other; the description and actions sit below, indented by the icon slot width plus gap so they start at the lead text's left edge. * refactor(app): drop the unused Alert icon override
431 lines
13 KiB
TypeScript
431 lines
13 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
resolveStartupBlocker,
|
|
resolveStartupNavigationReady,
|
|
resolveHostIndexRoute,
|
|
resolveStartupRoute,
|
|
shouldRunStartupGiveUpTimer,
|
|
startHostRuntimeBootstrap,
|
|
bindHostRuntimeAppState,
|
|
} from "./host-runtime-bootstrap";
|
|
import type { DaemonStartResult, StartDaemonIfEnabledInput } from "@/runtime/daemon-start-service";
|
|
|
|
describe("startHostRuntimeBootstrap", () => {
|
|
it("boots the host registry and starts the managed-daemon decision independently", async () => {
|
|
const events: string[] = [];
|
|
const shouldStartDaemon = async () => true;
|
|
const store = {
|
|
boot: async () => {
|
|
events.push("boot");
|
|
},
|
|
};
|
|
const receivedDecisions: Array<Promise<boolean>> = [];
|
|
const daemonStartService = {
|
|
startIfEnabled: async (input: StartDaemonIfEnabledInput) => {
|
|
receivedDecisions.push(
|
|
Promise.resolve(
|
|
typeof input.shouldStart === "boolean" ? input.shouldStart : input.shouldStart(),
|
|
),
|
|
);
|
|
events.push("daemon-start-decision");
|
|
return { ok: true as const };
|
|
},
|
|
};
|
|
|
|
startHostRuntimeBootstrap({
|
|
store,
|
|
daemonStartService,
|
|
shouldStartDaemon,
|
|
});
|
|
|
|
expect(events).toEqual(["boot", "daemon-start-decision"]);
|
|
expect(await receivedDecisions[0]).toBe(true);
|
|
expect(events).toEqual(["boot", "daemon-start-decision"]);
|
|
});
|
|
|
|
it("loads the registry before evaluating managed-daemon startup", async () => {
|
|
const events: string[] = [];
|
|
let resolveBoot!: () => void;
|
|
const booted = new Promise<void>((resolve) => {
|
|
resolveBoot = resolve;
|
|
});
|
|
const store = {
|
|
boot: () => {
|
|
events.push("boot");
|
|
return booted;
|
|
},
|
|
};
|
|
let startFinished!: Promise<DaemonStartResult>;
|
|
const daemonStartService = {
|
|
startIfEnabled: (input: StartDaemonIfEnabledInput) => {
|
|
events.push("daemon-start-service");
|
|
startFinished = (async () => {
|
|
const shouldStart =
|
|
typeof input.shouldStart === "boolean" ? input.shouldStart : await input.shouldStart();
|
|
events.push(`decision:${shouldStart}`);
|
|
return { ok: true as const };
|
|
})();
|
|
return startFinished;
|
|
},
|
|
};
|
|
|
|
startHostRuntimeBootstrap({
|
|
store,
|
|
daemonStartService,
|
|
shouldStartDaemon: () => {
|
|
events.push("evaluate-daemon-setting");
|
|
return true;
|
|
},
|
|
});
|
|
|
|
await Promise.resolve();
|
|
expect(events).toEqual(["boot", "daemon-start-service"]);
|
|
|
|
resolveBoot();
|
|
await startFinished;
|
|
expect(events).toEqual([
|
|
"boot",
|
|
"daemon-start-service",
|
|
"evaluate-daemon-setting",
|
|
"decision:true",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("startup blocking policy", () => {
|
|
const noBlockerInput = {
|
|
isDesktopRuntime: false,
|
|
anyOnlineHostServerId: null,
|
|
daemonStartIsRunning: false,
|
|
daemonStartError: null,
|
|
};
|
|
|
|
it("runs the give-up timer when no startup blocker is active", () => {
|
|
const blocker = resolveStartupBlocker(noBlockerInput);
|
|
|
|
expect(blocker).toEqual({ kind: "none" });
|
|
expect(resolveStartupNavigationReady({ startupBlocker: blocker })).toBe(true);
|
|
expect(
|
|
shouldRunStartupGiveUpTimer({
|
|
startupBlocker: blocker,
|
|
anyOnlineHostServerId: null,
|
|
hasGivenUpWaitingForHost: false,
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("blocks navigation while desktop is starting the managed daemon", () => {
|
|
const blocker = resolveStartupBlocker({
|
|
...noBlockerInput,
|
|
isDesktopRuntime: true,
|
|
daemonStartIsRunning: true,
|
|
});
|
|
|
|
expect(blocker).toEqual({ kind: "managed-daemon-starting" });
|
|
expect(resolveStartupNavigationReady({ startupBlocker: blocker })).toBe(false);
|
|
expect(
|
|
shouldRunStartupGiveUpTimer({
|
|
startupBlocker: blocker,
|
|
anyOnlineHostServerId: null,
|
|
hasGivenUpWaitingForHost: false,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("unblocks navigation when any host is online", () => {
|
|
const blocker = resolveStartupBlocker({
|
|
...noBlockerInput,
|
|
isDesktopRuntime: true,
|
|
anyOnlineHostServerId: "srv_desktop",
|
|
daemonStartIsRunning: true,
|
|
});
|
|
|
|
expect(blocker).toEqual({ kind: "none" });
|
|
expect(resolveStartupNavigationReady({ startupBlocker: blocker })).toBe(true);
|
|
});
|
|
|
|
it("keeps desktop daemon startup errors on the startup error surface", () => {
|
|
const blocker = resolveStartupBlocker({
|
|
...noBlockerInput,
|
|
isDesktopRuntime: true,
|
|
daemonStartError: "daemon failed to start",
|
|
});
|
|
|
|
expect(blocker).toEqual({
|
|
kind: "managed-daemon-error",
|
|
message: "daemon failed to start",
|
|
});
|
|
expect(resolveStartupNavigationReady({ startupBlocker: blocker })).toBe(true);
|
|
expect(
|
|
shouldRunStartupGiveUpTimer({
|
|
startupBlocker: blocker,
|
|
anyOnlineHostServerId: null,
|
|
hasGivenUpWaitingForHost: false,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("resolveStartupRoute", () => {
|
|
const baseIndexInput = {
|
|
route: { kind: "index" as const, pathname: "/" },
|
|
startupBlocker: { kind: "none" as const },
|
|
hostRegistryStatus: "ready" as const,
|
|
hosts: [],
|
|
anyOnlineHostServerId: null,
|
|
workspaceSelection: null,
|
|
workspaceSelectionStatus: "unknown" as const,
|
|
isWorkspaceSelectionLoaded: true,
|
|
hasGivenUpWaitingForHost: false,
|
|
};
|
|
const baseHostInput = {
|
|
route: { kind: "host" as const, serverId: "server-saved" },
|
|
startupBlocker: { kind: "none" as const },
|
|
hostRegistryStatus: "ready" as const,
|
|
hosts: [],
|
|
};
|
|
|
|
it("renders non-index routes instead of making an index startup decision", () => {
|
|
expect(
|
|
resolveStartupRoute({
|
|
...baseIndexInput,
|
|
route: { kind: "index", pathname: "/settings" },
|
|
}),
|
|
).toEqual({ kind: "render" });
|
|
});
|
|
|
|
it("keeps startup on the splash while the persisted workspace selection is loading", () => {
|
|
expect(
|
|
resolveStartupRoute({
|
|
...baseIndexInput,
|
|
anyOnlineHostServerId: "server-1",
|
|
isWorkspaceSelectionLoaded: false,
|
|
}),
|
|
).toEqual({ kind: "splash" });
|
|
});
|
|
|
|
it("keeps startup on the splash while the host registry is loading", () => {
|
|
expect(
|
|
resolveStartupRoute({
|
|
...baseIndexInput,
|
|
hostRegistryStatus: "loading",
|
|
}),
|
|
).toEqual({ kind: "splash" });
|
|
});
|
|
|
|
it("does not treat loading hosts as an empty registry when a workspace is already restored", () => {
|
|
expect(
|
|
resolveStartupRoute({
|
|
...baseIndexInput,
|
|
hostRegistryStatus: "loading",
|
|
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
|
hasGivenUpWaitingForHost: true,
|
|
}),
|
|
).toEqual({ kind: "splash" });
|
|
});
|
|
|
|
it("enters the host boundary for saved workspace restore after the host registry proves the host exists", () => {
|
|
expect(
|
|
resolveStartupRoute({
|
|
...baseIndexInput,
|
|
hosts: [{ serverId: "server-1" }],
|
|
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
|
workspaceSelectionStatus: "exists",
|
|
}),
|
|
).toEqual({ kind: "redirect", href: "/h/server-1" });
|
|
});
|
|
|
|
it("restores the last workspace host even when a different host is already online", () => {
|
|
expect(
|
|
resolveStartupRoute({
|
|
...baseIndexInput,
|
|
hosts: [{ serverId: "server-offline" }, { serverId: "server-online" }],
|
|
anyOnlineHostServerId: "server-online",
|
|
workspaceSelection: { serverId: "server-offline", workspaceId: "workspace-a" },
|
|
workspaceSelectionStatus: "unknown",
|
|
}),
|
|
).toEqual({ kind: "redirect", href: "/h/server-offline" });
|
|
});
|
|
|
|
it("does not restore a saved workspace after workspace hydration proves it is missing", () => {
|
|
expect(
|
|
resolveStartupRoute({
|
|
...baseIndexInput,
|
|
hosts: [{ serverId: "server-1" }],
|
|
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
|
workspaceSelectionStatus: "missing",
|
|
}),
|
|
).toEqual({ kind: "redirect", href: "/h/server-1" });
|
|
});
|
|
|
|
it("falls back to a saved host when the restored workspace host is no longer saved", () => {
|
|
expect(
|
|
resolveStartupRoute({
|
|
...baseIndexInput,
|
|
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
|
|
hosts: [{ serverId: "server-next" }],
|
|
hasGivenUpWaitingForHost: true,
|
|
}),
|
|
).toEqual({ kind: "redirect", href: "/h/server-next" });
|
|
});
|
|
|
|
it("redirects to the online host when no saved workspace is selected", () => {
|
|
expect(
|
|
resolveStartupRoute({
|
|
...baseIndexInput,
|
|
anyOnlineHostServerId: "srv-desktop",
|
|
}),
|
|
).toEqual({ kind: "redirect", href: "/h/srv-desktop" });
|
|
});
|
|
|
|
it("keeps a known connecting host in app-owned routing instead of showing welcome", () => {
|
|
expect(
|
|
resolveStartupRoute({
|
|
...baseIndexInput,
|
|
hosts: [{ serverId: "server-saved" }],
|
|
hasGivenUpWaitingForHost: true,
|
|
}),
|
|
).toEqual({ kind: "redirect", href: "/h/server-saved" });
|
|
});
|
|
|
|
it("shows welcome after root startup gives up and no host exists", () => {
|
|
expect(
|
|
resolveStartupRoute({
|
|
...baseIndexInput,
|
|
hasGivenUpWaitingForHost: true,
|
|
}),
|
|
).toEqual({ kind: "redirect", href: "/welcome" });
|
|
});
|
|
|
|
it("keeps host routes mounted while the host registry is loading", () => {
|
|
expect(
|
|
resolveStartupRoute({
|
|
...baseHostInput,
|
|
hostRegistryStatus: "loading",
|
|
}),
|
|
).toEqual({ kind: "render" });
|
|
});
|
|
|
|
it("keeps host routes mounted while the managed daemon is starting", () => {
|
|
expect(
|
|
resolveStartupRoute({
|
|
...baseHostInput,
|
|
startupBlocker: { kind: "managed-daemon-starting" },
|
|
}),
|
|
).toEqual({ kind: "render" });
|
|
});
|
|
|
|
it("renders a host route once the route host is known", () => {
|
|
expect(
|
|
resolveStartupRoute({
|
|
...baseHostInput,
|
|
hosts: [{ serverId: "server-saved" }],
|
|
}),
|
|
).toEqual({ kind: "render" });
|
|
});
|
|
|
|
it("sends removed host routes to global project selection instead of welcome", () => {
|
|
expect(
|
|
resolveStartupRoute({
|
|
...baseHostInput,
|
|
route: { kind: "host", serverId: "server-removed" },
|
|
hosts: [{ serverId: "server-next" }],
|
|
}),
|
|
).toEqual({ kind: "redirect", href: "/open-project" });
|
|
});
|
|
|
|
it("shows welcome from a host route only after the registry proves no hosts exist", () => {
|
|
expect(
|
|
resolveStartupRoute({
|
|
...baseHostInput,
|
|
route: { kind: "host", serverId: "server-removed" },
|
|
}),
|
|
).toEqual({ kind: "redirect", href: "/welcome" });
|
|
});
|
|
});
|
|
|
|
describe("resolveHostIndexRoute", () => {
|
|
it("restores the remembered workspace when the host index opens for the same host", () => {
|
|
expect(
|
|
resolveHostIndexRoute({
|
|
serverId: "server-saved",
|
|
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
|
|
workspaceSelectionStatus: "exists",
|
|
}),
|
|
).toEqual("/h/server-saved/workspace/workspace-a");
|
|
});
|
|
|
|
it("keeps restoring a remembered workspace before the host workspace list hydrates", () => {
|
|
expect(
|
|
resolveHostIndexRoute({
|
|
serverId: "server-saved",
|
|
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
|
|
workspaceSelectionStatus: "unknown",
|
|
}),
|
|
).toEqual("/h/server-saved/workspace/workspace-a");
|
|
});
|
|
|
|
it("opens global project selection when the remembered workspace is proven missing", () => {
|
|
expect(
|
|
resolveHostIndexRoute({
|
|
serverId: "server-saved",
|
|
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
|
|
workspaceSelectionStatus: "missing",
|
|
}),
|
|
).toEqual("/open-project");
|
|
});
|
|
|
|
it("opens global project selection when the remembered workspace belongs to another host", () => {
|
|
expect(
|
|
resolveHostIndexRoute({
|
|
serverId: "server-saved",
|
|
workspaceSelection: { serverId: "server-other", workspaceId: "workspace-a" },
|
|
workspaceSelectionStatus: "exists",
|
|
}),
|
|
).toEqual("/open-project");
|
|
});
|
|
|
|
it("opens global project selection when no workspace is remembered", () => {
|
|
expect(
|
|
resolveHostIndexRoute({
|
|
serverId: "server-saved",
|
|
workspaceSelection: null,
|
|
workspaceSelectionStatus: "unknown",
|
|
}),
|
|
).toEqual("/open-project");
|
|
});
|
|
});
|
|
|
|
describe("host runtime app lifecycle", () => {
|
|
it.each(["inactive", "background"] as const)(
|
|
"applies initial visibility and forwards lifecycle changes when mounted %s",
|
|
(currentState) => {
|
|
const visibility: boolean[] = [];
|
|
let listener: ((state: "active" | "inactive" | "background") => void) | undefined;
|
|
let subscribed = true;
|
|
const dispose = bindHostRuntimeAppState(
|
|
{ setAppVisible: (visible) => visibility.push(visible) },
|
|
{
|
|
currentState,
|
|
addEventListener: (_event, handler) => {
|
|
listener = handler;
|
|
return {
|
|
remove: () => {
|
|
subscribed = false;
|
|
},
|
|
};
|
|
},
|
|
},
|
|
);
|
|
expect(visibility).toEqual([false]);
|
|
listener?.("active");
|
|
listener?.("inactive");
|
|
listener?.("background");
|
|
listener?.("active");
|
|
expect(visibility).toEqual([false, true, false, false, true]);
|
|
dispose();
|
|
expect(subscribed).toBe(false);
|
|
},
|
|
);
|
|
});
|