Manage orchestration skills per host (#3451)
* feat(skills): manage orchestration skills per host Agent skills execute on the daemon host, so their bundle, selection, and filesystem convergence must share that owner. Preserve the existing install semantics while letting every capable client manage the selected host and migrating the legacy desktop preference once. * fix(client): keep skill management internal Keep host skill management on the low-level daemon client used by the app. Do not expose daemon settings or the desktop-only compatibility migration through the shared Paseo API handed to plugins. * fix(e2e): declare skills fixture dependencies Use Playwright's required destructuring form for the test-scoped daemon sandbox fixture so CI can collect the real-host skills suite. * test(skills): use native managed paths Assert fixed skill targets relative to the daemon user's real home with platform-native path joining so the ownership contract holds on Windows and POSIX hosts.
This commit is contained in:
@@ -1,77 +0,0 @@
|
||||
import { expect, test, type Page } from "../../app/e2e/support/fixtures";
|
||||
import { gotoAppShell } from "../../app/e2e/support/helpers/app";
|
||||
import {
|
||||
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 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("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("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 });
|
||||
});
|
||||
});
|
||||
@@ -1,333 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,410 +0,0 @@
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user