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:
@@ -2434,7 +2434,7 @@ async function runAutomationGroup() {
|
||||
// 1px resident host, and Electron does not propagate CSS-box resizes to a
|
||||
// parked guest's capture surface (see docs/browser-capture-harness.md).
|
||||
// The production resize path is app-owned webview sizing, covered by
|
||||
// packages/app/src/browser-automation/handler.test.ts.
|
||||
// packages/app/src/desktop/browser/automation/handler.test.ts.
|
||||
|
||||
await fsp.writeFile(
|
||||
path.join(OUT_DIR, "automation-results.json"),
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { expect, test, type Page } from "../../app/e2e/support/fixtures";
|
||||
import { gotoAppShell } from "../../app/e2e/support/helpers/app";
|
||||
import {
|
||||
readRails,
|
||||
readSheetHeaderRails,
|
||||
readVerticalGap,
|
||||
waitForSettledPosition,
|
||||
} from "../../app/e2e/support/helpers/sheet-layout";
|
||||
import {
|
||||
openCompactSkillsIntegrations,
|
||||
openSkillSelection,
|
||||
openSkillsIntegrations,
|
||||
startSkillsSandbox,
|
||||
type SkillsSandbox,
|
||||
} from "./support/skills-selection";
|
||||
|
||||
const COMPACT_VIEWPORT = { width: 390, height: 844 };
|
||||
const DESKTOP_VIEWPORT = { width: 1280, height: 900 };
|
||||
|
||||
const sandboxes: SkillsSandbox[] = [];
|
||||
|
||||
test.afterEach(async () => {
|
||||
for (const sandbox of sandboxes.splice(0)) {
|
||||
await sandbox.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function openChooseSkillsOnPhone(page: Page): Promise<void> {
|
||||
sandboxes.push(await startSkillsSandbox(page));
|
||||
await gotoAppShell(page);
|
||||
await openCompactSkillsIntegrations(page);
|
||||
await openSkillSelection(page);
|
||||
await waitForSettledPosition(page.getByTestId("skill-selection-sheet"));
|
||||
}
|
||||
|
||||
async function openChooseSkillsOnDesktop(page: Page): Promise<void> {
|
||||
sandboxes.push(await startSkillsSandbox(page));
|
||||
await gotoAppShell(page);
|
||||
await openSkillsIntegrations(page);
|
||||
await openSkillSelection(page);
|
||||
}
|
||||
|
||||
function sheetHeaderRails(page: Page) {
|
||||
return readSheetHeaderRails(page, "skill-selection-sheet", "Choose skills");
|
||||
}
|
||||
|
||||
function allSkillsCard(page: Page) {
|
||||
return page.getByTestId("skill-selection-all-card");
|
||||
}
|
||||
|
||||
function skillListCard(page: Page) {
|
||||
return page.getByTestId("skill-selection-list-card");
|
||||
}
|
||||
|
||||
function bundledSkillsLabel(page: Page) {
|
||||
return page.getByText("Bundled skills", { exact: true });
|
||||
}
|
||||
|
||||
test.describe("Choose skills sheet on a compact form factor", () => {
|
||||
test.use({ viewport: COMPACT_VIEWPORT });
|
||||
|
||||
test("keeps its cards on the sheet header rails", async ({ page }) => {
|
||||
await openChooseSkillsOnPhone(page);
|
||||
|
||||
const header = await sheetHeaderRails(page);
|
||||
|
||||
expect(await readRails(allSkillsCard(page))).toEqual(header);
|
||||
expect(await readRails(skillListCard(page))).toEqual(header);
|
||||
});
|
||||
|
||||
test("gives the Bundled skills label more room above it than below", async ({ page }) => {
|
||||
await openChooseSkillsOnPhone(page);
|
||||
|
||||
expect({
|
||||
aboveLabel: await readVerticalGap(allSkillsCard(page), bundledSkillsLabel(page)),
|
||||
belowLabel: await readVerticalGap(bundledSkillsLabel(page), skillListCard(page)),
|
||||
}).toEqual({ aboveLabel: 16, belowLabel: 12 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Choose skills sheet on a desktop form factor", () => {
|
||||
test.use({ viewport: DESKTOP_VIEWPORT });
|
||||
|
||||
test("keeps its cards on the sheet header rails", async ({ page }) => {
|
||||
await openChooseSkillsOnDesktop(page);
|
||||
|
||||
const header = await sheetHeaderRails(page);
|
||||
|
||||
expect(await readRails(allSkillsCard(page))).toEqual(header);
|
||||
expect(await readRails(skillListCard(page))).toEqual(header);
|
||||
});
|
||||
|
||||
test("gives the Bundled skills label more room above it than below", async ({ page }) => {
|
||||
await openChooseSkillsOnDesktop(page);
|
||||
|
||||
expect({
|
||||
aboveLabel: await readVerticalGap(allSkillsCard(page), bundledSkillsLabel(page)),
|
||||
belowLabel: await readVerticalGap(bundledSkillsLabel(page), skillListCard(page)),
|
||||
}).toEqual({ aboveLabel: 16, belowLabel: 12 });
|
||||
});
|
||||
});
|
||||
+14
-14
@@ -18,10 +18,10 @@ const desktopDir = path.resolve(scriptDir, "..");
|
||||
const rootDir = path.resolve(desktopDir, "../..");
|
||||
const devRunner = path.join(desktopDir, "scripts", "dev-runner.mjs");
|
||||
const workspaceIds = [
|
||||
"tab-bridge-original",
|
||||
"tab-bridge-evict-one",
|
||||
"tab-bridge-evict-two",
|
||||
"tab-bridge-evict-three",
|
||||
"desktop-browser-original",
|
||||
"desktop-browser-evict-one",
|
||||
"desktop-browser-evict-two",
|
||||
"desktop-browser-evict-three",
|
||||
];
|
||||
const timeoutMs = 90_000;
|
||||
|
||||
@@ -91,7 +91,7 @@ function seedPaseoHome(paseoHome, listen, workspaceRoot) {
|
||||
projectId: `project-${workspaceId}`,
|
||||
rootPath: cwd,
|
||||
kind: "non_git",
|
||||
displayName: `Tab bridge project ${index + 1}`,
|
||||
displayName: `Desktop browser project ${index + 1}`,
|
||||
customName: null,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
@@ -103,8 +103,8 @@ function seedPaseoHome(paseoHome, listen, workspaceRoot) {
|
||||
projectId: projects[index].projectId,
|
||||
cwd: projects[index].rootPath,
|
||||
kind: "directory",
|
||||
displayName: `Tab bridge workspace ${index + 1}`,
|
||||
title: `Tab bridge workspace ${index + 1}`,
|
||||
displayName: `Desktop browser workspace ${index + 1}`,
|
||||
title: `Desktop browser workspace ${index + 1}`,
|
||||
branch: null,
|
||||
baseBranch: null,
|
||||
createdAt: timestamp,
|
||||
@@ -196,7 +196,7 @@ async function startTargetPage() {
|
||||
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
||||
response.end(`<!doctype html>
|
||||
<html>
|
||||
<head><title>Tab bridge target</title></head>
|
||||
<head><title>Desktop browser target</title></head>
|
||||
<body>
|
||||
<button id="bridge-target" onclick="this.textContent = 'Clicked'">Bridge target</button>
|
||||
</body>
|
||||
@@ -240,7 +240,7 @@ async function createCallerAgent(daemonPort) {
|
||||
args: {
|
||||
relationship: { kind: "detached" },
|
||||
workspace: { kind: "existing", workspaceId: workspaceIds[0] },
|
||||
title: "Browser tab bridge E2E caller",
|
||||
title: "Browser desktop browser E2E caller",
|
||||
provider: "mock/ten-second-stream",
|
||||
settings: { modeId: "load-test" },
|
||||
initialPrompt: "Remain available while the browser bridge regression runs.",
|
||||
@@ -356,9 +356,9 @@ async function runRegression({ page, client, serverId, targetUrl }) {
|
||||
|
||||
async function main() {
|
||||
const artifactDir =
|
||||
process.env.PASEO_TAB_BRIDGE_E2E_ARTIFACT_DIR ??
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), "paseo-tab-bridge-e2e-artifacts-"));
|
||||
const runtimeDir = fs.mkdtempSync(path.join(os.tmpdir(), "paseo-tab-bridge-e2e-"));
|
||||
process.env.PASEO_DESKTOP_BROWSER_E2E_ARTIFACT_DIR ??
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), "paseo-desktop-browser-e2e-artifacts-"));
|
||||
const runtimeDir = fs.mkdtempSync(path.join(os.tmpdir(), "paseo-desktop-browser-e2e-"));
|
||||
fs.mkdirSync(artifactDir, { recursive: true });
|
||||
const paseoHome = path.join(runtimeDir, "paseo-home");
|
||||
const userData = path.join(runtimeDir, "electron-user-data");
|
||||
@@ -449,10 +449,10 @@ async function main() {
|
||||
});
|
||||
writeJson(path.join(artifactDir, "result.json"), report);
|
||||
console.log(
|
||||
`Browser tab bridge E2E passed: WebContents ${report.originalWebContentsId} -> ${report.replacementWebContentsId}; list, snapshot, click passed.`,
|
||||
`Browser desktop browser E2E passed: WebContents ${report.originalWebContentsId} -> ${report.replacementWebContentsId}; list, snapshot, click passed.`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Browser tab bridge E2E failed. Artifacts: ${artifactDir}`);
|
||||
console.error(`Browser desktop browser E2E failed. Artifacts: ${artifactDir}`);
|
||||
console.error(error);
|
||||
throw error;
|
||||
} finally {
|
||||
@@ -0,0 +1,42 @@
|
||||
import { test } from "../../app/e2e/support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../../app/e2e/support/helpers/app";
|
||||
import { getServerId } from "../../app/e2e/support/helpers/server-id";
|
||||
import { expectLocalHostEntryFirst } from "../../app/e2e/support/helpers/settings";
|
||||
|
||||
test("sidebar pins the local desktop daemon host first", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
await page.addInitScript((localServerId) => {
|
||||
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
|
||||
platform: "darwin",
|
||||
invoke: async (command: string) => {
|
||||
if (command === "desktop_daemon_status") {
|
||||
return {
|
||||
serverId: localServerId,
|
||||
status: "running",
|
||||
listen: null,
|
||||
hostname: null,
|
||||
pid: null,
|
||||
home: "",
|
||||
version: null,
|
||||
desktopManaged: true,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
if (command === "get_desktop_settings") {
|
||||
return {
|
||||
releaseChannel: "stable",
|
||||
daemon: { manageBuiltInDaemon: false, keepRunningAfterQuit: true },
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
getPendingOpenProject: async () => null,
|
||||
events: { on: async () => () => undefined },
|
||||
};
|
||||
}, serverId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await expectLocalHostEntryFirst(page, serverId);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { readFile, rm } from "node:fs/promises";
|
||||
import { expect, test, type Page } from "../../app/e2e/support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../../app/e2e/support/helpers/app";
|
||||
import { installDesktopRuntime } from "./support/runtime";
|
||||
import { clickSettingsBackToWorkspace } from "../../app/e2e/support/helpers/settings";
|
||||
|
||||
interface EditorOpenRecord {
|
||||
editorId: string;
|
||||
workspacePath: string;
|
||||
filePath?: string;
|
||||
line?: number;
|
||||
column?: number;
|
||||
}
|
||||
|
||||
function requireE2EEnv(name: string): string {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) {
|
||||
throw new Error(`${name} is not set.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function readEditorOpenRecords(recordPath: string): Promise<EditorOpenRecord[]> {
|
||||
try {
|
||||
const contents = await readFile(recordPath, "utf8");
|
||||
return contents
|
||||
.split("\n")
|
||||
.filter((line) => line.trim().length > 0)
|
||||
.map((line) => JSON.parse(line) as EditorOpenRecord);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function chooseEditorTarget(page: Page, targetId: "vscode"): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-open-in-editor-primary")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByTestId("workspace-open-in-editor-caret").click();
|
||||
await expect(page.getByTestId("workspace-open-in-editor-menu")).toBeVisible();
|
||||
await page.getByTestId(`workspace-open-in-editor-item-${targetId}`).click();
|
||||
}
|
||||
|
||||
async function expectEditorOpened(input: {
|
||||
recordPath: string;
|
||||
editorId: string;
|
||||
path: string;
|
||||
afterCount: number;
|
||||
}): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const records = await readEditorOpenRecords(input.recordPath);
|
||||
return records
|
||||
.slice(input.afterCount)
|
||||
.some(
|
||||
(record) => record.editorId === input.editorId && record.workspacePath === input.path,
|
||||
);
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
test.describe("Workspace open in editor", () => {
|
||||
test("keeps the selected editor target after leaving and returning to the workspace", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const serverId = requireE2EEnv("E2E_SERVER_ID");
|
||||
const recordPath = requireE2EEnv("E2E_EDITOR_RECORD_PATH");
|
||||
await rm(recordPath, { force: true });
|
||||
await installDesktopRuntime(page, {
|
||||
serverId,
|
||||
editorTargets: [
|
||||
{
|
||||
id: "cursor",
|
||||
label: "Cursor",
|
||||
kind: "editor",
|
||||
icon: { kind: "symbol", name: "terminal" },
|
||||
},
|
||||
{
|
||||
id: "vscode",
|
||||
label: "VS Code",
|
||||
kind: "editor",
|
||||
icon: { kind: "symbol", name: "terminal" },
|
||||
},
|
||||
],
|
||||
editorRecordPath: recordPath,
|
||||
});
|
||||
|
||||
const workspace = await withWorkspace({ prefix: "workspace-editor-target-" });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await chooseEditorTarget(page, "vscode");
|
||||
await expectEditorOpened({
|
||||
recordPath,
|
||||
editorId: "vscode",
|
||||
path: workspace.repoPath,
|
||||
afterCount: 0,
|
||||
});
|
||||
const recordsAfterSelection = (await readEditorOpenRecords(recordPath)).length;
|
||||
|
||||
await openSettings(page);
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
|
||||
await page.getByTestId("workspace-open-in-editor-primary").click();
|
||||
await expectEditorOpened({
|
||||
recordPath,
|
||||
editorId: "vscode",
|
||||
path: workspace.repoPath,
|
||||
afterCount: recordsAfterSelection,
|
||||
});
|
||||
const recordsAfterReturnOpen = (await readEditorOpenRecords(recordPath)).length;
|
||||
|
||||
await gotoAppShell(page);
|
||||
await workspace.navigateTo();
|
||||
await page.getByTestId("workspace-open-in-editor-primary").click();
|
||||
await expectEditorOpened({
|
||||
recordPath,
|
||||
editorId: "vscode",
|
||||
path: workspace.repoPath,
|
||||
afterCount: recordsAfterReturnOpen,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { expect, test } from "../../app/e2e/support/fixtures";
|
||||
import {
|
||||
startIsolatedHostDaemon,
|
||||
type IsolatedHostDaemon,
|
||||
} from "../../app/e2e/support/helpers/isolated-host-daemon";
|
||||
import { connectDaemonClient } from "../../app/e2e/support/helpers/daemon-client-loader";
|
||||
import {
|
||||
closePairDeviceModal,
|
||||
declineRelay,
|
||||
enableRelayAndExpectFailure,
|
||||
enableRelayAndExpectOffer,
|
||||
expectDaemonPidUnchanged,
|
||||
expectPairingOffer,
|
||||
expectPairingDisconnected,
|
||||
expectRelayUpdateRequired,
|
||||
expectRelayConsent,
|
||||
openPairDeviceModal,
|
||||
openPairDeviceFromHome,
|
||||
openRelaySecurityDocs,
|
||||
observePairingOfferRequests,
|
||||
prepareLocalPairingHost,
|
||||
reloadAndOpenPairDevice,
|
||||
retryRelayAndExpectFailure,
|
||||
switchPairDeviceToHost,
|
||||
} from "../../app/e2e/support/helpers/pair-device";
|
||||
|
||||
interface RelayConfigDaemonClient {
|
||||
close(): Promise<void>;
|
||||
connect(): Promise<void>;
|
||||
patchDaemonConfig(config: { relay: { enabled: boolean } }): Promise<unknown>;
|
||||
}
|
||||
|
||||
test.describe("local device relay pairing", () => {
|
||||
let relayOffDaemon: IsolatedHostDaemon;
|
||||
let relayEnableDaemon: IsolatedHostDaemon;
|
||||
let relayOverrideDaemon: IsolatedHostDaemon;
|
||||
let relayEnabledDaemon: IsolatedHostDaemon;
|
||||
let invalidRelayDaemon: IsolatedHostDaemon;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
[
|
||||
relayOffDaemon,
|
||||
relayEnableDaemon,
|
||||
relayOverrideDaemon,
|
||||
relayEnabledDaemon,
|
||||
invalidRelayDaemon,
|
||||
] = await Promise.all([
|
||||
startIsolatedHostDaemon("pair-device-relay-off", {
|
||||
mutableRelay: { enabled: false },
|
||||
}),
|
||||
startIsolatedHostDaemon("pair-device-relay-enable", {
|
||||
mutableRelay: { enabled: false },
|
||||
}),
|
||||
startIsolatedHostDaemon("pair-device-relay-override"),
|
||||
startIsolatedHostDaemon("pair-device-relay-enabled", {
|
||||
mutableRelay: { enabled: true },
|
||||
}),
|
||||
startIsolatedHostDaemon("pair-device-relay-invalid", {
|
||||
mutableRelay: { enabled: false, endpoint: "invalid-endpoint" },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await Promise.all([
|
||||
relayOffDaemon.close(),
|
||||
relayEnableDaemon.close(),
|
||||
relayOverrideDaemon.close(),
|
||||
relayEnabledDaemon.close(),
|
||||
invalidRelayDaemon.close(),
|
||||
]);
|
||||
});
|
||||
|
||||
test("asks for consent and creates no QR when declined", async ({ page }) => {
|
||||
await prepareLocalPairingHost(page, relayOffDaemon);
|
||||
await openPairDeviceModal(page);
|
||||
await expectRelayConsent(page);
|
||||
await declineRelay(page);
|
||||
await openPairDeviceModal(page);
|
||||
await expectRelayConsent(page);
|
||||
});
|
||||
|
||||
test("opens relay security documentation through the desktop opener", async ({ page }) => {
|
||||
await prepareLocalPairingHost(page, relayOffDaemon);
|
||||
await openPairDeviceModal(page);
|
||||
await openRelaySecurityDocs(page);
|
||||
});
|
||||
|
||||
test("opens the same relay consent dialog from the home screen", async ({ page }) => {
|
||||
await prepareLocalPairingHost(page, relayOffDaemon);
|
||||
await openPairDeviceFromHome(page);
|
||||
const modal = page.getByTestId("open-project-pair-device-modal");
|
||||
await expect(modal.getByText("Enable relay?", { exact: true })).toBeVisible();
|
||||
await expect(modal.getByRole("button", { name: "Enable relay", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows an actionable error when the daemon disconnects", async ({ page }) => {
|
||||
const daemon = await startIsolatedHostDaemon("pair-device-disconnect", {
|
||||
mutableRelay: { enabled: false },
|
||||
});
|
||||
try {
|
||||
await prepareLocalPairingHost(page, daemon);
|
||||
await openPairDeviceModal(page);
|
||||
await expectRelayConsent(page);
|
||||
await daemon.close();
|
||||
await expectPairingDisconnected(page);
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("pairs a device through the selected remote host", async ({ page }) => {
|
||||
await prepareLocalPairingHost(page, relayOffDaemon, [
|
||||
{
|
||||
serverId: relayEnabledDaemon.serverId,
|
||||
label: "Remote pairing host",
|
||||
endpoint: `127.0.0.1:${relayEnabledDaemon.port}`,
|
||||
},
|
||||
]);
|
||||
await switchPairDeviceToHost(page, relayEnabledDaemon.serverId);
|
||||
await openPairDeviceModal(page);
|
||||
await expectPairingOffer(page);
|
||||
});
|
||||
|
||||
test("enables relay live and keeps the saved offer after reload", async ({ page }) => {
|
||||
const daemonPid = relayEnableDaemon.getPid();
|
||||
await prepareLocalPairingHost(page, relayEnableDaemon);
|
||||
await openPairDeviceModal(page);
|
||||
await enableRelayAndExpectOffer(page);
|
||||
expectDaemonPidUnchanged(daemonPid, relayEnableDaemon.getPid());
|
||||
await closePairDeviceModal(page);
|
||||
await reloadAndOpenPairDevice(page);
|
||||
await expectPairingOffer(page);
|
||||
});
|
||||
|
||||
test("keeps consent actionable when a launch override rejects enable", async ({ page }) => {
|
||||
await prepareLocalPairingHost(page, relayOverrideDaemon);
|
||||
await openPairDeviceModal(page);
|
||||
await expectRelayConsent(page);
|
||||
await enableRelayAndExpectFailure(page);
|
||||
await retryRelayAndExpectFailure(page);
|
||||
});
|
||||
|
||||
test("keeps consent actionable when relay transport startup fails", async ({ page }) => {
|
||||
await prepareLocalPairingHost(page, invalidRelayDaemon);
|
||||
await openPairDeviceModal(page);
|
||||
await expectRelayConsent(page);
|
||||
await enableRelayAndExpectFailure(page, "Invalid host:port");
|
||||
await retryRelayAndExpectFailure(page, "Invalid host:port");
|
||||
});
|
||||
|
||||
test("shows an offer immediately when relay is already enabled", async ({ page }) => {
|
||||
await prepareLocalPairingHost(page, relayEnabledDaemon);
|
||||
await openPairDeviceModal(page);
|
||||
await expectPairingOffer(page);
|
||||
});
|
||||
|
||||
test("refreshes the mounted offer when another client disables relay", async ({ page }) => {
|
||||
const daemon = await startIsolatedHostDaemon("pair-device-live-config-push", {
|
||||
mutableRelay: { enabled: true },
|
||||
});
|
||||
const client = await connectDaemonClient<RelayConfigDaemonClient>({
|
||||
clientIdPrefix: "pair-device-live-config-push",
|
||||
port: daemon.port,
|
||||
});
|
||||
try {
|
||||
await prepareLocalPairingHost(page, daemon);
|
||||
await openPairDeviceModal(page);
|
||||
await expectPairingOffer(page);
|
||||
await client.patchDaemonConfig({ relay: { enabled: false } });
|
||||
await expectRelayConsent(page);
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("asks for a host update when live relay config is unsupported", async ({
|
||||
page,
|
||||
relayConfigOutdatedDaemon,
|
||||
}) => {
|
||||
const expectNoUnsupportedRequest = observePairingOfferRequests(page, relayConfigOutdatedDaemon);
|
||||
await prepareLocalPairingHost(page, relayConfigOutdatedDaemon);
|
||||
await openPairDeviceModal(page);
|
||||
await expectRelayUpdateRequired(page);
|
||||
expectNoUnsupportedRequest();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { test, expect } from "../../app/e2e/support/fixtures";
|
||||
import { gotoAppShell } from "../../app/e2e/support/helpers/app";
|
||||
import {
|
||||
expectNewWorkspaceForAddedProject,
|
||||
openAddProjectFlow,
|
||||
} from "../../app/e2e/support/helpers/add-project-flow";
|
||||
import { getE2EDaemonPort } from "../../app/e2e/support/helpers/daemon-port";
|
||||
import { waitForConnectedHost } from "../../app/e2e/support/helpers/hosts";
|
||||
import { expectOpenedProject } from "../../app/e2e/support/helpers/project-picker-ui";
|
||||
import { getServerId } from "../../app/e2e/support/helpers/server-id";
|
||||
import { connectSeedClient } from "../../app/e2e/support/helpers/seed-client";
|
||||
import { installDesktopRuntime, waitForDirectoryDialog } from "./support/runtime";
|
||||
|
||||
test("Browse opens the folder selected by the desktop dialog", async ({
|
||||
page,
|
||||
projectPickerFixture,
|
||||
}) => {
|
||||
await installDesktopRuntime(page, {
|
||||
serverId: getServerId(),
|
||||
manageBuiltInDaemon: false,
|
||||
dialogOpenResult: projectPickerFixture.projectPath,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await waitForConnectedHost(page, {
|
||||
serverId: getServerId(),
|
||||
endpoint: `localhost:${getE2EDaemonPort()}`,
|
||||
});
|
||||
|
||||
await openAddProjectFlow(page);
|
||||
const browse = page.getByRole("button", { name: /^Browse/ });
|
||||
await expect(browse).toBeVisible({ timeout: 30_000 });
|
||||
await browse.click();
|
||||
|
||||
const projectId = await expectOpenedProject(page, projectPickerFixture.projectName);
|
||||
projectPickerFixture.rememberProjectId(projectId);
|
||||
await expectNewWorkspaceForAddedProject(page, {
|
||||
serverId: getServerId(),
|
||||
projectId,
|
||||
projectName: projectPickerFixture.projectName,
|
||||
projectPath: projectPickerFixture.projectPath,
|
||||
});
|
||||
const client = await connectSeedClient();
|
||||
try {
|
||||
expect((await client.fetchWorkspaces({ filter: { projectId } })).entries).toEqual([]);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("canceling Browse returns to the Add Project methods", async ({
|
||||
page,
|
||||
projectPickerFixture,
|
||||
}) => {
|
||||
await installDesktopRuntime(page, {
|
||||
serverId: getServerId(),
|
||||
manageBuiltInDaemon: false,
|
||||
dialogOpenResult: null,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await waitForConnectedHost(page, {
|
||||
serverId: getServerId(),
|
||||
endpoint: `localhost:${getE2EDaemonPort()}`,
|
||||
});
|
||||
|
||||
await openAddProjectFlow(page);
|
||||
const browse = page.getByRole("button", { name: /^Browse/ });
|
||||
await expect(browse).toBeVisible({ timeout: 30_000 });
|
||||
await browse.click();
|
||||
|
||||
const dialogOptions = await waitForDirectoryDialog(page);
|
||||
expect(dialogOptions).toEqual({
|
||||
createDirectory: true,
|
||||
directory: true,
|
||||
multiple: false,
|
||||
});
|
||||
await expect(browse).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: projectPickerFixture.projectName }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { test, expect } from "../../app/e2e/support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../../app/e2e/support/helpers/app";
|
||||
|
||||
test.describe("Settings sidebar scrolling", () => {
|
||||
test.use({ viewport: { width: 900, height: 260 } });
|
||||
|
||||
test("desktop drag region does not cover the scroll body", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
window.paseoDesktop = {
|
||||
platform: "darwin",
|
||||
events: { on: () => () => {} },
|
||||
invoke: async (command: string) => {
|
||||
if (command === "get_desktop_settings") {
|
||||
return {
|
||||
releaseChannel: "stable",
|
||||
daemon: { manageBuiltInDaemon: true, keepRunningAfterQuit: true },
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
const geometry = await sidebar.evaluate((node) => {
|
||||
let scroller: HTMLElement | null = null;
|
||||
for (const element of node.querySelectorAll<HTMLElement>("*")) {
|
||||
if (element.scrollHeight > element.clientHeight) {
|
||||
scroller = element;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!scroller) return null;
|
||||
|
||||
const scrollerRect = scroller.getBoundingClientRect();
|
||||
const dragRegions = [];
|
||||
for (const element of node.querySelectorAll<HTMLElement>("*")) {
|
||||
if (getComputedStyle(element).getPropertyValue("-webkit-app-region") === "drag") {
|
||||
const rect = element.getBoundingClientRect();
|
||||
dragRegions.push({ bottom: rect.bottom });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
scrollBodyTop: scrollerRect.top,
|
||||
dragRegions,
|
||||
};
|
||||
});
|
||||
|
||||
expect(geometry).not.toBeNull();
|
||||
expect(geometry!.dragRegions).not.toEqual([]);
|
||||
for (const dragRegion of geometry!.dragRegions) {
|
||||
expect(dragRegion.bottom).toBeLessThanOrEqual(geometry!.scrollBodyTop + 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,333 @@
|
||||
import { test as base } from "../../app/e2e/support/fixtures";
|
||||
import { expect } from "@playwright/test";
|
||||
import { gotoAppShell } from "../../app/e2e/support/helpers/app";
|
||||
import { installDesktopRuntime } from "./support/runtime";
|
||||
import { getServerId } from "../../app/e2e/support/helpers/server-id";
|
||||
import {
|
||||
cancelSkillSelection,
|
||||
chooseAllSkills,
|
||||
chooseCustomSkills,
|
||||
createSkillsSandbox,
|
||||
expectAllSkillsSelected,
|
||||
expectNoRemovalWarning,
|
||||
expectRemovalWarning,
|
||||
expectSavedSkillSelection,
|
||||
expectSaveErrorKeepsSheetOpen,
|
||||
expectSelectedSkills,
|
||||
expectSkillChoices,
|
||||
expectSkillFilePresent,
|
||||
expectSkillPresentIn,
|
||||
expectSkillSelectionOpen,
|
||||
expectSkillsInstalled,
|
||||
openSkillsIntegrations,
|
||||
openSkillSelection,
|
||||
saveSkillSelection,
|
||||
toggleSkill,
|
||||
serveRealSkillsCommands,
|
||||
type SkillsSandbox,
|
||||
type SkillsSandboxOptions,
|
||||
} from "./support/skills-selection";
|
||||
|
||||
interface SkillsSandboxSetup extends SkillsSandboxOptions {
|
||||
/** What the desktop confirm dialog answers when the removal warning fires. */
|
||||
confirmRemoval?: boolean;
|
||||
}
|
||||
|
||||
type StartSkills = (setup?: SkillsSandboxSetup) => Promise<SkillsSandbox>;
|
||||
|
||||
// Every skills command runs against the real desktop handlers over a temp bundle
|
||||
// and temp user data, so the assertions cover persistence and convergence, not a
|
||||
// browser stand-in for them.
|
||||
const test = base.extend<{ startSkills: StartSkills }>({
|
||||
startSkills: async ({ page }, provide) => {
|
||||
const sandboxes: SkillsSandbox[] = [];
|
||||
await provide(async (setup = {}) => {
|
||||
const { confirmRemoval, ...options } = setup;
|
||||
const sandbox = await createSkillsSandbox(options);
|
||||
await installDesktopRuntime(page, {
|
||||
serverId: getServerId(),
|
||||
confirmShouldAccept: confirmRemoval ?? false,
|
||||
});
|
||||
await serveRealSkillsCommands(page, sandbox);
|
||||
sandboxes.push(sandbox);
|
||||
return sandbox;
|
||||
});
|
||||
for (const sandbox of sandboxes) {
|
||||
await sandbox.cleanup();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
test.describe("Choosing installed skills", () => {
|
||||
test("installs only the skills selected in Settings", async ({ page, startSkills }) => {
|
||||
const skills = await startSkills();
|
||||
await gotoAppShell(page);
|
||||
await openSkillsIntegrations(page);
|
||||
|
||||
await openSkillSelection(page);
|
||||
await chooseCustomSkills(page, ["paseo", "paseo-loop"]);
|
||||
await saveSkillSelection(page);
|
||||
|
||||
await expectSkillsInstalled(skills, ["paseo", "paseo-loop"]);
|
||||
await expectSelectedSkills(page, ["paseo", "paseo-loop"]);
|
||||
});
|
||||
|
||||
test("all skills includes every available skill", async ({ page, startSkills }) => {
|
||||
const skills = await startSkills();
|
||||
await gotoAppShell(page);
|
||||
await openSkillsIntegrations(page);
|
||||
await openSkillSelection(page);
|
||||
await chooseCustomSkills(page, ["paseo"]);
|
||||
await saveSkillSelection(page);
|
||||
|
||||
await openSkillSelection(page);
|
||||
await chooseAllSkills(page);
|
||||
await saveSkillSelection(page);
|
||||
|
||||
await expectSkillsInstalled(skills, skills.bundledSkills);
|
||||
await expectAllSkillsSelected(page);
|
||||
});
|
||||
|
||||
test("cancelling leaves the installed skills untouched", async ({ page, startSkills }) => {
|
||||
const skills = await startSkills();
|
||||
await gotoAppShell(page);
|
||||
await openSkillsIntegrations(page);
|
||||
await openSkillSelection(page);
|
||||
await chooseCustomSkills(page, ["paseo"]);
|
||||
await saveSkillSelection(page);
|
||||
|
||||
await openSkillSelection(page);
|
||||
await toggleSkill(page, "paseo-loop");
|
||||
await cancelSkillSelection(page);
|
||||
|
||||
await expectSkillsInstalled(skills, ["paseo"]);
|
||||
await expectSelectedSkills(page, ["paseo"]);
|
||||
});
|
||||
|
||||
test("a failed save keeps the sheet open with an error", async ({ page, startSkills }) => {
|
||||
const skills = await startSkills({ blockAgentsDir: true });
|
||||
await gotoAppShell(page);
|
||||
await openSkillsIntegrations(page);
|
||||
|
||||
await openSkillSelection(page);
|
||||
await chooseCustomSkills(page, ["paseo"]);
|
||||
await saveSkillSelection(page);
|
||||
|
||||
await expectSaveErrorKeepsSheetOpen(page);
|
||||
await expectSkillsInstalled(skills, []);
|
||||
});
|
||||
|
||||
test("an empty selection does not offer an Install action that cannot install anything", async ({
|
||||
page,
|
||||
startSkills,
|
||||
}) => {
|
||||
await startSkills({ installed: { mode: "all" }, confirmRemoval: true });
|
||||
await gotoAppShell(page);
|
||||
await openSkillsIntegrations(page);
|
||||
|
||||
await openSkillSelection(page);
|
||||
await chooseCustomSkills(page, []);
|
||||
await saveSkillSelection(page);
|
||||
|
||||
// The remaining Install action belongs to the command-line integration.
|
||||
await expect(page.getByRole("button", { name: "Install", exact: true })).toHaveCount(1);
|
||||
await expect(page.getByRole("button", { name: "Choose skills", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("a selection containing only retired skills does not offer a no-op Install", async ({
|
||||
page,
|
||||
startSkills,
|
||||
}) => {
|
||||
await startSkills({ installed: { mode: "custom", skills: ["retired-skill"] } });
|
||||
await gotoAppShell(page);
|
||||
await openSkillsIntegrations(page);
|
||||
|
||||
// The remaining Install action belongs to the command-line integration.
|
||||
await expect(page.getByRole("button", { name: "Install", exact: true })).toHaveCount(1);
|
||||
await expect(page.getByRole("button", { name: "Choose skills", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("an empty bundled catalog does not offer a no-op Install", async ({ page, startSkills }) => {
|
||||
await startSkills({ bundledSkills: [] });
|
||||
await gotoAppShell(page);
|
||||
await openSkillsIntegrations(page);
|
||||
|
||||
// The remaining Install action belongs to the command-line integration.
|
||||
await expect(page.getByRole("button", { name: "Install", exact: true })).toHaveCount(1);
|
||||
await expect(page.getByRole("button", { name: "Choose skills", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("cannot dismiss the sheet while Save is pending", async ({ page, startSkills }) => {
|
||||
const skills = await startSkills({ holdSave: true });
|
||||
await gotoAppShell(page);
|
||||
await openSkillsIntegrations(page);
|
||||
await openSkillSelection(page);
|
||||
await chooseCustomSkills(page, ["paseo"]);
|
||||
|
||||
await saveSkillSelection(page);
|
||||
await skills.waitForHeldSave();
|
||||
await page.keyboard.press("Escape");
|
||||
await expectSkillSelectionOpen(page);
|
||||
|
||||
skills.releaseHeldSave();
|
||||
await expect(page.getByRole("switch", { name: "All skills", exact: true })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Removing an installed skill", () => {
|
||||
test("delete-only drift stays in Choose skills instead of Update", async ({
|
||||
page,
|
||||
startSkills,
|
||||
}) => {
|
||||
await startSkills({
|
||||
installed: { mode: "custom", skills: ["paseo"] },
|
||||
placeOnDisk: {
|
||||
skill: "paseo-loop",
|
||||
target: "claude",
|
||||
files: { "SKILL.md": "# paseo-loop\n", "notes/mine.md": "my notes" },
|
||||
},
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await openSkillsIntegrations(page);
|
||||
|
||||
await expect(page.getByText("Update available", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Update", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Choose skills", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("warns before deleting the skill folders", async ({ page, startSkills }) => {
|
||||
const skills = await startSkills({ installed: { mode: "all" }, confirmRemoval: false });
|
||||
await gotoAppShell(page);
|
||||
await openSkillsIntegrations(page);
|
||||
|
||||
await openSkillSelection(page);
|
||||
await chooseCustomSkills(page, ["paseo", "paseo-advisor"]);
|
||||
await saveSkillSelection(page);
|
||||
|
||||
await expectRemovalWarning(page, ["paseo-loop"]);
|
||||
await expectSkillSelectionOpen(page);
|
||||
await expectSkillsInstalled(skills, skills.bundledSkills);
|
||||
await expectSavedSkillSelection(skills, { mode: "all" });
|
||||
});
|
||||
|
||||
test("confirming the warning removes the skill", async ({ page, startSkills }) => {
|
||||
const skills = await startSkills({ installed: { mode: "all" }, confirmRemoval: true });
|
||||
await gotoAppShell(page);
|
||||
await openSkillsIntegrations(page);
|
||||
|
||||
await openSkillSelection(page);
|
||||
await chooseCustomSkills(page, ["paseo", "paseo-advisor"]);
|
||||
await saveSkillSelection(page);
|
||||
|
||||
await expectRemovalWarning(page, ["paseo-loop"]);
|
||||
await expectSkillsInstalled(skills, ["paseo", "paseo-advisor"]);
|
||||
await expectSelectedSkills(page, ["paseo", "paseo-advisor"]);
|
||||
});
|
||||
|
||||
test("warns for a skill installed in only one agent directory", async ({ page, startSkills }) => {
|
||||
const skills = await startSkills({
|
||||
installed: { mode: "all" },
|
||||
keepOnlyIn: { skill: "paseo-loop", target: "claude" },
|
||||
confirmRemoval: false,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await openSkillsIntegrations(page);
|
||||
|
||||
await openSkillSelection(page);
|
||||
await chooseCustomSkills(page, ["paseo", "paseo-advisor"]);
|
||||
await saveSkillSelection(page);
|
||||
|
||||
await expectRemovalWarning(page, ["paseo-loop"]);
|
||||
await expectSkillSelectionOpen(page);
|
||||
await expectSkillPresentIn(skills, "claude", "paseo-loop");
|
||||
await expectSavedSkillSelection(skills, { mode: "all" });
|
||||
});
|
||||
|
||||
test("warns for a skill on disk that the saved selection never mentioned", async ({
|
||||
page,
|
||||
startSkills,
|
||||
}) => {
|
||||
// The committed preference excludes paseo-loop, but the directory is back —
|
||||
// a manual reinstall or an external sync. Saving any edit deletes it.
|
||||
const skills = await startSkills({
|
||||
installed: { mode: "custom", skills: ["paseo"] },
|
||||
placeOnDisk: {
|
||||
skill: "paseo-loop",
|
||||
target: "claude",
|
||||
files: { "SKILL.md": "# paseo-loop\n", "notes/mine.md": "my notes" },
|
||||
},
|
||||
confirmRemoval: false,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await openSkillsIntegrations(page);
|
||||
|
||||
await openSkillSelection(page);
|
||||
await toggleSkill(page, "paseo-advisor");
|
||||
await saveSkillSelection(page);
|
||||
|
||||
await expectRemovalWarning(page, ["paseo-loop"]);
|
||||
await expectSkillSelectionOpen(page);
|
||||
await expectSkillFilePresent(skills, "claude", "paseo-loop", "notes/mine.md");
|
||||
await expectSavedSkillSelection(skills, { mode: "custom", skills: ["paseo"] });
|
||||
});
|
||||
|
||||
test("warns about a directory that appears after the sheet was opened", async ({
|
||||
page,
|
||||
startSkills,
|
||||
}) => {
|
||||
const skills = await startSkills({
|
||||
installed: { mode: "custom", skills: ["paseo"] },
|
||||
confirmRemoval: false,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await openSkillsIntegrations(page);
|
||||
await openSkillSelection(page);
|
||||
|
||||
// Nothing to delete when the sheet opened; the host looks again at save time.
|
||||
await skills.placeSkillOnDisk("paseo-loop", "claude");
|
||||
await toggleSkill(page, "paseo-advisor");
|
||||
await saveSkillSelection(page);
|
||||
|
||||
await expectRemovalWarning(page, ["paseo-loop"]);
|
||||
await expectSkillSelectionOpen(page);
|
||||
await expectSkillPresentIn(skills, "claude", "paseo-loop");
|
||||
await expectSavedSkillSelection(skills, { mode: "custom", skills: ["paseo"] });
|
||||
});
|
||||
|
||||
test("keeps draft choices when fresher status arrives while the sheet is open", async ({
|
||||
page,
|
||||
startSkills,
|
||||
}) => {
|
||||
const skills = await startSkills({ installed: { mode: "all" }, confirmRemoval: false });
|
||||
await gotoAppShell(page);
|
||||
await openSkillsIntegrations(page);
|
||||
await openSkillSelection(page);
|
||||
await chooseCustomSkills(page, ["paseo", "paseo-advisor"]);
|
||||
|
||||
// Another window commits a different selection. Saving picks that up, so the
|
||||
// sheet is handed a selection it did not start from while an edit is open.
|
||||
await skills.commitSelectionExternally({ mode: "custom", skills: ["paseo", "paseo-loop"] }, [
|
||||
"paseo-advisor",
|
||||
]);
|
||||
await saveSkillSelection(page);
|
||||
await expectRemovalWarning(page, ["paseo-loop"]);
|
||||
|
||||
await expectSkillChoices(page, ["paseo", "paseo-advisor"]);
|
||||
});
|
||||
|
||||
test("adding a skill saves without a warning", async ({ page, startSkills }) => {
|
||||
const skills = await startSkills({
|
||||
installed: { mode: "custom", skills: ["paseo"] },
|
||||
confirmRemoval: false,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await openSkillsIntegrations(page);
|
||||
|
||||
await openSkillSelection(page);
|
||||
await toggleSkill(page, "paseo-loop");
|
||||
await saveSkillSelection(page);
|
||||
|
||||
await expectSkillsInstalled(skills, ["paseo", "paseo-loop"]);
|
||||
await expectNoRemovalWarning(page);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -20,9 +20,10 @@
|
||||
"capture-harness": "./capture-harness/run.sh",
|
||||
"dev": "./scripts/dev.sh",
|
||||
"dev:win": "powershell ./scripts/dev.ps1",
|
||||
"test:e2e:browser-tab-bridge": "npm run build:main && node ./scripts/browser-tab-bridge.e2e.mjs",
|
||||
"test:e2e:renderer": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --config=playwright.config.ts --project=desktop",
|
||||
"test:e2e:browser-tabs": "npm run build:main && node ./e2e/browser-tabs.e2e.mjs",
|
||||
"verify:electron-cdp": "node ./scripts/verify-electron-cdp.mjs",
|
||||
"test": "vitest run",
|
||||
"test": "vitest run --exclude \"e2e/**\"",
|
||||
"typecheck": "tsgo --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const baseURL =
|
||||
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
testMatch: ["**/*.spec.ts"],
|
||||
globalSetup: "../app/e2e/support/global-setup.ts",
|
||||
timeout: 60_000,
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
},
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
reporter: [["list"]],
|
||||
use: {
|
||||
baseURL,
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: process.env.E2E_RECORD_VIDEO === "1" ? "on" : "retain-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "desktop",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const { smokePackagedDesktopApp } = require("./smoke-packaged-desktop-app.js");
|
||||
const { smokePackagedDesktopApp } = require("../e2e/packaged-app-smoke.js");
|
||||
|
||||
const EXECUTABLE_NAME = "Paseo";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const path = require("node:path");
|
||||
|
||||
const { smokePackagedDesktopApp } = require("./smoke-packaged-desktop-app.js");
|
||||
const { smokePackagedDesktopApp } = require("../e2e/packaged-app-smoke.js");
|
||||
|
||||
const EXECUTABLE_NAME = "Paseo";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user