* 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
228 lines
7.9 KiB
TypeScript
228 lines
7.9 KiB
TypeScript
import { test as base, expect, type Page } from "@playwright/test";
|
|
import { startOutdatedDaemon, type OutdatedDaemon } from "./helpers/daemon-update";
|
|
import { startE2EWorker } from "./helpers/e2e-worker";
|
|
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
|
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
|
|
import {
|
|
createProjectPickerFixture,
|
|
removeProjectPickerFixture,
|
|
type ProjectPickerFixture,
|
|
} from "./helpers/project-picker-fixture";
|
|
import { connectSeedClient, type SeedDaemonClient } from "./helpers/seed-client";
|
|
import { createWithWorkspace, type WithWorkspace } from "./helpers/with-workspace";
|
|
|
|
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
|
|
|
|
interface TrackedProjectPickerFixture extends ProjectPickerFixture {
|
|
rememberProjectId: (projectId: string) => void;
|
|
}
|
|
|
|
// Test setup is wired through an `auto: true` fixture rather than `test.beforeEach`.
|
|
// `test.beforeEach` declared at the top level of a non-test fixture file is unreliable
|
|
// across spec-file boundaries — Playwright sometimes skips it for the first test of a
|
|
// subsequent spec when multiple specs run in the same worker. Auto fixtures run
|
|
// reliably for every test that uses this `test` object.
|
|
const metroTest = base.extend({
|
|
baseURL: async ({}, provide) => {
|
|
const metroPort = process.env.E2E_METRO_PORT;
|
|
if (!metroPort) {
|
|
throw new Error("E2E_METRO_PORT not set - globalSetup must run first");
|
|
}
|
|
await provide(`http://localhost:${metroPort}`);
|
|
},
|
|
});
|
|
|
|
const test = metroTest.extend<
|
|
{
|
|
paseoE2ESetup: void;
|
|
projectOwnership: void;
|
|
outdatedDaemon: OutdatedDaemon;
|
|
desktopManagedOutdatedDaemon: OutdatedDaemon;
|
|
relayConfigOutdatedDaemon: OutdatedDaemon;
|
|
projectPickerFixture: TrackedProjectPickerFixture;
|
|
withWorkspace: WithWorkspace;
|
|
},
|
|
{ e2eWorker: void; e2eWorkerClient: SeedDaemonClient }
|
|
>({
|
|
e2eWorker: [
|
|
async ({}, provide, workerInfo) => {
|
|
const worker = await startE2EWorker(workerInfo.workerIndex);
|
|
try {
|
|
await provide();
|
|
} finally {
|
|
await worker.close();
|
|
}
|
|
},
|
|
{ scope: "worker", auto: true },
|
|
],
|
|
e2eWorkerClient: [
|
|
async ({ e2eWorker }, provide) => {
|
|
void e2eWorker;
|
|
const client = await connectSeedClient();
|
|
try {
|
|
await provide(client);
|
|
} finally {
|
|
await client.close().catch(() => undefined);
|
|
}
|
|
},
|
|
{ scope: "worker" },
|
|
],
|
|
projectOwnership: [
|
|
async ({ e2eWorkerClient }, provide, testInfo) => {
|
|
const before = new Set(
|
|
(await e2eWorkerClient.fetchWorkspaces()).entries.map((workspace) => workspace.projectId),
|
|
);
|
|
|
|
await provide();
|
|
|
|
const leaked = [
|
|
...new Set(
|
|
(await e2eWorkerClient.fetchWorkspaces()).entries.map((workspace) => workspace.projectId),
|
|
),
|
|
].filter((projectId) => !before.has(projectId));
|
|
for (const projectId of leaked) {
|
|
await e2eWorkerClient.removeProject(projectId).catch(() => undefined);
|
|
}
|
|
if (leaked.length > 0) {
|
|
const message = `Test leaked daemon project records: ${leaked.join(", ")}`;
|
|
if (testInfo.status === testInfo.expectedStatus) throw new Error(message);
|
|
await testInfo.attach("project-leaks", { body: message, contentType: "text/plain" });
|
|
}
|
|
},
|
|
{ auto: true },
|
|
],
|
|
paseoE2ESetup: [
|
|
async ({ page }, provide, testInfo) => {
|
|
const daemonPort = getE2EDaemonPort();
|
|
const metroPort = process.env.E2E_METRO_PORT;
|
|
if (!metroPort) {
|
|
throw new Error(
|
|
"E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.",
|
|
);
|
|
}
|
|
|
|
// Hard guardrail: never allow tests to hit the developer's default daemon.
|
|
// This blocks both HTTP and WS attempts to :6767 (before any navigation).
|
|
await page.route(/:(6767)\b/, (route) => route.abort());
|
|
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
|
|
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
|
|
});
|
|
|
|
const entries: string[] = [];
|
|
|
|
page.on("console", (message) => {
|
|
entries.push(`[console:${message.type()}] ${message.text()}`);
|
|
});
|
|
|
|
page.on("pageerror", (error) => {
|
|
entries.push(`[pageerror] ${error.message}`);
|
|
});
|
|
|
|
const nowIso = new Date().toISOString();
|
|
const seedNonce = Math.random().toString(36).slice(2);
|
|
const serverId = process.env.E2E_SERVER_ID;
|
|
if (!serverId) {
|
|
throw new Error("E2E_SERVER_ID is not set - expected from the Playwright worker fixture.");
|
|
}
|
|
const testDaemon = buildSeededHost({
|
|
serverId,
|
|
endpoint: `127.0.0.1:${daemonPort}`,
|
|
nowIso,
|
|
});
|
|
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
|
|
|
|
await page.addInitScript(
|
|
({ daemon, preferences, seedNonce: nonce, extraHostsKey }) => {
|
|
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
|
|
// override storage and reload; they can opt out of seeding for the *next* navigation by
|
|
// setting this flag before the reload.
|
|
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
|
|
const disableValue = localStorage.getItem(disableOnceKey);
|
|
if (disableValue) {
|
|
localStorage.removeItem(disableOnceKey);
|
|
if (disableValue === nonce) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
localStorage.setItem("@paseo:e2e", "1");
|
|
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
|
|
|
|
const rawExtraHosts = localStorage.getItem(extraHostsKey);
|
|
const extraHosts = rawExtraHosts ? JSON.parse(rawExtraHosts) : [];
|
|
|
|
// Hard-reset anything that could point to a developer's real daemon.
|
|
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon, ...extraHosts]));
|
|
localStorage.removeItem("@paseo:settings");
|
|
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
|
},
|
|
{
|
|
daemon: testDaemon,
|
|
preferences: createAgentPreferences,
|
|
seedNonce,
|
|
extraHostsKey: EXTRA_HOSTS_KEY,
|
|
},
|
|
);
|
|
|
|
await provide();
|
|
|
|
if (entries.length > 0 && testInfo.status !== testInfo.expectedStatus) {
|
|
await testInfo.attach("browser-console", {
|
|
body: entries.join("\n"),
|
|
contentType: "text/plain",
|
|
});
|
|
}
|
|
},
|
|
{ auto: true },
|
|
],
|
|
outdatedDaemon: async ({}, provide) => {
|
|
const daemon = await startOutdatedDaemon();
|
|
await provide(daemon);
|
|
await daemon.close();
|
|
},
|
|
desktopManagedOutdatedDaemon: async ({}, provide) => {
|
|
const daemon = await startOutdatedDaemon({ desktopManaged: true });
|
|
await provide(daemon);
|
|
await daemon.close();
|
|
},
|
|
relayConfigOutdatedDaemon: async ({}, provide) => {
|
|
const daemon = await startOutdatedDaemon({
|
|
daemonStatusRpcCapability: false,
|
|
relayConfigCapability: false,
|
|
});
|
|
await provide(daemon);
|
|
await daemon.close();
|
|
},
|
|
projectPickerFixture: async ({}, provide) => {
|
|
const resource = await createProjectPickerFixture();
|
|
const { fixture } = resource;
|
|
let projectId: string | null = null;
|
|
try {
|
|
await provide({
|
|
...fixture,
|
|
rememberProjectId: (openedProjectId) => {
|
|
projectId = openedProjectId;
|
|
},
|
|
});
|
|
} finally {
|
|
try {
|
|
const client = await connectSeedClient();
|
|
try {
|
|
await removeProjectPickerFixture(client, fixture, projectId);
|
|
} finally {
|
|
await client.close();
|
|
}
|
|
} finally {
|
|
await resource.removeDirectory();
|
|
}
|
|
}
|
|
},
|
|
withWorkspace: async ({ page }, provide) => {
|
|
const handle = createWithWorkspace(page);
|
|
await provide(handle.withWorkspace);
|
|
await handle.cleanup();
|
|
},
|
|
});
|
|
|
|
export { test, metroTest, expect, type Page };
|