mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 18:35:17 +00:00
* feat(app): make import session a first-class entry point * feat(server): improve import session discovery resilience Search up to 500 persisted candidates per provider and isolate listing failures behind eight-second deadlines so healthy results remain usable. Preserve native persisted title precedence while keeping discovery read-only. Parsing now continues through each transcript so native titles and the true last prompt are retained; the old early exit made lastPromptPreview equal firstPromptPreview. A cheap record prefilter avoids JSON.parse for unrelated lines during the full scan. * feat(app): searchable, grouped import session sheet with per-provider errors The sheet now carries a debounced search field gated on the host's importSessionSearch feature, grows its per-provider page on demand (15 -> 45 -> 90 -> 200) instead of capping at the 15 most recent, and groups unscoped rows by folder under the project name the app knows for that directory. Worktrees of one project would otherwise all head up as the same bare name, so a directory below its project root carries the path under that root beside the name. A workspace-scoped sheet says so and offers "Show all sessions". Importing a row from another directory once that is on drops the workspace from the request, which the daemon would otherwise reject, and routes the user to the agent's own workspace rather than opening a dead tab in the current one. That decision lives in resolveImportTarget: a scoped listing is trusted wholesale because the daemon already matched it realpath-aware, which the client cannot. A provider that fails now gets its own error row with a Retry that refetches only that provider, sourced from both a failed request and the daemon's providerErrors. React Query no longer retries these, so the header spinner settles once every provider has answered or given up (#2512). * test(e2e): import session flow with fixture Claude sessions * fix(app): shorten import sheet scope copy so it fits at phone width At 390px the scoped subtitle rendered "Sessions in this wor..." — the label, its action, and the sheet's refresh and close buttons do not share one line at phone width. The label already sits under the sheet title "Import session", so "Sessions in this" was repeating context the header had: it becomes "This workspace" and the action becomes "Show all". The unscoped label still names the host, which nothing else on the sheet does. * refactor(app): flat recency-ordered import list with per-row folder label Grouping by folder buried recency: a busy directory's block of fifteen pushed a two-minute-old session in a quiet directory below the fold, and the axis a user is scanning on is when they last touched the session, not where it lives. The unscoped sheet is now one list in the order aggregateSessionEntries already produces, and each row carries its folder as a muted third line. resolveDirectoryLabel stays — it is what keeps two worktrees of one project apart — and formatDirectoryLabel renders its two parts as the single ellipsised line a row has room for. A scoped sheet lists one directory, so its rows carry no folder line at all. * fix(app): close import sheet before refreshing sessions Provider-list invalidation waited for every active query before the sheet could close. Mark the disabled picker queries stale after close and navigation so reopening refreshes without delaying the imported agent. * fix(server): reuse workspaces for session imports Host-wide imports should join the oldest active workspace for their directory, or restore its archived workspace, instead of minting duplicates. Rollback remains limited to workspace records created by the failed import. * test(e2e): express the import session journey through a helper DSL * test(e2e): align sidebar journeys with import entry point
251 lines
7.5 KiB
TypeScript
251 lines
7.5 KiB
TypeScript
import type { Route } from "@playwright/test";
|
|
import { expect, type Page } from "../fixtures";
|
|
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
|
import { wsRoutePatternForPort } from "./daemon-port";
|
|
|
|
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
|
|
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
|
|
const REGISTRY_KEY = "@paseo:daemon-registry";
|
|
const E2E_KEY = "@paseo:e2e";
|
|
const STORAGE_SEED_HTML = "<!doctype html><html><body>storage seed</body></html>";
|
|
|
|
interface SavedHostInput {
|
|
serverId: string;
|
|
label: string;
|
|
endpoint: string;
|
|
}
|
|
|
|
export function startupScenario(page: Page) {
|
|
return new StartupScenario(page);
|
|
}
|
|
|
|
class StartupScenario {
|
|
private readonly page: Page;
|
|
private savedHosts: SavedHostInput[] = [];
|
|
private desktopBridge = false;
|
|
private blockedEndpointPorts = new Set<string>();
|
|
private viewport: { width: number; height: number } | null = null;
|
|
|
|
constructor(page: Page) {
|
|
this.page = page;
|
|
}
|
|
|
|
withMobileViewport(): this {
|
|
this.viewport = { width: 390, height: 844 };
|
|
return this;
|
|
}
|
|
|
|
withSavedHost(input: SavedHostInput): this {
|
|
this.savedHosts.push(input);
|
|
const port = input.endpoint.match(/:(\d+)$/)?.[1];
|
|
if (port) {
|
|
this.blockedEndpointPorts.add(port);
|
|
}
|
|
return this;
|
|
}
|
|
|
|
withPendingDesktopDaemon(): this {
|
|
this.desktopBridge = true;
|
|
return this;
|
|
}
|
|
|
|
withBlockedPort(port: string): this {
|
|
this.blockedEndpointPorts.add(port);
|
|
return this;
|
|
}
|
|
|
|
async openRoot(): Promise<StartupAssertions> {
|
|
await this.prepare();
|
|
await this.page.goto("/");
|
|
return new StartupAssertions(this.page);
|
|
}
|
|
|
|
async openHostWorkspace(input: {
|
|
serverId: string;
|
|
workspaceId: string;
|
|
}): Promise<StartupAssertions> {
|
|
await this.prepare();
|
|
await this.page.goto(
|
|
`/h/${encodeURIComponent(input.serverId)}/workspace/${encodeURIComponent(input.workspaceId)}`,
|
|
);
|
|
return new StartupAssertions(this.page);
|
|
}
|
|
|
|
private async prepare(): Promise<void> {
|
|
if (this.viewport) {
|
|
await this.page.setViewportSize(this.viewport);
|
|
}
|
|
|
|
if (this.desktopBridge) {
|
|
await installPendingDesktopBridge(this.page);
|
|
}
|
|
|
|
for (const port of this.blockedEndpointPorts) {
|
|
await this.page.routeWebSocket(wsRoutePatternForPort(port), async (ws) => {
|
|
await ws.close({ code: 1008, reason: "Blocked unreachable startup test host." });
|
|
});
|
|
}
|
|
|
|
if (this.savedHosts.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Create same-origin storage without booting the app. If the default host runtime
|
|
// starts first, it can race this scenario's registry override during CI.
|
|
await openStorageSeedDocument(this.page);
|
|
const nowIso = new Date().toISOString();
|
|
const registry = this.savedHosts.map((host) =>
|
|
buildStoredHost({
|
|
serverId: host.serverId,
|
|
endpoint: host.endpoint,
|
|
label: host.label,
|
|
nowIso,
|
|
}),
|
|
);
|
|
const firstHost = registry[0];
|
|
if (!firstHost) {
|
|
throw new Error("Expected at least one startup test host.");
|
|
}
|
|
const createAgentPreferences = buildStoredCreateAgentPreferences();
|
|
|
|
await this.page.evaluate(
|
|
({ keys, registry: storedRegistry, createAgentPreferences: storedPreferences }) => {
|
|
const nonce = localStorage.getItem(keys.seedNonce);
|
|
if (!nonce) {
|
|
throw new Error("Expected e2e seed nonce before overriding startup registry.");
|
|
}
|
|
|
|
localStorage.setItem(keys.e2e, "1");
|
|
localStorage.setItem(keys.registry, JSON.stringify(storedRegistry));
|
|
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(storedPreferences));
|
|
localStorage.setItem(keys.disableDefaultSeedOnce, nonce);
|
|
},
|
|
{
|
|
keys: {
|
|
disableDefaultSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
|
|
e2e: E2E_KEY,
|
|
registry: REGISTRY_KEY,
|
|
seedNonce: SEED_NONCE_KEY,
|
|
},
|
|
registry,
|
|
createAgentPreferences,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
async function openStorageSeedDocument(page: Page): Promise<void> {
|
|
await page.route(
|
|
"**/*",
|
|
async (route: Route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "text/html",
|
|
body: STORAGE_SEED_HTML,
|
|
});
|
|
},
|
|
{ times: 1 },
|
|
);
|
|
await page.goto("/");
|
|
}
|
|
|
|
class StartupAssertions {
|
|
private readonly page: Page;
|
|
|
|
constructor(page: Page) {
|
|
this.page = page;
|
|
}
|
|
|
|
async expectsSavedHostShell(input: { serverId: string; label: string }): Promise<this> {
|
|
await this.page.getByRole("button", { name: "Open menu", exact: true }).click();
|
|
await this.page.getByTestId("sidebar-hosts-trigger").click();
|
|
const hostRow = this.page.getByTestId(`sidebar-host-row-${input.serverId}`);
|
|
await expect(hostRow).toBeVisible({ timeout: 15_000 });
|
|
await expect(hostRow).toContainText(input.label);
|
|
await expect(this.page.getByTestId("sidebar-add-project")).toBeVisible();
|
|
await expect(this.page.getByTestId("sidebar-import-session")).toBeVisible();
|
|
await expect(this.page.getByTestId("sidebar-settings")).toBeVisible();
|
|
await expect(this.page.getByTestId("welcome-screen")).toHaveCount(0);
|
|
return this;
|
|
}
|
|
|
|
async expectsNoSavedHostErrorStatus(): Promise<this> {
|
|
await expect(this.page.getByText("Connection error", { exact: true })).toHaveCount(0);
|
|
await expect(this.page.getByText("Offline", { exact: true })).toHaveCount(0);
|
|
return this;
|
|
}
|
|
|
|
async expectsNoLocalServerStartupCopy(): Promise<this> {
|
|
await expect(this.page.getByText("Starting local server...", { exact: true })).toHaveCount(0);
|
|
await expect(this.page.getByText("Connecting to local server...", { exact: true })).toHaveCount(
|
|
0,
|
|
);
|
|
return this;
|
|
}
|
|
|
|
async expectsDesktopDaemonStartup(): Promise<this> {
|
|
await expect(this.page.getByTestId("startup-splash")).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
return this;
|
|
}
|
|
|
|
async expectsSidebarHidden(): Promise<this> {
|
|
await expect(this.page.locator('[data-testid="sidebar-settings"]:visible')).toHaveCount(0);
|
|
await expect(this.page.locator('[data-testid="sidebar-project-list"]:visible')).toHaveCount(0);
|
|
return this;
|
|
}
|
|
|
|
async expectsNoUndefinedRoute(): Promise<this> {
|
|
await expect(this.page).not.toHaveURL(/\/h\/undefined\/workspace\/undefined/);
|
|
return this;
|
|
}
|
|
}
|
|
|
|
async function installPendingDesktopBridge(page: Page): Promise<void> {
|
|
await page.addInitScript(() => {
|
|
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
|
|
platform: "darwin",
|
|
invoke: async (command: string) => {
|
|
if (command === "start_desktop_daemon") {
|
|
await new Promise(() => {
|
|
// Keep the daemon in the startup phase until the test ends.
|
|
});
|
|
}
|
|
if (command === "desktop_daemon_status") {
|
|
return {
|
|
serverId: "srv_desktop_pending",
|
|
status: "starting",
|
|
listen: null,
|
|
hostname: null,
|
|
pid: null,
|
|
home: "",
|
|
version: null,
|
|
desktopManaged: true,
|
|
error: null,
|
|
};
|
|
}
|
|
if (command === "desktop_daemon_logs") {
|
|
return { logPath: "", contents: "" };
|
|
}
|
|
return null;
|
|
},
|
|
getPendingOpenProject: async () => null,
|
|
events: { on: async () => () => undefined },
|
|
};
|
|
});
|
|
}
|
|
|
|
function buildStoredHost(input: {
|
|
serverId: string;
|
|
endpoint: string;
|
|
label: string;
|
|
nowIso: string;
|
|
}) {
|
|
return buildSeededHost(input);
|
|
}
|
|
|
|
function buildStoredCreateAgentPreferences() {
|
|
return buildCreateAgentPreferences();
|
|
}
|