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:
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { readFile, rm } from "node:fs/promises";
|
||||
import { expect, test, type Page } from "../../app/e2e/support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../../app/e2e/support/helpers/app";
|
||||
import { installDesktopRuntime } from "./support/runtime";
|
||||
import { clickSettingsBackToWorkspace } from "../../app/e2e/support/helpers/settings";
|
||||
|
||||
interface EditorOpenRecord {
|
||||
editorId: string;
|
||||
workspacePath: string;
|
||||
filePath?: string;
|
||||
line?: number;
|
||||
column?: number;
|
||||
}
|
||||
|
||||
function requireE2EEnv(name: string): string {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) {
|
||||
throw new Error(`${name} is not set.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function readEditorOpenRecords(recordPath: string): Promise<EditorOpenRecord[]> {
|
||||
try {
|
||||
const contents = await readFile(recordPath, "utf8");
|
||||
return contents
|
||||
.split("\n")
|
||||
.filter((line) => line.trim().length > 0)
|
||||
.map((line) => JSON.parse(line) as EditorOpenRecord);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function chooseEditorTarget(page: Page, targetId: "vscode"): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-open-in-editor-primary")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByTestId("workspace-open-in-editor-caret").click();
|
||||
await expect(page.getByTestId("workspace-open-in-editor-menu")).toBeVisible();
|
||||
await page.getByTestId(`workspace-open-in-editor-item-${targetId}`).click();
|
||||
}
|
||||
|
||||
async function expectEditorOpened(input: {
|
||||
recordPath: string;
|
||||
editorId: string;
|
||||
path: string;
|
||||
afterCount: number;
|
||||
}): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const records = await readEditorOpenRecords(input.recordPath);
|
||||
return records
|
||||
.slice(input.afterCount)
|
||||
.some(
|
||||
(record) => record.editorId === input.editorId && record.workspacePath === input.path,
|
||||
);
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
test.describe("Workspace open in editor", () => {
|
||||
test("keeps the selected editor target after leaving and returning to the workspace", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const serverId = requireE2EEnv("E2E_SERVER_ID");
|
||||
const recordPath = requireE2EEnv("E2E_EDITOR_RECORD_PATH");
|
||||
await rm(recordPath, { force: true });
|
||||
await installDesktopRuntime(page, {
|
||||
serverId,
|
||||
editorTargets: [
|
||||
{
|
||||
id: "cursor",
|
||||
label: "Cursor",
|
||||
kind: "editor",
|
||||
icon: { kind: "symbol", name: "terminal" },
|
||||
},
|
||||
{
|
||||
id: "vscode",
|
||||
label: "VS Code",
|
||||
kind: "editor",
|
||||
icon: { kind: "symbol", name: "terminal" },
|
||||
},
|
||||
],
|
||||
editorRecordPath: recordPath,
|
||||
});
|
||||
|
||||
const workspace = await withWorkspace({ prefix: "workspace-editor-target-" });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await chooseEditorTarget(page, "vscode");
|
||||
await expectEditorOpened({
|
||||
recordPath,
|
||||
editorId: "vscode",
|
||||
path: workspace.repoPath,
|
||||
afterCount: 0,
|
||||
});
|
||||
const recordsAfterSelection = (await readEditorOpenRecords(recordPath)).length;
|
||||
|
||||
await openSettings(page);
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
|
||||
await page.getByTestId("workspace-open-in-editor-primary").click();
|
||||
await expectEditorOpened({
|
||||
recordPath,
|
||||
editorId: "vscode",
|
||||
path: workspace.repoPath,
|
||||
afterCount: recordsAfterSelection,
|
||||
});
|
||||
const recordsAfterReturnOpen = (await readEditorOpenRecords(recordPath)).length;
|
||||
|
||||
await gotoAppShell(page);
|
||||
await workspace.navigateTo();
|
||||
await page.getByTestId("workspace-open-in-editor-primary").click();
|
||||
await expectEditorOpened({
|
||||
recordPath,
|
||||
editorId: "vscode",
|
||||
path: workspace.repoPath,
|
||||
afterCount: recordsAfterReturnOpen,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user