mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 01:14:52 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3b72b1550 | ||
|
|
f42926cffb |
@@ -64,6 +64,99 @@ 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,4 +1,8 @@
|
||||
import { startDesktopDaemon, type DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
|
||||
import {
|
||||
getDesktopDaemonStatus,
|
||||
startDesktopDaemon,
|
||||
type DesktopDaemonStatus,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
import { connectionFromListen } from "@/types/host-connection";
|
||||
import type { HostRuntimeStore } from "@/runtime/host-runtime";
|
||||
|
||||
@@ -14,6 +18,7 @@ type DaemonConnectionStore = Pick<HostRuntimeStore, "getHosts" | "upsertConnecti
|
||||
export interface DaemonStartServiceDeps {
|
||||
store: DaemonConnectionStore;
|
||||
startDesktopDaemon?: () => Promise<DesktopDaemonStatus>;
|
||||
getDesktopDaemonStatus?: () => Promise<DesktopDaemonStatus>;
|
||||
}
|
||||
|
||||
export async function upsertDesktopDaemonConnection(
|
||||
@@ -48,6 +53,7 @@ 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;
|
||||
@@ -55,6 +61,7 @@ export class DaemonStartService {
|
||||
constructor(deps: DaemonStartServiceDeps) {
|
||||
this.store = deps.store;
|
||||
this.invokeStartDesktopDaemon = deps.startDesktopDaemon ?? startDesktopDaemon;
|
||||
this.invokeGetDesktopDaemonStatus = deps.getDesktopDaemonStatus ?? getDesktopDaemonStatus;
|
||||
}
|
||||
|
||||
async start(): Promise<DaemonStartResult> {
|
||||
@@ -79,7 +86,21 @@ export class DaemonStartService {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const daemon = await this.invokeStartDesktopDaemon();
|
||||
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 result = await upsertDesktopDaemonConnection(this.store, daemon);
|
||||
return result.ok ? result : this.fail(result.error);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
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,6 +1,5 @@
|
||||
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";
|
||||
@@ -590,7 +589,6 @@ 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 } : {}),
|
||||
@@ -671,7 +669,6 @@ function createDefaultDeps(
|
||||
null,
|
||||
),
|
||||
reconnect: { enabled: false },
|
||||
logger: createHostLogger(() => host.serverId),
|
||||
appVersion: resolveAppVersion() ?? undefined,
|
||||
createPluginClientBundleStore: createHostPluginClientBundleStore,
|
||||
});
|
||||
|
||||
@@ -2,11 +2,9 @@ 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,
|
||||
@@ -19,11 +17,9 @@ 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",
|
||||
@@ -31,13 +27,11 @@ 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;
|
||||
@@ -45,12 +39,6 @@ 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";
|
||||
@@ -88,34 +76,6 @@ 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,7 +7,6 @@ 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 {
|
||||
@@ -30,7 +29,6 @@ export interface DaemonProbeClient {
|
||||
}
|
||||
|
||||
export interface DaemonConnectionDependencies<TClient extends DaemonProbeClient> {
|
||||
createLogger: typeof createHostLogger;
|
||||
getClientId(): Promise<string>;
|
||||
resolveAppVersion(): string | null;
|
||||
createDesktopTransportFactory(): DaemonClientConfig["transportFactory"] | null;
|
||||
@@ -39,7 +37,6 @@ export interface DaemonConnectionDependencies<TClient extends DaemonProbeClient>
|
||||
}
|
||||
|
||||
const defaultDaemonConnectionDependencies: DaemonConnectionDependencies<DaemonClient> = {
|
||||
createLogger: createHostLogger,
|
||||
getClientId: getOrCreateClientId,
|
||||
resolveAppVersion,
|
||||
createDesktopTransportFactory: createDesktopDaemonTransportFactory,
|
||||
@@ -320,9 +317,5 @@ 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);
|
||||
let serverId = options?.serverId;
|
||||
config.logger = deps.createLogger(() => serverId);
|
||||
const result = await connectAndProbe(config, resolveTimeout(connection, options), deps);
|
||||
serverId = result.serverId;
|
||||
return result;
|
||||
return connectAndProbe(config, resolveTimeout(connection, options), deps);
|
||||
}
|
||||
|
||||
@@ -9,23 +9,6 @@ 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")}`);
|
||||
}
|
||||
@@ -131,7 +114,6 @@ 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();
|
||||
@@ -201,15 +183,3 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -181,23 +181,6 @@ test("keeps the directory's ranking window when searching", async ({ page }) =>
|
||||
);
|
||||
});
|
||||
|
||||
test("submits the visible search term when its input event was missed", async ({ page }) => {
|
||||
await page.goto("/plugins?window=month");
|
||||
await expect(page.getByRole("button", { name: "Language: English" }).first()).toBeEnabled();
|
||||
await fillSearchWithMissedInputEvent(page, "graphite");
|
||||
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expect(page).toHaveURL(/\/plugins\/all\?q=graphite&window=month$/);
|
||||
await expect(
|
||||
page.getByRole("heading", { level: 1, name: /^Results for “graphite”/ }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "This month" })).toHaveAttribute(
|
||||
"aria-current",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
test("searches for a term typed before the page finished loading", async ({ page }) => {
|
||||
const loadScripts = await holdScripts(page);
|
||||
await page.goto("/plugins?window=month", { waitUntil: "domcontentloaded" });
|
||||
@@ -436,21 +419,6 @@ async function searchPlugins(page: Page, term: string) {
|
||||
await page.getByRole("searchbox", { name: "Search plugins" }).fill(term);
|
||||
}
|
||||
|
||||
/** Reproduce text entered before hydration's input listener can observe it, without a clock race. */
|
||||
async function fillSearchWithMissedInputEvent(page: Page, term: string) {
|
||||
const search = page.getByRole("searchbox", { name: "Search plugins" });
|
||||
await search.evaluate(() => {
|
||||
for (const eventType of ["input", "change"]) {
|
||||
document.addEventListener(eventType, (event) => event.stopImmediatePropagation(), {
|
||||
capture: true,
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
await search.fill(term);
|
||||
await expect(search).toHaveValue(term);
|
||||
}
|
||||
|
||||
async function submitSearch(page: Page, term: string) {
|
||||
await searchPlugins(page, term);
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useHydrated, useLocation, useNavigate } from "@tanstack/react-router";
|
||||
import { 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,7 +33,6 @@ 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();
|
||||
@@ -126,7 +125,6 @@ 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}
|
||||
|
||||
@@ -68,16 +68,12 @@ export function PluginSearch({
|
||||
input.current?.focus();
|
||||
}, [edit]);
|
||||
const handleSubmit = useCallback(
|
||||
(event: FormEvent<HTMLFormElement>) => {
|
||||
(event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
// Live results already show the term.
|
||||
if (!live) {
|
||||
// Hydration can attach this handler before state adopts text already in the input.
|
||||
const submittedTerm = String(new FormData(event.currentTarget).get("q") ?? "");
|
||||
show(submittedTerm, { replace: false });
|
||||
}
|
||||
if (!live) show(term, { replace: false });
|
||||
},
|
||||
[live, show],
|
||||
[live, show, term],
|
||||
);
|
||||
return (
|
||||
<form
|
||||
|
||||
Reference in New Issue
Block a user