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
@@ -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 });
});
});
+472
View File
@@ -0,0 +1,472 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import fs from "node:fs";
import { createServer } from "node:http";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { setTimeout as delay } from "node:timers/promises";
import { fileURLToPath } from "node:url";
import { experimental_createMCPClient } from "ai";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { chromium } from "playwright";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const desktopDir = path.resolve(scriptDir, "..");
const rootDir = path.resolve(desktopDir, "../..");
const devRunner = path.join(desktopDir, "scripts", "dev-runner.mjs");
const workspaceIds = [
"desktop-browser-original",
"desktop-browser-evict-one",
"desktop-browser-evict-two",
"desktop-browser-evict-three",
];
const timeoutMs = 90_000;
function assert(condition, message) {
if (!condition) throw new Error(message);
}
async function reservePort() {
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
server.close((error) => {
if (error) return reject(error);
if (!address || typeof address === "string") {
return reject(new Error("Failed to reserve a local port"));
}
resolve(address.port);
});
});
});
}
async function waitForPort(port, label, processInfo) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (
processInfo &&
(processInfo.child.exitCode !== null || processInfo.child.signalCode !== null)
) {
throw new Error(
`${label} process exited before opening its port; see ${processInfo.logPath}`,
);
}
const connected = await new Promise((resolve) => {
const socket = net.createConnection({ host: "127.0.0.1", port });
socket.setTimeout(500);
socket.once("connect", () => {
socket.destroy();
resolve(true);
});
socket.once("timeout", () => {
socket.destroy();
resolve(false);
});
socket.once("error", () => resolve(false));
});
if (connected) return;
await delay(200);
}
throw new Error(`Timed out waiting for ${label} on port ${port}`);
}
function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
}
function seedPaseoHome(paseoHome, listen, workspaceRoot) {
const timestamp = "2026-01-01T00:00:00.000Z";
const projects = workspaceIds.map((workspaceId, index) => {
const cwd = path.join(workspaceRoot, `workspace-${index + 1}`);
fs.mkdirSync(cwd, { recursive: true });
return {
projectId: `project-${workspaceId}`,
rootPath: cwd,
kind: "non_git",
displayName: `Desktop browser project ${index + 1}`,
customName: null,
createdAt: timestamp,
updatedAt: timestamp,
archivedAt: null,
};
});
const workspaces = workspaceIds.map((workspaceId, index) => ({
workspaceId,
projectId: projects[index].projectId,
cwd: projects[index].rootPath,
kind: "directory",
displayName: `Desktop browser workspace ${index + 1}`,
title: `Desktop browser workspace ${index + 1}`,
branch: null,
baseBranch: null,
createdAt: timestamp,
updatedAt: timestamp,
archivedAt: null,
pinnedAt: null,
}));
writeJson(path.join(paseoHome, "config.json"), {
version: 1,
daemon: {
listen,
relay: { enabled: false },
mcp: { enabled: true, injectIntoAgents: false },
browserTools: { enabled: true },
cors: { allowedOrigins: ["*"] },
},
});
writeJson(path.join(paseoHome, "projects", "projects.json"), projects);
writeJson(path.join(paseoHome, "projects", "workspaces.json"), workspaces);
}
function spawnLogged(name, command, args, options, logDir) {
const logPath = path.join(logDir, `${name}.log`);
const log = fs.createWriteStream(logPath, { flags: "a" });
const child = spawn(command, args, {
...options,
detached: process.platform !== "win32",
stdio: ["ignore", "pipe", "pipe"],
});
let openStreams = 2;
const closeLogStream = () => {
openStreams -= 1;
if (openStreams === 0) log.end();
};
child.stdout.pipe(log, { end: false });
child.stderr.pipe(log, { end: false });
child.stdout.once("end", closeLogStream);
child.stderr.once("end", closeLogStream);
return { child, logPath };
}
function stopProcess(child) {
if (!child?.pid || child.exitCode !== null) return;
try {
if (process.platform === "win32") child.kill("SIGTERM");
else process.kill(-child.pid, "SIGTERM");
} catch {
// The process may exit between the liveness check and signal delivery.
}
}
async function waitForAppPage(browser, expoPort) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
for (const context of browser.contexts()) {
for (const page of context.pages()) {
if (page.url().includes(`localhost:${expoPort}`)) return page;
}
}
await delay(250);
}
throw new Error("Timed out waiting for the real Electron app renderer");
}
async function waitForDesktopStatus(page) {
const deadline = Date.now() + timeoutMs;
let lastError;
while (Date.now() < deadline) {
try {
const status = await page.evaluate(async () => {
if (typeof window.paseoDesktop?.invoke !== "function") return null;
return await window.paseoDesktop.invoke("desktop_daemon_status");
});
if (typeof status?.serverId === "string") return status;
} catch (error) {
// Metro may replace the renderer execution context during its initial load.
lastError = error;
}
await delay(250);
}
throw new Error(
`Timed out waiting for the Electron desktop bridge${lastError ? `: ${String(lastError)}` : ""}`,
);
}
async function startTargetPage() {
const server = createServer((_request, response) => {
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
response.end(`<!doctype html>
<html>
<head><title>Desktop browser target</title></head>
<body>
<button id="bridge-target" onclick="this.textContent = 'Clicked'">Bridge target</button>
</body>
</html>`);
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
assert(address && typeof address !== "string", "Target page did not bind a TCP port");
return { server, url: `http://127.0.0.1:${address.port}/` };
}
async function closeServer(server) {
await new Promise((resolve) => server.close(resolve));
}
function mcpPayload(result, command) {
const payload = result.structuredContent;
assert(
payload && typeof payload === "object",
`${command} returned no structured payload: ${JSON.stringify(result)}`,
);
assert(payload.ok === true, `${command} failed: ${JSON.stringify(payload)}`);
return payload.result;
}
async function callBrowserTool(client, name, args = {}) {
return mcpPayload(await client.callTool({ name, args }), name);
}
async function createCallerAgent(daemonPort) {
const transport = new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${daemonPort}/mcp/agents`),
);
const client = await experimental_createMCPClient({ transport });
try {
const response = await client.callTool({
name: "create_agent",
args: {
relationship: { kind: "detached" },
workspace: { kind: "existing", workspaceId: workspaceIds[0] },
title: "Browser desktop browser E2E caller",
provider: "mock/ten-second-stream",
settings: { modeId: "load-test" },
initialPrompt: "Remain available while the browser bridge regression runs.",
background: true,
},
});
const result = response.structuredContent;
assert(
result && typeof result === "object",
`create_agent returned no structured payload: ${JSON.stringify(response)}`,
);
assert(typeof result.agentId === "string", "create_agent returned no caller agent id");
assert(
result.workspaceId === workspaceIds[0],
`MCP caller attached to unexpected workspace ${result.workspaceId}`,
);
return result.agentId;
} finally {
await client.close();
}
}
async function readGuest(page, browserId) {
return await page.evaluate((id) => {
const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`);
if (!(webview instanceof HTMLElement) || typeof webview.getWebContentsId !== "function") {
return null;
}
return {
webContentsId: webview.getWebContentsId(),
parentId: webview.parentElement?.id ?? null,
};
}, browserId);
}
async function runRegression({ page, client, serverId, targetUrl }) {
const originalWorkspaceId = workspaceIds[0];
const originalWorkspaceRow = page.getByTestId(
`sidebar-workspace-row-${serverId}:${originalWorkspaceId}`,
);
await originalWorkspaceRow.waitFor({ state: "visible", timeout: timeoutMs });
await originalWorkspaceRow.click();
const created = await callBrowserTool(client, "browser_new_tab", { url: targetUrl });
const browserId = created.browserId;
assert(typeof browserId === "string", "browser_new_tab returned no browserId");
const originalDeck = page.getByTestId(`workspace-deck-entry-${serverId}:${originalWorkspaceId}`);
await originalDeck.getByTestId(`workspace-tab-browser_${browserId}`).click();
await page.waitForFunction(
(id) => {
const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`);
return webview && webview.parentElement?.id !== "paseo-browser-resident-webviews";
},
browserId,
{ timeout: timeoutMs },
);
const firstGuest = await readGuest(page, browserId);
assert(firstGuest, "Original browser guest was not attached to its workspace pane");
for (const workspaceId of workspaceIds.slice(1)) {
await page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`).click();
await page
.getByTestId(`workspace-deck-entry-${serverId}:${workspaceId}`)
.waitFor({ state: "visible" });
}
await page.waitForFunction(
({ id, previousWebContentsId }) => {
const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`);
return (
webview?.parentElement?.id === "paseo-browser-resident-webviews" &&
typeof webview.getWebContentsId === "function" &&
webview.getWebContentsId() !== previousWebContentsId
);
},
{ id: browserId, previousWebContentsId: firstGuest.webContentsId },
{ timeout: timeoutMs },
);
const replacementGuest = await readGuest(page, browserId);
assert(replacementGuest, "Replacement browser guest was not parked after workspace eviction");
const listed = await callBrowserTool(client, "browser_list_tabs");
assert(
listed.tabs.some((tab) => tab.browserId === browserId),
"browser_list_tabs lost the original tab after guest replacement",
);
const snapshot = await callBrowserTool(client, "browser_snapshot", { browserId });
const ref = snapshot.snapshot.match(/button "Bridge target" \[ref=(@e\d+)\]/)?.[1];
assert(ref, `browser_snapshot did not expose the target button: ${snapshot.snapshot}`);
const clicked = await callBrowserTool(client, "browser_click", { browserId, ref });
assert(
clicked.browserId === browserId && clicked.ref === ref,
"browser_click targeted another tab",
);
await callBrowserTool(client, "browser_wait", {
browserId,
text: "Clicked",
timeoutMs: 5_000,
});
return {
browserId,
originalWebContentsId: firstGuest.webContentsId,
replacementWebContentsId: replacementGuest.webContentsId,
list: "passed",
snapshot: "passed",
click: "passed",
};
}
async function main() {
const artifactDir =
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");
const workspaceRoot = path.join(runtimeDir, "workspaces");
fs.mkdirSync(paseoHome, { recursive: true });
const [daemonPort, expoPort, cdpPort] = await Promise.all([
reservePort(),
reservePort(),
reservePort(),
]);
const listen = `127.0.0.1:${daemonPort}`;
seedPaseoHome(paseoHome, listen, workspaceRoot);
const target = await startTargetPage();
const children = [];
let browser = null;
let client = null;
try {
const commonEnv = {
...process.env,
PASEO_HOME: paseoHome,
PASEO_LISTEN: listen,
PASEO_DAEMON_ENDPOINT: `localhost:${daemonPort}`,
PASEO_CORS_ORIGINS: "*",
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0",
PASEO_DICTATION_ENABLED: "0",
PASEO_VOICE_MODE_ENABLED: "0",
FORCE_COLOR: "0",
NO_COLOR: "1",
};
const daemon = spawnLogged(
"daemon",
process.execPath,
["--import", "tsx", path.join(rootDir, "packages/server/scripts/dev-runner.ts")],
{ cwd: rootDir, env: { ...commonEnv, PASEO_NODE_ENV: "development" } },
artifactDir,
);
children.push(daemon.child);
await waitForPort(daemonPort, "daemon", daemon);
const desktopArgs = [
process.execPath,
devRunner,
...(process.platform === "linux" ? ["--no-sandbox"] : []),
];
const desktopCommand = process.platform === "linux" ? "xvfb-run" : desktopArgs.shift();
const desktopCommandArgs =
process.platform === "linux"
? ["-a", "--server-args=-screen 0 1280x800x24", ...desktopArgs]
: desktopArgs;
const desktop = spawnLogged(
"desktop",
desktopCommand,
desktopCommandArgs,
{
cwd: rootDir,
env: {
...commonEnv,
EXPO_PORT: String(expoPort),
EXPO_DEV_URL: `http://localhost:${expoPort}`,
PASEO_ELECTRON_REMOTE_DEBUGGING_PORT: String(cdpPort),
PASEO_ELECTRON_USER_DATA_DIR: userData,
PASEO_ELECTRON_FLAGS: `--remote-debugging-address=127.0.0.1 --remote-debugging-port=${cdpPort}`,
},
},
artifactDir,
);
children.push(desktop.child);
await waitForPort(cdpPort, "Electron CDP", desktop);
browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`);
const page = await waitForAppPage(browser, expoPort);
const status = await waitForDesktopStatus(page);
const callerAgentId = await createCallerAgent(daemonPort);
const transport = new StreamableHTTPClientTransport(
new URL(
`http://127.0.0.1:${daemonPort}/mcp/agents?callerAgentId=${encodeURIComponent(callerAgentId)}`,
),
);
client = await experimental_createMCPClient({ transport });
const report = await runRegression({
page,
client,
serverId: status.serverId,
targetUrl: target.url,
});
writeJson(path.join(artifactDir, "result.json"), report);
console.log(
`Browser desktop browser E2E passed: WebContents ${report.originalWebContentsId} -> ${report.replacementWebContentsId}; list, snapshot, click passed.`,
);
} catch (error) {
console.error(`Browser desktop browser E2E failed. Artifacts: ${artifactDir}`);
console.error(error);
throw error;
} finally {
await client?.close().catch(() => undefined);
await browser?.close().catch(() => undefined);
for (const child of children.toReversed()) stopProcess(child);
await closeServer(target.server);
await delay(1_000);
try {
fs.rmSync(runtimeDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch (error) {
console.warn(`Failed to remove isolated E2E state ${runtimeDir}`, error);
}
}
}
await main();
@@ -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,
});
});
});
+932
View File
@@ -0,0 +1,932 @@
const { spawn, spawnSync } = require("node:child_process");
const fs = require("node:fs");
const net = require("node:net");
const os = require("node:os");
const path = require("node:path");
const { setTimeout: delay } = require("node:timers/promises");
const { chromium } = require("playwright");
const EXECUTABLE_NAME = "Paseo";
const SMOKE_TIMEOUT_MS = 60_000;
const EXIT_TIMEOUT_MS = 10_000;
const TERMINAL_CAPTURE_ATTEMPTS = 20;
const TERMINAL_CAPTURE_INTERVAL_MS = 500;
const REQUIRED_DESKTOP_BRIDGE_KEYS = [
"platform",
"invoke",
"getPendingOpenProject",
"events",
"window",
"dialog",
"notification",
"opener",
"editor",
"webUtils",
"menu",
"browser",
];
function createTempDir(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
function assertExecutable(filePath, label) {
if (!fs.existsSync(filePath)) {
throw new Error(`${label} does not exist: ${filePath}`);
}
if (process.platform !== "win32") {
fs.accessSync(filePath, fs.constants.X_OK);
}
}
function getExecutablePath(appPath) {
if (process.platform === "darwin") {
return path.join(appPath, "Contents", "MacOS", EXECUTABLE_NAME);
}
if (process.platform === "win32") {
return path.join(appPath, `${EXECUTABLE_NAME}.exe`);
}
return path.join(appPath, EXECUTABLE_NAME);
}
function getCliShimPath(appPath) {
if (process.platform === "darwin") {
return path.join(appPath, "Contents", "Resources", "bin", "paseo");
}
if (process.platform === "win32") {
return path.join(appPath, "resources", "bin", "paseo.cmd");
}
return path.join(appPath, "resources", "bin", "paseo");
}
function getMacMainExecutablePath(appPath) {
return path.join(appPath, "Contents", "MacOS", EXECUTABLE_NAME);
}
function ensureLinuxSandboxPermissions(appPath) {
if (process.platform !== "linux") {
return;
}
const sandboxPath = path.join(appPath, "chrome-sandbox");
if (!fs.existsSync(sandboxPath)) {
throw new Error(`Chromium sandbox helper does not exist: ${sandboxPath}`);
}
const hasRequiredPermissions = () => {
const stat = fs.statSync(sandboxPath);
return stat.uid === 0 && (stat.mode & 0o7777) === 0o4755;
};
if (hasRequiredPermissions()) {
return;
}
const chown = spawnSync("sudo", ["-n", "chown", "root:root", sandboxPath], {
encoding: "utf8",
});
const chmod =
chown.status === 0
? spawnSync("sudo", ["-n", "chmod", "4755", sandboxPath], { encoding: "utf8" })
: null;
if (chown.error || chown.status !== 0 || chmod?.error || chmod?.status !== 0) {
throw new Error(
`Failed to configure Chromium sandbox helper ${sandboxPath}. Run: sudo chown root:root ${sandboxPath} && sudo chmod 4755 ${sandboxPath}.\n${chown.stderr?.trim() || chmod?.stderr?.trim() || chown.error || chmod?.error || "Permissions remained incorrect."}`,
);
}
if (!hasRequiredPermissions()) {
throw new Error(`Chromium sandbox helper permissions remained incorrect: ${sandboxPath}`);
}
}
function getLaunchCommand(executablePath) {
if (process.platform !== "linux") {
return {
command: executablePath,
args: [],
};
}
return {
command: "xvfb-run",
args: ["-a", "--server-args=-screen 0 1280x800x24", executablePath],
};
}
function shellQuote(value) {
return `'${value.replace(/'/g, "'\\''")}'`;
}
function shellQuoteCliArg(value) {
if (process.platform === "win32") {
return `"${String(value).replace(/"/g, '""')}"`;
}
return shellQuote(String(value));
}
function getTerminalHookSmokeCommand(marker) {
if (process.platform === "win32") {
const script = [
"& $env:PASEO_HOOK_CLI hooks codex Stop",
"if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }",
`Write-Output '${marker}'`,
].join("; ");
const encodedScript = Buffer.from(script, "utf16le").toString("base64");
return `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${encodedScript}`;
}
return `"$PASEO_HOOK_CLI" hooks codex Stop && echo ${marker}`;
}
function getShellCommand(script) {
if (process.platform === "win32") {
return {
command: "cmd.exe",
args: ["/d", "/c", script],
};
}
return {
command: "/bin/sh",
args: ["-lc", script],
};
}
function createDefaultDaemonEnv(extraEnv) {
const env = {
...process.env,
...extraEnv,
};
delete env.PASEO_HOME;
delete env.PASEO_LISTEN;
return env;
}
function createIsolatedDesktopEnv({ home, listen, userData, cdpPort }) {
return {
...process.env,
PASEO_HOME: home,
PASEO_LISTEN: listen,
PASEO_ELECTRON_USER_DATA_DIR: userData,
PASEO_ELECTRON_FLAGS: `--remote-debugging-address=127.0.0.1 --remote-debugging-port=${cdpPort}`,
};
}
function configureIsolatedDaemonHome(home, listen) {
fs.writeFileSync(
path.join(home, "config.json"),
`${JSON.stringify(
{
version: 1,
daemon: {
listen,
relay: { enabled: false },
mcp: { enabled: false, injectIntoAgents: false },
},
},
null,
2,
)}\n`,
);
}
function reserveLocalTcpPort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to reserve a TCP port for smoke test")));
return;
}
const { port } = address;
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve(port);
});
});
});
}
async function waitForFile(filePath, label) {
const deadline = Date.now() + SMOKE_TIMEOUT_MS;
while (Date.now() < deadline) {
if (fs.existsSync(filePath)) {
return fs.readFileSync(filePath, "utf8");
}
await delay(250);
}
throw new Error(`Timed out waiting for ${label}: ${filePath}`);
}
async function waitForChildPids(parentPid) {
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
const childPids = listChildPids(parentPid);
if (childPids.length > 0) {
return childPids;
}
await delay(250);
}
throw new Error(`Timed out waiting for daemon worker child of supervisor PID ${parentPid}`);
}
function listChildPids(parentPid) {
const result = spawnSync("ps", ["-axo", "pid=,ppid="], { encoding: "utf8" });
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(`ps failed while listing child processes: ${result.stderr.trim()}`);
}
const children = [];
for (const line of result.stdout.split(/\r?\n/)) {
const [pidText, ppidText] = line.trim().split(/\s+/);
const pid = Number(pidText);
const ppid = Number(ppidText);
if (Number.isInteger(pid) && ppid === parentPid) {
children.push(pid);
}
}
return children;
}
function listDarwinTextExecutables(pid) {
const result = spawnSync("lsof", ["-a", "-p", String(pid), "-d", "txt", "-Fn"], {
encoding: "utf8",
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(
`lsof failed while inspecting PID ${pid}: ${
result.stderr.trim() || result.stdout.trim() || "<empty>"
}`,
);
}
return result.stdout
.split(/\r?\n/)
.filter((line) => line.startsWith("n"))
.map((line) => line.slice(1));
}
function assertDarwinProcessDoesNotUseMainAppExecutable({ appPath, pid, label }) {
const mainExecutablePath = getMacMainExecutablePath(appPath);
assertExecutable(mainExecutablePath, "Packaged app executable");
const textExecutables = listDarwinTextExecutables(pid);
if (textExecutables.includes(mainExecutablePath)) {
throw new Error(
`${label} PID ${pid} launched through the main app executable ${mainExecutablePath}.\nText executables:\n${textExecutables.join(
"\n",
)}`,
);
}
}
function readIfExists(filePath) {
return fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : null;
}
function formatLogs({ stdout, stderr, userData, daemonHome }) {
const desktopLog = readIfExists(path.join(userData, "logs", "main.log"));
const daemonLog = readIfExists(path.join(daemonHome, "daemon.log"));
return [
`App stdout:\n${stdout.join("").trim() || "<empty>"}`,
`App stderr:\n${stderr.join("").trim() || "<empty>"}`,
`Desktop log:\n${desktopLog?.trim() || "<missing>"}`,
`Daemon log:\n${daemonLog?.trim() || "<missing>"}`,
].join("\n\n");
}
async function writeFailureArtifacts({ page, stdout, stderr, userData, daemonHome, error }) {
const artifactDir = process.env.PASEO_DESKTOP_SMOKE_ARTIFACT_DIR?.trim();
if (!artifactDir) {
return;
}
fs.mkdirSync(artifactDir, { recursive: true });
fs.writeFileSync(
path.join(artifactDir, "failure.txt"),
`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n\n${formatLogs({
stdout,
stderr,
userData,
daemonHome,
})}\n`,
);
const desktopLog = readIfExists(path.join(userData, "logs", "main.log"));
if (desktopLog !== null) {
fs.writeFileSync(path.join(artifactDir, "desktop-main.log"), desktopLog);
}
const daemonLog = readIfExists(path.join(daemonHome, "daemon.log"));
if (daemonLog !== null) {
fs.writeFileSync(path.join(artifactDir, "daemon.log"), daemonLog);
}
if (page) {
const renderer = await page
.evaluate(() => ({
url: window.location.href,
title: document.title,
rootChildCount: document.querySelector("#root")?.childElementCount ?? 0,
rootText: document.querySelector("#root")?.textContent?.trim().slice(0, 2_000) ?? "",
bridgeKeys:
typeof window.paseoDesktop === "object" && window.paseoDesktop !== null
? Object.keys(window.paseoDesktop)
: [],
}))
.catch((evaluationError) => ({ evaluationError: String(evaluationError) }));
fs.writeFileSync(
path.join(artifactDir, "renderer.json"),
`${JSON.stringify(renderer, null, 2)}\n`,
);
await page
.screenshot({ path: path.join(artifactDir, "renderer.png"), fullPage: true })
.catch(() => undefined);
}
}
function isRunning(child) {
return child.exitCode === null && child.signalCode === null;
}
function terminateChild(child, signal = "SIGTERM") {
if (!isRunning(child)) {
return;
}
if (process.platform === "win32" && child.pid) {
spawnSync("taskkill.exe", ["/pid", String(child.pid), "/t", "/f"], { stdio: "ignore" });
return;
}
if (child.pid) {
try {
process.kill(-child.pid, signal);
return;
} catch {}
}
child.kill(signal);
}
function waitForChildExit(child, timeoutMs = EXIT_TIMEOUT_MS) {
if (!isRunning(child)) {
return Promise.resolve(true);
}
let onExit;
const exitPromise = new Promise((resolve) => {
onExit = () => resolve(true);
child.once("exit", onExit);
});
return Promise.race([exitPromise, delay(timeoutMs, false)]).finally(() => {
child.off("exit", onExit);
});
}
function releaseChildHandles(child) {
child.stdin?.destroy();
child.stdout?.destroy();
child.stderr?.destroy();
child.unref();
}
async function removeTempDir(tempDir) {
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
fs.rmSync(tempDir, { recursive: true, force: true });
return;
} catch (error) {
if (!["EBUSY", "ENOTEMPTY", "EPERM"].includes(error?.code) || attempt === 4) {
console.warn(`Packaged desktop smoke: failed to remove temp dir ${tempDir}: ${error}`);
return;
}
await delay(250);
}
}
}
function remainingTime(deadline) {
return Math.max(1, deadline - Date.now());
}
async function connectToPackagedApp({
child,
cdpPort,
stdout,
stderr,
userData,
daemonHome,
deadline,
}) {
let lastError = null;
while (Date.now() < deadline) {
if (!isRunning(child)) {
throw new Error(
`Packaged app exited before opening its debugging endpoint (code ${child.exitCode}, signal ${
child.signalCode ?? "none"
}).\n${formatLogs({ stdout, stderr, userData, daemonHome })}`,
);
}
try {
return await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`);
} catch (error) {
lastError = error;
await delay(250);
}
}
throw new Error(
`Timed out connecting to the packaged app over CDP: ${lastError}.\n${formatLogs({
stdout,
stderr,
userData,
daemonHome,
})}`,
);
}
async function waitForPackagedAppPage(browser, deadline) {
while (Date.now() < deadline) {
const page = browser
.contexts()
.flatMap((context) => context.pages())
.find((candidate) => candidate.url().startsWith("paseo://app/"));
if (page) {
return page;
}
await delay(250);
}
throw new Error("Timed out waiting for the packaged paseo://app/ renderer");
}
async function assertPackagedRendererLoaded(page, deadline) {
await page.waitForFunction(
() => {
const root = document.querySelector("#root");
return root instanceof HTMLElement && root.childElementCount > 0;
},
undefined,
{ timeout: remainingTime(deadline) },
);
const bridgeKeys = await page.evaluate(() =>
typeof window.paseoDesktop === "object" && window.paseoDesktop !== null
? Object.keys(window.paseoDesktop)
: [],
);
const missingBridgeKeys = REQUIRED_DESKTOP_BRIDGE_KEYS.filter((key) => !bridgeKeys.includes(key));
if (missingBridgeKeys.length > 0) {
throw new Error(
`Packaged renderer is missing desktop preload bridge keys: ${missingBridgeKeys.join(", ")}. Present keys: ${bridgeKeys.join(", ") || "<none>"}`,
);
}
}
async function waitForRendererStartedDaemon({
page,
daemonHome,
listen,
stdout,
stderr,
userData,
deadline,
}) {
let lastStatus = null;
let lastError = null;
while (Date.now() < deadline) {
try {
lastStatus = await page.evaluate(() => window.paseoDesktop.invoke("desktop_daemon_status"));
if (
lastStatus?.status === "running" &&
lastStatus.desktopManaged === true &&
typeof lastStatus.pid === "number" &&
typeof lastStatus.serverId === "string" &&
lastStatus.serverId.length > 0 &&
lastStatus.listen === listen &&
path.resolve(lastStatus.home) === path.resolve(daemonHome)
) {
return lastStatus;
}
} catch (error) {
lastError = error;
}
await delay(250);
}
throw new Error(
`Packaged renderer did not start its desktop-managed daemon. Last status: ${JSON.stringify(lastStatus)}. Last error: ${lastError}.\n${formatLogs({ stdout, stderr, userData, daemonHome })}`,
);
}
function runShellCommand({ script, env, label }) {
return new Promise((resolve, reject) => {
let settled = false;
const shell = getShellCommand(script);
const child = spawn(shell.command, shell.args, {
detached: process.platform !== "win32",
env,
stdio: ["ignore", "pipe", "pipe"],
windowsVerbatimArguments: process.platform === "win32",
});
const stdout = [];
const stderr = [];
const finish = (callback, value) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
releaseChildHandles(child);
callback(value);
};
const timer = setTimeout(() => {
terminateChild(child);
finish(
reject,
new Error(
`${label} timed out.\nStdout:\n${stdout.join("").trim() || "<empty>"}\n\nStderr:\n${
stderr.join("").trim() || "<empty>"
}`,
),
);
}, SMOKE_TIMEOUT_MS);
child.stdout.on("data", (chunk) => stdout.push(chunk.toString()));
child.stderr.on("data", (chunk) => stderr.push(chunk.toString()));
child.once("error", (error) => finish(reject, error));
child.once("exit", (code, signal) => {
if (code === 0) {
finish(resolve, {
stdout: stdout.join(""),
stderr: stderr.join(""),
});
return;
}
finish(
reject,
new Error(
`${label} failed (code ${code}, signal ${signal ?? "none"}).\nStdout:\n${
stdout.join("").trim() || "<empty>"
}\n\nStderr:\n${stderr.join("").trim() || "<empty>"}`,
),
);
});
});
}
function getCliShimScript(cliShimPath, args) {
const commandArgs = args.map(shellQuoteCliArg).join(" ");
if (process.platform === "win32") {
return `call "${cliShimPath}" ${commandArgs}`;
}
return `${shellQuote(cliShimPath)} ${commandArgs}`;
}
async function runCliShimCommand({ appPath, env, args, label }) {
const cliShimPath = getCliShimPath(appPath);
assertExecutable(cliShimPath, "Bundled CLI shim");
return await runShellCommand({
script: getCliShimScript(cliShimPath, args),
env,
label,
});
}
async function runCliShimJsonCommand({ appPath, env, args, label }) {
const result = await runCliShimCommand({
appPath,
env,
args: [...args, "--json"],
label,
});
const output = result.stdout.trim();
if (!output) {
throw new Error(`${label} produced empty JSON output`);
}
try {
return JSON.parse(output);
} catch (error) {
throw new Error(`${label} produced invalid JSON: ${output}`, { cause: error });
}
}
async function smokeCliShim({ appPath, env }) {
console.log("Packaged desktop smoke: running bundled CLI shim daemon status");
const result = await runCliShimCommand({
appPath,
env,
args: ["daemon", "status"],
label: "Bundled CLI shim daemon status",
});
assertCleanDaemonStatusOutput(`${result.stdout}\n${result.stderr}`);
}
async function smokeColdCliDaemonStart({ appPath }) {
const home = createTempDir("paseo-smoke-cli-daemon-home-");
const pidPath = path.join(home, "paseo.pid");
const port = await reserveLocalTcpPort();
const listen = `127.0.0.1:${port}`;
const env = createDefaultDaemonEnv();
try {
console.log("Packaged desktop smoke: cold-starting daemon through bundled CLI shim");
await runCliShimCommand({
appPath,
env,
args: [
"daemon",
"start",
"--home",
home,
"--listen",
listen,
"--no-relay",
"--no-mcp",
"--no-inject-mcp",
],
label: "Bundled CLI shim cold daemon start",
});
const pidInfo = JSON.parse(await waitForFile(pidPath, "cold CLI daemon pid file"));
if (!pidInfo || typeof pidInfo.pid !== "number") {
throw new Error(`Cold CLI daemon wrote invalid pid file: ${JSON.stringify(pidInfo)}`);
}
if (process.platform === "darwin") {
assertDarwinProcessDoesNotUseMainAppExecutable({
appPath,
pid: pidInfo.pid,
label: "Cold CLI daemon supervisor",
});
const childPids = await waitForChildPids(pidInfo.pid);
for (const childPid of childPids) {
assertDarwinProcessDoesNotUseMainAppExecutable({
appPath,
pid: childPid,
label: "Cold CLI daemon worker",
});
}
}
} finally {
if (fs.existsSync(pidPath)) {
await runCliShimCommand({
appPath,
env,
args: ["daemon", "stop", "--home", home, "--force"],
label: "Bundled CLI shim cold daemon stop",
}).catch((error) => {
console.warn(`Packaged desktop smoke: failed to stop cold CLI daemon: ${error}`);
});
}
await removeTempDir(home);
}
}
function assertCleanDaemonStatusOutput(output) {
const failureNeedles = [
"Get-CimInstance",
"Get-Process :",
"Cannot bind parameter",
"wmic failed",
];
const failure = failureNeedles.find((needle) => output.includes(needle));
if (failure) {
throw new Error(`Bundled CLI shim daemon status included process lookup failure: ${failure}`);
}
}
async function smokeCliTerminal({ appPath, env }) {
const cwd = createTempDir("paseo-smoke-terminal-cwd-");
const marker = `paseo-packaged-terminal-smoke-${Date.now()}`;
const name = `packaged-smoke-${process.pid}-${Date.now()}`;
let terminalId = null;
try {
console.log("Packaged desktop smoke: creating terminal through bundled CLI shim");
const created = await runCliShimJsonCommand({
appPath,
env,
args: ["terminal", "create", "--cwd", cwd, "--name", name],
label: "Bundled CLI shim terminal create",
});
if (!created || typeof created.id !== "string" || created.id.length === 0) {
throw new Error(`Terminal create returned unexpected payload: ${JSON.stringify(created)}`);
}
terminalId = created.id;
const terminals = await runCliShimJsonCommand({
appPath,
env,
args: ["terminal", "ls", "--all"],
label: "Bundled CLI shim terminal ls",
});
if (!Array.isArray(terminals) || !terminals.some((terminal) => terminal.id === terminalId)) {
throw new Error(`Terminal ${terminalId} was not listed after create`);
}
await runCliShimJsonCommand({
appPath,
env,
args: ["terminal", "send-keys", terminalId, getTerminalHookSmokeCommand(marker), "Enter"],
label: "Bundled CLI shim terminal hook command",
});
for (let attempt = 1; attempt <= TERMINAL_CAPTURE_ATTEMPTS; attempt += 1) {
const capture = await runCliShimJsonCommand({
appPath,
env,
args: ["terminal", "capture", terminalId, "--scrollback"],
label: "Bundled CLI shim terminal capture",
});
const lines = Array.isArray(capture?.lines) ? capture.lines : [];
if (lines.join("\n").includes(marker)) {
console.log("Packaged desktop smoke: terminal hook command completed");
return;
}
if (attempt < TERMINAL_CAPTURE_ATTEMPTS) {
await delay(TERMINAL_CAPTURE_INTERVAL_MS);
}
}
throw new Error(`Timed out waiting for terminal capture marker ${marker}`);
} finally {
if (terminalId) {
await runCliShimJsonCommand({
appPath,
env,
args: ["terminal", "kill", terminalId],
label: "Bundled CLI shim terminal kill",
}).catch((error) => {
console.warn(`Packaged desktop smoke: failed to kill terminal ${terminalId}: ${error}`);
});
}
await removeTempDir(cwd);
}
}
async function stopCliDaemon({ appPath, env }) {
console.log("Packaged desktop smoke: stopping daemon through bundled CLI shim");
await runCliShimCommand({
appPath,
env,
args: ["daemon", "stop", "--force"],
label: "Bundled CLI shim daemon stop",
});
}
async function smokePackagedDesktopApp({ appPath }) {
const executablePath = getExecutablePath(appPath);
assertExecutable(executablePath, "Packaged app executable");
ensureLinuxSandboxPermissions(appPath);
await smokeColdCliDaemonStart({ appPath });
const userData = createTempDir("paseo-smoke-user-data-");
const daemonHome = createTempDir("paseo-smoke-daemon-home-");
const daemonPort = await reserveLocalTcpPort();
let cdpPort = await reserveLocalTcpPort();
for (let attempt = 0; cdpPort === daemonPort && attempt < 10; attempt += 1) {
cdpPort = await reserveLocalTcpPort();
}
if (cdpPort === daemonPort) {
throw new Error("Failed to reserve distinct TCP ports for the daemon and CDP");
}
const listen = `127.0.0.1:${daemonPort}`;
configureIsolatedDaemonHome(daemonHome, listen);
const env = createIsolatedDesktopEnv({
home: daemonHome,
listen,
userData,
cdpPort,
});
const stdout = [];
const stderr = [];
const launch = getLaunchCommand(executablePath);
console.log(`Packaged desktop smoke: launching ${launch.command} ${launch.args.join(" ")}`);
const child = spawn(launch.command, launch.args, {
detached: process.platform !== "win32",
env,
stdio: ["pipe", "pipe", "pipe"],
});
child.stdout.on("data", (chunk) => stdout.push(chunk.toString()));
child.stderr.on("data", (chunk) => stderr.push(chunk.toString()));
child.once("error", (error) =>
stderr.push(`Packaged app launch error: ${error.stack ?? error}\n`),
);
const deadline = Date.now() + SMOKE_TIMEOUT_MS;
let browser = null;
let page = null;
let daemonStopped = false;
const stopDaemonForCleanup = async () => {
if (daemonStopped) {
return;
}
await stopCliDaemon({ appPath, env });
daemonStopped = true;
};
try {
browser = await connectToPackagedApp({
child,
cdpPort,
stdout,
stderr,
userData,
daemonHome,
deadline,
});
page = await waitForPackagedAppPage(browser, deadline);
await assertPackagedRendererLoaded(page, deadline);
console.log("Packaged desktop smoke: real app renderer and preload bridge loaded");
const status = await waitForRendererStartedDaemon({
page,
daemonHome,
listen,
stdout,
stderr,
userData,
deadline,
});
console.log("Packaged desktop smoke: renderer-started desktop daemon reported running");
await smokeCliShim({ appPath, env });
await smokeCliTerminal({ appPath, env });
await stopDaemonForCleanup();
console.log(
`Packaged desktop smoke passed: real renderer and preload loaded; renderer-started desktop daemon pid ${status.pid}, listen ${status.listen}; CLI shim daemon status and terminal smoke succeeded`,
);
} catch (error) {
await writeFailureArtifacts({ page, stdout, stderr, userData, daemonHome, error }).catch(
(artifactError) => {
console.warn(`Packaged desktop smoke: failed to write failure artifacts: ${artifactError}`);
},
);
if (!daemonStopped) {
try {
await stopDaemonForCleanup();
} catch {}
}
throw error;
} finally {
await browser?.close().catch(() => undefined);
if (isRunning(child)) {
terminateChild(child);
if (!(await waitForChildExit(child))) {
terminateChild(child, "SIGKILL");
await waitForChildExit(child);
}
}
releaseChildHandles(child);
await removeTempDir(userData);
await removeTempDir(daemonHome);
}
}
module.exports = {
smokePackagedDesktopApp,
};
if (require.main === module) {
const appIndex = process.argv.indexOf("--app");
const appPath = appIndex >= 0 ? process.argv[appIndex + 1] : null;
if (!appPath) {
process.stderr.write("Usage: node smoke-packaged-desktop-app.js --app <Paseo.app>\n");
process.exit(2);
}
smokePackagedDesktopApp({ appPath }).catch((error) => {
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
process.stderr.write(`${message}\n`);
process.exit(1);
});
}
@@ -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();
}
});