Files
tkmind_go/ui/goose2/tests/e2e/fixtures/tauri-mock.ts
T
2026-05-05 15:20:49 +00:00

445 lines
16 KiB
TypeScript

/**
* Playwright custom fixture that injects a Tauri IPC mock into the page
* before every navigation. This allows E2E tests to run against the frontend
* without the real Tauri backend.
*
* Also installs a `window.WebSocket` stub for the ACP connection so features
* like skills (which use `client.extMethod("_goose/sources/...")`) can run
* without a live goose-acp server.
*/
import { test as base, expect, type Page } from "@playwright/test";
import { MOCK_PERSONAS, MOCK_PROJECTS, MOCK_SKILLS } from "./mock-data";
/**
* Build the init script that will be injected into the page via
* `page.addInitScript()`. The script sets up `window.__TAURI_INTERNALS__`
* with an `invoke` handler that returns mock data for every Tauri command
* the app is known to call, plus a WebSocket mock for ACP traffic.
*
* Callers can override the default personas and skills arrays to test
* empty-state or custom scenarios.
*/
export function buildInitScript(options?: {
personas?: unknown[];
skills?: unknown[];
projects?: unknown[];
}): string {
const personas = JSON.stringify(options?.personas ?? MOCK_PERSONAS);
const skills = JSON.stringify(options?.skills ?? MOCK_SKILLS);
const projects = JSON.stringify(options?.projects ?? MOCK_PROJECTS);
return `
(() => {
const PERSONAS = ${personas};
const SKILLS = ${skills};
const PROJECTS = ${projects};
const DISTRO = {
present: false,
};
const FAKE_ACP_URL = "ws://127.0.0.1:0/mock-acp";
const ACP_SESSIONS = [];
const PROVIDER_INVENTORY = [
{
providerId: "claude",
providerName: "Claude",
description: "Claude provider",
defaultModel: "claude-sonnet-4-20250514",
configured: true,
providerType: "Preferred",
configKeys: [],
setupSteps: [],
supportsRefresh: true,
refreshing: false,
lastUpdatedAt: null,
lastRefreshAttemptAt: null,
lastRefreshError: null,
stale: false,
modelSelectionHint: null,
models: [
{
id: "claude-sonnet-4-20250514",
name: "Claude Sonnet 4",
family: "Claude",
recommended: true,
},
],
},
{
providerId: "openai",
providerName: "OpenAI",
description: "OpenAI provider",
defaultModel: "gpt-4.1",
configured: true,
providerType: "Preferred",
configKeys: [],
setupSteps: [],
supportsRefresh: true,
refreshing: false,
lastUpdatedAt: null,
lastRefreshAttemptAt: null,
lastRefreshError: null,
stale: false,
modelSelectionHint: null,
models: [
{
id: "gpt-4.1",
name: "GPT-4.1",
family: "OpenAI",
recommended: true,
},
],
},
];
const skillToSourceEntry = (s) => ({
type: "skill",
name: s.name,
description: s.description,
content: s.instructions ?? s.content ?? "",
directory: (s.path ?? ("/mock/.agents/skills/" + s.name + "/SKILL.md")).replace(/\\/SKILL\\.md$/, ""),
global: s.global ?? true,
supportingFiles: [],
});
function nowIso() {
return new Date().toISOString();
}
function buildSession(sessionId, providerId = "goose") {
return {
sessionId,
title: "New Chat",
updatedAt: nowIso(),
messageCount: 0,
providerId,
modelId: null,
};
}
function findSession(sessionId) {
return ACP_SESSIONS.find((session) => session.sessionId === sessionId) ?? null;
}
function jsonRpcResult(id, result) {
return { jsonrpc: "2.0", id, result };
}
function handleAcpRequest(message) {
switch (message.method) {
case "initialize":
return jsonRpcResult(message.id, {
protocolVersion: "0.1.0",
agentCapabilities: {
loadSession: {},
listSessions: {},
},
agentInfo: {
name: "mock-goose",
version: "0.0.0",
},
authMethods: [],
});
case "session/list":
return jsonRpcResult(message.id, {
sessions: ACP_SESSIONS.map((session) => ({
sessionId: session.sessionId,
title: session.title,
updatedAt: session.updatedAt,
_meta: {
messageCount: session.messageCount,
},
})),
});
case "session/new": {
const providerId = message.params?.meta?.provider ?? "goose";
const sessionId = "session-" + Math.random().toString(36).slice(2, 10);
ACP_SESSIONS.unshift(buildSession(sessionId, providerId));
return jsonRpcResult(message.id, { sessionId });
}
case "session/load":
return jsonRpcResult(message.id, {});
case "session/set_config_option": {
const session = findSession(message.params?.sessionId);
if (session) {
if (message.params?.configId === "provider") {
session.providerId = message.params?.value ?? session.providerId;
session.modelId = null;
}
if (message.params?.configId === "model") {
session.modelId = message.params?.value ?? null;
}
session.updatedAt = nowIso();
}
return jsonRpcResult(message.id, {});
}
case "session/prompt": {
const session = findSession(message.params?.sessionId);
if (session) {
session.messageCount += 1;
session.updatedAt = nowIso();
}
return jsonRpcResult(message.id, { stopReason: "end_turn" });
}
case "_goose/providers/list":
return jsonRpcResult(message.id, { entries: PROVIDER_INVENTORY });
case "_goose/providers/inventory/refresh":
return jsonRpcResult(message.id, { started: [], skipped: [] });
case "_goose/working_dir/update":
case "goose/working_dir/update":
return jsonRpcResult(message.id, {});
case "_goose/sources/list":
return jsonRpcResult(message.id, { sources: SKILLS.map(skillToSourceEntry) });
case "_goose/sources/create":
return jsonRpcResult(message.id, {
source: {
name: message.params?.name ?? "new-skill",
type: "skill",
description: message.params?.description ?? "",
content: message.params?.content ?? "",
directory: "/mock/.agents/skills/" + (message.params?.name ?? "new-skill"),
global: message.params?.global ?? true,
},
});
case "_goose/sources/update":
case "goose/sources/update": {
const path = message.params?.path ?? "/mock/.agents/skills/updated-skill";
const nextName = message.params?.name;
const name =
typeof nextName === "string" && nextName.length > 0
? nextName
: String(path).split("/").filter(Boolean).at(-1) ?? "updated-skill";
const segments = String(path).split("/").filter(Boolean);
if (segments.length > 0) {
segments[segments.length - 1] = name;
}
const directory = \`/\${segments.join("/")}\`;
return jsonRpcResult(message.id, {
source: {
name,
type: "skill",
description: message.params?.description ?? "",
content: message.params?.content ?? "",
directory,
global: message.params?.global ?? true,
supportingFiles: [],
},
});
}
case "_goose/sources/delete":
case "goose/sources/delete":
return jsonRpcResult(message.id, {});
case "_goose/sources/export":
case "goose/sources/export": {
const path = message.params?.path ?? "/mock/.agents/skills/skill";
const name = String(path).split("/").filter(Boolean).at(-1) ?? "skill";
return jsonRpcResult(message.id, {
json: "{}",
filename: name + ".skill.json",
});
}
case "_goose/sources/import":
return jsonRpcResult(message.id, { sources: SKILLS.map(skillToSourceEntry) });
default:
return jsonRpcResult(message.id, {});
}
}
class MockWebSocket extends EventTarget {
constructor(url) {
super();
this.url = url;
this.readyState = 0;
queueMicrotask(() => {
this.readyState = 1;
this.dispatchEvent(new Event("open"));
});
}
send(raw) {
const message = JSON.parse(raw);
const response =
message && typeof message === "object" && "id" in message
? handleAcpRequest(message)
: null;
if (!response) {
return;
}
queueMicrotask(() => {
this.dispatchEvent(
new MessageEvent("message", {
data: JSON.stringify(response),
}),
);
});
}
close() {
this.readyState = 3;
this.dispatchEvent(new CloseEvent("close"));
}
}
window.WebSocket = MockWebSocket;
window.__TAURI_INTERNALS__ = {
invoke(cmd, args) {
switch (cmd) {
// ---- ACP transport ----
case "get_goose_serve_url":
return Promise.resolve(FAKE_ACP_URL);
case "get_distro_bundle":
return Promise.resolve(DISTRO);
// ---- Personas ----
case "list_personas":
return Promise.resolve(PERSONAS);
case "refresh_personas":
return Promise.resolve(PERSONAS);
case "create_persona":
return Promise.resolve({
id: "mock-" + Math.random().toString(36).slice(2, 10),
displayName: args?.displayName ?? "New Agent",
systemPrompt: args?.systemPrompt ?? "",
isBuiltin: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...(args?.provider ? { provider: args.provider } : {}),
...(args?.model ? { model: args.model } : {}),
});
case "update_persona":
return Promise.resolve({
id: args?.id ?? "mock-updated",
displayName: args?.displayName ?? "Updated Agent",
systemPrompt: args?.systemPrompt ?? "",
isBuiltin: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...(args?.provider ? { provider: args.provider } : {}),
...(args?.model ? { model: args.model } : {}),
});
case "delete_persona":
return Promise.resolve(null);
case "export_persona":
return Promise.resolve({
json: "{}",
suggestedFilename: "persona.json",
});
case "import_personas":
return Promise.resolve(PERSONAS);
// ---- Sessions / Misc ----
case "list_sessions":
return Promise.resolve(
ACP_SESSIONS.map((session) => ({
sessionId: session.sessionId,
title: session.title,
updatedAt: session.updatedAt,
messageCount: session.messageCount,
})),
);
case "create_session":
return Promise.resolve({
id: "session-" + Math.random().toString(36).slice(2, 10),
title: "New Chat",
agentId: args?.agentId ?? null,
projectId: args?.projectId ?? null,
providerId: null,
personaId: null,
modelName: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
archivedAt: null,
messageCount: 0,
});
case "update_session":
return Promise.resolve(null);
case "get_session_messages":
return Promise.resolve([]);
case "archive_session":
return Promise.resolve(null);
case "list_projects":
return Promise.resolve(PROJECTS);
case "get_project":
return Promise.resolve(PROJECTS.find(p => p.id === args?.id) ?? null);
case "get_avatars_dir":
return Promise.resolve("/tmp/avatars");
case "save_persona_avatar_bytes":
return Promise.resolve("avatar.png");
case "list_files_for_mentions":
return Promise.resolve([]);
case "get_home_dir":
return Promise.resolve("/tmp/home");
case "path_exists":
return Promise.resolve(false);
case "resolve_path": {
const parts = args?.request?.parts ?? [];
const path = parts
.filter((part) => typeof part === "string" && part.length > 0)
.join("/");
const normalizedPath = path.startsWith("~/")
? "/tmp/home/" + path.slice(2)
: path;
return Promise.resolve({ path: normalizedPath });
}
// ---- Fallback ----
default:
console.warn("[tauri-mock] unhandled invoke command:", cmd, args);
return Promise.resolve(null);
}
},
transformCallback(callback, once) {
return Math.floor(Math.random() * 1_000_000);
},
convertFileSrc(path) {
return path;
},
};
})();
`;
}
// ---------------------------------------------------------------------------
// Playwright fixture
// ---------------------------------------------------------------------------
export const test = base.extend<{ tauriMocked: Page }>({
tauriMocked: async ({ page }, use) => {
await page.addInitScript({ content: buildInitScript() });
await use(page);
},
});
export { expect };
// ---------------------------------------------------------------------------
// Navigation helpers
// ---------------------------------------------------------------------------
export async function waitForHome(page: Page) {
await expect(page.getByText(/Good (morning|afternoon|evening)/)).toBeVisible({
timeout: 10_000,
});
}
export async function navigateToAgents(page: Page) {
await page.goto("/");
await expect(page.getByText(/Good (morning|afternoon|evening)/)).toBeVisible({
timeout: 10_000,
});
await page.getByRole("button", { name: "Agents" }).click();
await expect(page.locator("h1", { hasText: "Agents" })).toBeVisible();
}
export async function navigateToSkills(page: Page) {
await page.goto("/");
await expect(page.getByText(/Good (morning|afternoon|evening)/)).toBeVisible({
timeout: 10_000,
});
await page.getByRole("button", { name: "Skills" }).click();
await expect(page.locator("h1", { hasText: "Skills" })).toBeVisible();
}