Skip unrelated required checks without losing their names (#2542)

* 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
This commit is contained in:
Mohamed Boudra
2026-08-02 00:56:05 +08:00
committed by GitHub
parent 429155b536
commit 5a30affd7f
256 changed files with 2693 additions and 2674 deletions
+1 -1
View File
@@ -2434,7 +2434,7 @@ async function runAutomationGroup() {
// 1px resident host, and Electron does not propagate CSS-box resizes to a
// parked guest's capture surface (see docs/browser-capture-harness.md).
// The production resize path is app-owned webview sizing, covered by
// packages/app/src/browser-automation/handler.test.ts.
// packages/app/src/desktop/browser/automation/handler.test.ts.
await fsp.writeFile(
path.join(OUT_DIR, "automation-results.json"),
@@ -0,0 +1,101 @@
import { expect, test, type Page } from "../../app/e2e/support/fixtures";
import { gotoAppShell } from "../../app/e2e/support/helpers/app";
import {
readRails,
readSheetHeaderRails,
readVerticalGap,
waitForSettledPosition,
} from "../../app/e2e/support/helpers/sheet-layout";
import {
openCompactSkillsIntegrations,
openSkillSelection,
openSkillsIntegrations,
startSkillsSandbox,
type SkillsSandbox,
} from "./support/skills-selection";
const COMPACT_VIEWPORT = { width: 390, height: 844 };
const DESKTOP_VIEWPORT = { width: 1280, height: 900 };
const sandboxes: SkillsSandbox[] = [];
test.afterEach(async () => {
for (const sandbox of sandboxes.splice(0)) {
await sandbox.cleanup();
}
});
async function openChooseSkillsOnPhone(page: Page): Promise<void> {
sandboxes.push(await startSkillsSandbox(page));
await gotoAppShell(page);
await openCompactSkillsIntegrations(page);
await openSkillSelection(page);
await waitForSettledPosition(page.getByTestId("skill-selection-sheet"));
}
async function openChooseSkillsOnDesktop(page: Page): Promise<void> {
sandboxes.push(await startSkillsSandbox(page));
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
}
function sheetHeaderRails(page: Page) {
return readSheetHeaderRails(page, "skill-selection-sheet", "Choose skills");
}
function allSkillsCard(page: Page) {
return page.getByTestId("skill-selection-all-card");
}
function skillListCard(page: Page) {
return page.getByTestId("skill-selection-list-card");
}
function bundledSkillsLabel(page: Page) {
return page.getByText("Bundled skills", { exact: true });
}
test.describe("Choose skills sheet on a compact form factor", () => {
test.use({ viewport: COMPACT_VIEWPORT });
test("keeps its cards on the sheet header rails", async ({ page }) => {
await openChooseSkillsOnPhone(page);
const header = await sheetHeaderRails(page);
expect(await readRails(allSkillsCard(page))).toEqual(header);
expect(await readRails(skillListCard(page))).toEqual(header);
});
test("gives the Bundled skills label more room above it than below", async ({ page }) => {
await openChooseSkillsOnPhone(page);
expect({
aboveLabel: await readVerticalGap(allSkillsCard(page), bundledSkillsLabel(page)),
belowLabel: await readVerticalGap(bundledSkillsLabel(page), skillListCard(page)),
}).toEqual({ aboveLabel: 16, belowLabel: 12 });
});
});
test.describe("Choose skills sheet on a desktop form factor", () => {
test.use({ viewport: DESKTOP_VIEWPORT });
test("keeps its cards on the sheet header rails", async ({ page }) => {
await openChooseSkillsOnDesktop(page);
const header = await sheetHeaderRails(page);
expect(await readRails(allSkillsCard(page))).toEqual(header);
expect(await readRails(skillListCard(page))).toEqual(header);
});
test("gives the Bundled skills label more room above it than below", async ({ page }) => {
await openChooseSkillsOnDesktop(page);
expect({
aboveLabel: await readVerticalGap(allSkillsCard(page), bundledSkillsLabel(page)),
belowLabel: await readVerticalGap(bundledSkillsLabel(page), skillListCard(page)),
}).toEqual({ aboveLabel: 16, belowLabel: 12 });
});
});
@@ -18,10 +18,10 @@ const desktopDir = path.resolve(scriptDir, "..");
const rootDir = path.resolve(desktopDir, "../..");
const devRunner = path.join(desktopDir, "scripts", "dev-runner.mjs");
const workspaceIds = [
"tab-bridge-original",
"tab-bridge-evict-one",
"tab-bridge-evict-two",
"tab-bridge-evict-three",
"desktop-browser-original",
"desktop-browser-evict-one",
"desktop-browser-evict-two",
"desktop-browser-evict-three",
];
const timeoutMs = 90_000;
@@ -91,7 +91,7 @@ function seedPaseoHome(paseoHome, listen, workspaceRoot) {
projectId: `project-${workspaceId}`,
rootPath: cwd,
kind: "non_git",
displayName: `Tab bridge project ${index + 1}`,
displayName: `Desktop browser project ${index + 1}`,
customName: null,
createdAt: timestamp,
updatedAt: timestamp,
@@ -103,8 +103,8 @@ function seedPaseoHome(paseoHome, listen, workspaceRoot) {
projectId: projects[index].projectId,
cwd: projects[index].rootPath,
kind: "directory",
displayName: `Tab bridge workspace ${index + 1}`,
title: `Tab bridge workspace ${index + 1}`,
displayName: `Desktop browser workspace ${index + 1}`,
title: `Desktop browser workspace ${index + 1}`,
branch: null,
baseBranch: null,
createdAt: timestamp,
@@ -196,7 +196,7 @@ async function startTargetPage() {
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
response.end(`<!doctype html>
<html>
<head><title>Tab bridge target</title></head>
<head><title>Desktop browser target</title></head>
<body>
<button id="bridge-target" onclick="this.textContent = 'Clicked'">Bridge target</button>
</body>
@@ -240,7 +240,7 @@ async function createCallerAgent(daemonPort) {
args: {
relationship: { kind: "detached" },
workspace: { kind: "existing", workspaceId: workspaceIds[0] },
title: "Browser tab bridge E2E caller",
title: "Browser desktop browser E2E caller",
provider: "mock/ten-second-stream",
settings: { modeId: "load-test" },
initialPrompt: "Remain available while the browser bridge regression runs.",
@@ -356,9 +356,9 @@ async function runRegression({ page, client, serverId, targetUrl }) {
async function main() {
const artifactDir =
process.env.PASEO_TAB_BRIDGE_E2E_ARTIFACT_DIR ??
fs.mkdtempSync(path.join(os.tmpdir(), "paseo-tab-bridge-e2e-artifacts-"));
const runtimeDir = fs.mkdtempSync(path.join(os.tmpdir(), "paseo-tab-bridge-e2e-"));
process.env.PASEO_DESKTOP_BROWSER_E2E_ARTIFACT_DIR ??
fs.mkdtempSync(path.join(os.tmpdir(), "paseo-desktop-browser-e2e-artifacts-"));
const runtimeDir = fs.mkdtempSync(path.join(os.tmpdir(), "paseo-desktop-browser-e2e-"));
fs.mkdirSync(artifactDir, { recursive: true });
const paseoHome = path.join(runtimeDir, "paseo-home");
const userData = path.join(runtimeDir, "electron-user-data");
@@ -449,10 +449,10 @@ async function main() {
});
writeJson(path.join(artifactDir, "result.json"), report);
console.log(
`Browser tab bridge E2E passed: WebContents ${report.originalWebContentsId} -> ${report.replacementWebContentsId}; list, snapshot, click passed.`,
`Browser desktop browser E2E passed: WebContents ${report.originalWebContentsId} -> ${report.replacementWebContentsId}; list, snapshot, click passed.`,
);
} catch (error) {
console.error(`Browser tab bridge E2E failed. Artifacts: ${artifactDir}`);
console.error(`Browser desktop browser E2E failed. Artifacts: ${artifactDir}`);
console.error(error);
throw error;
} finally {
@@ -0,0 +1,42 @@
import { test } from "../../app/e2e/support/fixtures";
import { gotoAppShell, openSettings } from "../../app/e2e/support/helpers/app";
import { getServerId } from "../../app/e2e/support/helpers/server-id";
import { expectLocalHostEntryFirst } from "../../app/e2e/support/helpers/settings";
test("sidebar pins the local desktop daemon host first", async ({ page }) => {
const serverId = getServerId();
await page.addInitScript((localServerId) => {
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
platform: "darwin",
invoke: async (command: string) => {
if (command === "desktop_daemon_status") {
return {
serverId: localServerId,
status: "running",
listen: null,
hostname: null,
pid: null,
home: "",
version: null,
desktopManaged: true,
error: null,
};
}
if (command === "get_desktop_settings") {
return {
releaseChannel: "stable",
daemon: { manageBuiltInDaemon: false, keepRunningAfterQuit: true },
};
}
return null;
},
getPendingOpenProject: async () => null,
events: { on: async () => () => undefined },
};
}, serverId);
await gotoAppShell(page);
await openSettings(page);
await expectLocalHostEntryFirst(page, serverId);
});
+132
View File
@@ -0,0 +1,132 @@
import { readFile, rm } from "node:fs/promises";
import { expect, test, type Page } from "../../app/e2e/support/fixtures";
import { gotoAppShell, openSettings } from "../../app/e2e/support/helpers/app";
import { installDesktopRuntime } from "./support/runtime";
import { clickSettingsBackToWorkspace } from "../../app/e2e/support/helpers/settings";
interface EditorOpenRecord {
editorId: string;
workspacePath: string;
filePath?: string;
line?: number;
column?: number;
}
function requireE2EEnv(name: string): string {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`${name} is not set.`);
}
return value;
}
async function readEditorOpenRecords(recordPath: string): Promise<EditorOpenRecord[]> {
try {
const contents = await readFile(recordPath, "utf8");
return contents
.split("\n")
.filter((line) => line.trim().length > 0)
.map((line) => JSON.parse(line) as EditorOpenRecord);
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
return [];
}
throw error;
}
}
async function chooseEditorTarget(page: Page, targetId: "vscode"): Promise<void> {
await expect(page.getByTestId("workspace-open-in-editor-primary")).toBeVisible({
timeout: 30_000,
});
await page.getByTestId("workspace-open-in-editor-caret").click();
await expect(page.getByTestId("workspace-open-in-editor-menu")).toBeVisible();
await page.getByTestId(`workspace-open-in-editor-item-${targetId}`).click();
}
async function expectEditorOpened(input: {
recordPath: string;
editorId: string;
path: string;
afterCount: number;
}): Promise<void> {
await expect
.poll(
async () => {
const records = await readEditorOpenRecords(input.recordPath);
return records
.slice(input.afterCount)
.some(
(record) => record.editorId === input.editorId && record.workspacePath === input.path,
);
},
{ timeout: 30_000 },
)
.toBe(true);
}
test.describe("Workspace open in editor", () => {
test("keeps the selected editor target after leaving and returning to the workspace", async ({
page,
withWorkspace,
}) => {
test.setTimeout(90_000);
const serverId = requireE2EEnv("E2E_SERVER_ID");
const recordPath = requireE2EEnv("E2E_EDITOR_RECORD_PATH");
await rm(recordPath, { force: true });
await installDesktopRuntime(page, {
serverId,
editorTargets: [
{
id: "cursor",
label: "Cursor",
kind: "editor",
icon: { kind: "symbol", name: "terminal" },
},
{
id: "vscode",
label: "VS Code",
kind: "editor",
icon: { kind: "symbol", name: "terminal" },
},
],
editorRecordPath: recordPath,
});
const workspace = await withWorkspace({ prefix: "workspace-editor-target-" });
await workspace.navigateTo();
await chooseEditorTarget(page, "vscode");
await expectEditorOpened({
recordPath,
editorId: "vscode",
path: workspace.repoPath,
afterCount: 0,
});
const recordsAfterSelection = (await readEditorOpenRecords(recordPath)).length;
await openSettings(page);
await clickSettingsBackToWorkspace(page);
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
await page.getByTestId("workspace-open-in-editor-primary").click();
await expectEditorOpened({
recordPath,
editorId: "vscode",
path: workspace.repoPath,
afterCount: recordsAfterSelection,
});
const recordsAfterReturnOpen = (await readEditorOpenRecords(recordPath)).length;
await gotoAppShell(page);
await workspace.navigateTo();
await page.getByTestId("workspace-open-in-editor-primary").click();
await expectEditorOpened({
recordPath,
editorId: "vscode",
path: workspace.repoPath,
afterCount: recordsAfterReturnOpen,
});
});
});
@@ -0,0 +1,188 @@
import { expect, test } from "../../app/e2e/support/fixtures";
import {
startIsolatedHostDaemon,
type IsolatedHostDaemon,
} from "../../app/e2e/support/helpers/isolated-host-daemon";
import { connectDaemonClient } from "../../app/e2e/support/helpers/daemon-client-loader";
import {
closePairDeviceModal,
declineRelay,
enableRelayAndExpectFailure,
enableRelayAndExpectOffer,
expectDaemonPidUnchanged,
expectPairingOffer,
expectPairingDisconnected,
expectRelayUpdateRequired,
expectRelayConsent,
openPairDeviceModal,
openPairDeviceFromHome,
openRelaySecurityDocs,
observePairingOfferRequests,
prepareLocalPairingHost,
reloadAndOpenPairDevice,
retryRelayAndExpectFailure,
switchPairDeviceToHost,
} from "../../app/e2e/support/helpers/pair-device";
interface RelayConfigDaemonClient {
close(): Promise<void>;
connect(): Promise<void>;
patchDaemonConfig(config: { relay: { enabled: boolean } }): Promise<unknown>;
}
test.describe("local device relay pairing", () => {
let relayOffDaemon: IsolatedHostDaemon;
let relayEnableDaemon: IsolatedHostDaemon;
let relayOverrideDaemon: IsolatedHostDaemon;
let relayEnabledDaemon: IsolatedHostDaemon;
let invalidRelayDaemon: IsolatedHostDaemon;
test.beforeAll(async () => {
[
relayOffDaemon,
relayEnableDaemon,
relayOverrideDaemon,
relayEnabledDaemon,
invalidRelayDaemon,
] = await Promise.all([
startIsolatedHostDaemon("pair-device-relay-off", {
mutableRelay: { enabled: false },
}),
startIsolatedHostDaemon("pair-device-relay-enable", {
mutableRelay: { enabled: false },
}),
startIsolatedHostDaemon("pair-device-relay-override"),
startIsolatedHostDaemon("pair-device-relay-enabled", {
mutableRelay: { enabled: true },
}),
startIsolatedHostDaemon("pair-device-relay-invalid", {
mutableRelay: { enabled: false, endpoint: "invalid-endpoint" },
}),
]);
});
test.afterAll(async () => {
await Promise.all([
relayOffDaemon.close(),
relayEnableDaemon.close(),
relayOverrideDaemon.close(),
relayEnabledDaemon.close(),
invalidRelayDaemon.close(),
]);
});
test("asks for consent and creates no QR when declined", async ({ page }) => {
await prepareLocalPairingHost(page, relayOffDaemon);
await openPairDeviceModal(page);
await expectRelayConsent(page);
await declineRelay(page);
await openPairDeviceModal(page);
await expectRelayConsent(page);
});
test("opens relay security documentation through the desktop opener", async ({ page }) => {
await prepareLocalPairingHost(page, relayOffDaemon);
await openPairDeviceModal(page);
await openRelaySecurityDocs(page);
});
test("opens the same relay consent dialog from the home screen", async ({ page }) => {
await prepareLocalPairingHost(page, relayOffDaemon);
await openPairDeviceFromHome(page);
const modal = page.getByTestId("open-project-pair-device-modal");
await expect(modal.getByText("Enable relay?", { exact: true })).toBeVisible();
await expect(modal.getByRole("button", { name: "Enable relay", exact: true })).toBeVisible();
});
test("shows an actionable error when the daemon disconnects", async ({ page }) => {
const daemon = await startIsolatedHostDaemon("pair-device-disconnect", {
mutableRelay: { enabled: false },
});
try {
await prepareLocalPairingHost(page, daemon);
await openPairDeviceModal(page);
await expectRelayConsent(page);
await daemon.close();
await expectPairingDisconnected(page);
} finally {
await daemon.close();
}
});
test("pairs a device through the selected remote host", async ({ page }) => {
await prepareLocalPairingHost(page, relayOffDaemon, [
{
serverId: relayEnabledDaemon.serverId,
label: "Remote pairing host",
endpoint: `127.0.0.1:${relayEnabledDaemon.port}`,
},
]);
await switchPairDeviceToHost(page, relayEnabledDaemon.serverId);
await openPairDeviceModal(page);
await expectPairingOffer(page);
});
test("enables relay live and keeps the saved offer after reload", async ({ page }) => {
const daemonPid = relayEnableDaemon.getPid();
await prepareLocalPairingHost(page, relayEnableDaemon);
await openPairDeviceModal(page);
await enableRelayAndExpectOffer(page);
expectDaemonPidUnchanged(daemonPid, relayEnableDaemon.getPid());
await closePairDeviceModal(page);
await reloadAndOpenPairDevice(page);
await expectPairingOffer(page);
});
test("keeps consent actionable when a launch override rejects enable", async ({ page }) => {
await prepareLocalPairingHost(page, relayOverrideDaemon);
await openPairDeviceModal(page);
await expectRelayConsent(page);
await enableRelayAndExpectFailure(page);
await retryRelayAndExpectFailure(page);
});
test("keeps consent actionable when relay transport startup fails", async ({ page }) => {
await prepareLocalPairingHost(page, invalidRelayDaemon);
await openPairDeviceModal(page);
await expectRelayConsent(page);
await enableRelayAndExpectFailure(page, "Invalid host:port");
await retryRelayAndExpectFailure(page, "Invalid host:port");
});
test("shows an offer immediately when relay is already enabled", async ({ page }) => {
await prepareLocalPairingHost(page, relayEnabledDaemon);
await openPairDeviceModal(page);
await expectPairingOffer(page);
});
test("refreshes the mounted offer when another client disables relay", async ({ page }) => {
const daemon = await startIsolatedHostDaemon("pair-device-live-config-push", {
mutableRelay: { enabled: true },
});
const client = await connectDaemonClient<RelayConfigDaemonClient>({
clientIdPrefix: "pair-device-live-config-push",
port: daemon.port,
});
try {
await prepareLocalPairingHost(page, daemon);
await openPairDeviceModal(page);
await expectPairingOffer(page);
await client.patchDaemonConfig({ relay: { enabled: false } });
await expectRelayConsent(page);
} finally {
await client.close();
await daemon.close();
}
});
test("asks for a host update when live relay config is unsupported", async ({
page,
relayConfigOutdatedDaemon,
}) => {
const expectNoUnsupportedRequest = observePairingOfferRequests(page, relayConfigOutdatedDaemon);
await prepareLocalPairingHost(page, relayConfigOutdatedDaemon);
await openPairDeviceModal(page);
await expectRelayUpdateRequired(page);
expectNoUnsupportedRequest();
});
});
@@ -0,0 +1,82 @@
import { test, expect } from "../../app/e2e/support/fixtures";
import { gotoAppShell } from "../../app/e2e/support/helpers/app";
import {
expectNewWorkspaceForAddedProject,
openAddProjectFlow,
} from "../../app/e2e/support/helpers/add-project-flow";
import { getE2EDaemonPort } from "../../app/e2e/support/helpers/daemon-port";
import { waitForConnectedHost } from "../../app/e2e/support/helpers/hosts";
import { expectOpenedProject } from "../../app/e2e/support/helpers/project-picker-ui";
import { getServerId } from "../../app/e2e/support/helpers/server-id";
import { connectSeedClient } from "../../app/e2e/support/helpers/seed-client";
import { installDesktopRuntime, waitForDirectoryDialog } from "./support/runtime";
test("Browse opens the folder selected by the desktop dialog", async ({
page,
projectPickerFixture,
}) => {
await installDesktopRuntime(page, {
serverId: getServerId(),
manageBuiltInDaemon: false,
dialogOpenResult: projectPickerFixture.projectPath,
});
await gotoAppShell(page);
await waitForConnectedHost(page, {
serverId: getServerId(),
endpoint: `localhost:${getE2EDaemonPort()}`,
});
await openAddProjectFlow(page);
const browse = page.getByRole("button", { name: /^Browse/ });
await expect(browse).toBeVisible({ timeout: 30_000 });
await browse.click();
const projectId = await expectOpenedProject(page, projectPickerFixture.projectName);
projectPickerFixture.rememberProjectId(projectId);
await expectNewWorkspaceForAddedProject(page, {
serverId: getServerId(),
projectId,
projectName: projectPickerFixture.projectName,
projectPath: projectPickerFixture.projectPath,
});
const client = await connectSeedClient();
try {
expect((await client.fetchWorkspaces({ filter: { projectId } })).entries).toEqual([]);
} finally {
await client.close();
}
});
test("canceling Browse returns to the Add Project methods", async ({
page,
projectPickerFixture,
}) => {
await installDesktopRuntime(page, {
serverId: getServerId(),
manageBuiltInDaemon: false,
dialogOpenResult: null,
});
await gotoAppShell(page);
await waitForConnectedHost(page, {
serverId: getServerId(),
endpoint: `localhost:${getE2EDaemonPort()}`,
});
await openAddProjectFlow(page);
const browse = page.getByRole("button", { name: /^Browse/ });
await expect(browse).toBeVisible({ timeout: 30_000 });
await browse.click();
const dialogOptions = await waitForDirectoryDialog(page);
expect(dialogOptions).toEqual({
createDirectory: true,
directory: true,
multiple: false,
});
await expect(browse).toBeVisible();
await expect(
page
.locator('[data-testid^="sidebar-project-row-"]')
.filter({ hasText: projectPickerFixture.projectName }),
).toHaveCount(0);
});
@@ -0,0 +1,61 @@
import { test, expect } from "../../app/e2e/support/fixtures";
import { gotoAppShell, openSettings } from "../../app/e2e/support/helpers/app";
test.describe("Settings sidebar scrolling", () => {
test.use({ viewport: { width: 900, height: 260 } });
test("desktop drag region does not cover the scroll body", async ({ page }) => {
await page.addInitScript(() => {
window.paseoDesktop = {
platform: "darwin",
events: { on: () => () => {} },
invoke: async (command: string) => {
if (command === "get_desktop_settings") {
return {
releaseChannel: "stable",
daemon: { manageBuiltInDaemon: true, keepRunningAfterQuit: true },
};
}
return null;
},
};
});
await gotoAppShell(page);
await openSettings(page);
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible();
const geometry = await sidebar.evaluate((node) => {
let scroller: HTMLElement | null = null;
for (const element of node.querySelectorAll<HTMLElement>("*")) {
if (element.scrollHeight > element.clientHeight) {
scroller = element;
break;
}
}
if (!scroller) return null;
const scrollerRect = scroller.getBoundingClientRect();
const dragRegions = [];
for (const element of node.querySelectorAll<HTMLElement>("*")) {
if (getComputedStyle(element).getPropertyValue("-webkit-app-region") === "drag") {
const rect = element.getBoundingClientRect();
dragRegions.push({ bottom: rect.bottom });
}
}
return {
scrollBodyTop: scrollerRect.top,
dragRegions,
};
});
expect(geometry).not.toBeNull();
expect(geometry!.dragRegions).not.toEqual([]);
for (const dragRegion of geometry!.dragRegions) {
expect(dragRegion.bottom).toBeLessThanOrEqual(geometry!.scrollBodyTop + 1);
}
});
});
@@ -0,0 +1,333 @@
import { test as base } from "../../app/e2e/support/fixtures";
import { expect } from "@playwright/test";
import { gotoAppShell } from "../../app/e2e/support/helpers/app";
import { installDesktopRuntime } from "./support/runtime";
import { getServerId } from "../../app/e2e/support/helpers/server-id";
import {
cancelSkillSelection,
chooseAllSkills,
chooseCustomSkills,
createSkillsSandbox,
expectAllSkillsSelected,
expectNoRemovalWarning,
expectRemovalWarning,
expectSavedSkillSelection,
expectSaveErrorKeepsSheetOpen,
expectSelectedSkills,
expectSkillChoices,
expectSkillFilePresent,
expectSkillPresentIn,
expectSkillSelectionOpen,
expectSkillsInstalled,
openSkillsIntegrations,
openSkillSelection,
saveSkillSelection,
toggleSkill,
serveRealSkillsCommands,
type SkillsSandbox,
type SkillsSandboxOptions,
} from "./support/skills-selection";
interface SkillsSandboxSetup extends SkillsSandboxOptions {
/** What the desktop confirm dialog answers when the removal warning fires. */
confirmRemoval?: boolean;
}
type StartSkills = (setup?: SkillsSandboxSetup) => Promise<SkillsSandbox>;
// Every skills command runs against the real desktop handlers over a temp bundle
// and temp user data, so the assertions cover persistence and convergence, not a
// browser stand-in for them.
const test = base.extend<{ startSkills: StartSkills }>({
startSkills: async ({ page }, provide) => {
const sandboxes: SkillsSandbox[] = [];
await provide(async (setup = {}) => {
const { confirmRemoval, ...options } = setup;
const sandbox = await createSkillsSandbox(options);
await installDesktopRuntime(page, {
serverId: getServerId(),
confirmShouldAccept: confirmRemoval ?? false,
});
await serveRealSkillsCommands(page, sandbox);
sandboxes.push(sandbox);
return sandbox;
});
for (const sandbox of sandboxes) {
await sandbox.cleanup();
}
},
});
test.describe("Choosing installed skills", () => {
test("installs only the skills selected in Settings", async ({ page, startSkills }) => {
const skills = await startSkills();
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, ["paseo", "paseo-loop"]);
await saveSkillSelection(page);
await expectSkillsInstalled(skills, ["paseo", "paseo-loop"]);
await expectSelectedSkills(page, ["paseo", "paseo-loop"]);
});
test("all skills includes every available skill", async ({ page, startSkills }) => {
const skills = await startSkills();
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, ["paseo"]);
await saveSkillSelection(page);
await openSkillSelection(page);
await chooseAllSkills(page);
await saveSkillSelection(page);
await expectSkillsInstalled(skills, skills.bundledSkills);
await expectAllSkillsSelected(page);
});
test("cancelling leaves the installed skills untouched", async ({ page, startSkills }) => {
const skills = await startSkills();
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, ["paseo"]);
await saveSkillSelection(page);
await openSkillSelection(page);
await toggleSkill(page, "paseo-loop");
await cancelSkillSelection(page);
await expectSkillsInstalled(skills, ["paseo"]);
await expectSelectedSkills(page, ["paseo"]);
});
test("a failed save keeps the sheet open with an error", async ({ page, startSkills }) => {
const skills = await startSkills({ blockAgentsDir: true });
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, ["paseo"]);
await saveSkillSelection(page);
await expectSaveErrorKeepsSheetOpen(page);
await expectSkillsInstalled(skills, []);
});
test("an empty selection does not offer an Install action that cannot install anything", async ({
page,
startSkills,
}) => {
await startSkills({ installed: { mode: "all" }, confirmRemoval: true });
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, []);
await saveSkillSelection(page);
// The remaining Install action belongs to the command-line integration.
await expect(page.getByRole("button", { name: "Install", exact: true })).toHaveCount(1);
await expect(page.getByRole("button", { name: "Choose skills", exact: true })).toBeVisible();
});
test("a selection containing only retired skills does not offer a no-op Install", async ({
page,
startSkills,
}) => {
await startSkills({ installed: { mode: "custom", skills: ["retired-skill"] } });
await gotoAppShell(page);
await openSkillsIntegrations(page);
// The remaining Install action belongs to the command-line integration.
await expect(page.getByRole("button", { name: "Install", exact: true })).toHaveCount(1);
await expect(page.getByRole("button", { name: "Choose skills", exact: true })).toBeVisible();
});
test("an empty bundled catalog does not offer a no-op Install", async ({ page, startSkills }) => {
await startSkills({ bundledSkills: [] });
await gotoAppShell(page);
await openSkillsIntegrations(page);
// The remaining Install action belongs to the command-line integration.
await expect(page.getByRole("button", { name: "Install", exact: true })).toHaveCount(1);
await expect(page.getByRole("button", { name: "Choose skills", exact: true })).toBeVisible();
});
test("cannot dismiss the sheet while Save is pending", async ({ page, startSkills }) => {
const skills = await startSkills({ holdSave: true });
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, ["paseo"]);
await saveSkillSelection(page);
await skills.waitForHeldSave();
await page.keyboard.press("Escape");
await expectSkillSelectionOpen(page);
skills.releaseHeldSave();
await expect(page.getByRole("switch", { name: "All skills", exact: true })).toHaveCount(0);
});
});
test.describe("Removing an installed skill", () => {
test("delete-only drift stays in Choose skills instead of Update", async ({
page,
startSkills,
}) => {
await startSkills({
installed: { mode: "custom", skills: ["paseo"] },
placeOnDisk: {
skill: "paseo-loop",
target: "claude",
files: { "SKILL.md": "# paseo-loop\n", "notes/mine.md": "my notes" },
},
});
await gotoAppShell(page);
await openSkillsIntegrations(page);
await expect(page.getByText("Update available", { exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Update", exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Choose skills", exact: true })).toBeVisible();
});
test("warns before deleting the skill folders", async ({ page, startSkills }) => {
const skills = await startSkills({ installed: { mode: "all" }, confirmRemoval: false });
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, ["paseo", "paseo-advisor"]);
await saveSkillSelection(page);
await expectRemovalWarning(page, ["paseo-loop"]);
await expectSkillSelectionOpen(page);
await expectSkillsInstalled(skills, skills.bundledSkills);
await expectSavedSkillSelection(skills, { mode: "all" });
});
test("confirming the warning removes the skill", async ({ page, startSkills }) => {
const skills = await startSkills({ installed: { mode: "all" }, confirmRemoval: true });
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, ["paseo", "paseo-advisor"]);
await saveSkillSelection(page);
await expectRemovalWarning(page, ["paseo-loop"]);
await expectSkillsInstalled(skills, ["paseo", "paseo-advisor"]);
await expectSelectedSkills(page, ["paseo", "paseo-advisor"]);
});
test("warns for a skill installed in only one agent directory", async ({ page, startSkills }) => {
const skills = await startSkills({
installed: { mode: "all" },
keepOnlyIn: { skill: "paseo-loop", target: "claude" },
confirmRemoval: false,
});
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, ["paseo", "paseo-advisor"]);
await saveSkillSelection(page);
await expectRemovalWarning(page, ["paseo-loop"]);
await expectSkillSelectionOpen(page);
await expectSkillPresentIn(skills, "claude", "paseo-loop");
await expectSavedSkillSelection(skills, { mode: "all" });
});
test("warns for a skill on disk that the saved selection never mentioned", async ({
page,
startSkills,
}) => {
// The committed preference excludes paseo-loop, but the directory is back —
// a manual reinstall or an external sync. Saving any edit deletes it.
const skills = await startSkills({
installed: { mode: "custom", skills: ["paseo"] },
placeOnDisk: {
skill: "paseo-loop",
target: "claude",
files: { "SKILL.md": "# paseo-loop\n", "notes/mine.md": "my notes" },
},
confirmRemoval: false,
});
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await toggleSkill(page, "paseo-advisor");
await saveSkillSelection(page);
await expectRemovalWarning(page, ["paseo-loop"]);
await expectSkillSelectionOpen(page);
await expectSkillFilePresent(skills, "claude", "paseo-loop", "notes/mine.md");
await expectSavedSkillSelection(skills, { mode: "custom", skills: ["paseo"] });
});
test("warns about a directory that appears after the sheet was opened", async ({
page,
startSkills,
}) => {
const skills = await startSkills({
installed: { mode: "custom", skills: ["paseo"] },
confirmRemoval: false,
});
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
// Nothing to delete when the sheet opened; the host looks again at save time.
await skills.placeSkillOnDisk("paseo-loop", "claude");
await toggleSkill(page, "paseo-advisor");
await saveSkillSelection(page);
await expectRemovalWarning(page, ["paseo-loop"]);
await expectSkillSelectionOpen(page);
await expectSkillPresentIn(skills, "claude", "paseo-loop");
await expectSavedSkillSelection(skills, { mode: "custom", skills: ["paseo"] });
});
test("keeps draft choices when fresher status arrives while the sheet is open", async ({
page,
startSkills,
}) => {
const skills = await startSkills({ installed: { mode: "all" }, confirmRemoval: false });
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, ["paseo", "paseo-advisor"]);
// Another window commits a different selection. Saving picks that up, so the
// sheet is handed a selection it did not start from while an edit is open.
await skills.commitSelectionExternally({ mode: "custom", skills: ["paseo", "paseo-loop"] }, [
"paseo-advisor",
]);
await saveSkillSelection(page);
await expectRemovalWarning(page, ["paseo-loop"]);
await expectSkillChoices(page, ["paseo", "paseo-advisor"]);
});
test("adding a skill saves without a warning", async ({ page, startSkills }) => {
const skills = await startSkills({
installed: { mode: "custom", skills: ["paseo"] },
confirmRemoval: false,
});
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await toggleSkill(page, "paseo-loop");
await saveSkillSelection(page);
await expectSkillsInstalled(skills, ["paseo", "paseo-loop"]);
await expectNoRemovalWarning(page);
});
});
+32
View File
@@ -0,0 +1,32 @@
import { test } from "../../app/e2e/support/fixtures";
import { getE2EDaemonPort } from "../../app/e2e/support/helpers/daemon-port";
import { getServerId } from "../../app/e2e/support/helpers/server-id";
import { startupScenario } from "../../app/e2e/support/helpers/startup-dsl";
test.describe("Desktop startup", () => {
test("daemon bootstrap keeps the local server startup copy desktop-only", async ({ page }) => {
const startup = await startupScenario(page)
.withPendingDesktopDaemon()
.withBlockedPort(getE2EDaemonPort())
.openRoot();
await startup.expectsDesktopDaemonStartup();
await startup.expectsSidebarHidden();
await startup.expectsNoUndefinedRoute();
});
test("host-route refresh does not render route chrome around the bootstrap splash", async ({
page,
}) => {
const startup = await startupScenario(page)
.withPendingDesktopDaemon()
.withBlockedPort(getE2EDaemonPort())
.openHostWorkspace({
serverId: getServerId(),
workspaceId: "workspace-1",
});
await startup.expectsDesktopDaemonStartup();
await startup.expectsSidebarHidden();
});
});
+423
View File
@@ -0,0 +1,423 @@
import { readFileSync } from "node:fs";
import { appendFile } from "node:fs/promises";
import { expect, type Page } from "@playwright/test";
import { openSettings } from "../../../app/e2e/support/helpers/app";
import { getE2EDaemonPort } from "../../../app/e2e/support/helpers/daemon-port";
import { escapeRegex } from "../../../app/e2e/support/helpers/regex";
import {
openSettingsHost,
openSettingsHostSection,
openSettingsSection,
} from "../../../app/e2e/support/helpers/settings";
interface DaemonApiStatus {
version: string;
serverId: string;
hostname: string;
}
interface PidFileContent {
pid: number;
desktopManaged: boolean;
}
export interface RealDaemonState {
version: string;
pid: number | null;
logPath: string;
}
/**
* Reads live state from the running E2E test daemon: version from the HTTP
* status endpoint, PID from the paseo.pid lock file, log path from the
* E2E_PASEO_HOME directory. Call this in Node test code (not in the browser).
*/
export async function loadRealDaemonState(): Promise<RealDaemonState> {
const port = getE2EDaemonPort();
const paseoHome = process.env.E2E_PASEO_HOME;
if (!paseoHome) throw new Error("E2E_PASEO_HOME not set — the worker fixture must run first");
const resp = await fetch(`http://127.0.0.1:${port}/api/status`);
const data: DaemonApiStatus = await resp.json();
let pid: number | null = null;
try {
const raw = readFileSync(`${paseoHome}/paseo.pid`, "utf8");
const pidContent: PidFileContent = JSON.parse(raw);
pid = pidContent.pid ?? null;
} catch (err) {
// PID file may not be present yet on a very fresh daemon start
console.warn("[desktop-updates] paseo.pid not found:", err);
}
return { version: data.version, pid, logPath: `${paseoHome}/daemon.log` };
}
export interface DesktopRuntimeConfig {
serverId: string;
updateAvailable?: boolean;
latestVersion?: string;
updateReadyToInstall?: boolean;
slowInstall?: boolean;
/** Initial PID reported by desktop_daemon_status. Defaults to null. */
daemonPid?: number | null;
daemonVersion?: string | null;
daemonLogPath?: string;
/** Initial manageBuiltInDaemon setting. Defaults to false. */
manageBuiltInDaemon?: boolean;
/** Daemon listen address reported by desktop_daemon_status. Defaults to 127.0.0.1:6767. */
daemonListen?: string;
/** Keep start_desktop_daemon pending to hold the desktop startup blocker open. */
hangDaemonStart?: boolean;
/** Delay the desktop settings IPC response to exercise startup ordering. */
desktopSettingsDelayMs?: number;
/**
* Controls what dialog.ask returns when the daemon management confirm dialog
* fires. True = confirm (proceed with the action), false = cancel. Defaults to
* false so tests that only assert copy don't inadvertently trigger state changes.
*/
confirmShouldAccept?: boolean;
dialogOpenResult?: string | string[] | null;
editorTargets?: DesktopEditorTargetConfig[];
editorRecordPath?: string;
}
interface DesktopEditorTargetConfig {
id: string;
label: string;
kind: "editor" | "file-manager";
icon: { kind: "image"; dataUrl: string } | { kind: "symbol"; name: "folder" | "terminal" };
}
interface DesktopEditorOpenRecord {
editorId: string;
workspacePath: string;
filePath?: string;
line?: number;
column?: number;
}
export interface ConfirmDialogCall {
message: string;
title: string | undefined;
}
declare global {
interface Window {
__capturedDialogCall: ConfirmDialogCall | undefined;
__capturedDialogOpenCalls: Array<Record<string, unknown> | undefined>;
__recordDesktopEditorOpen?: (input: DesktopEditorOpenRecord) => Promise<void>;
__desktopDaemonStartRequested?: boolean;
}
}
/**
* Injects window.paseoDesktop before app load so all Electron-gated code
* activates. The update-check IPC is mocked at the boundary so the real
* auto-updater never fires. Daemon start/stop commands are stateful: the mock
* tracks running state and assigns a fresh PID on each start, letting tests
* observe PID changes without touching the real E2E daemon process.
* dialog.ask captures call arguments on window.__capturedDialogCall so tests
* can assert dialog copy without depending on window.confirm concatenation.
*/
export async function installDesktopRuntime(
page: Page,
config: DesktopRuntimeConfig,
): Promise<void> {
if (config.editorRecordPath) {
await page.exposeFunction(
"__recordDesktopEditorOpen",
async (input: DesktopEditorOpenRecord) => {
await appendFile(config.editorRecordPath as string, `${JSON.stringify(input)}\n`, "utf8");
},
);
}
await page.addInitScript((cfg) => {
// Mutable state shared across IPC calls within this page
let manageDaemon = cfg.manageBuiltInDaemon ?? false;
let daemonRunning = true;
let currentPid: number | null = cfg.daemonPid ?? null;
let startCount = 0;
window.__desktopDaemonStartRequested = false;
function buildDaemonStatus() {
return {
serverId: cfg.serverId,
status: daemonRunning ? "running" : "stopped",
listen: cfg.daemonListen ?? "127.0.0.1:6767",
hostname: null,
pid: currentPid,
home: "",
version: cfg.daemonVersion ?? null,
desktopManaged: manageDaemon,
error: null,
};
}
function startDesktopDaemon() {
window.__desktopDaemonStartRequested = true;
if (cfg.hangDaemonStart) {
return new Promise(() => undefined);
}
startCount += 1;
daemonRunning = true;
// First start (bootstrap) returns the configured PID; subsequent starts
// (after a stop) get a fresh PID so tests can observe the change.
currentPid = (cfg.daemonPid ?? 10000) + (startCount - 1) * 1000;
return buildDaemonStatus();
}
async function waitForDesktopSettingsResponse() {
const delayMs = cfg.desktopSettingsDelayMs ?? 0;
if (delayMs > 0) {
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
}
}
const desktopBridge: {
platform: string;
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
dialog: {
ask: (message: string, options?: Record<string, unknown>) => Promise<boolean>;
open: (options?: Record<string, unknown>) => Promise<string | string[] | null>;
};
getPendingOpenProject: () => Promise<string | null>;
events: { on: () => Promise<() => void> };
editor?: {
listTargets: () => Promise<DesktopEditorTargetConfig[]>;
openTarget: (input: DesktopEditorOpenRecord) => Promise<void>;
};
} = {
platform: "darwin",
invoke: async (command: string, args?: Record<string, unknown>) => {
if (command === "check_app_update") {
return cfg.updateAvailable
? {
hasUpdate: true,
readyToInstall: cfg.updateReadyToInstall ?? true,
currentVersion: "1.0.0",
latestVersion: cfg.latestVersion ?? "1.2.3",
body: null,
date: null,
}
: {
hasUpdate: false,
readyToInstall: false,
currentVersion: "1.0.0",
latestVersion: null,
body: null,
date: null,
};
}
if (command === "install_app_update") {
if (cfg.slowInstall) {
await new Promise<void>((resolve) => setTimeout(resolve, 3000));
}
return {
installed: true,
version: cfg.latestVersion ?? "1.2.3",
message: "App update installed. Restart required.",
};
}
if (command === "desktop_daemon_status") {
return buildDaemonStatus();
}
if (command === "desktop_daemon_logs") {
return { logPath: cfg.daemonLogPath ?? "", contents: "" };
}
if (command === "get_desktop_settings") {
await waitForDesktopSettingsResponse();
return {
releaseChannel: "stable",
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
};
}
if (command === "patch_desktop_settings") {
const daemon = args?.daemon;
if (
daemon !== null &&
typeof daemon === "object" &&
"manageBuiltInDaemon" in daemon &&
typeof daemon.manageBuiltInDaemon === "boolean"
) {
manageDaemon = daemon.manageBuiltInDaemon;
}
return {
releaseChannel: "stable",
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
};
}
if (command === "stop_desktop_daemon") {
daemonRunning = false;
currentPid = null;
return buildDaemonStatus();
}
if (command === "start_desktop_daemon") {
return startDesktopDaemon();
}
return null;
},
dialog: {
ask: async (message: string, options?: Record<string, unknown>) => {
window.__capturedDialogCall = {
message,
title: typeof options?.title === "string" ? options.title : undefined,
};
return cfg.confirmShouldAccept ?? false;
},
open: async (options?: Record<string, unknown>) => {
window.__capturedDialogOpenCalls.push(options);
return cfg.dialogOpenResult ?? null;
},
},
getPendingOpenProject: async () => null,
events: { on: async () => () => undefined },
};
if (cfg.editorTargets) {
desktopBridge.editor = {
listTargets: async () => cfg.editorTargets ?? [],
openTarget: async (input: DesktopEditorOpenRecord) => {
await window.__recordDesktopEditorOpen?.(input);
},
};
}
window.__capturedDialogOpenCalls = [];
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = desktopBridge;
}, config);
}
export async function waitForDesktopDaemonStartRequest(page: Page): Promise<void> {
await page.waitForFunction(() => window.__desktopDaemonStartRequested === true);
// Give the startup state two paints to expose any app → splash regression.
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
),
);
}
export async function waitForDirectoryDialog(
page: Page,
): Promise<Record<string, unknown> | undefined> {
await expect.poll(() => page.evaluate(() => window.__capturedDialogOpenCalls.length)).toBe(1);
return page.evaluate(() => window.__capturedDialogOpenCalls[0]);
}
export async function openDesktopSettings(page: Page, serverId: string): Promise<void> {
await openSettings(page);
await openSettingsHost(page, serverId);
// The daemon-lifecycle card moved to the Host section in the flat-settings
// layout; navigate there before asserting it.
await openSettingsHostSection(page, serverId, "host");
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toBeVisible({
timeout: 15_000,
});
}
export async function openDesktopAboutSettings(page: Page): Promise<void> {
await openSettings(page);
await openSettingsSection(page, "about");
await expect(page.getByText("App updates", { exact: true })).toBeVisible();
}
export async function expectUpdateBanner(page: Page, version: string): Promise<void> {
const callout = page.getByTestId("update-callout");
await expect(callout).toBeVisible({ timeout: 15_000 });
await expect(callout).toContainText(`v${version.replace(/^v/i, "")}`);
}
export async function clickCheckForUpdates(page: Page): Promise<void> {
await page.getByRole("button", { name: "Check" }).click();
}
export async function expectPendingUpdateCheckResult(page: Page, version: string): Promise<void> {
const normalizedVersion = `v${version.replace(/^v/i, "")}`;
await expect(
page.getByText(
new RegExp(`Update found: ${escapeRegex(normalizedVersion)}\\. Downloading\\.\\.\\.`),
),
).toBeVisible();
await expect(page.getByText(`Ready to install: ${normalizedVersion}`)).toHaveCount(0);
await expect(page.getByRole("button", { name: "Update" })).toBeDisabled();
}
export async function clickInstallUpdate(page: Page): Promise<void> {
await page.getByRole("button", { name: "Install & restart" }).click();
}
export async function expectInstallInProgress(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Installing..." })).toBeVisible();
}
/**
* Clicks the daemon management switch and waits for dialog.ask to fire in the
* mock, then returns the captured call args (message + title). The mock auto-
* dismisses via confirmShouldAccept=false so callers can assert copy without
* worrying about state changes.
*/
export async function interceptDaemonManagementConfirmDialog(
page: Page,
): Promise<ConfirmDialogCall> {
await page.getByRole("switch", { name: "Manage built-in daemon" }).click();
await page.waitForFunction(() => !!window.__capturedDialogCall, { timeout: 5_000 });
return page.evaluate(() => window.__capturedDialogCall!);
}
export async function toggleDaemonManagement(
page: Page,
_action: "enable" | "disable",
): Promise<void> {
await page.getByRole("switch", { name: "Manage built-in daemon" }).click();
}
export function expectDaemonManagementConfirmDialog(args: ConfirmDialogCall): void {
expect(args.title).toBe("Pause built-in daemon");
expect(args.message).toContain("stop the built-in daemon immediately");
}
export async function expectDaemonManagementEnabled(page: Page): Promise<void> {
await expect(page.getByRole("switch", { name: "Manage built-in daemon" })).toBeChecked();
}
export async function expectDaemonManagementDisabled(page: Page): Promise<void> {
await expect(page.getByRole("switch", { name: "Manage built-in daemon" })).not.toBeChecked();
}
/**
* Asserts the daemon status card shows the given PID. Pass null to assert
* the cleared state (shown as "PID —" when the daemon is stopped).
*/
export async function expectDaemonStatusPid(page: Page, pid: number | null): Promise<void> {
const expected = pid !== null ? `PID ${pid}` : "PID —";
await expect(
page.getByTestId("host-page-daemon-lifecycle-card").getByText(expected),
).toBeVisible();
}
export async function expectDaemonStatusLogPath(page: Page, logPath: string): Promise<void> {
await expect(
page.getByTestId("host-page-daemon-lifecycle-card").getByText(logPath),
).toBeVisible();
}
/**
* Asserts the host page identity badge shows the given version string.
* The badge is populated from the live WebSocket session's serverInfo.version.
*/
export async function expectDaemonStatusVersion(page: Page, version: string): Promise<void> {
await expect(
page.getByTestId("host-page-identity").getByText(version, { exact: false }),
).toBeVisible({ timeout: 15_000 });
}
@@ -0,0 +1,410 @@
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import { buildOpenProjectRoute } from "../../../app/src/utils/host-routes";
import { openSettings } from "../../../app/e2e/support/helpers/app";
import { installDesktopRuntime } from "./runtime";
import { getServerId } from "../../../app/e2e/support/helpers/server-id";
import {
openCompactSettings,
openSettingsSection,
} from "../../../app/e2e/support/helpers/settings";
// The desktop skills module is the real command surface: the same handlers the
// Electron main process registers, running against a temp bundle and temp user
// data. Nothing about persistence or convergence is simulated in the browser.
import type { SkillSelection, SkillTargets } from "../../src/integrations/skills/operations.js";
import { createSkillSelectionStore } from "../../src/integrations/skills/selection-store.js";
import { createSkillsCommandHandlers } from "../../src/integrations/skills/skills-commands.js";
import { createSkillsController } from "../../src/integrations/skills/skills-controller.js";
const SKILL_COMMANDS = [
"get_skills_status",
"install_skills",
"update_skills",
"uninstall_skills",
"save_skills_selection",
] as const;
declare global {
interface Window {
__paseoSkillsInvoke?: (
command: string,
args: Record<string, unknown> | null,
) => Promise<unknown>;
}
}
export interface SkillsSandbox {
targets: SkillTargets;
userDataPath: string;
bundledSkills: string[];
/** Writes a managed skill directory the way an external tool would. */
placeSkillOnDisk: (skill: string, target: SkillTargetName) => Promise<void>;
/** Commits a selection through the real controller, as another window would. */
commitSelectionExternally: (
selection: SkillSelection,
confirmedRemovals?: readonly string[],
) => Promise<void>;
/** Waits until a held save reaches the desktop command boundary. */
waitForHeldSave: () => Promise<void>;
/** Releases a save held at the desktop command boundary. */
releaseHeldSave: () => void;
/** Test-only command-boundary gate used to keep the renderer in its pending state. */
holdSaveResponse?: () => Promise<void>;
cleanup: () => Promise<void>;
}
export interface SkillsSandboxOptions {
bundledSkills?: string[];
/**
* Puts a regular file where the `.agents` skills tree has to go, so the real
* convergence fails with ENOTDIR instead of a stubbed rejection.
*/
blockAgentsDir?: boolean;
/** Converge and commit this selection before the app loads, through the real controller. */
installed?: SkillSelection;
/**
* Leaves a skill installed in one agent home only, the way a partial install
* or a hand-deleted directory leaves it.
*/
keepOnlyIn?: { skill: string; target: SkillTargetName };
/**
* Writes a skill directory straight into one agent home after the selection is
* committed, the way a manual reinstall or an external sync leaves a directory
* the saved preference does not mention.
*/
placeOnDisk?: { skill: string; target: SkillTargetName; files: Record<string, string> };
/** Holds the save response so dismissal behavior can be asserted while pending. */
holdSave?: boolean;
}
export type SkillTargetName = "agents" | "claude" | "codex";
function targetDir(targets: SkillTargets, target: SkillTargetName): string {
if (target === "agents") return targets.agentsDir;
if (target === "claude") return targets.claudeDir;
return targets.codexDir;
}
export async function createSkillsSandbox(
options: SkillsSandboxOptions = {},
): Promise<SkillsSandbox> {
let releaseHeldSave: (() => void) | null = null;
let markSaveHeld: (() => void) | null = null;
const heldSave = new Promise<void>((resolve) => {
markSaveHeld = resolve;
});
const saveRelease = new Promise<void>((resolve) => {
releaseHeldSave = resolve;
});
const bundledSkills = options.bundledSkills ?? ["paseo", "paseo-advisor", "paseo-loop"];
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-e2e-skills-"));
const targets: SkillTargets = {
sourceDir: path.join(root, "bundle"),
agentsDir: path.join(root, "home", ".agents", "skills"),
claudeDir: path.join(root, "home", ".claude", "skills"),
codexDir: path.join(root, "home", ".codex", "skills"),
};
for (const name of bundledSkills) {
await mkdir(path.join(targets.sourceDir, name), { recursive: true });
await writeFile(path.join(targets.sourceDir, name, "SKILL.md"), `# ${name}\n`, "utf8");
}
const userDataPath = path.join(root, "user-data");
if (options.installed) {
await createSkillsController({
resolveTargets: () => targets,
selectionStore: createSkillSelectionStore({ userDataPath }),
}).save(options.installed);
}
if (options.keepOnlyIn) {
const kept = targetDir(targets, options.keepOnlyIn.target);
for (const dir of [targets.agentsDir, targets.claudeDir, targets.codexDir]) {
if (dir === kept) continue;
await rm(path.join(dir, options.keepOnlyIn.skill), { recursive: true, force: true });
}
}
if (options.placeOnDisk) {
const dir = path.join(
targetDir(targets, options.placeOnDisk.target),
options.placeOnDisk.skill,
);
for (const [rel, contents] of Object.entries(options.placeOnDisk.files)) {
const file = path.join(dir, rel);
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, contents, "utf8");
}
}
if (options.blockAgentsDir) {
await mkdir(path.dirname(targets.agentsDir), { recursive: true });
await writeFile(targets.agentsDir, "not a directory", "utf8");
}
return {
targets,
userDataPath,
bundledSkills,
placeSkillOnDisk: async (skill, target) => {
const dir = path.join(targetDir(targets, target), skill);
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, "SKILL.md"), `# ${skill}\n`, "utf8");
},
commitSelectionExternally: async (selection, confirmedRemovals = []) => {
await createSkillsController({
resolveTargets: () => targets,
selectionStore: createSkillSelectionStore({ userDataPath }),
}).save({ ...selection, confirmedRemovals: [...confirmedRemovals] });
},
waitForHeldSave: () => heldSave,
releaseHeldSave: () => releaseHeldSave?.(),
holdSaveResponse: options.holdSave
? async () => {
markSaveHeld?.();
await saveRelease;
}
: undefined,
// The save path atomically renames skill directories. On Linux/overlayfs,
// recursive removal can briefly observe a repopulated directory and fail
// with ENOTEMPTY even after the command has completed. Node only retries
// that transient when maxRetries is set.
cleanup: () => rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),
};
}
/** Reads the committed selection back through the same store the app writes. */
export async function expectSavedSkillSelection(
sandbox: SkillsSandbox,
expected: SkillSelection,
): Promise<void> {
const store = createSkillSelectionStore({ userDataPath: sandbox.userDataPath });
await expect.poll(() => store.get(), { timeout: 15_000 }).toEqual(expected);
}
/**
* Routes the skills desktop commands to the real handlers in Node, leaving every
* other command on the injected desktop bridge. Call after `installDesktopRuntime`
* and before navigation.
*/
export async function serveRealSkillsCommands(page: Page, sandbox: SkillsSandbox): Promise<void> {
const handlers = createSkillsCommandHandlers({
controller: createSkillsController({
resolveTargets: () => sandbox.targets,
selectionStore: createSkillSelectionStore({ userDataPath: sandbox.userDataPath }),
}),
});
await page.exposeFunction(
"__paseoSkillsInvoke",
async (command: string, args: Record<string, unknown> | null) => {
const handler = handlers[command];
if (!handler) throw new Error(`Unknown skills command: ${command}`);
const result = await handler(args ?? undefined);
if (command === "save_skills_selection") {
await sandbox.holdSaveResponse?.();
}
return result;
},
);
await page.addInitScript((commands: readonly string[]) => {
const bridge = (window as unknown as { paseoDesktop?: { invoke: unknown } }).paseoDesktop;
if (!bridge) throw new Error("Desktop bridge must be injected before the skills bridge.");
const skillCommands = new Set(commands);
const inner = bridge.invoke as (
command: string,
args?: Record<string, unknown>,
) => Promise<unknown>;
bridge.invoke = (command: string, args?: Record<string, unknown>) =>
skillCommands.has(command)
? window.__paseoSkillsInvoke!(command, args ?? null)
: inner(command, args);
}, SKILL_COMMANDS);
}
/** Real skills commands over a temp bundle, wired to this page's desktop bridge. */
export async function startSkillsSandbox(
page: Page,
options: SkillsSandboxOptions = {},
): Promise<SkillsSandbox> {
const sandbox = await createSkillsSandbox(options);
await installDesktopRuntime(page, { serverId: getServerId(), confirmShouldAccept: false });
await serveRealSkillsCommands(page, sandbox);
return sandbox;
}
export async function openSkillsIntegrations(page: Page): Promise<void> {
await openSettings(page);
await openSettingsSection(page, "integrations");
await expect(page.getByText("Orchestration skills", { exact: true })).toBeVisible();
}
/** Settings on a compact form factor stacks list and detail, so it needs the menu first. */
export async function openCompactSkillsIntegrations(page: Page): Promise<void> {
await openCompactSettings(page, buildOpenProjectRoute());
await openSettingsSection(page, "integrations");
await expect(page.getByText("Orchestration skills", { exact: true })).toBeVisible();
}
export async function openSkillSelection(page: Page): Promise<void> {
await page.getByRole("button", { name: "Choose skills", exact: true }).click();
await expectSkillSelectionOpen(page);
}
export async function expectSkillSelectionOpen(page: Page): Promise<void> {
await expect(page.getByRole("switch", { name: "All skills", exact: true })).toBeVisible();
}
export async function expectSkillSelectionClosed(page: Page): Promise<void> {
await expect(page.getByRole("switch", { name: "All skills", exact: true })).toHaveCount(0);
}
export async function chooseAllSkills(page: Page): Promise<void> {
const allSkills = page.getByRole("switch", { name: "All skills", exact: true });
await expect(allSkills).not.toBeChecked();
await allSkills.click();
await expect(allSkills).toBeChecked();
}
export async function chooseCustomSkills(page: Page, skills: string[]): Promise<void> {
const allSkills = page.getByRole("switch", { name: "All skills", exact: true });
await expect(allSkills).toBeChecked();
await allSkills.click();
await expect(allSkills).not.toBeChecked();
const keep = new Set(skills);
for (const name of await listSkillChoices(page)) {
if (keep.has(name)) continue;
await page.getByRole("checkbox", { name, exact: true }).click();
}
await expectSkillChoices(page, skills);
}
export async function toggleSkill(page: Page, name: string): Promise<void> {
await page.getByRole("checkbox", { name, exact: true }).click();
}
export async function saveSkillSelection(page: Page): Promise<void> {
await page.getByRole("button", { name: "Save", exact: true }).click();
}
export async function cancelSkillSelection(page: Page): Promise<void> {
await page.getByRole("button", { name: "Cancel", exact: true }).click();
await expectSkillSelectionClosed(page);
}
export async function listSkillChoices(page: Page): Promise<string[]> {
const rows = page.getByRole("checkbox");
const names = await rows.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("aria-label") ?? ""),
);
return names.filter((name) => name.length > 0);
}
/** Asserts exactly which skills are checked in the open sheet. */
export async function expectSkillChoices(page: Page, skills: string[]): Promise<void> {
const expected = new Set(skills);
for (const name of await listSkillChoices(page)) {
const choice = page.getByRole("checkbox", { name, exact: true });
if (expected.has(name)) {
await expect(choice).toBeChecked();
continue;
}
await expect(choice).not.toBeChecked();
}
}
/** Reopens the saved selection and asserts what the user sees, then closes it. */
export async function expectSelectedSkills(page: Page, skills: string[]): Promise<void> {
await openSkillSelection(page);
await expect(page.getByRole("switch", { name: "All skills", exact: true })).not.toBeChecked();
await expectSkillChoices(page, skills);
await cancelSkillSelection(page);
}
export async function expectAllSkillsSelected(page: Page): Promise<void> {
await openSkillSelection(page);
const allSkills = page.getByRole("switch", { name: "All skills", exact: true });
await expect(allSkills).toBeChecked();
await expectSkillChoices(page, await listSkillChoices(page));
await cancelSkillSelection(page);
}
export async function expectSaveErrorKeepsSheetOpen(page: Page): Promise<void> {
await expect(page.getByText("Could not save your skill selection.", { exact: true })).toBeVisible(
{ timeout: 15_000 },
);
await expectSkillSelectionOpen(page);
}
/**
* The removal warning is the desktop confirm dialog, which has no DOM to query.
* The injected bridge records the call the same way the daemon-management tests
* assert their confirmation copy.
*/
export async function expectRemovalWarning(page: Page, skills: string[]): Promise<void> {
await page.waitForFunction(() => !!window.__capturedDialogCall, { timeout: 15_000 });
const call = await page.evaluate(() => window.__capturedDialogCall);
expect(call?.title).toBe("Remove deselected skills?");
for (const name of skills) {
expect(call?.message).toContain(name);
}
for (const dir of ["~/.agents", "~/.claude", "~/.codex"]) {
expect(call?.message).toContain(dir);
}
expect(call?.message).toContain("Anything you added inside those skill folders is deleted too.");
}
export async function expectNoRemovalWarning(page: Page): Promise<void> {
expect(await page.evaluate(() => window.__capturedDialogCall)).toBeUndefined();
}
export async function expectSkillPresentIn(
sandbox: SkillsSandbox,
target: SkillTargetName,
skill: string,
): Promise<void> {
await expect
.poll(() => readInstalledSkills(targetDir(sandbox.targets, target)), { timeout: 15_000 })
.toContain(skill);
}
export async function expectSkillFilePresent(
sandbox: SkillsSandbox,
target: SkillTargetName,
skill: string,
relativePath: string,
): Promise<void> {
const file = path.join(targetDir(sandbox.targets, target), skill, relativePath);
await expect
.poll(() => readFile(file, "utf8").catch(() => null), { timeout: 15_000 })
.not.toBeNull();
}
export async function expectSkillsInstalled(
sandbox: SkillsSandbox,
skills: string[],
): Promise<void> {
const dirs = [
sandbox.targets.agentsDir,
sandbox.targets.claudeDir,
sandbox.targets.codexDir,
] as const;
for (const dir of dirs) {
await expect
.poll(async () => readInstalledSkills(dir), { timeout: 15_000 })
.toEqual([...skills].sort());
}
}
async function readInstalledSkills(dir: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
}
+185
View File
@@ -0,0 +1,185 @@
import { expect, test } from "../../app/e2e/support/fixtures";
import { gotoAppShell, openSettings } from "../../app/e2e/support/helpers/app";
import { getServerId } from "../../app/e2e/support/helpers/server-id";
import {
openHostSection,
openSettingsHost,
seedSavedSettingsHosts,
} from "../../app/e2e/support/helpers/settings";
import {
loadRealDaemonState,
installDesktopRuntime,
openDesktopAboutSettings,
openDesktopSettings,
expectUpdateBanner,
clickCheckForUpdates,
expectPendingUpdateCheckResult,
clickInstallUpdate,
expectInstallInProgress,
interceptDaemonManagementConfirmDialog,
toggleDaemonManagement,
expectDaemonManagementConfirmDialog,
expectDaemonManagementEnabled,
expectDaemonManagementDisabled,
expectDaemonStatusPid,
expectDaemonStatusLogPath,
expectDaemonStatusVersion,
} from "./support/runtime";
// No Playwright Electron runner exists; we simulate the desktop bridge via
// addInitScript so Electron-gated UI activates without a real Electron process.
test.describe("Desktop updates", () => {
test("a desktop-managed daemon explains why its update action is disabled", async ({
page,
desktopManagedOutdatedDaemon,
}) => {
await seedSavedSettingsHosts(page, [desktopManagedOutdatedDaemon]);
await page.reload();
await openSettings(page);
await openSettingsHost(page, desktopManagedOutdatedDaemon.serverId);
await openHostSection(page, desktopManagedOutdatedDaemon.serverId, "host");
const updateCard = page.getByTestId("host-page-update-card");
await expect(updateCard).toBeVisible();
await expect(updateCard).toContainText(
"This daemon is managed by Paseo Desktop. Update Paseo Desktop on the host.",
);
await expect(page.getByTestId("host-page-update-button")).toBeDisabled();
});
test("update banner appears in the sidebar when an app update is available", async ({ page }) => {
await installDesktopRuntime(page, {
serverId: getServerId(),
updateAvailable: true,
latestVersion: "1.2.3",
});
await gotoAppShell(page);
await expectUpdateBanner(page, "1.2.3");
});
test("clicking install shows the installing state on the callout", async ({ page }) => {
await installDesktopRuntime(page, {
serverId: getServerId(),
updateAvailable: true,
latestVersion: "1.2.3",
slowInstall: true,
});
await gotoAppShell(page);
await expectUpdateBanner(page, "1.2.3");
await clickInstallUpdate(page);
await expectInstallInProgress(page);
});
test("manual check reports a found update while it downloads", async ({ page }) => {
await installDesktopRuntime(page, {
serverId: getServerId(),
updateAvailable: true,
latestVersion: "1.2.3",
updateReadyToInstall: false,
});
await gotoAppShell(page);
await openDesktopAboutSettings(page);
await clickCheckForUpdates(page);
await expectPendingUpdateCheckResult(page, "1.2.3");
});
});
test.describe("Desktop daemon management", () => {
test("disabling built-in daemon management shows confirm dialog with correct copy", async ({
page,
}) => {
const serverId = getServerId();
await installDesktopRuntime(page, {
serverId,
manageBuiltInDaemon: true,
confirmShouldAccept: false,
});
await gotoAppShell(page);
await openDesktopSettings(page, serverId);
const dialogArgs = await interceptDaemonManagementConfirmDialog(page);
expectDaemonManagementConfirmDialog(dialogArgs);
await expectDaemonManagementEnabled(page);
});
test("cancelling the confirm dialog leaves the daemon management toggle on", async ({ page }) => {
const serverId = getServerId();
await installDesktopRuntime(page, {
serverId,
manageBuiltInDaemon: true,
confirmShouldAccept: false,
});
await gotoAppShell(page);
await openDesktopSettings(page, serverId);
await expectDaemonManagementEnabled(page);
await toggleDaemonManagement(page, "disable");
await expectDaemonManagementEnabled(page);
});
test("confirming the dialog disables built-in daemon management", async ({ page }) => {
const serverId = getServerId();
await installDesktopRuntime(page, {
serverId,
manageBuiltInDaemon: true,
confirmShouldAccept: true,
});
await gotoAppShell(page);
await openDesktopSettings(page, serverId);
await toggleDaemonManagement(page, "disable");
await expectDaemonManagementDisabled(page);
});
test("daemon status panel renders version, PID, and log path from the real daemon", async ({
page,
}) => {
const serverId = getServerId();
const realState = await loadRealDaemonState();
await installDesktopRuntime(page, {
serverId,
manageBuiltInDaemon: false,
daemonPid: realState.pid,
daemonVersion: realState.version,
daemonLogPath: realState.logPath,
});
await gotoAppShell(page);
await openDesktopSettings(page, serverId);
await expectDaemonStatusVersion(page, realState.version);
await expectDaemonStatusPid(page, realState.pid);
await expectDaemonStatusLogPath(page, realState.logPath);
});
test("stopping and restarting the daemon updates the PID", async ({ page }) => {
const serverId = getServerId();
const realState = await loadRealDaemonState();
await installDesktopRuntime(page, {
serverId,
manageBuiltInDaemon: true,
daemonPid: realState.pid,
daemonVersion: realState.version,
daemonLogPath: realState.logPath,
confirmShouldAccept: true,
});
await gotoAppShell(page);
await openDesktopSettings(page, serverId);
await expectDaemonStatusPid(page, realState.pid);
await toggleDaemonManagement(page, "disable");
await expectDaemonManagementDisabled(page);
await expectDaemonStatusPid(page, null);
await toggleDaemonManagement(page, "enable");
await expectDaemonManagementEnabled(page);
const newPid = realState.pid !== null ? realState.pid + 1000 : 11000;
await expectDaemonStatusPid(page, newPid);
});
});
@@ -0,0 +1,120 @@
import { buildHostWorkspaceRoute } from "../../app/src/utils/host-routes";
import { expect, test, type Page } from "../../app/e2e/support/fixtures";
import { gotoAppShell } from "../../app/e2e/support/helpers/app";
import { expectWorkspaceTabVisible } from "../../app/e2e/support/helpers/archive-tab";
import { getE2EDaemonPort } from "../../app/e2e/support/helpers/daemon-port";
import { installDaemonWebSocketGate } from "../../app/e2e/support/helpers/daemon-websocket-gate";
import { expectAppRoute } from "../../app/e2e/support/helpers/route-assertions";
import { seedWorkspace } from "../../app/e2e/support/helpers/seed-client";
import { getServerId } from "../../app/e2e/support/helpers/server-id";
import { waitForWorkspaceTabsVisible } from "../../app/e2e/support/helpers/workspace-tabs";
import {
expectWorkspaceHeader,
switchWorkspaceViaSidebar,
waitForSidebarHydration,
} from "../../app/e2e/support/helpers/workspace-ui";
import { installDesktopRuntime, waitForDesktopDaemonStartRequest } from "./support/runtime";
type StartupPresentation = "splash" | "app";
declare global {
interface Window {
__paseoStartupPresentationTrace?: StartupPresentation[];
}
}
async function observeStartupPresentation(page: Page): Promise<void> {
await page.addInitScript(() => {
const trace: StartupPresentation[] = [];
window.__paseoStartupPresentationTrace = trace;
document.addEventListener("DOMContentLoaded", () => {
const recordPresentation = () => {
let presentation: StartupPresentation | null = null;
if (document.querySelector('[data-testid="startup-splash"]')) {
presentation = "splash";
} else if (
document.querySelector(
'[data-testid="workspace-header-title"], [data-testid="sidebar-settings"]',
)
) {
presentation = "app";
}
if (presentation && trace.at(-1) !== presentation) {
trace.push(presentation);
}
};
new MutationObserver(recordPresentation).observe(document.documentElement, {
childList: true,
subtree: true,
});
recordPresentation();
});
});
}
async function getStartupPresentation(page: Page): Promise<StartupPresentation[]> {
return page.evaluate(() => window.__paseoStartupPresentationTrace?.slice() ?? []);
}
async function expectWorkspaceLocation(
page: Page,
input: {
serverId: string;
workspace: Awaited<ReturnType<typeof seedWorkspace>>;
},
): Promise<void> {
await expectAppRoute(page, buildHostWorkspaceRoute(input.serverId, input.workspace.workspaceId), {
timeout: 30_000,
});
await expectWorkspaceHeader(page, {
title: input.workspace.workspaceName,
subtitle: input.workspace.projectDisplayName,
});
}
test("refresh keeps one continuous splash before restoring the desktop workspace", async ({
page,
}) => {
const serverId = getServerId();
const daemonGate = await installDaemonWebSocketGate(page);
const workspace = await seedWorkspace({ repoPrefix: "workspace-refresh-route-" });
try {
const agent = await workspace.client.createAgent({
provider: "mock",
model: "ten-second-stream",
modeId: "load-test",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `workspace-refresh-route-${Date.now()}`,
});
await installDesktopRuntime(page, {
serverId,
manageBuiltInDaemon: true,
hangDaemonStart: true,
desktopSettingsDelayMs: 250,
daemonListen: `127.0.0.1:${getE2EDaemonPort()}`,
});
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: workspace.workspaceId });
await waitForWorkspaceTabsVisible(page);
await expectWorkspaceTabVisible(page, agent.id);
await expectWorkspaceLocation(page, { serverId, workspace });
await observeStartupPresentation(page);
await daemonGate.drop();
await page.reload();
await waitForDesktopDaemonStartRequest(page);
daemonGate.restore();
await waitForSidebarHydration(page);
await expectWorkspaceLocation(page, { serverId, workspace });
await waitForWorkspaceTabsVisible(page);
expect(await getStartupPresentation(page)).toEqual(["splash", "app"]);
} finally {
daemonGate.restore();
await workspace.cleanup();
}
});
+3 -2
View File
@@ -20,9 +20,10 @@
"capture-harness": "./capture-harness/run.sh",
"dev": "./scripts/dev.sh",
"dev:win": "powershell ./scripts/dev.ps1",
"test:e2e:browser-tab-bridge": "npm run build:main && node ./scripts/browser-tab-bridge.e2e.mjs",
"test:e2e:renderer": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --config=playwright.config.ts --project=desktop",
"test:e2e:browser-tabs": "npm run build:main && node ./e2e/browser-tabs.e2e.mjs",
"verify:electron-cdp": "node ./scripts/verify-electron-cdp.mjs",
"test": "vitest run",
"test": "vitest run --exclude \"e2e/**\"",
"typecheck": "tsgo --noEmit -p tsconfig.json"
},
"dependencies": {
+30
View File
@@ -0,0 +1,30 @@
import { defineConfig, devices } from "@playwright/test";
const baseURL =
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
export default defineConfig({
testDir: "./e2e",
testMatch: ["**/*.spec.ts"],
globalSetup: "../app/e2e/support/global-setup.ts",
timeout: 60_000,
expect: {
timeout: 10_000,
},
fullyParallel: false,
workers: 1,
retries: process.env.CI ? 1 : 0,
reporter: [["list"]],
use: {
baseURL,
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: process.env.E2E_RECORD_VIDEO === "1" ? "on" : "retain-on-failure",
},
projects: [
{
name: "desktop",
use: { ...devices["Desktop Chrome"] },
},
],
});
+1 -1
View File
@@ -1,7 +1,7 @@
const fs = require("fs");
const path = require("path");
const { smokePackagedDesktopApp } = require("./smoke-packaged-desktop-app.js");
const { smokePackagedDesktopApp } = require("../e2e/packaged-app-smoke.js");
const EXECUTABLE_NAME = "Paseo";
+1 -1
View File
@@ -1,6 +1,6 @@
const path = require("node:path");
const { smokePackagedDesktopApp } = require("./smoke-packaged-desktop-app.js");
const { smokePackagedDesktopApp } = require("../e2e/packaged-app-smoke.js");
const EXECUTABLE_NAME = "Paseo";