Files
paseo/packages/app/src/runtime/host-runtime.test.ts
T
6d4cc53a0a ci: fix all tests to green (#236)
* ci: add CI status tracker for test fix iteration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* cli: honor daemon connect timeout

* app: fix e2e helper server path

* e2e: fix helper imports and ws cleanup

* cli: align daemon status tests

* style: autoformat with biome to fix 195 formatting errors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* server: fix 4 stale test expectations and setup bug

- logger.test.ts: update expected default level from trace to debug
  matching intentional product change
- session.workspaces.test.ts: only opened worktree reconciles, not
  siblings; add explicit reconcileWorkspaceRecord before owner-change
  assertion
- worktree.test.ts: add explicit git checkout -B main origin/main
  for deterministic CI branch state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* server: align daemon-client test expectations with ignoreWhitespace field

normalizeCheckoutDiffCompare() now always emits ignoreWhitespace: false,
so update the two checkout-diff subscribe test assertions to match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: format daemon-client test to satisfy biome

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* cli: update provider test for new providers and model lineup

Update 15-provider test expectations to match current product state:
- Provider count: 3 → 5 (added copilot, pi)
- Claude models: 3 → 4 (added claude-opus-4-6[1m])
- Codex models: replace retired gpt-5.1-* with gpt-5.4/gpt-5.4-mini

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* app: fix 18 failing test files with vitest setup and stale expectations

Add vitest.setup.ts to define __DEV__, shim Expo globals, mock
react-native-unistyles/expo-linking, and stub @xterm/addon-ligatures.
Update stale test expectations across combined-model-selector,
use-settings, tool-call-display, sidebar-project-row-model,
sidebar-shortcuts, keyboard-shortcuts, host-runtime,
use-agent-form-state, desktop-permissions, and voice-runtime
to match current source behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: add missing highlight build step to app-tests job

The app-tests CI job was missing the `npm run build --workspace=@getpaseo/highlight`
step that all other jobs have. This caused diff-highlighter.test.ts to fail with
"Failed to resolve entry for package @getpaseo/highlight" because the dist/ directory
did not exist.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: install codex and opencode CLIs in cli-tests job

The 15-provider test expects `provider models codex` and
`provider models opencode` to succeed, which requires the
actual CLI binaries to be on PATH.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* app: fix Playwright e2e helpers using import.meta.url in CJS context

Revert dynamic import path resolution from `new URL(..., import.meta.url)`
to `path.resolve(__dirname, ...)` + `pathToFileURL(...)` in three e2e
helpers. Playwright's TS loader emits CommonJS, where import.meta.url
is undefined, causing "exports is not defined in ES module scope" and
blocking all e2e test discovery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: add missing server build step to playwright job

The Playwright e2e helpers dynamically import from
packages/server/dist/server/server/exports.js, but the CI job
only built highlight and relay dependencies. Add the server build
step after relay so the dist artifacts exist when tests run.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test(cli): make codex model assertions resilient to catalog changes

The 15-provider test hard-coded exact model IDs (gpt-5.3-codex-spark,
etc.) which depend on the external codex CLI's model/list endpoint.
Replace with structural checks: all IDs in gpt- family, at least one
codex-optimized model, and all models have required fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test(cli): make opencode model assertions resilient to catalog changes

The 15-provider test hard-coded exact model IDs (opencode/gpt-5-nano,
openrouter/openai/gpt-5.3-codex) which break when the external opencode
CLI updates its model catalog. Replace with structural checks: at least
one first-party opencode model, at least one OpenAI-backed model, at
least one codex-optimized model, and all models have required fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* app: fix Playwright E2E WebSocket errors on Node 20 + fix format

E2E helpers used DaemonClient without a webSocketFactory, which relies
on globalThis.WebSocket — unavailable in Node 20 (CI). Add a shared
node-ws-factory helper using the ws package (matching the CLI pattern)
and inject it in all three E2E connection helpers. Also fix a biome
formatting issue in the cli provider test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: update lockfile signatures and Nix hash

* test(cli): replace OpenAI-specific assertions with generic third-party check

The opencode provider test asserted models with "openai/" or
"openrouter/openai/" prefixes, but these are environment-dependent —
opencode returns whatever providers are connected, and CI may not have
OpenAI configured. Replace with a generic check that at least one
non-opencode/ namespaced model exists.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: advance stale schedule nextRunAt on daemon restart

On restart, persisted nextRunAt could be in the past, showing stale
dates in `schedule ls`. Now recoverInterruptedRuns() advances any
past-due nextRunAt forward to the next future tick.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: install agent CLIs in playwright job + remove env-dependent opencode test assertions

The Playwright E2E tests (archive-tab, terminal-performance) fail because
the CI job doesn't install Codex/OpenCode binaries that the tests need to
create agents. Add the same install step already used in cli-tests.

The 15-provider CLI test still asserts third-party providers are connected
in OpenCode, which is environment-dependent. Remove that assertion and
lower the minimum model count to 1, keeping only deterministic structural
checks (namespacing, required fields, first-party models).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: apply biome formatting to schedule service files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(cli): parse localDaemon field in daemon supervisor test assertions

The daemon status JSON uses `localDaemon` not `status`, so the polling
condition was always null and timed out after 120s on CI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: apply biome formatting to daemon supervisor test files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(cli): spawn supervisor directly via node --import tsx instead of npx wrapper

The daemon supervisor tests (22, 23, 25) spawned the supervisor through
`npx tsx` which creates a wrapper process. When SIGINT was sent, the npx
wrapper died with signal=SIGINT instead of forwarding it to the actual
supervisor which handles graceful shutdown. Now matches production startup
pattern using process.execPath with --import tsx.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(server): acquire pid lock for unsupervised daemon workers

The direct worker path (non-supervisor) was not writing paseo.pid,
so `paseo daemon status` could not detect the running daemon. This
caused test 26-daemon-restart-unsupervised to time out in CI waiting
for the status to become "running".

Acquire the pid lock before daemon creation, update it with the
listen address after start, and release on shutdown/error. Supervision
detection now requires both PASEO_SUPERVISED=1 and an active IPC
channel to avoid misclassification when the env var is inherited.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(e2e): correct terminal tab testid and navigation URL in Playwright helper

navigateToTerminal() used the bare workspace route instead of the URL
with ?open=terminal:<id> intent, and looked for testid
"workspace-tab-terminal:<id>" (colon) when the real tab key is
"terminal_<id>" (underscore). Both bugs prevented the terminal surface
from ever appearing, causing terminal-performance tests to timeout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(e2e): wait for open-intent redirect before interacting with terminal surface

The navigateToTerminal helper was racing the workspace layout's ?open= redirect,
which returns null during the useEffect cycle. Now waits for the clean workspace
URL before looking for the terminal tab, and removes the fallback that created
a different terminal via the "New terminal tab" button.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: apply biome formatting to terminal-perf.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(app): ungate host routes from storeReady to preserve deep links

Host routes (workspace, agent, sessions, etc.) were inside
Stack.Protected guard={storeReady}, causing deep links to be rejected
during initial storage hydration and redirected to /welcome. Move them
outside the guard so they can render their own loading state while
stores hydrate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(app): gate open-intent consumption on navigation readiness

After moving host routes outside Stack.Protected, the workspace layout
could mount before the root navigator was ready. router.replace() would
silently fail, but consumedIntentRef was already set, preventing retries.
Gate the effect on rootNavigationState.key so the intent is only consumed
once Expo Router can actually process the replace.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(app): use history.replaceState to strip ?open since Expo Router skips query-only changes

Expo Router's findDivergentState ignores search params, so
router.replace with the same pathname but without ?open is a no-op.
Use window.history.replaceState on web to directly strip the query
param, and track intent consumption in component state so
WorkspaceScreen renders once the tab is prepared.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: update stale codex model from gpt-5.1-codex-mini to gpt-5.4-mini

The Codex CLI no longer supports gpt-5.1-codex-mini. Update all
references to gpt-5.4-mini, which is available in the current CLI.
This fixes archive-tab Playwright tests that create codex agents
which were erroring due to the unsupported model.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(ci): pin codex CLI to 0.105.0 and improve turn_failed diagnostics

Playwright archive-tab tests fail because CI installs @openai/codex@latest
(0.120.0) which has breaking protocol changes vs the known-working 0.105.0.
Pin the version and add diagnostics for future debugging: elevate turn_failed
log from TRACE to WARN, and include error details in test assertions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(e2e): switch archive-tab tests from codex to opencode provider

Codex CLI requires OAuth auth (~/.codex/auth.json) that CI lacks —
OPENAI_API_KEY alone gets 401. These tests verify archive tab behavior,
not any specific provider. Switch to opencode/gpt-5-nano which
authenticates via standard OpenAI API that the CI key supports.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: retrigger CI for PR #236

* ci: trigger CI for opencode provider switch

Previous commits (3cbd7976, bdf768d6) were pushed with a token
that did not trigger GitHub Actions workflows. This empty commit
forces a fresh pull_request event.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: add workflow_dispatch trigger to CI workflow

Enable manual triggering of the CI workflow. Previous pushes to
ci/fix-tests-green failed to trigger pull_request events for the
CI workflow, so this allows manual dispatch as a fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(ci): unblock remaining test lanes

* test(server): allow slower CI Claude model listing

* fix(cli): stabilize unsupervised restart regression

* test(ci): harden restart and archive e2e timing

* test(server): align workspace reconciliation expectation

* test(server): tolerate platform-specific workspace ids

* fix(app): gate host route logic on bootstrap readiness

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-12 09:56:05 +08:00

1355 lines
44 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import type {
DaemonClient,
ConnectionState,
FetchAgentsEntry,
FetchAgentsOptions,
} from "@server/client/daemon-client";
import type { ConnectionOffer } from "@server/shared/connection-offer";
import type { HostConnection, HostProfile } from "@/types/host-connection";
import { useSessionStore, type Agent } from "@/stores/session-store";
import {
HostRuntimeController,
HostRuntimeStore,
type HostRuntimeControllerDeps,
} from "./host-runtime";
class FakeDaemonClient {
private state: ConnectionState = { status: "idle" };
private listeners = new Set<(status: ConnectionState) => void>();
private error: string | null = null;
public connectCalls = 0;
public closeCalls = 0;
public ensureConnectedCalls = 0;
public fetchAgentsCalls: FetchAgentsOptions[] = [];
public fetchAgentsResponses: Array<Awaited<ReturnType<DaemonClient["fetchAgents"]>>> = [];
async connect(): Promise<void> {
this.connectCalls += 1;
this.setConnectionState({ status: "connected" });
}
async close(): Promise<void> {
this.closeCalls += 1;
this.setConnectionState({ status: "disconnected", reason: "client_closed" });
}
ensureConnected(): void {
this.ensureConnectedCalls += 1;
if (this.state.status !== "connected") {
this.setConnectionState({ status: "connected" });
}
}
getConnectionState(): ConnectionState {
return this.state;
}
subscribeConnectionStatus(listener: (status: ConnectionState) => void): () => void {
this.listeners.add(listener);
listener(this.state);
return () => {
this.listeners.delete(listener);
};
}
get lastError(): string | null {
return this.error;
}
async fetchAgents(
options?: FetchAgentsOptions,
): Promise<Awaited<ReturnType<DaemonClient["fetchAgents"]>>> {
this.fetchAgentsCalls.push(options ?? {});
const queued = this.fetchAgentsResponses.shift();
if (queued) {
return queued;
}
return makeFetchAgentsPayload({
entries: [],
subscriptionId: options?.subscribe?.subscriptionId ?? undefined,
});
}
async ping(): Promise<{ rttMs: number }> {
return { rttMs: 0 };
}
setReconnectEnabled(_enabled: boolean): void {}
setConnectionState(next: ConnectionState): void {
this.state = next;
if (next.status === "disconnected") {
this.error = next.reason ?? this.error;
}
for (const listener of this.listeners) {
listener(next);
}
}
}
function makeFetchAgentsPayload(input: {
entries: FetchAgentsEntry[];
hasMore?: boolean;
nextCursor?: string | null;
subscriptionId?: string;
}): Awaited<ReturnType<DaemonClient["fetchAgents"]>> {
return {
entries: input.entries,
pageInfo: {
nextCursor: input.nextCursor ?? null,
prevCursor: null,
hasMore: input.hasMore ?? false,
} as Awaited<ReturnType<DaemonClient["fetchAgents"]>>["pageInfo"],
...(input.subscriptionId ? { subscriptionId: input.subscriptionId } : {}),
requestId: "req_test",
};
}
function makeFetchAgentsEntry(input: {
id: string;
cwd: string;
updatedAt: string;
title?: string | null;
requiresAttention?: boolean;
attentionReason?: "permission" | "error" | null;
archivedAt?: string | null;
}): FetchAgentsEntry {
return {
agent: {
id: input.id,
provider: "codex",
status: "idle",
createdAt: input.updatedAt,
updatedAt: input.updatedAt,
lastUserMessageAt: null,
lastError: undefined,
runtimeInfo: {
provider: "codex",
sessionId: null,
},
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: input.title ?? null,
cwd: input.cwd,
model: null,
thinkingOptionId: null,
requiresAttention: input.requiresAttention ?? false,
attentionReason: input.attentionReason ?? null,
attentionTimestamp: input.requiresAttention && input.attentionReason ? input.updatedAt : null,
archivedAt: input.archivedAt ?? null,
labels: {},
},
project: {
projectKey: input.cwd,
projectName: "workspace",
checkout: {
cwd: input.cwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
};
}
function makeHost(input?: Partial<HostProfile>): HostProfile {
const direct: HostConnection = {
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
};
const relay: HostConnection = {
id: "relay:relay.paseo.sh:443",
type: "relay",
relayEndpoint: "relay.paseo.sh:443",
daemonPublicKeyB64: "pk_test",
};
return {
serverId: input?.serverId ?? "srv_test",
label: input?.label ?? "test host",
lifecycle: input?.lifecycle ?? {},
connections: input?.connections ?? [direct, relay],
preferredConnectionId: input?.preferredConnectionId ?? direct.id,
createdAt: input?.createdAt ?? new Date(0).toISOString(),
updatedAt: input?.updatedAt ?? new Date(0).toISOString(),
};
}
function makeOffer(input?: Partial<ConnectionOffer>): ConnectionOffer {
return {
v: 2,
serverId: input?.serverId ?? "srv_offer",
daemonPublicKeyB64: input?.daemonPublicKeyB64 ?? "pk_test_offer",
relay: {
endpoint: input?.relay?.endpoint ?? "relay.paseo.sh:443",
},
};
}
function makeDeps(
latencyByConnectionId: Record<string, number | Error>,
createdClients: FakeDaemonClient[],
): HostRuntimeControllerDeps {
return {
createClient: () => {
const client = new FakeDaemonClient();
createdClients.push(client);
return client as unknown as DaemonClient;
},
connectToDaemon: async ({ host, connection }) => {
const value = latencyByConnectionId[connection.id];
if (value instanceof Error) {
throw value;
}
if (typeof value !== "number") {
throw new Error(`missing latency for ${connection.id}`);
}
const client = new FakeDaemonClient();
client.connectCalls = 1;
client.setConnectionState({ status: "connected" });
client.ping = async () => ({ rttMs: value });
createdClients.push(client);
return {
client: client as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
};
},
getClientId: async () => "cid_test_runtime",
};
}
function createDeferred<T>() {
let resolve: ((value: T | PromiseLike<T>) => void) | null = null;
let reject: ((reason?: unknown) => void) | null = null;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return {
promise,
resolve: (value: T | PromiseLike<T>) => resolve?.(value),
reject: (reason?: unknown) => reject?.(reason),
};
}
function makeConnectedProbeClient(latencyMs: number): FakeDaemonClient {
const client = new FakeDaemonClient();
client.connectCalls = 1;
client.setConnectionState({ status: "connected" });
client.ping = async () => ({ rttMs: latencyMs });
return client;
}
function clearProbeBackoff(controller: HostRuntimeController): void {
(
controller as unknown as {
connectionLastProbedAt: Map<string, number>;
}
).connectionLastProbedAt.clear();
}
describe("HostRuntimeController", () => {
it("keeps known hosts in connecting when a created client reports idle during connect", async () => {
const host = makeHost({
connections: [
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
},
],
});
const idleClient = new FakeDaemonClient();
const deps: HostRuntimeControllerDeps = {
createClient: () => idleClient as unknown as DaemonClient,
connectToDaemon: async () => {
throw new Error("probe unavailable");
},
getClientId: async () => "cid_test_runtime",
};
const controller = new HostRuntimeController({
host,
deps,
});
idleClient.connect = async () => {
idleClient.connectCalls += 1;
// Intentionally do not emit a connected state; stay in idle.
};
await (
controller as unknown as {
switchToConnection: (input: { connectionId: string }) => Promise<void>;
}
).switchToConnection({ connectionId: "direct:lan:6767" });
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
expect(controller.getSnapshot().connectionStatus).toBe("connecting");
expect(controller.getSnapshot().agentDirectoryStatus).toBe("initial_loading");
});
it("passes resolved client id into created active clients", async () => {
const host = makeHost({
connections: [
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
},
],
});
const seenClientIds: string[] = [];
const fakeClient = new FakeDaemonClient();
const controller = new HostRuntimeController({
host,
deps: {
createClient: ({ clientId }) => {
seenClientIds.push(clientId);
return fakeClient as unknown as DaemonClient;
},
connectToDaemon: async () => {
throw new Error("probe unavailable");
},
getClientId: async () => "cid_runtime_stable",
},
});
await (
controller as unknown as {
switchToConnection: (input: { connectionId: string }) => Promise<void>;
}
).switchToConnection({ connectionId: "direct:lan:6767" });
expect(seenClientIds).toEqual(["cid_runtime_stable"]);
expect(controller.getSnapshot().connectionStatus).toBe("online");
});
it("adopts the first successful probe on startup", async () => {
const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
const clients: FakeDaemonClient[] = [];
const latencies: Record<string, number | Error> = {
"direct:lan:6767": 82,
"relay:relay.paseo.sh:443": 18,
};
const controller = new HostRuntimeController({
host,
deps: makeDeps(latencies, clients),
});
await controller.start({ autoProbe: false });
const snapshot = controller.getSnapshot();
expect(snapshot.activeConnectionId).toBe("direct:lan:6767");
expect(snapshot.connectionStatus).toBe("online");
expect(clients).toHaveLength(2);
expect(snapshot.client).toBe(clients[0] as unknown as DaemonClient);
expect(clients[0]?.connectCalls).toBe(1);
expect(clients[1]?.closeCalls).toBe(1);
});
it("activates the first successful probe without waiting for slower probes", async () => {
const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
const slowPing = createDeferred<number>();
const clients: FakeDaemonClient[] = [];
const controller = new HostRuntimeController({
host,
deps: {
createClient: () => {
throw new Error("should adopt the probe client");
},
connectToDaemon: async ({ host, connection }) => {
const client = makeConnectedProbeClient(connection.id === "direct:lan:6767" ? 12 : 30);
if (connection.id === "relay:relay.paseo.sh:443") {
client.ping = async () => ({ rttMs: await slowPing.promise });
}
clients.push(client);
return {
client: client as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
};
},
getClientId: async () => "cid_test_runtime",
},
});
const probeCycle = controller.runProbeCycleNow();
const timeoutAt = Date.now() + 200;
while (Date.now() < timeoutAt) {
const snapshot = controller.getSnapshot();
if (
snapshot.activeConnectionId === "direct:lan:6767" &&
snapshot.connectionStatus === "online"
) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 0));
}
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
expect(controller.getSnapshot().connectionStatus).toBe("online");
slowPing.resolve(30);
await probeCycle;
});
it("fails over when active connection becomes unavailable", async () => {
const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
const clients: FakeDaemonClient[] = [];
const latencies: Record<string, number | Error> = {
"direct:lan:6767": 15,
"relay:relay.paseo.sh:443": 55,
};
const controller = new HostRuntimeController({
host,
deps: makeDeps(latencies, clients),
});
await controller.start({ autoProbe: false });
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
const initialClient = controller.getSnapshot().client;
expect(initialClient).toBeTruthy();
latencies["direct:lan:6767"] = new Error("direct unavailable");
latencies["relay:relay.paseo.sh:443"] = 42;
clearProbeBackoff(controller);
await controller.runProbeCycleNow();
const snapshot = controller.getSnapshot();
expect(snapshot.activeConnectionId).toBe("relay:relay.paseo.sh:443");
expect(snapshot.connectionStatus).toBe("online");
expect(snapshot.client).not.toBe(initialClient);
expect((initialClient as unknown as FakeDaemonClient | null)?.closeCalls).toBe(1);
});
it("switches only after the faster alternative wins consecutive probes", async () => {
const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
const clients: FakeDaemonClient[] = [];
const latencies: Record<string, number | Error> = {
"direct:lan:6767": 15,
"relay:relay.paseo.sh:443": 60,
};
const controller = new HostRuntimeController({
host,
deps: makeDeps(latencies, clients),
});
await controller.start({ autoProbe: false });
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
latencies["direct:lan:6767"] = 95;
latencies["relay:relay.paseo.sh:443"] = 30;
clearProbeBackoff(controller);
await controller.runProbeCycleNow();
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
clearProbeBackoff(controller);
await controller.runProbeCycleNow();
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
let switched = controller.getSnapshot().activeConnectionId === "relay:relay.paseo.sh:443";
for (let index = 0; index < 6 && !switched; index += 1) {
clearProbeBackoff(controller);
await controller.runProbeCycleNow();
switched = controller.getSnapshot().activeConnectionId === "relay:relay.paseo.sh:443";
}
expect(switched).toBe(true);
expect(controller.getSnapshot().client).not.toBeNull();
});
it("does not switch on a transient latency spike", async () => {
const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
const clients: FakeDaemonClient[] = [];
const latencies: Record<string, number | Error> = {
"direct:lan:6767": 15,
"relay:relay.paseo.sh:443": 80,
};
const controller = new HostRuntimeController({
host,
deps: makeDeps(latencies, clients),
});
await controller.start({ autoProbe: false });
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
latencies["direct:lan:6767"] = 100;
latencies["relay:relay.paseo.sh:443"] = 20;
clearProbeBackoff(controller);
await controller.runProbeCycleNow();
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
latencies["direct:lan:6767"] = 20;
latencies["relay:relay.paseo.sh:443"] = 90;
clearProbeBackoff(controller);
await controller.runProbeCycleNow();
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
latencies["direct:lan:6767"] = 100;
latencies["relay:relay.paseo.sh:443"] = 20;
clearProbeBackoff(controller);
await controller.runProbeCycleNow();
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
clearProbeBackoff(controller);
await controller.runProbeCycleNow();
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
let switched = controller.getSnapshot().activeConnectionId === "relay:relay.paseo.sh:443";
for (let index = 0; index < 6 && !switched; index += 1) {
clearProbeBackoff(controller);
await controller.runProbeCycleNow();
switched = controller.getSnapshot().activeConnectionId === "relay:relay.paseo.sh:443";
}
expect(switched).toBe(true);
});
it("exposes one snapshot with active connection and status from same source", async () => {
const host = makeHost();
const clients: FakeDaemonClient[] = [];
const latencies: Record<string, number | Error> = {
"direct:lan:6767": 12,
"relay:relay.paseo.sh:443": 65,
};
const controller = new HostRuntimeController({
host,
deps: makeDeps(latencies, clients),
});
const observed = new Array<ReturnType<typeof controller.getSnapshot>>();
const unsubscribe = controller.subscribe(() => {
observed.push(controller.getSnapshot());
});
await controller.start({ autoProbe: false });
clients[0]?.setConnectionState({
status: "disconnected",
reason: "transport closed",
});
const latest = observed[observed.length - 1];
expect(latest?.activeConnectionId).toBe("direct:lan:6767");
expect(latest?.connectionStatus).toBe("error");
expect(latest?.lastError).toBe("transport closed");
unsubscribe();
});
it("does not emit legacy typed reason-code transition logs", async () => {
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => undefined);
try {
const host = makeHost({
connections: [
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
},
],
});
const clients: FakeDaemonClient[] = [];
const controller = new HostRuntimeController({
host,
deps: makeDeps(
{
"direct:lan:6767": 12,
},
clients,
),
});
await controller.start({ autoProbe: false });
clients[0]?.setConnectionState({
status: "disconnected",
reason: "transport closed",
});
const transitionPayloads = infoSpy.mock.calls
.filter((call) => call[0] === "[HostRuntimeTransition]")
.map((call) => call[1] as { reasonCode?: string | null });
const lastTransition = transitionPayloads[transitionPayloads.length - 1] ?? null;
expect(lastTransition?.reasonCode).toBeUndefined();
} finally {
infoSpy.mockRestore();
}
});
it("marks directory loading on first connection before any directory sync succeeds", async () => {
const host = makeHost();
const clients: FakeDaemonClient[] = [];
const latencies: Record<string, number | Error> = {
"direct:lan:6767": 12,
"relay:relay.paseo.sh:443": 65,
};
const controller = new HostRuntimeController({
host,
deps: makeDeps(latencies, clients),
});
await controller.start({ autoProbe: false });
const snapshot = controller.getSnapshot();
expect(snapshot.connectionStatus).toBe("online");
expect(snapshot.hasEverLoadedAgentDirectory).toBe(false);
expect(snapshot.agentDirectoryStatus).toBe("initial_loading");
});
it("keeps directory ready through reconnects after the first successful directory load", async () => {
const host = makeHost();
const clients: FakeDaemonClient[] = [];
const latencies: Record<string, number | Error> = {
"direct:lan:6767": 12,
"relay:relay.paseo.sh:443": 65,
};
const controller = new HostRuntimeController({
host,
deps: makeDeps(latencies, clients),
});
await controller.start({ autoProbe: false });
controller.markAgentDirectorySyncReady();
expect(controller.getSnapshot().agentDirectoryStatus).toBe("ready");
expect(controller.getSnapshot().hasEverLoadedAgentDirectory).toBe(true);
clients[0]?.setConnectionState({
status: "disconnected",
reason: "client_closed",
});
expect(controller.getSnapshot().connectionStatus).toBe("offline");
expect(controller.getSnapshot().agentDirectoryStatus).toBe("ready");
clients[0]?.setConnectionState({ status: "connected" });
expect(controller.getSnapshot().connectionStatus).toBe("online");
expect(controller.getSnapshot().agentDirectoryStatus).toBe("ready");
});
it("stores directory sync errors as non-blocking after a successful directory load", async () => {
const host = makeHost();
const clients: FakeDaemonClient[] = [];
const latencies: Record<string, number | Error> = {
"direct:lan:6767": 12,
"relay:relay.paseo.sh:443": 65,
};
const controller = new HostRuntimeController({
host,
deps: makeDeps(latencies, clients),
});
await controller.start({ autoProbe: false });
controller.markAgentDirectorySyncReady();
controller.markAgentDirectorySyncError("bootstrap failed");
const snapshot = controller.getSnapshot();
expect(snapshot.agentDirectoryStatus).toBe("error_after_ready");
expect(snapshot.agentDirectoryError).toBe("bootstrap failed");
expect(snapshot.hasEverLoadedAgentDirectory).toBe(true);
});
it("keeps online snapshots coupled to a live client reference", async () => {
const host = makeHost();
const clients: FakeDaemonClient[] = [];
const latencies: Record<string, number | Error> = {
"direct:lan:6767": 12,
"relay:relay.paseo.sh:443": 65,
};
const controller = new HostRuntimeController({
host,
deps: makeDeps(latencies, clients),
});
const observed = new Array<ReturnType<typeof controller.getSnapshot>>();
const unsubscribe = controller.subscribe(() => {
observed.push(controller.getSnapshot());
});
await controller.start({ autoProbe: false });
for (const snapshot of observed) {
if (snapshot.connectionStatus === "online") {
expect(snapshot.client).toBeTruthy();
}
}
expect(controller.getSnapshot().connectionStatus).toBe("online");
expect(controller.getSnapshot().client).toBeTruthy();
unsubscribe();
});
it("ignores stale switch failures after a newer connection is already online", async () => {
const host = makeHost({
connections: [
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
},
{
id: "relay:relay.paseo.sh:443",
type: "relay",
relayEndpoint: "relay.paseo.sh:443",
daemonPublicKeyB64: "pk_test",
},
],
});
const firstConnectGate = createDeferred<void>();
const createdClients: FakeDaemonClient[] = [];
const deps: HostRuntimeControllerDeps = {
createClient: ({ connection }) => {
const client = new FakeDaemonClient();
if (connection.id === "direct:lan:6767") {
client.connect = async () => {
client.connectCalls += 1;
await firstConnectGate.promise;
throw new Error("stale direct connect failed");
};
}
createdClients.push(client);
return client as unknown as DaemonClient;
},
connectToDaemon: async ({ host }) => ({
client: makeConnectedProbeClient(10) as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
};
const controller = new HostRuntimeController({
host,
deps,
});
const waitUntil = async (predicate: () => boolean, timeoutMs = 200): Promise<void> => {
const timeoutAt = Date.now() + timeoutMs;
while (!predicate()) {
if (Date.now() >= timeoutAt) {
throw new Error("timed out waiting for predicate");
}
await new Promise((resolve) => setTimeout(resolve, 0));
}
};
const switchDirect = (
controller as unknown as {
switchToConnection: (input: { connectionId: string }) => Promise<void>;
}
).switchToConnection({ connectionId: "direct:lan:6767" });
await waitUntil(() => {
const snapshot = controller.getSnapshot();
return (
createdClients.length === 1 &&
snapshot.activeConnectionId === "direct:lan:6767" &&
snapshot.connectionStatus === "connecting"
);
});
const switchRelay = (
controller as unknown as {
switchToConnection: (input: { connectionId: string }) => Promise<void>;
}
).switchToConnection({ connectionId: "relay:relay.paseo.sh:443" });
await waitUntil(() => {
const snapshot = controller.getSnapshot();
return (
snapshot.activeConnectionId === "relay:relay.paseo.sh:443" &&
snapshot.connectionStatus === "online"
);
});
firstConnectGate.resolve();
await Promise.allSettled([switchDirect, switchRelay]);
const snapshot = controller.getSnapshot();
expect(snapshot.activeConnectionId).toBe("relay:relay.paseo.sh:443");
expect(snapshot.connectionStatus).toBe("online");
expect(snapshot.lastError).toBeNull();
expect(createdClients).toHaveLength(2);
expect(createdClients[0]?.closeCalls).toBe(1);
});
it("ignores stale probe results when overlapping probe cycles finish out of order", async () => {
const host = makeHost({
connections: [
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
},
],
});
const slowProbe = createDeferred<number>();
const fastProbe = createDeferred<number>();
let probeCalls = 0;
const controller = new HostRuntimeController({
host,
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => {
probeCalls += 1;
const client = new FakeDaemonClient();
client.connectCalls = 1;
client.setConnectionState({ status: "connected" });
client.ping = async () => {
if (probeCalls === 1) {
return { rttMs: await slowProbe.promise };
}
if (probeCalls === 2) {
return { rttMs: await fastProbe.promise };
}
throw new Error("unexpected probe call");
};
return {
client: client as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
};
},
getClientId: async () => "cid_test_runtime",
},
});
const first = controller.runProbeCycleNow();
clearProbeBackoff(controller);
const second = controller.runProbeCycleNow();
fastProbe.resolve(12);
await second;
const probeAfterSecond = controller.getSnapshot().probeByConnectionId.get("direct:lan:6767");
expect(probeAfterSecond).toEqual({
status: "available",
latencyMs: 12,
});
slowProbe.resolve(900);
await first;
const probeAfterFirstSettles = controller
.getSnapshot()
.probeByConnectionId.get("direct:lan:6767");
expect(probeAfterFirstSettles).toEqual({
status: "available",
latencyMs: 12,
});
});
it("keeps active client generation stable during background probe cycles", async () => {
const host = makeHost({
connections: [
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
},
],
});
const createdClients: FakeDaemonClient[] = [];
const controller = new HostRuntimeController({
host,
deps: {
createClient: () => {
const client = new FakeDaemonClient();
createdClients.push(client);
return client as unknown as DaemonClient;
},
connectToDaemon: async ({ host }) => {
const client = makeConnectedProbeClient(10);
return {
client: client as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
};
},
getClientId: async () => "cid_test_runtime",
},
});
await controller.start({ autoProbe: false });
const activeClientBeforeProbes = controller.getSnapshot().client;
const generationBeforeProbes = controller.getSnapshot().clientGeneration;
clearProbeBackoff(controller);
await controller.runProbeCycleNow();
expect(controller.getSnapshot().client).toBe(activeClientBeforeProbes);
expect(controller.getSnapshot().clientGeneration).toBe(generationBeforeProbes);
expect(createdClients).toHaveLength(0);
});
});
describe("HostRuntimeStore", () => {
it("bootstraps agent directory subscription when host transitions online", async () => {
const host = makeHost({
connections: [
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
},
],
});
const fakeClient = new FakeDaemonClient();
fakeClient.setConnectionState({ status: "connected" });
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
connectToDaemon: async ({ host }) => ({
client: fakeClient as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
},
});
useSessionStore
.getState()
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient);
store.syncHosts([host]);
const timeoutAt = Date.now() + 200;
while (fakeClient.fetchAgentsCalls.length === 0 && Date.now() < timeoutAt) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
expect(fakeClient.fetchAgentsCalls).toHaveLength(1);
expect(fakeClient.fetchAgentsCalls[0]).toEqual({
filter: { includeArchived: true },
sort: [{ key: "updated_at", direction: "desc" }],
subscribe: { subscriptionId: "app:srv_test" },
page: { limit: 200 },
});
const snapshot = store.getSnapshot(host.serverId);
expect(snapshot?.agentDirectoryStatus).toBe("ready");
expect(snapshot?.hasEverLoadedAgentDirectory).toBe(true);
store.syncHosts([]);
useSessionStore.getState().clearSession(host.serverId);
});
it("bootstraps agent directory immediately when connection goes online (no session required)", async () => {
const host = makeHost({
serverId: "srv_no_session",
connections: [
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
},
],
});
const fakeClient = new FakeDaemonClient();
fakeClient.setConnectionState({ status: "connected" });
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
connectToDaemon: async ({ host }) => ({
client: fakeClient as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
},
});
store.syncHosts([host]);
const timeoutAt = Date.now() + 200;
while (fakeClient.fetchAgentsCalls.length === 0 && Date.now() < timeoutAt) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
expect(fakeClient.fetchAgentsCalls).toHaveLength(1);
expect(fakeClient.fetchAgentsCalls[0]).toEqual({
filter: { includeArchived: true },
sort: [{ key: "updated_at", direction: "desc" }],
subscribe: { subscriptionId: "app:srv_no_session" },
page: { limit: 200 },
});
store.syncHosts([]);
});
it("fetches all pages during bootstrap so older workspace agents are present", async () => {
const host = makeHost({
serverId: "srv_paged",
connections: [
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
},
],
});
const fakeClient = new FakeDaemonClient();
fakeClient.setConnectionState({ status: "connected" });
fakeClient.fetchAgentsResponses.push(
makeFetchAgentsPayload({
entries: [
makeFetchAgentsEntry({
id: "agent-recent",
cwd: "/Users/moboudra/dev/paseo",
updatedAt: "2026-03-04T12:00:00.000Z",
title: "Recent agent",
}),
],
hasMore: true,
nextCursor: "cursor-page-2",
subscriptionId: "app:srv_paged",
}),
makeFetchAgentsPayload({
entries: [
makeFetchAgentsEntry({
id: "agent-stale-attention",
cwd: "/Users/moboudra/dev/paseo-pr67-review",
updatedAt: "2026-02-20T08:00:00.000Z",
title: "Needs triage",
requiresAttention: true,
attentionReason: "error",
}),
],
hasMore: false,
}),
);
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
connectToDaemon: async ({ host }) => ({
client: fakeClient as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
},
});
useSessionStore
.getState()
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient);
store.syncHosts([host]);
const timeoutAt = Date.now() + 300;
while (fakeClient.fetchAgentsCalls.length < 2 && Date.now() < timeoutAt) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
expect(fakeClient.fetchAgentsCalls).toHaveLength(2);
expect(fakeClient.fetchAgentsCalls[0]).toEqual({
filter: { includeArchived: true },
sort: [{ key: "updated_at", direction: "desc" }],
subscribe: { subscriptionId: "app:srv_paged" },
page: { limit: 200 },
});
expect(fakeClient.fetchAgentsCalls[1]).toEqual({
filter: { includeArchived: true },
sort: [{ key: "updated_at", direction: "desc" }],
page: { limit: 200, cursor: "cursor-page-2" },
});
let staleAgent =
useSessionStore.getState().sessions[host.serverId]?.agents?.get("agent-stale-attention") ??
null;
const staleTimeoutAt = Date.now() + 300;
while (!staleAgent && Date.now() < staleTimeoutAt) {
await new Promise((resolve) => setTimeout(resolve, 0));
staleAgent =
useSessionStore.getState().sessions[host.serverId]?.agents?.get("agent-stale-attention") ??
null;
}
expect(staleAgent?.requiresAttention).toBe(true);
expect(staleAgent?.attentionReason).toBe("error");
const snapshot = store.getSnapshot(host.serverId);
expect(snapshot?.agentDirectoryStatus).toBe("ready");
expect(snapshot?.hasEverLoadedAgentDirectory).toBe(true);
store.syncHosts([]);
useSessionStore.getState().clearSession(host.serverId);
});
it("re-subscribes agent directory updates after reconnect", async () => {
const host = makeHost({
serverId: "srv_resubscribe",
connections: [
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
},
],
});
const fakeClient = new FakeDaemonClient();
fakeClient.setConnectionState({ status: "connected" });
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
connectToDaemon: async ({ host }) => ({
client: fakeClient as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
},
});
useSessionStore
.getState()
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient);
store.syncHosts([host]);
const initialTimeoutAt = Date.now() + 200;
while (fakeClient.fetchAgentsCalls.length < 1 && Date.now() < initialTimeoutAt) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
fakeClient.setConnectionState({
status: "disconnected",
reason: "client_closed",
});
fakeClient.setConnectionState({ status: "connected" });
const reconnectTimeoutAt = Date.now() + 200;
while (fakeClient.fetchAgentsCalls.length < 2 && Date.now() < reconnectTimeoutAt) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
expect(fakeClient.fetchAgentsCalls).toEqual([
{
filter: { includeArchived: true },
sort: [{ key: "updated_at", direction: "desc" }],
subscribe: { subscriptionId: "app:srv_resubscribe" },
page: { limit: 200 },
},
{
filter: { includeArchived: true },
sort: [{ key: "updated_at", direction: "desc" }],
subscribe: { subscriptionId: "app:srv_resubscribe" },
page: { limit: 200 },
},
]);
store.syncHosts([]);
useSessionStore.getState().clearSession(host.serverId);
});
it("rehydrates archived agents over stale active session state after reconnect bootstrap", async () => {
const host = makeHost({
serverId: "srv_archived_rehydrate",
connections: [
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
},
],
});
const fakeClient = new FakeDaemonClient();
fakeClient.setConnectionState({ status: "connected" });
fakeClient.fetchAgentsResponses.push(
makeFetchAgentsPayload({
entries: [
makeFetchAgentsEntry({
id: "agent-archived",
cwd: "/Users/moboudra/dev/paseo",
updatedAt: "2026-03-30T15:30:00.000Z",
archivedAt: "2026-03-30T15:31:00.000Z",
title: "Archived remotely",
}),
],
subscriptionId: "app:srv_archived_rehydrate",
}),
);
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
connectToDaemon: async ({ host }) => ({
client: fakeClient as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
},
});
useSessionStore
.getState()
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient);
useSessionStore.getState().setAgents(host.serverId, () => {
const stale = makeFetchAgentsEntry({
id: "agent-archived",
cwd: "/Users/moboudra/dev/paseo",
updatedAt: "2026-03-30T15:29:00.000Z",
archivedAt: null,
title: "Stale active copy",
}).agent;
const staleAgent: Agent = {
...stale,
serverId: host.serverId,
createdAt: new Date(stale.createdAt),
updatedAt: new Date(stale.updatedAt),
lastUserMessageAt: null,
lastActivityAt: new Date(stale.updatedAt),
archivedAt: stale.archivedAt ? new Date(stale.archivedAt) : null,
attentionTimestamp: stale.attentionTimestamp ? new Date(stale.attentionTimestamp) : null,
};
return new Map([[stale.id, staleAgent]]);
});
store.syncHosts([host]);
const timeoutAt = Date.now() + 300;
let archivedAt =
useSessionStore.getState().sessions[host.serverId]?.agents.get("agent-archived")
?.archivedAt ?? null;
while (!archivedAt && Date.now() < timeoutAt) {
await new Promise((resolve) => setTimeout(resolve, 0));
archivedAt =
useSessionStore.getState().sessions[host.serverId]?.agents.get("agent-archived")
?.archivedAt ?? null;
}
expect(archivedAt?.toISOString()).toBe("2026-03-30T15:31:00.000Z");
store.syncHosts([]);
useSessionStore.getState().clearSession(host.serverId);
});
it("records unavailable startup probes when no connection can be established", async () => {
const host = makeHost({
connections: [
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
},
],
});
const store = new HostRuntimeStore({
deps: {
createClient: () => {
throw new Error("create client failed");
},
connectToDaemon: async () => {
throw new Error("probe unavailable");
},
getClientId: async () => "cid_test_runtime",
},
});
store.syncHosts([host]);
let snapshot = store.getSnapshot(host.serverId);
const timeoutAt = Date.now() + 100;
while (
snapshot?.probeByConnectionId.get("direct:lan:6767")?.status !== "unavailable" &&
Date.now() < timeoutAt
) {
await new Promise((resolve) => setTimeout(resolve, 0));
snapshot = store.getSnapshot(host.serverId);
}
expect(snapshot?.connectionStatus).toBe("connecting");
expect(snapshot?.lastError).toBeNull();
expect(snapshot?.probeByConnectionId.get("direct:lan:6767")).toEqual({
status: "unavailable",
latencyMs: null,
});
});
it("renameHost updates label in memory", async () => {
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => ({
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
},
});
// upsertDirectConnection goes through setHostsAndSync, which both sets
// this.hosts and syncs controllers — matching the real init path.
await store.upsertDirectConnection({
serverId: "srv_rename",
endpoint: "lan:6767",
label: "old name",
});
expect(store.getHosts().find((h) => h.serverId === "srv_rename")?.label).toBe("old name");
// persistHosts may throw in test env (no AsyncStorage/window), but the
// in-memory state should still be updated by setHostsAndSync.
await store.renameHost("srv_rename", "new name").catch(() => undefined);
const renamed = store.getHosts().find((h) => h.serverId === "srv_rename");
expect(renamed?.label).toBe("new name");
store.syncHosts([]);
});
it("uses the advertised hostname when adding a relay host from a pairing offer", async () => {
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => ({
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
},
});
await store.upsertConnectionFromOffer(makeOffer(), "mbp");
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
expect(pairedHost?.label).toBe("mbp");
store.syncHosts([]);
});
it("uses the latest advertised hostname when re-pairing an existing relay host", async () => {
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => ({
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
},
});
await store.upsertRelayConnection({
serverId: "srv_offer",
relayEndpoint: "relay.paseo.sh:443",
daemonPublicKeyB64: "pk_test_offer",
label: "Custom name",
});
await store.upsertConnectionFromOffer(makeOffer(), "mbp");
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
expect(pairedHost?.label).toBe("mbp");
store.syncHosts([]);
});
});