feat(plugins): add bundled relays workspace panel plugin

This commit is contained in:
Federico Jaramillo Martinez
2026-07-28 23:14:21 +02:00
parent d19fca4090
commit 87c09982e9
13 changed files with 1591 additions and 0 deletions
@@ -0,0 +1,478 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi, type Mock } from "vitest";
import type { FileContentResponse, FileTreeEntry, WorkspaceFiles, WorkspacePanelContext } from "@jmfederico/pi-web/plugin-api";
import { RELAYS_ROOT, type RelayDiscoveryFiles } from "./relayDiscovery";
import { defineRelaysPanelElement, relaysPanelTagName } from "./relaysPanelElement";
interface RelaysPanelTestElement extends HTMLElement {
context: WorkspacePanelContext | undefined;
}
declare global {
interface HTMLElementTagNameMap {
"pi-web-relays-panel": RelaysPanelTestElement;
}
}
afterEach(() => {
document.body.replaceChildren();
});
describe("workspace states", () => {
it("asks for a workspace when no context is set", async () => {
const panel = await mountPanel();
expect(shadow(panel).textContent).toContain("Select a workspace.");
});
it("explains the relays convention when the workspace has no relays root", async () => {
// The fake rejects unknown paths with "Path does not exist".
const panel = await mountPanel(panelContext(workspaceFilesFake()));
expect(viewerText(panel)).toContain("No relays in this workspace.");
expect(viewerText(panel)).toContain(`${RELAYS_ROOT}/<name>/`);
});
it("shows the same empty state when the relays root exists but is empty", async () => {
const fake = workspaceFilesFake();
fake.addDirectory(RELAYS_ROOT, []);
const panel = await mountPanel(panelContext(fake));
expect(viewerText(panel)).toContain("No relays in this workspace.");
});
it("surfaces a scan failure with its detail", async () => {
const fake = workspaceFilesFake();
fake.failWith(RELAYS_ROOT, new Error("connection lost"));
const panel = await mountPanel(panelContext(fake));
const error = shadow(panel).querySelector(".status.error");
expect(error?.textContent).toContain("Could not scan workspace relays.");
expect(error?.textContent).toContain("connection lost");
});
});
describe("single relay", () => {
it("auto-opens the relay without a picker and renders its default document", async () => {
const fake = workspaceFilesFake();
fake.addDirectory(RELAYS_ROOT, [relayDirectory("only-relay", "2026-02-01T00:00:00.000Z")]);
fake.addDirectory(`${RELAYS_ROOT}/only-relay`, [
relayDocument("only-relay", "charter.md"),
relayDocument("only-relay", "status.md"),
]);
fake.addDocument(`${RELAYS_ROOT}/only-relay/status.md`, "# Status\nAll good.");
fake.addDocument(`${RELAYS_ROOT}/only-relay/charter.md`, "# Charter");
const panel = await mountPanel(panelContext(fake));
expect(picker(panel)).toBeNull();
expect(shadow(panel).querySelector(".relay-name")?.textContent).toBe("only-relay");
expect(tabNames(panel)).toEqual(["status.md", "charter.md"]);
expect(activeTab(panel)?.textContent).toBe("status.md");
expect(documentText(panel)).toContain("All good.");
expect(fake.readFile).toHaveBeenCalledWith(`${RELAYS_ROOT}/only-relay/status.md`);
});
it("escapes filesystem-derived names and paths", async () => {
const fake = workspaceFilesFake();
fake.addDirectory(RELAYS_ROOT, [relayDirectory("<img src=x>")]);
fake.addDirectory(`${RELAYS_ROOT}/<img src=x>`, [relayDocument("<img src=x>", "status.md")]);
fake.addDocument(`${RELAYS_ROOT}/<img src=x>/status.md`, "safe");
const panel = await mountPanel(panelContext(fake));
expect(shadow(panel).querySelector("img")).toBeNull();
expect(shadow(panel).querySelector(".relay-name")?.textContent).toBe("<img src=x>");
});
});
describe("multiple relays", () => {
it("pre-selects the most recently modified relay and opens another on picker change", async () => {
const fake = workspaceFilesFake();
fake.addDirectory(RELAYS_ROOT, [
relayDirectory("older", "2026-01-01T00:00:00.000Z"),
relayDirectory("newer", "2026-03-01T00:00:00.000Z"),
]);
fake.addDirectory(`${RELAYS_ROOT}/newer`, [relayDocument("newer", "status.md")]);
fake.addDirectory(`${RELAYS_ROOT}/older`, [relayDocument("older", "status.md")]);
fake.addDocument(`${RELAYS_ROOT}/newer/status.md`, "newer status");
fake.addDocument(`${RELAYS_ROOT}/older/status.md`, "older status");
const panel = await mountPanel(panelContext(fake));
expect(picker(panel)?.value).toBe(`${RELAYS_ROOT}/newer`);
expect(documentText(panel)).toBe("newer status");
const select = picker(panel);
if (select === null) throw new Error("relay picker missing");
select.value = `${RELAYS_ROOT}/older`;
select.dispatchEvent(new Event("change"));
await flushAsync();
expect(fake.listFiles).toHaveBeenCalledWith(`${RELAYS_ROOT}/older`);
expect(documentText(panel)).toBe("older status");
});
});
describe("document tabs", () => {
it("orders tabs with anchor documents first and loads the clicked tab's document", async () => {
const fake = workspaceFilesFake();
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
fake.addDirectory(`${RELAYS_ROOT}/relay`, [
relayDocument("relay", "notes.md"),
relayDocument("relay", "log.md"),
relayDocument("relay", "data.json"),
relayDocument("relay", "charter.md"),
relayDocument("relay", "status.md"),
]);
fake.addDocument(`${RELAYS_ROOT}/relay/status.md`, "status body");
fake.addDocument(`${RELAYS_ROOT}/relay/log.md`, "log body");
const panel = await mountPanel(panelContext(fake));
expect(tabNames(panel)).toEqual(["status.md", "charter.md", "log.md", "data.json", "notes.md"]);
expect(activeTab(panel)?.textContent).toBe("status.md");
tabNamed(panel, "log.md").click();
await flushAsync();
expect(fake.readFile).toHaveBeenCalledWith(`${RELAYS_ROOT}/relay/log.md`);
expect(activeTab(panel)?.textContent).toBe("log.md");
expect(documentText(panel)).toBe("log body");
});
it("renders all document tabs as direct children of one tab strip", async () => {
// The strip scrolls horizontally instead of wrapping, so every tab must be
// a direct child of the single nav.document-tabs row.
const fake = workspaceFilesFake();
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
fake.addDirectory(`${RELAYS_ROOT}/relay`, [
relayDocument("relay", "notes.md"),
relayDocument("relay", "log.md"),
relayDocument("relay", "status.md"),
]);
fake.addDocument(`${RELAYS_ROOT}/relay/status.md`, "status body");
const panel = await mountPanel(panelContext(fake));
const strips = shadow(panel).querySelectorAll("nav.document-tabs");
expect(strips.length).toBe(1);
const strip = strips[0];
expect(strip?.getAttribute("aria-label")).toBe("Relay documents");
const children = [...(strip?.children ?? [])];
expect(children.every((child) => child instanceof HTMLButtonElement)).toBe(true);
expect(children.map((child) => child.textContent)).toEqual(["status.md", "log.md", "notes.md"]);
});
it("shows a truncation notice when the open document is truncated", async () => {
const fake = workspaceFilesFake();
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
fake.addDirectory(`${RELAYS_ROOT}/relay`, [relayDocument("relay", "log.md")]);
fake.addDocument(`${RELAYS_ROOT}/relay/log.md`, "partial log", { truncated: true });
const panel = await mountPanel(panelContext(fake));
expect(shadow(panel).querySelector(".status.info")?.textContent).toContain("truncated");
expect(documentText(panel)).toBe("partial log");
});
it("explains when a document has no text preview", async () => {
const fake = workspaceFilesFake();
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
fake.addDirectory(`${RELAYS_ROOT}/relay`, [relayDocument("relay", "status.md")]);
fake.addDocument(`${RELAYS_ROOT}/relay/status.md`, "AAAA", { binary: true });
const panel = await mountPanel(panelContext(fake));
expect(viewerText(panel)).toContain("Binary file: status.md");
expect(shadow(panel).querySelector("pre.document")).toBeNull();
});
it("explains when the open document vanishes between listing and read", async () => {
const fake = workspaceFilesFake();
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
fake.addDirectory(`${RELAYS_ROOT}/relay`, [relayDocument("relay", "status.md")]);
// No addDocument: the fake readFile rejects with "Path does not exist".
const panel = await mountPanel(panelContext(fake));
expect(viewerText(panel)).toContain("This document no longer exists.");
});
it("explains when a relay has no documents", async () => {
const fake = workspaceFilesFake();
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
fake.addDirectory(`${RELAYS_ROOT}/relay`, []);
const panel = await mountPanel(panelContext(fake));
expect(viewerText(panel)).toContain("This relay has no documents yet.");
});
});
describe("markdown rendering", () => {
it("renders .md documents as sanitized markdown HTML", async () => {
const fake = workspaceFilesFake();
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
fake.addDirectory(`${RELAYS_ROOT}/relay`, [relayDocument("relay", "status.md")]);
fake.addDocument(`${RELAYS_ROOT}/relay/status.md`, [
"# Status",
"",
"See [the docs](https://example.com/docs).",
"",
"<script>alert('xss')</script>",
"",
"[click](javascript:alert('xss'))",
].join("\n"));
const panel = await mountPanel(panelContext(fake));
const rendered = shadow(panel).querySelector(".document.markdown");
expect(rendered).not.toBeNull();
expect(shadow(panel).querySelector("pre.document")).toBeNull();
expect(rendered?.querySelector("h1")?.textContent).toBe("Status");
expect(rendered?.querySelector("script")).toBeNull();
const docsLink = rendered?.querySelector('a[href="https://example.com/docs"]');
expect(docsLink?.getAttribute("target")).toBe("_blank");
expect(docsLink?.getAttribute("rel")).toBe("noreferrer noopener");
const unsafeLink = [...rendered?.querySelectorAll("a") ?? []].find((link) => link.textContent === "click");
expect(unsafeLink?.hasAttribute("href")).toBe(false);
});
it("keeps non-markdown documents as escaped preformatted text", async () => {
const fake = workspaceFilesFake();
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
fake.addDirectory(`${RELAYS_ROOT}/relay`, [relayDocument("relay", "notes.txt")]);
fake.addDocument(`${RELAYS_ROOT}/relay/notes.txt`, "# not a heading\n<script>alert('xss')</script>");
const panel = await mountPanel(panelContext(fake));
expect(shadow(panel).querySelector(".document.markdown")).toBeNull();
const pre = shadow(panel).querySelector("pre.document");
expect(pre?.textContent).toContain("# not a heading");
expect(shadow(panel).querySelector(".viewer h1")).toBeNull();
expect(shadow(panel).querySelector(".viewer script")).toBeNull();
});
it("keeps the truncation notice ahead of rendered markdown", async () => {
const fake = workspaceFilesFake();
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
fake.addDirectory(`${RELAYS_ROOT}/relay`, [relayDocument("relay", "log.md")]);
fake.addDocument(`${RELAYS_ROOT}/relay/log.md`, "# Partial", { truncated: true });
const panel = await mountPanel(panelContext(fake));
const viewer = shadow(panel).querySelector(".viewer");
expect(viewer?.querySelector(".status.info")?.textContent).toContain("truncated");
expect(viewer?.querySelector(".document.markdown h1")?.textContent).toBe("Partial");
});
});
describe("refresh and context changes", () => {
it("renders Refresh as an icon-only button with an accessible name", async () => {
const fake = workspaceFilesFake();
fake.addDirectory(RELAYS_ROOT, []);
const panel = await mountPanel(panelContext(fake));
const button = refreshButton(panel);
expect(button.getAttribute("aria-label")).toBe("Refresh");
expect(button.getAttribute("title")).toBe("Refresh");
expect(button.textContent.trim()).toBe("");
const icon = button.querySelector("svg");
expect(icon).not.toBeNull();
expect(icon?.getAttribute("aria-hidden")).toBe("true");
fake.listFiles.mockClear();
button.click();
await flushAsync();
expect(fake.listFiles).toHaveBeenCalledWith(RELAYS_ROOT);
});
it("re-scans on Refresh while keeping the open relay and document", async () => {
const fake = workspaceFilesFake();
fake.addDirectory(RELAYS_ROOT, [
relayDirectory("alpha", "2026-01-01T00:00:00.000Z"),
relayDirectory("beta", "2026-02-01T00:00:00.000Z"),
]);
fake.addDirectory(`${RELAYS_ROOT}/alpha`, [relayDocument("alpha", "status.md"), relayDocument("alpha", "log.md")]);
fake.addDirectory(`${RELAYS_ROOT}/beta`, [relayDocument("beta", "status.md")]);
fake.addDocument(`${RELAYS_ROOT}/alpha/status.md`, "alpha status");
fake.addDocument(`${RELAYS_ROOT}/alpha/log.md`, "alpha log");
fake.addDocument(`${RELAYS_ROOT}/beta/status.md`, "beta status");
const panel = await mountPanel(panelContext(fake));
const select = picker(panel);
if (select === null) throw new Error("relay picker missing");
select.value = `${RELAYS_ROOT}/alpha`;
select.dispatchEvent(new Event("change"));
await flushAsync();
tabNamed(panel, "log.md").click();
await flushAsync();
expect(documentText(panel)).toBe("alpha log");
fake.listFiles.mockClear();
fake.readFile.mockClear();
refreshButton(panel).click();
await flushAsync();
expect(fake.listFiles).toHaveBeenCalledWith(RELAYS_ROOT);
expect(fake.listFiles).toHaveBeenCalledWith(`${RELAYS_ROOT}/alpha`);
expect(fake.readFile).toHaveBeenCalledWith(`${RELAYS_ROOT}/alpha/log.md`);
expect(picker(panel)?.value).toBe(`${RELAYS_ROOT}/alpha`);
expect(activeTab(panel)?.textContent).toBe("log.md");
expect(documentText(panel)).toBe("alpha log");
});
it("does not rescan when the same workspace context is set again, but rescans for a new workspace", async () => {
const fake = workspaceFilesFake();
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
fake.addDirectory(`${RELAYS_ROOT}/relay`, [relayDocument("relay", "status.md")]);
fake.addDocument(`${RELAYS_ROOT}/relay/status.md`, "status");
const panel = await mountPanel(panelContext(fake));
const callsAfterMount = fake.listFiles.mock.calls.length;
panel.context = panelContext(fake);
await flushAsync();
expect(fake.listFiles.mock.calls.length).toBe(callsAfterMount);
panel.context = panelContext(fake, "ws-2");
await flushAsync();
expect(fake.listFiles.mock.calls.length).toBeGreaterThan(callsAfterMount);
});
});
interface WorkspaceFilesFake {
files: WorkspaceFiles;
listFiles: Mock<RelayDiscoveryFiles["listFiles"]>;
readFile: Mock<RelayDiscoveryFiles["readFile"]>;
addDirectory(path: string, entries: FileTreeEntry[]): void;
addDocument(path: string, content: string, overrides?: Partial<FileContentResponse>): void;
failWith(path: string, error: Error): void;
}
/** In-memory WorkspaceFiles fake: unknown paths reject with "Path does not exist", like the real helper. */
function workspaceFilesFake(): WorkspaceFilesFake {
const directories = new Map<string, FileTreeEntry[]>();
const documents = new Map<string, FileContentResponse>();
const failures = new Map<string, Error>();
const listFiles = vi.fn<RelayDiscoveryFiles["listFiles"]>((path) => {
const failure = failures.get(path);
if (failure !== undefined) return Promise.reject(failure);
const entries = directories.get(path);
if (entries === undefined) return Promise.reject(new Error("Path does not exist"));
return Promise.resolve({ path, entries, scannedAt: "2026-01-01T00:00:00.000Z", truncated: false });
});
const readFile = vi.fn<RelayDiscoveryFiles["readFile"]>((path) => {
const failure = failures.get(path);
if (failure !== undefined) return Promise.reject(failure);
const file = documents.get(path);
if (file === undefined) return Promise.reject(new Error("Path does not exist"));
return Promise.resolve(file);
});
return {
files: {
listFiles,
readFile,
// The panel is read-only; the mutating helpers exist only to satisfy WorkspaceFiles.
writeFile: () => Promise.reject(new Error("writeFile not used")),
deleteFile: () => Promise.reject(new Error("deleteFile not used")),
moveFile: () => Promise.reject(new Error("moveFile not used")),
},
listFiles,
readFile,
addDirectory: (path, entries) => { directories.set(path, entries); },
addDocument: (path, content, overrides = {}) => {
documents.set(path, {
path,
encoding: "utf8",
size: content.length,
modifiedAt: "2026-01-01T00:00:00.000Z",
content,
truncated: false,
binary: false,
...overrides,
});
},
failWith: (path, error) => { failures.set(path, error); },
};
}
function panelContext(fake: WorkspaceFilesFake, workspaceId = "ws-1"): WorkspacePanelContext {
return {
machine: { id: "machine-1", name: "Local", kind: "local" },
workspace: { id: workspaceId, projectId: "project-1", path: "/repo", label: "repo", isMain: true, isGitRepo: true, isGitWorktree: false },
files: fake.files,
host: { requestRender: () => undefined },
prompt: { insertText: () => undefined, getText: () => "", getSelection: () => null },
terminal: { open: () => undefined, runCommand: () => Promise.reject(new Error("terminal not used")) },
};
}
async function mountPanel(context?: WorkspacePanelContext): Promise<RelaysPanelTestElement> {
defineRelaysPanelElement();
const panel = document.createElement(relaysPanelTagName);
document.body.append(panel);
if (context !== undefined) panel.context = context;
await flushAsync();
return panel;
}
/** One macrotask drains the full microtask chain of the panel's immediate-fake scans. */
function flushAsync(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
function relayDirectory(name: string, modifiedAt?: string): FileTreeEntry {
return { name, path: `${RELAYS_ROOT}/${name}`, type: "directory", ...(modifiedAt === undefined ? {} : { modifiedAt }) };
}
function relayDocument(relayName: string, name: string, modifiedAt?: string): FileTreeEntry {
return { name, path: `${RELAYS_ROOT}/${relayName}/${name}`, type: "file", ...(modifiedAt === undefined ? {} : { modifiedAt }) };
}
function shadow(panel: RelaysPanelTestElement): ShadowRoot {
const root = panel.shadowRoot;
if (root === null) throw new Error("panel has no shadow root");
return root;
}
function viewerText(panel: RelaysPanelTestElement): string {
return shadow(panel).querySelector(".viewer")?.textContent ?? "";
}
/** Text of the open document viewer, whether it rendered markdown or a <pre> block. */
function documentText(panel: RelaysPanelTestElement): string | null {
return shadow(panel).querySelector(".viewer .document")?.textContent.trim() ?? null;
}
function picker(panel: RelaysPanelTestElement): HTMLSelectElement | null {
const select = shadow(panel).querySelector("select[data-relay-picker]");
return select instanceof HTMLSelectElement ? select : null;
}
function refreshButton(panel: RelaysPanelTestElement): HTMLElement {
const button = shadow(panel).querySelector("button[data-refresh]");
if (!(button instanceof HTMLElement)) throw new Error("refresh button missing");
return button;
}
function tabNames(panel: RelaysPanelTestElement): string[] {
return [...shadow(panel).querySelectorAll("button[data-document-path]")].map((tab) => tab.textContent);
}
function activeTab(panel: RelaysPanelTestElement): Element | null {
return shadow(panel).querySelector("button[data-document-path].active");
}
function tabNamed(panel: RelaysPanelTestElement, name: string): HTMLElement {
const tab = [...shadow(panel).querySelectorAll("button[data-document-path]")].find((candidate) => candidate.textContent === name);
if (!(tab instanceof HTMLElement)) throw new Error(`tab "${name}" not found`);
return tab;
}