* fix(app): validate persisted client state Persisted replica data could satisfy TypeScript through an assertion while missing fields required by renderers. Treat every AsyncStorage value as untrusted input, clear values that fail strict schemas, and version the replica DTO so incompatible caches cannot hydrate. * fix(app): preserve validated legacy state * fix(app): preserve validated preferences Preserve supported legacy location fields and every shipped theme while keeping unknown persisted fields rejected. Align browser test seeds with the current preference shape so strict validation exercises the intended provider. * fix(app): migrate legacy agent preferences Released clients stored host/location fields and per-provider thinkingOptionId together. Migrate those exact strict bytes into current preferences without admitting unknown fields. * fix(app): preserve drafts with legacy reviews Accept the exact strict shape released for workspace review attachments so migration can discard that obsolete attachment without clearing every persisted draft.
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-home")).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();
|
|
}
|