mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 01:14:52 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3972cc1559 | ||
|
|
d92495487f | ||
|
|
19848899d0 |
@@ -46,6 +46,31 @@ function makeHost(): HostProfile {
|
||||
};
|
||||
}
|
||||
|
||||
function makeSnapshot(host: HostProfile): HostRuntimeSnapshot {
|
||||
return {
|
||||
serverId: host.serverId,
|
||||
activeConnectionId: "relay:relay.secret.test:443",
|
||||
activeConnection: {
|
||||
type: "relay",
|
||||
endpoint: "relay.secret.test:443",
|
||||
display: "relay",
|
||||
},
|
||||
connectionStatus: "online",
|
||||
client: null,
|
||||
lastError: null,
|
||||
lastOnlineAt: "2026-06-25T00:00:00.000Z",
|
||||
agentDirectoryStatus: "ready",
|
||||
agentDirectoryError: null,
|
||||
hasEverLoadedAgentDirectory: true,
|
||||
probeByConnectionId: new Map([
|
||||
["direct:secret.example.test:6767", { status: "available", latencyMs: 42 }],
|
||||
["relay:relay.secret.test:443", { status: "available", latencyMs: 8 }],
|
||||
]),
|
||||
clientGeneration: 1,
|
||||
connectionEpoch: 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe("app diagnostics report", () => {
|
||||
test("reports whether the connected daemon is managed by Paseo Desktop", () => {
|
||||
const report = formatServerInfoSection({
|
||||
@@ -61,31 +86,11 @@ describe("app diagnostics report", () => {
|
||||
|
||||
test("formats connection rows without raw connection details", () => {
|
||||
const host = makeHost();
|
||||
const snapshot: HostRuntimeSnapshot = {
|
||||
serverId: host.serverId,
|
||||
activeConnectionId: "relay:relay.secret.test:443",
|
||||
activeConnection: {
|
||||
type: "relay",
|
||||
endpoint: "relay.secret.test:443",
|
||||
display: "relay",
|
||||
},
|
||||
connectionStatus: "online",
|
||||
client: null,
|
||||
lastError: null,
|
||||
lastOnlineAt: "2026-06-25T00:00:00.000Z",
|
||||
agentDirectoryStatus: "ready",
|
||||
agentDirectoryError: null,
|
||||
hasEverLoadedAgentDirectory: true,
|
||||
probeByConnectionId: new Map([
|
||||
["direct:secret.example.test:6767", { status: "available", latencyMs: 42 }],
|
||||
["relay:relay.secret.test:443", { status: "available", latencyMs: 8 }],
|
||||
]),
|
||||
clientGeneration: 1,
|
||||
connectionEpoch: 1,
|
||||
};
|
||||
const snapshot = makeSnapshot(host);
|
||||
|
||||
const report = formatHostRuntimeSection({ host, snapshot });
|
||||
|
||||
expect(report).toContain("Last online: 2026-06-25T00:00:00.000Z");
|
||||
expect(report).toContain("direct TCP");
|
||||
expect(report).toContain("relay");
|
||||
expect(report).toContain("local socket");
|
||||
@@ -97,6 +102,27 @@ describe("app diagnostics report", () => {
|
||||
expect(report).not.toContain("tcp-password");
|
||||
});
|
||||
|
||||
test.each(["connecting", "offline", "error"] as const)(
|
||||
"does not claim a previously connected host was never online while %s",
|
||||
(connectionStatus) => {
|
||||
const host = makeHost();
|
||||
const snapshot = makeSnapshot(host);
|
||||
const report = formatHostRuntimeSection({
|
||||
host,
|
||||
snapshot: { ...snapshot, connectionStatus, lastOnlineAt: null },
|
||||
});
|
||||
|
||||
expect(report).toContain("Last online: unknown");
|
||||
expect(report).not.toContain("Last online: never");
|
||||
},
|
||||
);
|
||||
|
||||
test("does not infer connection history when the runtime has not started", () => {
|
||||
const report = formatHostRuntimeSection({ host: makeHost(), snapshot: null });
|
||||
|
||||
expect(report).toContain("Last online: unknown");
|
||||
});
|
||||
|
||||
test("redacts saved connection secrets from collected daemon and desktop text", () => {
|
||||
const host = makeHost();
|
||||
const redacted = redactAppDiagnosticReport(
|
||||
|
||||
@@ -45,7 +45,7 @@ export function formatHostRuntimeSection(input: {
|
||||
? describeConnectionKind(snapshot.activeConnection.type)
|
||||
: "none",
|
||||
},
|
||||
{ label: "Last online", value: snapshot?.lastOnlineAt ?? "never" },
|
||||
{ label: "Last online", value: snapshot?.lastOnlineAt ?? "unknown" },
|
||||
{ label: "Last error", value: snapshot?.lastError ?? "none" },
|
||||
{
|
||||
label: "Agent directory",
|
||||
|
||||
@@ -64,99 +64,6 @@ function makeRelayOnlyHost(serverId: string): HostProfile {
|
||||
}
|
||||
|
||||
describe("DaemonStartService", () => {
|
||||
it("reports a daemon that stops during startup without registering an incomplete host", async () => {
|
||||
const fake = createFakeStore();
|
||||
const service = new DaemonStartService({
|
||||
store: fake.store,
|
||||
startDesktopDaemon: async () =>
|
||||
makeStatus({ status: "starting", serverId: "", listen: null }),
|
||||
getDesktopDaemonStatus: async () =>
|
||||
makeStatus({ status: "stopped", serverId: "", listen: null, pid: null }),
|
||||
});
|
||||
|
||||
expect(await service.start()).toEqual({
|
||||
ok: false,
|
||||
error: "The local daemon stopped before startup completed.",
|
||||
});
|
||||
expect(service.isRunning()).toBe(false);
|
||||
expect(fake.upserts).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps startup pending until a slow daemon becomes ready without restarting it", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const fake = createFakeStore();
|
||||
const starting = makeStatus({ status: "starting", serverId: "", listen: null });
|
||||
const startDesktopDaemon = vi.fn(async () => starting);
|
||||
const getDesktopDaemonStatus = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(starting)
|
||||
.mockResolvedValueOnce(makeStatus());
|
||||
const service = new DaemonStartService({
|
||||
store: fake.store,
|
||||
startDesktopDaemon,
|
||||
getDesktopDaemonStatus,
|
||||
});
|
||||
const result = service.start();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(service.isRunning()).toBe(true);
|
||||
expect(service.getLastError()).toBeNull();
|
||||
expect(fake.upserts).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(await result).toEqual({ ok: true });
|
||||
expect(startDesktopDaemon).toHaveBeenCalledTimes(1);
|
||||
expect(fake.upserts).toEqual([
|
||||
{ listenAddress: "127.0.0.1:6767", serverId: "srv_desktop", hostname: "desktop" },
|
||||
]);
|
||||
expect(service.isRunning()).toBe(false);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows the daemon error if it exits while startup is pending", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const fake = createFakeStore();
|
||||
const service = new DaemonStartService({
|
||||
store: fake.store,
|
||||
startDesktopDaemon: async () => makeStatus({ status: "starting", serverId: "" }),
|
||||
getDesktopDaemonStatus: async () =>
|
||||
makeStatus({ status: "errored", error: "provider startup failed" }),
|
||||
});
|
||||
const result = service.start();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(await result).toEqual({ ok: false, error: "provider startup failed" });
|
||||
expect(fake.upserts).toEqual([]);
|
||||
expect(service.isRunning()).toBe(false);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("bounds the readiness wait and leaves a retryable error", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const fake = createFakeStore();
|
||||
const starting = makeStatus({ status: "starting", serverId: "" });
|
||||
const service = new DaemonStartService({
|
||||
store: fake.store,
|
||||
startDesktopDaemon: async () => starting,
|
||||
getDesktopDaemonStatus: async () => starting,
|
||||
});
|
||||
const result = service.start();
|
||||
await vi.advanceTimersByTimeAsync(120000);
|
||||
expect(await result).toEqual({
|
||||
ok: false,
|
||||
error: "The local daemon is still starting. Wait a moment and retry.",
|
||||
});
|
||||
expect(fake.upserts).toEqual([]);
|
||||
expect(service.isRunning()).toBe(false);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("upserts the connection on a successful daemon start", async () => {
|
||||
const fake = createFakeStore();
|
||||
const service = new DaemonStartService({
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
getDesktopDaemonStatus,
|
||||
startDesktopDaemon,
|
||||
type DesktopDaemonStatus,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
import { startDesktopDaemon, type DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
|
||||
import { connectionFromListen } from "@/types/host-connection";
|
||||
import type { HostRuntimeStore } from "@/runtime/host-runtime";
|
||||
|
||||
@@ -18,7 +14,6 @@ type DaemonConnectionStore = Pick<HostRuntimeStore, "getHosts" | "upsertConnecti
|
||||
export interface DaemonStartServiceDeps {
|
||||
store: DaemonConnectionStore;
|
||||
startDesktopDaemon?: () => Promise<DesktopDaemonStatus>;
|
||||
getDesktopDaemonStatus?: () => Promise<DesktopDaemonStatus>;
|
||||
}
|
||||
|
||||
export async function upsertDesktopDaemonConnection(
|
||||
@@ -53,7 +48,6 @@ export async function upsertDesktopDaemonConnection(
|
||||
export class DaemonStartService {
|
||||
private readonly store: DaemonConnectionStore;
|
||||
private readonly invokeStartDesktopDaemon: () => Promise<DesktopDaemonStatus>;
|
||||
private readonly invokeGetDesktopDaemonStatus: () => Promise<DesktopDaemonStatus>;
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private lastError: string | null = null;
|
||||
private inFlightCount = 0;
|
||||
@@ -61,7 +55,6 @@ export class DaemonStartService {
|
||||
constructor(deps: DaemonStartServiceDeps) {
|
||||
this.store = deps.store;
|
||||
this.invokeStartDesktopDaemon = deps.startDesktopDaemon ?? startDesktopDaemon;
|
||||
this.invokeGetDesktopDaemonStatus = deps.getDesktopDaemonStatus ?? getDesktopDaemonStatus;
|
||||
}
|
||||
|
||||
async start(): Promise<DaemonStartResult> {
|
||||
@@ -86,21 +79,7 @@ export class DaemonStartService {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
let daemon = await this.invokeStartDesktopDaemon();
|
||||
// The desktop start command can return after its readiness deadline while
|
||||
// the owned process keeps initializing. Keep the startup UI pending until
|
||||
// that process can supply its host identity; do not launch it again.
|
||||
const readinessDeadline = Date.now() + 120_000;
|
||||
while (daemon.status === "starting") {
|
||||
if (Date.now() >= readinessDeadline) {
|
||||
return this.fail("The local daemon is still starting. Wait a moment and retry.");
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 1000));
|
||||
daemon = await this.invokeGetDesktopDaemonStatus();
|
||||
}
|
||||
if (daemon.status !== "running") {
|
||||
return this.fail(daemon.error ?? "The local daemon stopped before startup completed.");
|
||||
}
|
||||
const daemon = await this.invokeStartDesktopDaemon();
|
||||
const result = await upsertDesktopDaemonConnection(this.store, daemon);
|
||||
return result.ok ? result : this.fail(result.error);
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { createHostLogger } from "./host-logger";
|
||||
|
||||
it("preserves the original transport cause and tolerates a cyclic cause chain", () => {
|
||||
const lines: string[] = [];
|
||||
const capture = (...values: unknown[]) => lines.push(values.map(String).join(" "));
|
||||
const logger = createHostLogger(() => "test-host", {
|
||||
log: capture,
|
||||
warn: capture,
|
||||
error: capture,
|
||||
});
|
||||
const transport = new Error("socket closed");
|
||||
const wrapper = new Error("Subscription failed", { cause: transport });
|
||||
Object.defineProperty(transport, "cause", { value: wrapper });
|
||||
logger.error({ err: wrapper }, "subscription_error");
|
||||
const record = JSON.parse(lines[0]!.slice("subscription_error ".length));
|
||||
expect(record.err.cause.message).toBe("socket closed");
|
||||
expect(record.err.cause.stack).toBe(transport.stack);
|
||||
expect(record.err.cause.cause).toBe("[Circular]");
|
||||
});
|
||||
|
||||
it("retains host and error details in the desktop's text-only console capture", () => {
|
||||
const lines: { level: string; text: string }[] = [];
|
||||
const capture =
|
||||
(level: string) =>
|
||||
(...values: unknown[]) => {
|
||||
lines.push({ level, text: values.map(String).join(" ") });
|
||||
};
|
||||
const logger = createHostLogger(() => "offline-host", {
|
||||
log: capture("info"),
|
||||
warn: capture("warn"),
|
||||
error: capture("error"),
|
||||
});
|
||||
logger.warn(
|
||||
{ err: { name: "Error", message: "malformed ready envelope" } },
|
||||
"relay_e2ee_channel_error",
|
||||
);
|
||||
expect(lines).toEqual([
|
||||
{
|
||||
level: "warn",
|
||||
text: 'relay_e2ee_channel_error {"serverId":"offline-host","err":{"name":"Error","message":"malformed ready envelope"}}',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps native Error messages and severity without failing on circular diagnostics", () => {
|
||||
const lines: { level: string; text: string }[] = [];
|
||||
const capture =
|
||||
(level: string) =>
|
||||
(...values: unknown[]) => {
|
||||
lines.push({ level, text: values.map(String).join(" ") });
|
||||
};
|
||||
const logger = createHostLogger(() => "test-host", {
|
||||
log: capture("info"),
|
||||
warn: capture("warn"),
|
||||
error: capture("error"),
|
||||
});
|
||||
const record: { err: Error; self?: object } = { err: new Error("connection lost") };
|
||||
record.self = record;
|
||||
logger.error(record, "Connection failed");
|
||||
expect(lines[0]?.level).toBe("error");
|
||||
expect(lines[0]?.text).toContain('"message":"connection lost"');
|
||||
expect(lines[0]?.text).toContain('"serverId":"test-host"');
|
||||
expect(lines[0]?.text).toContain('"[Circular]"');
|
||||
logger.info({ status: "offline" }, "Host status");
|
||||
expect(lines[1]).toEqual({
|
||||
level: "info",
|
||||
text: 'Host status {"serverId":"test-host","status":"offline"}',
|
||||
});
|
||||
logger.debug({ status: "offline" }, "Host status");
|
||||
expect(lines).toHaveLength(2);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Logger } from "@getpaseo/client/internal/daemon-client";
|
||||
|
||||
export function createHostLogger(
|
||||
readServerId: () => string | undefined,
|
||||
output: Pick<Console, "log" | "warn" | "error"> = console,
|
||||
): Logger {
|
||||
const format = (record: object, message?: string): string => {
|
||||
const seen = new WeakSet<object>();
|
||||
const details = JSON.stringify(
|
||||
{ serverId: readServerId(), ...record },
|
||||
(_key, value: unknown) => {
|
||||
if (value && typeof value === "object") {
|
||||
if (seen.has(value)) return "[Circular]";
|
||||
seen.add(value);
|
||||
if (value instanceof Error) {
|
||||
return {
|
||||
...value,
|
||||
name: value.name,
|
||||
message: value.message,
|
||||
stack: value.stack,
|
||||
cause: value.cause,
|
||||
};
|
||||
}
|
||||
}
|
||||
return value;
|
||||
},
|
||||
);
|
||||
return message ? `${message} ${details}` : details;
|
||||
};
|
||||
return {
|
||||
debug: () => {},
|
||||
info: (record, message) => output.log(format(record, message)),
|
||||
warn: (record, message) => output.warn(format(record, message)),
|
||||
error: (record, message) => output.error(format(record, message)),
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createHostPluginClientBundleStore } from "@/plugins/client-bundles";
|
||||
import { watchdog } from "@/watchdog";
|
||||
import { createHostLogger } from "./host-logger";
|
||||
import { createHostChannelTransport } from "@getpaseo/client/internal/hosts";
|
||||
import type { OwnedSubscription } from "@getpaseo/client";
|
||||
import type { HostDirectory } from "@getpaseo/protocol/hosts";
|
||||
@@ -589,6 +590,7 @@ function createDefaultDeps(
|
||||
runtimeGeneration,
|
||||
capabilities: appCapabilities,
|
||||
trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }),
|
||||
logger: createHostLogger(() => host.serverId),
|
||||
providerSnapshots: "wire",
|
||||
createPluginClientBundleStore: createHostPluginClientBundleStore,
|
||||
...(host.password ? { password: host.password } : {}),
|
||||
@@ -669,6 +671,7 @@ function createDefaultDeps(
|
||||
null,
|
||||
),
|
||||
reconnect: { enabled: false },
|
||||
logger: createHostLogger(() => host.serverId),
|
||||
appVersion: resolveAppVersion() ?? undefined,
|
||||
createPluginClientBundleStore: createHostPluginClientBundleStore,
|
||||
});
|
||||
|
||||
@@ -2,9 +2,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DaemonClientConfig } from "@getpaseo/client/internal/daemon-client";
|
||||
import { DaemonAuthenticationError } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { DaemonConnectionDependencies, DaemonProbeClient } from "./test-daemon-connection";
|
||||
import { createHostLogger } from "@/runtime/host-logger";
|
||||
|
||||
class FakeDaemonClient implements DaemonProbeClient {
|
||||
readonly lastError: string | null;
|
||||
private connected = false;
|
||||
|
||||
constructor(
|
||||
private readonly probe: FakeDaemonProbe,
|
||||
@@ -17,9 +19,11 @@ class FakeDaemonClient implements DaemonProbeClient {
|
||||
if (this.probe.nextConnectError) {
|
||||
throw this.probe.nextConnectError;
|
||||
}
|
||||
this.connected = true;
|
||||
}
|
||||
|
||||
getLastServerInfoMessage() {
|
||||
if (!this.connected) return null;
|
||||
return {
|
||||
serverId: "srv_probe_test",
|
||||
hostname: "probe-host",
|
||||
@@ -27,11 +31,13 @@ class FakeDaemonClient implements DaemonProbeClient {
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.connected = false;
|
||||
this.probe.closedClients.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeDaemonProbe {
|
||||
diagnosticLines: string[] = [];
|
||||
createdClients: FakeDaemonClient[] = [];
|
||||
closedClients: FakeDaemonClient[] = [];
|
||||
clientIdsRequested = 0;
|
||||
@@ -39,6 +45,12 @@ class FakeDaemonProbe {
|
||||
nextLastError: string | null = null;
|
||||
|
||||
readonly deps: DaemonConnectionDependencies<FakeDaemonClient> = {
|
||||
createLogger: (readServerId) => {
|
||||
const capture = (...values: unknown[]) => {
|
||||
this.diagnosticLines.push(values.map(String).join(" "));
|
||||
};
|
||||
return createHostLogger(readServerId, { log: capture, warn: capture, error: capture });
|
||||
},
|
||||
getClientId: async () => {
|
||||
this.clientIdsRequested += 1;
|
||||
return "cid_shared_probe_test";
|
||||
@@ -76,6 +88,34 @@ describe("test-daemon-connection connectToDaemon", () => {
|
||||
probe = new FakeDaemonProbe();
|
||||
});
|
||||
|
||||
it.each([undefined, "srv_probe_test"])(
|
||||
"logs learned host identity on an adopted probe initially given %s",
|
||||
async (serverId) => {
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
const result = await connectToDaemon(
|
||||
{ id: "direct:test", type: "directTcp", endpoint: "localhost:6767" },
|
||||
{ serverId },
|
||||
probe.deps,
|
||||
);
|
||||
try {
|
||||
result.client.config.logger!.error({ err: new Error("socket closed") }, "connection_error");
|
||||
const record = JSON.parse(probe.diagnosticLines[0]!.slice("connection_error ".length));
|
||||
expect(record.serverId).toBe("srv_probe_test");
|
||||
expect(record.err.message).toBe("socket closed");
|
||||
await result.client.close();
|
||||
expect(result.client.getLastServerInfoMessage()).toBeNull();
|
||||
result.client.config.logger!.error({ err: new Error("disconnected") }, "connection_error");
|
||||
const closedRecord = JSON.parse(
|
||||
probe.diagnosticLines[1]!.slice("connection_error ".length),
|
||||
);
|
||||
expect(closedRecord.serverId).toBe("srv_probe_test");
|
||||
expect(closedRecord.err.message).toBe("disconnected");
|
||||
} finally {
|
||||
await result.client.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ id: "direct:lan:6767", type: "directTcp" as const, endpoint: "lan:6767" },
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import type { DaemonClientConfig } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { HostConnection } from "@/types/host-connection";
|
||||
import { createAppWebSocketFactory } from "@/runtime/websocket-factory";
|
||||
import { createHostLogger } from "@/runtime/host-logger";
|
||||
import { getOrCreateClientId } from "./client-id";
|
||||
import { resolveAppVersion } from "./app-version";
|
||||
import {
|
||||
@@ -29,6 +30,7 @@ export interface DaemonProbeClient {
|
||||
}
|
||||
|
||||
export interface DaemonConnectionDependencies<TClient extends DaemonProbeClient> {
|
||||
createLogger: typeof createHostLogger;
|
||||
getClientId(): Promise<string>;
|
||||
resolveAppVersion(): string | null;
|
||||
createDesktopTransportFactory(): DaemonClientConfig["transportFactory"] | null;
|
||||
@@ -37,6 +39,7 @@ export interface DaemonConnectionDependencies<TClient extends DaemonProbeClient>
|
||||
}
|
||||
|
||||
const defaultDaemonConnectionDependencies: DaemonConnectionDependencies<DaemonClient> = {
|
||||
createLogger: createHostLogger,
|
||||
getClientId: getOrCreateClientId,
|
||||
resolveAppVersion,
|
||||
createDesktopTransportFactory: createDesktopDaemonTransportFactory,
|
||||
@@ -317,5 +320,9 @@ export async function connectToDaemon(
|
||||
deps: DaemonConnectionDependencies<DaemonProbeClient> = defaultDaemonConnectionDependencies,
|
||||
): Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }> {
|
||||
const config = await buildClientConfig(connection, options?.serverId, options, deps);
|
||||
return connectAndProbe(config, resolveTimeout(connection, options), deps);
|
||||
let serverId = options?.serverId;
|
||||
config.logger = deps.createLogger(() => serverId);
|
||||
const result = await connectAndProbe(config, resolveTimeout(connection, options), deps);
|
||||
serverId = result.serverId;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,23 @@ async function chooseLanguage(page: Page, language: string) {
|
||||
await page.getByRole("menuitemradio", { name: language }).click();
|
||||
}
|
||||
|
||||
async function withPageScriptsHeld(page: Page, run: (release: () => void) => Promise<void>) {
|
||||
let releaseScripts!: () => void;
|
||||
const scriptsReleased = new Promise<void>((resolve) => {
|
||||
releaseScripts = resolve;
|
||||
});
|
||||
await page.route("**/*.js", async (route) => {
|
||||
await scriptsReleased;
|
||||
await route.continue();
|
||||
});
|
||||
try {
|
||||
await run(releaseScripts);
|
||||
} finally {
|
||||
releaseScripts();
|
||||
await page.unrouteAll({ behavior: "wait" });
|
||||
}
|
||||
}
|
||||
|
||||
function describeAlternates(links: Element[]): string[] {
|
||||
return links.map((link) => `${link.getAttribute("hreflang")} ${link.getAttribute("href")}`);
|
||||
}
|
||||
@@ -114,6 +131,7 @@ test("shows the language as an abbreviation and lists every language by name", a
|
||||
|
||||
test("switches language from the keyboard", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(languageButton(page)).toBeEnabled();
|
||||
await languageButton(page).focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByRole("menuitemradio", { name: "English" })).toBeFocused();
|
||||
@@ -183,3 +201,15 @@ test("shows the changelog in Chinese", async ({ page }) => {
|
||||
await expect(page.getByRole("heading", { name: "新增" }).first()).toBeVisible();
|
||||
await expect(page.getByRole("note")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("waits for scripts before enabling the language menu", async ({ page }) => {
|
||||
await withPageScriptsHeld(page, async (releaseScripts) => {
|
||||
await page.goto("/docs/cli#running-agents", { waitUntil: "commit" });
|
||||
await expect(languageButton(page)).toBeVisible();
|
||||
await expect(languageButton(page)).toBeDisabled();
|
||||
|
||||
releaseScripts();
|
||||
await languageButton(page).click();
|
||||
await expect(page.getByRole("menuitemradio", { name: "English" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useLocation, useNavigate } from "@tanstack/react-router";
|
||||
import { useHydrated, useLocation, useNavigate } from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion, type Transition } from "framer-motion";
|
||||
import { Check, ChevronDown, Languages } from "lucide-react";
|
||||
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
||||
@@ -33,6 +33,7 @@ type OpenFocus = "current" | "first" | "last";
|
||||
* the menu button pattern: arrows move between languages, Escape and outside presses close it.
|
||||
*/
|
||||
export function LanguageSelector() {
|
||||
const hydrated = useHydrated();
|
||||
const { t } = useTranslation();
|
||||
const locale = useLocale();
|
||||
const location = useLocation();
|
||||
@@ -125,6 +126,7 @@ export function LanguageSelector() {
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
disabled={!hydrated}
|
||||
aria-label={t("languageSelector.current", { language: LANGUAGE_NAMES[locale] })}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
|
||||
Reference in New Issue
Block a user