* fix(ci): skip unrelated required checks cleanly Matrix jobs had to start no-op runners to preserve interpolated required-check names. Static named jobs let GitHub report genuine skipped conclusions while keeping those names stable. * fix(ci): gate changes to install patching script * fix(ci): route cross-package test contracts narrowly * fix(ci): keep routed contracts merge-blocking * fix(server): exclude nested test utilities from builds * fix(ci): cover app-owned native and browser domains * refactor(ci): align tests with runtime domains Own browser specs, desktop specs, and Electron-only app code by stable directories so PR routing does not depend on filename conventions. Keep desktop integration coverage inside the required desktop check. * fix(ci): keep routing and desktop suites isolated The routing regression must run before dependency installation, and desktop Vitest must not collect Playwright-owned specs. * fix(ci): isolate desktop e2e runtime * perf(test): isolate Playwright workers * perf(test): parallelize Playwright within specs * fix(test): keep Playwright file scheduling * fix(test): share dynamic Metro fixture * fix(test): preserve own-host restart fixtures * fix(ci): preserve routed contracts after rebase * Stabilize Playwright wheel-scroll assertions
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(firstHost.serverId);
|
|
|
|
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(serverId: string) {
|
|
return buildCreateAgentPreferences(serverId);
|
|
}
|