Files
paseo/packages/desktop/e2e/support/skills-selection.ts
T
Mohamed BoudraandGitHub 5a30affd7f 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
2026-08-02 00:56:05 +08:00

411 lines
15 KiB
TypeScript

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();
}