This repository has been archived on 2026-08-23. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
pi-web/src/client/src/api/federatedRouteContract.test.ts
T

191 lines
10 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Workspace } from "../../../shared/apiTypes";
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../../shared/federatedRoutes";
import { activityApi, configApi, filesApi, gitApi, piPackagesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
import { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./sockets";
import { workspaceImagePreviewUrl } from "./urls";
const machineId = "remote-a";
const workspace: Workspace = {
id: "w 1",
projectId: "p 1",
path: "/repo",
label: "repo",
isMain: true,
isGitRepo: true,
isGitWorktree: true,
};
const session = { id: "s 1", cwd: workspace.path };
beforeEach(() => {
vi.stubGlobal("document", { baseURI: "https://pi.example.test/" });
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("federated route contract", () => {
it("covers machine-scoped client HTTP calls with remote proxy routes", async () => {
const fetchMock = vi.fn<FetchLike>(() => Promise.resolve(jsonResponse({})));
vi.stubGlobal("fetch", fetchMock);
await Promise.all([
ignoreParseFailure(piWebApi.piWebStatus(machineId)),
ignoreParseFailure(piWebApi.checkForUpdates(machineId)),
ignoreParseFailure(configApi.config(machineId)),
ignoreParseFailure(configApi.saveConfig({ spawnSessions: true }, machineId)),
ignoreParseFailure(pluginsApi.plugins(machineId)),
ignoreParseFailure(piPackagesApi.packages(machineId)),
ignoreParseFailure(piPackagesApi.install("npm:@acme/tools", machineId)),
ignoreParseFailure(piPackagesApi.remove("npm:@acme/tools", "user", machineId)),
ignoreParseFailure(piPackagesApi.update("npm:@acme/tools", machineId)),
ignoreParseFailure(activityApi.workspaceActivity(machineId)),
ignoreParseFailure(projectsApi.projects(machineId)),
ignoreParseFailure(projectsApi.addProject("/repo", "Repo", false, machineId)),
ignoreParseFailure(projectsApi.closeProject("p 1", machineId)),
ignoreParseFailure(projectsApi.projectDirectories("/r", machineId)),
ignoreParseFailure(workspacesApi.workspaces("p 1", machineId)),
ignoreParseFailure(workspacesApi.deleteWorkspace("p 1", "w 1", machineId)),
ignoreParseFailure(workspacesApi.workspaceTree("p 1", "w 1", "src", machineId)),
ignoreParseFailure(workspacesApi.workspaceFile("p 1", "w 1", "README.md", machineId)),
ignoreParseFailure(workspacesApi.writeWorkspaceFile("p 1", "w 1", "README.md", "hello", { overwrite: false }, machineId)),
ignoreParseFailure(workspacesApi.deleteWorkspaceFile("p 1", "w 1", "README.md", machineId)),
ignoreParseFailure(workspacesApi.moveWorkspaceFile("p 1", "w 1", "README.md", "docs/README.md", { overwrite: false }, machineId)),
ignoreParseFailure(filesApi.files("/repo", "README", { kind: "tracked", mode: "file", machineId })),
ignoreParseFailure(filesApi.files("/repo", "README", { kind: "tracked", mode: "file", projectId: "p 1", workspaceId: "w 1", machineId, workspaceScoped: true })),
ignoreParseFailure(gitApi.gitStatus("p 1", "w 1", machineId)),
ignoreParseFailure(gitApi.gitDiff("p 1", "w 1", { path: "README.md", staged: true }, machineId)),
ignoreParseFailure(sessionsApi.sessions("/repo", machineId)),
ignoreParseFailure(sessionsApi.startSession("/repo", machineId)),
ignoreParseFailure(sessionsApi.cleanupPreview({ archiveIdleDays: 14 }, machineId)),
ignoreParseFailure(sessionsApi.cleanup({ archiveIdleDays: 14, deleteArchivedDays: 30, projectCwds: ["/repo"] }, machineId)),
ignoreParseFailure(sessionsApi.archiveMany([session], machineId)),
ignoreParseFailure(sessionsApi.deleteArchivedMany([session], machineId)),
ignoreParseFailure(sessionsApi.messages(session, { limit: 20, before: 10 }, machineId)),
ignoreParseFailure(sessionsApi.status(session, machineId)),
ignoreParseFailure(sessionsApi.models(session, machineId)),
ignoreParseFailure(sessionsApi.setModel(session, "openai", "gpt", machineId)),
ignoreParseFailure(sessionsApi.cycleModel(session, "forward", machineId)),
ignoreParseFailure(sessionsApi.thinkingLevels(session, machineId)),
ignoreParseFailure(sessionsApi.setThinkingLevel(session, "medium", machineId)),
ignoreParseFailure(sessionsApi.cycleThinkingLevel(session, machineId)),
ignoreParseFailure(sessionsApi.commands(session, machineId)),
ignoreParseFailure(sessionsApi.prompt(session, "hello", "followUp", machineId)),
ignoreParseFailure(sessionsApi.saveAttachments(session, [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }], machineId, "uploads")),
ignoreParseFailure(sessionsApi.shell(session, "ls", machineId)),
ignoreParseFailure(sessionsApi.runCommand(session, "/help", machineId)),
ignoreParseFailure(sessionsApi.respondToCommand(session, "req 1", "yes", machineId)),
ignoreParseFailure(sessionsApi.abort(session, machineId)),
ignoreParseFailure(sessionsApi.stop(session, machineId)),
ignoreParseFailure(sessionsApi.archive(session, machineId)),
ignoreParseFailure(sessionsApi.archiveWithDescendants(session, machineId)),
ignoreParseFailure(sessionsApi.restore(session, machineId)),
ignoreParseFailure(sessionsApi.deleteArchived(session, machineId)),
ignoreParseFailure(sessionsApi.reloadSession(session, machineId)),
ignoreParseFailure(sessionsApi.detachParent(session, machineId)),
ignoreParseFailure(sessionsApi.authProviders({ mode: "login", authType: "oauth", machineId })),
ignoreParseFailure(sessionsApi.saveApiKey("openai", "key", machineId)),
ignoreParseFailure(sessionsApi.logoutProvider("openai", machineId)),
ignoreParseFailure(sessionsApi.startOAuthLogin("openai", machineId)),
ignoreParseFailure(sessionsApi.oauthFlow("flow 1", machineId)),
ignoreParseFailure(sessionsApi.respondOAuthFlow("flow 1", "req 1", "code", machineId)),
ignoreParseFailure(sessionsApi.cancelOAuthFlow("flow 1", machineId)),
ignoreParseFailure(terminalsApi.terminals("p 1", "w 1", machineId)),
ignoreParseFailure(terminalsApi.startTerminal("p 1", "w 1", { cols: 120, rows: 40 }, machineId)),
ignoreParseFailure(terminalsApi.closeWorkspaceTerminals("p 1", "w 1", machineId)),
ignoreParseFailure(terminalsApi.closeTerminal("p 1", "w 1", "t 1", machineId)),
ignoreParseFailure(terminalsApi.continueTerminal("p 1", "w 1", "t 1", machineId)),
ignoreParseFailure(terminalsApi.runTerminalCommand("core", { workspace, title: "Build", command: "npm test" }, machineId)),
ignoreParseFailure(terminalsApi.listCommandRuns({ projectId: "p 1", workspaceId: "w 1", statuses: ["running"], metadata: { "pi.operation": "test" } }, machineId)),
ignoreParseFailure(terminalsApi.getCommandRun("run 1", machineId)),
ignoreParseFailure(terminalsApi.cancelCommandRun("run 1", machineId)),
]);
const observedRoutes = uniqueHttpRoutes([
...fetchMock.mock.calls.map((call) => fetchCallToRoute(call, machineId)),
routeFromMachineUrl("GET", workspaceImagePreviewUrl("p 1", "w 1", "diagram.svg", { machineId, modifiedAt: "2026-05-25T00:00:00.000Z" }), machineId),
]);
const unmatched = observedRoutes.filter((route) => !matchesHttpRoute(route, FEDERATED_HTTP_ROUTES));
expect(unmatched).toEqual([]);
});
it("covers machine-scoped client WebSocket calls with remote proxy routes", () => {
const webSocketUrls: string[] = [];
function FakeWebSocket(url: string): void {
webSocketUrls.push(url);
}
vi.stubGlobal("WebSocket", FakeWebSocket);
sessionEvents(session, machineId);
globalSessionEvents(machineId);
realtimeEvents(machineId);
terminalSocket("p 1", "w 1", "t 1", { cols: 120, rows: 40 }, machineId);
const observedPaths = uniqueStrings(webSocketUrls.map((url) => routeFromMachineUrl("GET", url, machineId).path));
const unmatched = observedPaths.filter((path) => !FEDERATED_WEBSOCKET_ROUTES.some((route) => pathMatchesPattern(path, route)));
expect(unmatched).toEqual([]);
});
});
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>;
interface ObservedHttpRoute {
method: string;
path: string;
}
async function ignoreParseFailure(promise: Promise<unknown>): Promise<void> {
await promise.catch(() => undefined);
}
function fetchCallToRoute(call: Parameters<FetchLike>, scopedMachineId: string): ObservedHttpRoute {
const [url, init] = call;
return routeFromMachineUrl((init?.method ?? "GET").toUpperCase(), url, scopedMachineId);
}
function routeFromMachineUrl(method: string, input: string | URL | Request, scopedMachineId: string): ObservedHttpRoute {
const url = toUrl(input);
const prefix = `/api/machines/${encodeURIComponent(scopedMachineId)}`;
const prefixIndex = url.pathname.lastIndexOf(prefix);
if (prefixIndex === -1) throw new Error(`Expected machine-scoped URL, got ${url.pathname}`);
return { method, path: url.pathname.slice(prefixIndex + prefix.length) || "/" };
}
function toUrl(input: string | URL | Request): URL {
if (input instanceof URL) return input;
if (input instanceof Request) return new URL(input.url);
return new URL(input, "https://pi.example.test");
}
function matchesHttpRoute(route: ObservedHttpRoute, specs: readonly FederatedHttpRouteSpec[]): boolean {
return specs.some((spec) => spec.method === route.method && pathMatchesPattern(route.path, spec.path));
}
function pathMatchesPattern(path: string, pattern: string): boolean {
const pathSegments = path.split("/").filter((segment) => segment !== "");
const patternSegments = pattern.split("/").filter((segment) => segment !== "");
return pathSegments.length === patternSegments.length
&& patternSegments.every((segment, index) => segment.startsWith(":") || segment === pathSegments[index]);
}
function uniqueHttpRoutes(routes: ObservedHttpRoute[]): ObservedHttpRoute[] {
const seen = new Set<string>();
return routes.filter((route) => {
const key = `${route.method} ${route.path}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function uniqueStrings(values: string[]): string[] {
return [...new Set(values)];
}
function jsonResponse(value: unknown): Response {
return new Response(JSON.stringify(value), { status: 200, headers: { "content-type": "application/json" } });
}