Files
paseo/packages/app/e2e/support/helpers/agent-skills.ts
T
95774ba71f Show each Paseo skill once in Codex (#5827)
* fix(skills): install one Codex-discoverable orchestration copy

* fix(skills): keep installed skills when agent skill roots are symlinked together

Retiring the old Codex copy deleted the skills just installed when
~/.codex/skills resolved to ~/.agents/skills or ~/.claude/skills (or the
reverse), and status reported drift forever. The Codex skill directory is
now treated as a leftover only when it is a separate directory from the
shared and Claude copies.

Co-authored-by: 3ae3ae <ogyshjsm@naver.com>

* test(e2e): expect agent skills in the shared and Claude roots only

The skills settings spec still expected every skill in ~/.codex/skills.
It now checks the two install roots and that ~/.codex/skills holds no
Paseo skill, which fails against the previous installer.

---------

Co-authored-by: 3ae3ae <ogyshjsm@naver.com>
Co-authored-by: Bae Seongmin <83870598+3ae3ae@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-01 12:04:05 +02:00

248 lines
8.4 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { expect, type Dialog, type Page } from "@playwright/test";
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AgentSkillSelection } from "@getpaseo/protocol/messages";
import { buildOpenProjectRoute } from "@/utils/host-routes";
import { gotoAppShell, openSettings } from "./app";
import { connectDaemonClient } from "./daemon-client-loader";
import { startIsolatedHostDaemon, type IsolatedHostDaemon } from "./isolated-host-daemon";
import {
addDirectHostFromSettings,
goBackInSettings,
openCompactSettings,
openHostSection,
selectSettingsHost,
} from "./settings";
type AgentSkillsClient = Pick<
InternalDaemonClient,
"close" | "connect" | "getAgentSkillsStatus" | "saveAgentSkillsSelection"
>;
export type SkillTargetName = "agents" | "claude";
export interface AgentSkillsSandbox {
available: string[];
client: AgentSkillsClient;
daemon: IsolatedHostDaemon;
home: string;
targets: Record<SkillTargetName, string>;
codexSkillsDir: string;
blockAgentsDirectory(): Promise<void>;
unblockAgentsDirectory(): Promise<void>;
close(): Promise<void>;
getStatus(): ReturnType<AgentSkillsClient["getAgentSkillsStatus"]>;
install(selection: AgentSkillSelection): Promise<void>;
keepSkillOnlyIn(skill: string, target: SkillTargetName): Promise<void>;
placeSkillOnDisk(
skill: string,
target: SkillTargetName,
files?: Record<string, string>,
): Promise<void>;
}
export async function startAgentSkillsSandbox(): Promise<AgentSkillsSandbox> {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-agent-skills-e2e-"));
const home = path.join(root, "home");
await mkdir(home, { recursive: true });
const daemon = await startIsolatedHostDaemon(`agent-skills-${randomUUID()}`, {
environment: {
HOME: home,
NODE_ENV: "development",
CLAUDE_CONFIG_DIR: path.join(root, "ignored-claude-config"),
CODEX_HOME: path.join(root, "ignored-codex-home"),
},
});
const client = await connectDaemonClient<AgentSkillsClient>({
clientIdPrefix: "app-e2e-agent-skills",
port: daemon.port,
});
const available = (await client.getAgentSkillsStatus()).available;
const targets = {
agents: path.join(home, ".agents", "skills"),
claude: path.join(home, ".claude", "skills"),
};
const codexSkillsDir = path.join(home, ".codex", "skills");
return {
available,
client,
daemon,
home,
targets,
codexSkillsDir,
blockAgentsDirectory: async () => {
await mkdir(path.dirname(targets.agents), { recursive: true });
await writeFile(targets.agents, "not a directory", "utf8");
},
unblockAgentsDirectory: async () => {
await rm(targets.agents, { force: true });
await mkdir(targets.agents, { recursive: true });
},
install: async (selection) => {
const result = await client.saveAgentSkillsSelection(selection, available);
if (result.confirmationRequired) {
throw new Error(`Unexpected removal confirmation: ${result.confirmationRequired.removals}`);
}
},
keepSkillOnlyIn: async (skill, target) => {
for (const [name, directory] of Object.entries(targets)) {
if (name === target) continue;
await rm(path.join(directory, skill), { recursive: true, force: true });
}
},
placeSkillOnDisk: async (skill, target, files = { "SKILL.md": `# ${skill}\n` }) => {
const directory = path.join(targets[target], skill);
for (const [relativePath, contents] of Object.entries(files)) {
const file = path.join(directory, relativePath);
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, contents, "utf8");
}
},
close: async () => {
await client.close().catch(() => undefined);
await daemon.close().catch(() => undefined);
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
},
getStatus: async () => {
try {
return await client.getAgentSkillsStatus();
} catch {
await client.connect();
return client.getAgentSkillsStatus();
}
},
};
}
export async function openAgentSkillsSettings(
page: Page,
sandbox: AgentSkillsSandbox,
options: { compact?: boolean } = {},
): Promise<void> {
await gotoAppShell(page);
if (options.compact) {
await openCompactSettings(page, buildOpenProjectRoute());
} else {
await openSettings(page);
}
await addDirectHostFromSettings(page, {
host: "127.0.0.1",
port: sandbox.daemon.port,
});
if (options.compact) await goBackInSettings(page);
await selectSettingsHost(page, sandbox.daemon.serverId);
await openHostSection(page, sandbox.daemon.serverId, "agents");
await expect(page.getByTestId("host-agent-skills-card")).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 chooseCustomSkills(page: Page, selected: string[]): Promise<void> {
const all = page.getByRole("switch", { name: "All skills", exact: true });
await expect(all).toBeChecked();
await all.click();
const keep = new Set(selected);
for (const name of await listSkillChoices(page)) {
if (!keep.has(name)) await page.getByRole("checkbox", { name, exact: true }).click();
}
await expectSkillChoices(page, selected);
}
export async function chooseAllSkills(page: Page): Promise<void> {
const all = page.getByRole("switch", { name: "All skills", exact: true });
await expect(all).not.toBeChecked();
await all.click();
await expect(all).toBeChecked();
}
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 expect(page.getByRole("switch", { name: "All skills", exact: true })).toHaveCount(0);
}
export async function toggleSkill(page: Page, name: string): Promise<void> {
await page.getByRole("checkbox", { name, exact: true }).click();
}
export function answerNextRemovalWarning(
page: Page,
answer: "accept" | "dismiss",
): Promise<Dialog> {
return new Promise((resolve) => {
page.once("dialog", (dialog) => {
resolve(dialog);
void (answer === "accept" ? dialog.accept() : dialog.dismiss());
});
});
}
export async function expectInstalledSkills(
sandbox: AgentSkillsSandbox,
expected: string[],
): Promise<void> {
for (const directory of Object.values(sandbox.targets)) {
await expect.poll(() => readInstalledSkills(directory)).toEqual([...expected].sort());
}
await expect.poll(() => readInstalledSkills(sandbox.codexSkillsDir)).toEqual([]);
}
export async function expectSavedSelection(
sandbox: AgentSkillsSandbox,
expected: AgentSkillSelection,
): Promise<void> {
await expect.poll(async () => (await sandbox.getStatus()).selection).toEqual(expected);
}
export async function expectSkillFile(
sandbox: AgentSkillsSandbox,
target: SkillTargetName,
skill: string,
relativePath: string,
): Promise<void> {
await expect
.poll(() => readFile(path.join(sandbox.targets[target], skill, relativePath), "utf8"))
.not.toBe("");
}
export async function expectSkillChoices(page: Page, selected: string[]): Promise<void> {
const expected = new Set(selected);
for (const name of await listSkillChoices(page)) {
await expect(page.getByRole("checkbox", { name, exact: true })).toBeChecked({
checked: expected.has(name),
});
}
}
async function listSkillChoices(page: Page): Promise<string[]> {
return page
.getByRole("checkbox")
.evaluateAll((elements) =>
elements
.map((element) => element.getAttribute("aria-label") ?? "")
.filter((name) => name.length > 0),
);
}
async function readInstalledSkills(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
}