feat: add public plugin workspace capabilities

This commit is contained in:
Federico Jaramillo Martinez
2026-06-04 13:00:25 +02:00
parent 93b50e61af
commit e3533ebf1f
28 changed files with 584 additions and 531 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
import type { PiWebPlugin } from "../../src/client/src/plugins/types";
import type { PiWebPlugin } from "@jmfederico/pi-web/plugin-api";
const plugin: PiWebPlugin = {
apiVersion: 1,
+38
View File
@@ -0,0 +1,38 @@
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const pluginRoot = "pi-web-plugins";
const forbiddenPatterns = [
{ pattern: /\bfetch\s*\(/u, message: "direct browser fetch" },
{ pattern: /["'`][^"'`]*\/api\//u, message: "direct PI WEB /api URL" },
{ pattern: /piWebInternal/u, message: "legacy internal plugin context" },
{ pattern: /(?:\.\.\/)+src\//u, message: "imports from PI WEB source internals" },
];
describe("bundled PI WEB plugins", () => {
it("use public plugin APIs instead of direct PI WEB internals", async () => {
const violations: string[] = [];
for (const file of await pluginSourceFiles(pluginRoot)) {
const content = await readFile(file, "utf8");
for (const { pattern, message } of forbiddenPatterns) {
if (pattern.test(content)) violations.push(`${file}: ${message}`);
}
if (content.includes("piWebUnstable") && !content.includes("@jmfederico/pi-web/plugin-api/unstable")) {
violations.push(`${file}: piWebUnstable use without explicit unstable type import`);
}
}
expect(violations).toEqual([]);
});
});
async function pluginSourceFiles(root: string): Promise<string[]> {
const files: string[] = [];
for (const entry of await readdir(root, { withFileTypes: true })) {
const path = join(root, entry.name);
if (entry.isDirectory()) files.push(...await pluginSourceFiles(path));
else if (entry.isFile() && entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts")) files.push(path);
}
return files;
}
+8 -10
View File
@@ -1,17 +1,15 @@
import type { TemplateResult } from "lit";
import type { AppState } from "../../src/client/src/appState";
import type { HtmlTemplateTag, PiWebPlugin } from "../../src/client/src/plugins/types";
import type { PiWebComponentStatus, PiWebInstallationInfo, PiWebStatusMessage, PiWebStatusResponse } from "../../src/shared/apiTypes";
import type { HtmlTemplateTag, PiWebComponentStatus, PiWebInstallationInfo, PiWebPlugin, PiWebStatusMessage, PiWebStatusResponse, PluginRuntimeState } from "@jmfederico/pi-web/plugin-api";
function messagesFor(state: AppState): PiWebStatusMessage[] {
return state.piWebStatus?.messages ?? [];
function messagesFor(state: PluginRuntimeState | undefined): PiWebStatusMessage[] {
return state?.piWebStatus?.messages ?? [];
}
function statusFor(state: AppState): PiWebStatusResponse | undefined {
return state.piWebStatus;
function statusFor(state: PluginRuntimeState | undefined): PiWebStatusResponse | undefined {
return state?.piWebStatus;
}
function messageCount(state: AppState): number {
function messageCount(state: PluginRuntimeState | undefined): number {
return messagesFor(state).length;
}
@@ -19,7 +17,7 @@ function isLocalOrUnknownInstallation(installation: PiWebInstallationInfo | unde
return installation === undefined || installation.kind === "local" || installation.kind === "unknown";
}
function shouldShowUpdatesPanel(state: AppState): boolean {
function shouldShowUpdatesPanel(state: PluginRuntimeState | undefined): boolean {
const status = statusFor(state);
if (messageCount(state) > 0) return true;
if (status === undefined) return false;
@@ -87,7 +85,7 @@ function renderCommands(html: HtmlTemplateTag, status: PiWebStatusResponse): Tem
`;
}
function renderUpdatesPanel(html: HtmlTemplateTag, state: AppState): TemplateResult {
function renderUpdatesPanel(html: HtmlTemplateTag, state: PluginRuntimeState | undefined): TemplateResult {
const status = statusFor(state);
if (status === undefined) {
return html`
@@ -1,7 +1,6 @@
import type { PiWebPlugin } from "@jmfederico/pi-web/plugin-api";
import { TASKS_CONFIG_PATH } from "./config.js";
import { defineTasksPanelElement, tasksPanelBadge } from "./tasksPanelElement.js";
import { terminalCommandRunsFromContext } from "./piWebInternal.js";
const plugin: PiWebPlugin = {
apiVersion: 1,
@@ -39,8 +38,8 @@ const plugin: PiWebPlugin = {
</svg>
`,
order: 40,
badge: ({ workspace }) => tasksPanelBadge(workspace),
render: (context) => html`<pi-web-workspace-tasks-panel .workspace=${context.workspace} .terminalCommandRuns=${terminalCommandRunsFromContext(context)} .openTerminal=${context.openTerminal}></pi-web-workspace-tasks-panel>`,
badge: (context) => tasksPanelBadge(context),
render: (context) => html`<pi-web-workspace-tasks-panel .context=${context}></pi-web-workspace-tasks-panel>`,
},
],
},
@@ -1,62 +0,0 @@
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
export interface InternalRunTerminalCommandInput {
workspace: Workspace;
title: string;
command: string;
metadata?: Record<string, string>;
open?: boolean;
}
export interface InternalTerminalCommandRun {
id: string;
origin: string;
projectId: string;
workspaceId: string;
terminalId: string;
title: string;
command: string;
status: "queued" | "running" | "succeeded" | "failed";
exitCode?: number;
createdAt: string;
startedAt?: string;
completedAt?: string;
metadata: Record<string, string>;
}
export interface InternalTerminalCommandRunHandle {
run: InternalTerminalCommandRun;
completed: Promise<InternalTerminalCommandRun>;
}
export interface InternalTerminalCommandRunsRuntime {
runCommand(input: InternalRunTerminalCommandInput): Promise<InternalTerminalCommandRunHandle>;
open(options?: { terminalId?: string | undefined }): void;
}
export function terminalCommandRunsFromContext(context: unknown): InternalTerminalCommandRunsRuntime | undefined {
if (!isRecord(context)) return undefined;
const internal = context["piWebInternal"];
if (!isRecord(internal)) return undefined;
const terminalCommandRuns = internal["terminalCommandRuns"];
if (!isRecord(terminalCommandRuns)) return undefined;
const runCommand = terminalCommandRuns["runCommand"];
const open = terminalCommandRuns["open"];
if (!isRunCommand(runCommand) || !isOpen(open)) return undefined;
return {
runCommand: (input) => runCommand(input),
open: (options) => { open(options); },
};
}
function isRunCommand(value: unknown): value is InternalTerminalCommandRunsRuntime["runCommand"] {
return typeof value === "function";
}
function isOpen(value: unknown): value is InternalTerminalCommandRunsRuntime["open"] {
return typeof value === "function";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
@@ -1,16 +0,0 @@
interface Updatable {
requestUpdate: () => void;
}
export function requestPiWebRender(): void {
const app = document.querySelector("pi-web-app");
if (isUpdatable(app)) app.requestUpdate();
}
function isUpdatable(value: unknown): value is Updatable {
return isRecord(value) && typeof value["requestUpdate"] === "function";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
@@ -1,24 +1,13 @@
import { describe, expect, it, vi } from "vitest";
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
import type { TerminalCommandRun, WorkspacePanelTerminal } from "@jmfederico/pi-web/plugin-api";
import { runWorkspaceTaskInTerminal } from "./taskRunner";
import type { WorkspaceTask } from "./config";
import type { InternalTerminalCommandRun, InternalTerminalCommandRunsRuntime } from "./piWebInternal";
const workspace: Workspace = {
id: "workspace 1",
projectId: "project/1",
path: "/repo",
label: "repo",
isMain: false,
isGitRepo: true,
isGitWorktree: true,
};
const run: InternalTerminalCommandRun = {
const run: TerminalCommandRun = {
id: "run1",
origin: "workspace-tasks",
projectId: workspace.projectId,
workspaceId: workspace.id,
projectId: "project/1",
workspaceId: "workspace 1",
terminalId: "term1",
title: "Build",
command: "npm run build",
@@ -28,20 +17,19 @@ const run: InternalTerminalCommandRun = {
};
describe("task runner", () => {
it("starts workspace tasks through the internal terminal command-run helper", async () => {
it("starts workspace tasks through the public workspace terminal helper", async () => {
const task: WorkspaceTask = { id: "build", title: "Build", command: "npm run build", confirm: false };
const runCommand = vi.fn<InternalTerminalCommandRunsRuntime["runCommand"]>(() => Promise.resolve({ run, completed: Promise.resolve(run) }));
const terminal: InternalTerminalCommandRunsRuntime = {
const runCommand = vi.fn<WorkspacePanelTerminal["runCommand"]>(() => Promise.resolve({ run, completed: Promise.resolve(run) }));
const terminal: WorkspacePanelTerminal = {
runCommand,
open: vi.fn(),
};
const handle = await runWorkspaceTaskInTerminal(terminal, workspace, task);
const handle = await runWorkspaceTaskInTerminal(terminal, task);
expect(handle.run).toEqual(run);
await expect(handle.completed).resolves.toEqual(run);
expect(runCommand).toHaveBeenCalledWith({
workspace,
title: "Build",
command: "npm run build",
open: true,
+2 -4
View File
@@ -1,10 +1,8 @@
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
import type { WorkspacePanelTerminal } from "@jmfederico/pi-web/plugin-api";
import type { WorkspaceTask } from "./config.js";
import type { InternalTerminalCommandRunsRuntime } from "./piWebInternal.js";
export function runWorkspaceTaskInTerminal(terminal: InternalTerminalCommandRunsRuntime, workspace: Workspace, task: WorkspaceTask): ReturnType<InternalTerminalCommandRunsRuntime["runCommand"]> {
export function runWorkspaceTaskInTerminal(terminal: WorkspacePanelTerminal, task: WorkspaceTask): ReturnType<WorkspacePanelTerminal["runCommand"]> {
return terminal.runCommand({
workspace,
title: task.title,
command: task.command,
open: true,
@@ -1,14 +1,10 @@
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
import type { WorkspacePanelContext } from "@jmfederico/pi-web/plugin-api";
import { TASKS_CONFIG_PATH, type WorkspaceTask } from "./config.js";
import { runWorkspaceTaskInTerminal } from "./taskRunner.js";
import { requestPiWebRender } from "./piWebPrivateUi.js";
import type { InternalTerminalCommandRunsRuntime } from "./piWebInternal.js";
import { loadWorkspaceTasksConfig, tasksConfigRefreshHint, tasksConfigUnavailableMessage, type WorkspaceTasksConfigLoadResult } from "./workspaceTasksClient.js";
export const tasksPanelTagName = "pi-web-workspace-tasks-panel";
export type OpenTerminal = (options?: { terminalId?: string | undefined }) => void;
const configChangedEvent = "pi-web-workspace-tasks-config-changed";
type ConfigState =
@@ -27,17 +23,15 @@ export function defineTasksPanelElement(): void {
if (!customElements.get(tasksPanelTagName)) customElements.define(tasksPanelTagName, PiWebTasksPanel);
}
export function tasksPanelBadge(workspace: Workspace): string | number | undefined {
const state = getCachedWorkspaceConfig(workspace);
export function tasksPanelBadge(context: WorkspacePanelContext): string | number | undefined {
const state = getCachedWorkspaceConfig(context);
if (state?.kind === "unavailable") return "!";
if (state?.kind === "loaded" && state.config.tasks.length > 0) return state.config.tasks.length;
return undefined;
}
class PiWebTasksPanel extends HTMLElement {
private workspaceValue: Workspace | undefined;
private openTerminalValue: OpenTerminal | undefined;
private terminalCommandRunsValue: InternalTerminalCommandRunsRuntime | undefined;
private contextValue: WorkspacePanelContext | undefined;
private runningTaskId: string | undefined;
private status: TaskStatus | undefined;
private readonly root: ShadowRoot;
@@ -50,10 +44,10 @@ class PiWebTasksPanel extends HTMLElement {
this.root = this.attachShadow({ mode: "open" });
}
set workspace(value: Workspace | undefined) {
const previousKey = this.workspaceValue === undefined ? undefined : cacheKeyForWorkspace(this.workspaceValue);
const nextKey = value === undefined ? undefined : cacheKeyForWorkspace(value);
this.workspaceValue = value;
set context(value: WorkspacePanelContext | undefined) {
const previousKey = this.contextValue === undefined ? undefined : cacheKeyForContext(this.contextValue);
const nextKey = value === undefined ? undefined : cacheKeyForContext(value);
this.contextValue = value;
// Parent app updates should not rebuild this shadow DOM for the same workspace:
// doing so resets the mobile scroll position and can replace buttons mid-click.
if (previousKey === nextKey) return;
@@ -62,14 +56,6 @@ class PiWebTasksPanel extends HTMLElement {
this.render();
}
set openTerminal(value: OpenTerminal | undefined) {
this.openTerminalValue = value;
}
set terminalCommandRuns(value: InternalTerminalCommandRunsRuntime | undefined) {
this.terminalCommandRunsValue = value;
}
connectedCallback(): void {
window.addEventListener(configChangedEvent, this.onConfigChanged);
this.render();
@@ -80,13 +66,13 @@ class PiWebTasksPanel extends HTMLElement {
}
private render(): void {
const workspace = this.workspaceValue;
if (workspace === undefined) {
const context = this.contextValue;
if (context === undefined) {
this.root.innerHTML = `${taskStyles()}<section class="empty">Select a workspace.</section>`;
return;
}
const state = getOrLoadWorkspaceConfig(workspace);
const state = getOrLoadWorkspaceConfig(context);
this.root.innerHTML = `
${taskStyles()}
<section class="toolbar">
@@ -103,12 +89,12 @@ class PiWebTasksPanel extends HTMLElement {
`;
this.root.querySelector("button[data-refresh-config]")?.addEventListener("click", () => {
void this.refreshConfig(workspace);
void this.refreshConfig(context);
});
for (const button of this.root.querySelectorAll("button[data-task-id]")) {
button.addEventListener("click", () => {
void this.dispatchTaskById(workspace, button.getAttribute("data-task-id"));
void this.dispatchTaskById(context, button.getAttribute("data-task-id"));
});
}
@@ -117,19 +103,19 @@ class PiWebTasksPanel extends HTMLElement {
});
}
private dispatchTaskById(workspace: Workspace, taskId: string | null): Promise<void> {
if (!this.isCurrentWorkspace(workspace)) return Promise.resolve();
const task = taskFromConfigState(getCachedWorkspaceConfig(workspace), taskId);
private dispatchTaskById(context: WorkspacePanelContext, taskId: string | null): Promise<void> {
if (!this.isCurrentContext(context)) return Promise.resolve();
const task = taskFromConfigState(getCachedWorkspaceConfig(context), taskId);
if (task === undefined) {
this.status = { kind: "error", message: "That task is no longer available. Click Refresh, then try again." };
this.render();
return Promise.resolve();
}
return this.dispatchTask(workspace, task);
return this.dispatchTask(context, task);
}
private isCurrentWorkspace(workspace: Workspace): boolean {
return this.workspaceValue !== undefined && cacheKeyForWorkspace(this.workspaceValue) === cacheKeyForWorkspace(workspace);
private isCurrentContext(context: WorkspacePanelContext): boolean {
return this.contextValue !== undefined && cacheKeyForContext(this.contextValue) === cacheKeyForContext(context);
}
private renderConfigState(state: ConfigState): string {
@@ -150,20 +136,20 @@ class PiWebTasksPanel extends HTMLElement {
return `<div class="status panel-status ${escapeAttr(this.status.kind)}">${escapeHtml(this.status.message)}${detail}</div>`;
}
private async refreshConfig(workspace: Workspace): Promise<void> {
private async refreshConfig(context: WorkspacePanelContext): Promise<void> {
this.status = { kind: "info", message: `Refreshing ${TASKS_CONFIG_PATH}` };
configCache.set(cacheKeyForWorkspace(workspace), { kind: "loading" });
configCache.set(cacheKeyForContext(context), { kind: "loading" });
this.render();
const state = await refreshWorkspaceConfig(workspace);
if (!this.isCurrentWorkspace(workspace)) return;
const state = await refreshWorkspaceConfig(context);
if (!this.isCurrentContext(context)) return;
this.status = state.kind === "loaded"
? { kind: "success", message: `Loaded ${String(state.config.tasks.length)} task${state.config.tasks.length === 1 ? "" : "s"}.` }
: undefined;
this.render();
}
private async dispatchTask(workspace: Workspace, task: WorkspaceTask): Promise<void> {
private async dispatchTask(context: WorkspacePanelContext, task: WorkspaceTask): Promise<void> {
if (this.runningTaskId !== undefined) {
this.status = { kind: "info", message: "Another task is already starting. Wait for it to finish dispatching, then try again." };
this.render();
@@ -175,20 +161,13 @@ class PiWebTasksPanel extends HTMLElement {
return;
}
const terminal = this.terminalCommandRunsValue;
if (terminal === undefined) {
this.status = { kind: "error", message: "This PI WEB version does not provide terminal command helpers to plugins." };
this.render();
return;
}
this.runningTaskId = task.id;
this.status = { kind: "info", message: `Starting ${task.title}` };
this.render();
try {
const handle = await runWorkspaceTaskInTerminal(terminal, workspace, task);
if (!this.isCurrentWorkspace(workspace)) return;
const handle = await runWorkspaceTaskInTerminal(context.terminal, task);
if (!this.isCurrentContext(context)) return;
this.status = {
kind: "success",
message: `Started terminal command “${handle.run.title}”.`,
@@ -197,7 +176,7 @@ class PiWebTasksPanel extends HTMLElement {
this.runningTaskId = undefined;
this.render();
} catch (error) {
if (!this.isCurrentWorkspace(workspace)) return;
if (!this.isCurrentContext(context)) return;
this.runningTaskId = undefined;
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
this.render();
@@ -205,50 +184,47 @@ class PiWebTasksPanel extends HTMLElement {
}
private openWorkspaceTerminal(terminalId?: string): void {
if (this.terminalCommandRunsValue !== undefined) {
this.terminalCommandRunsValue.open(terminalId === undefined ? undefined : { terminalId });
return;
}
if (this.openTerminalValue === undefined) {
this.status = { kind: "error", message: "This PI WEB version does not provide terminal navigation to plugins." };
const context = this.contextValue;
if (context === undefined) {
this.status = { kind: "error", message: "Select a workspace before opening a terminal." };
this.render();
return;
}
if (terminalId === undefined) this.openTerminalValue();
else this.openTerminalValue({ terminalId });
if (terminalId === undefined) context.terminal.open();
else context.terminal.open({ terminalId });
}
}
function getCachedWorkspaceConfig(workspace: Workspace): ConfigState | undefined {
return configCache.get(cacheKeyForWorkspace(workspace));
function getCachedWorkspaceConfig(context: WorkspacePanelContext): ConfigState | undefined {
return configCache.get(cacheKeyForContext(context));
}
function getOrLoadWorkspaceConfig(workspace: Workspace): ConfigState {
const cached = getCachedWorkspaceConfig(workspace);
function getOrLoadWorkspaceConfig(context: WorkspacePanelContext): ConfigState {
const cached = getCachedWorkspaceConfig(context);
if (cached !== undefined) return cached;
const loading: ConfigState = { kind: "loading" };
configCache.set(cacheKeyForWorkspace(workspace), loading);
void refreshWorkspaceConfig(workspace);
configCache.set(cacheKeyForContext(context), loading);
void refreshWorkspaceConfig(context);
return loading;
}
async function refreshWorkspaceConfig(workspace: Workspace): Promise<ConfigState> {
const key = cacheKeyForWorkspace(workspace);
const state = await loadWorkspaceTasksConfig(workspace).catch((error: unknown): ConfigState => ({
async function refreshWorkspaceConfig(context: WorkspacePanelContext): Promise<ConfigState> {
const key = cacheKeyForContext(context);
const state = await loadWorkspaceTasksConfig(context.files).catch((error: unknown): ConfigState => ({
kind: "unavailable",
message: tasksConfigUnavailableMessage,
hint: tasksConfigRefreshHint,
detail: error instanceof Error ? error.message : String(error),
}));
configCache.set(key, state);
requestPiWebRender();
context.requestRender();
window.dispatchEvent(new Event(configChangedEvent));
return state;
}
function cacheKeyForWorkspace(workspace: Workspace): string {
return `${workspace.projectId}:${workspace.id}`;
function cacheKeyForContext(context: WorkspacePanelContext): string {
return `${context.machine.id}:${context.workspace.projectId}:${context.workspace.id}`;
}
function renderMissingState(state: Extract<ConfigState, { kind: "missing" }>): string {
@@ -1,31 +1,24 @@
import { describe, expect, it } from "vitest";
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
import { describe, expect, it, vi } from "vitest";
import { TASKS_CONFIG_PATH } from "./config";
import { loadWorkspaceTasksConfig, parseWorkspaceFileResponse, workspaceFileUrl, type FetchLike } from "./workspaceTasksClient";
const workspace: Workspace = {
id: "workspace 1",
projectId: "project/1",
path: "/repo",
label: "repo",
isMain: false,
isGitRepo: true,
isGitWorktree: true,
};
import { loadWorkspaceTasksConfig, type WorkspaceTasksFileReader } from "./workspaceTasksClient";
describe("workspace tasks client", () => {
it("builds the private workspace file URL", () => {
expect(workspaceFileUrl(workspace, TASKS_CONFIG_PATH)).toBe("/api/projects/project%2F1/workspaces/workspace%201/file?path=.pi-web%2Ftasks.json");
it("loads the configured path through the public workspace file helper", async () => {
const readFile = vi.fn<WorkspaceTasksFileReader["readFile"]>(() => Promise.resolve({ content: JSON.stringify({ version: 1, tasks: [] }), truncated: false, binary: false }));
await loadWorkspaceTasksConfig({ readFile });
expect(readFile).toHaveBeenCalledWith(TASKS_CONFIG_PATH);
});
it("loads and parses a valid tasks config", async () => {
const fetcher: FetchLike = () => Promise.resolve(jsonResponse({
it("loads and parses a valid tasks config through the public workspace file helper", async () => {
const files = reader({
content: JSON.stringify({ version: 1, tasks: [{ id: "build", title: "Build", command: "npm run build" }] }),
truncated: false,
binary: false,
}));
});
await expect(loadWorkspaceTasksConfig(workspace, { fetch: fetcher })).resolves.toEqual({
await expect(loadWorkspaceTasksConfig(files)).resolves.toEqual({
kind: "loaded",
path: TASKS_CONFIG_PATH,
config: {
@@ -36,49 +29,40 @@ describe("workspace tasks client", () => {
});
it("treats a missing optional tasks config as unconfigured", async () => {
const fetcher: FetchLike = () => Promise.resolve(missingResponse());
const files: WorkspaceTasksFileReader = { readFile: () => Promise.reject(new Error("Path does not exist")) };
await expect(loadWorkspaceTasksConfig(workspace, { fetch: fetcher })).resolves.toEqual({
await expect(loadWorkspaceTasksConfig(files)).resolves.toEqual({
kind: "missing",
message: "No workspace tasks configured here.",
hint: `${TASKS_CONFIG_PATH} is optional. Create it in this workspace if you want custom tasks.`,
});
});
it("returns a visible unavailable state instead of throwing on request failures", async () => {
const fetcher: FetchLike = () => Promise.resolve(new Response(JSON.stringify({ error: "nope" }), { status: 400 }));
it("returns a visible unavailable state instead of throwing on read failures", async () => {
const files: WorkspaceTasksFileReader = { readFile: () => Promise.reject(new Error("nope")) };
await expect(loadWorkspaceTasksConfig(workspace, { fetch: fetcher })).resolves.toMatchObject({
await expect(loadWorkspaceTasksConfig(files)).resolves.toMatchObject({
kind: "unavailable",
message: "Could not load workspace tasks.",
hint: `Fix ${TASKS_CONFIG_PATH}, then click Refresh.`,
detail: `Unable to read ${TASKS_CONFIG_PATH}: HTTP 400: nope`,
detail: `Unable to read ${TASKS_CONFIG_PATH}: nope`,
});
});
it("returns parser details for invalid config files", async () => {
const fetcher: FetchLike = () => Promise.resolve(jsonResponse({
const files = reader({
content: JSON.stringify({ version: 2, tasks: [] }),
truncated: false,
binary: false,
}));
});
await expect(loadWorkspaceTasksConfig(workspace, { fetch: fetcher })).resolves.toMatchObject({
await expect(loadWorkspaceTasksConfig(files)).resolves.toMatchObject({
kind: "unavailable",
detail: "Config version must be 1",
});
});
it("validates workspace file responses", () => {
expect(parseWorkspaceFileResponse({ content: "{}", truncated: false, binary: false })).toEqual({ content: "{}", truncated: false, binary: false });
expect(parseWorkspaceFileResponse({ content: "{}", truncated: "no", binary: false })).toBeUndefined();
});
});
function jsonResponse(value: unknown): Response {
return new Response(JSON.stringify(value), { status: 200, headers: { "content-type": "application/json" } });
}
function missingResponse(): Response {
return new Response(JSON.stringify({ error: "Path does not exist" }), { status: 400 });
function reader(file: Awaited<ReturnType<WorkspaceTasksFileReader["readFile"]>>): WorkspaceTasksFileReader {
return { readFile: () => Promise.resolve(file) };
}
@@ -1,4 +1,3 @@
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
import { TASKS_CONFIG_PATH, parseTasksConfigText, type WorkspaceTasksConfig } from "./config.js";
export const tasksConfigMissingMessage = "No workspace tasks configured here.";
@@ -8,46 +7,30 @@ export const tasksConfigRefreshHint = `Fix ${TASKS_CONFIG_PATH}, then click Refr
const missingWorkspaceFileError = "Path does not exist";
export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
export interface WorkspaceTasksFileReader {
readFile(path: string): Promise<WorkspaceTasksFileContent>;
}
interface WorkspaceTasksFileContent {
content: string;
truncated: boolean;
binary: boolean;
}
export type WorkspaceTasksConfigLoadResult =
| { kind: "loaded"; config: WorkspaceTasksConfig; path: string }
| { kind: "missing"; message: string; hint: string }
| { kind: "unavailable"; message: string; hint: string; detail?: string };
interface WorkspaceFileResponse {
content: string;
truncated: boolean;
binary: boolean;
}
export async function loadWorkspaceTasksConfig(
workspace: Workspace,
deps: { fetch: FetchLike } = { fetch: window.fetch.bind(window) },
): Promise<WorkspaceTasksConfigLoadResult> {
let response: Response;
export async function loadWorkspaceTasksConfig(files: WorkspaceTasksFileReader): Promise<WorkspaceTasksConfigLoadResult> {
let file: WorkspaceTasksFileContent;
try {
response = await deps.fetch(workspaceFileUrl(workspace, TASKS_CONFIG_PATH), { cache: "no-store" });
file = await files.readFile(TASKS_CONFIG_PATH);
} catch (error) {
if (errorMessage(error) === missingWorkspaceFileError) return missing();
return unavailable(`Unable to read ${TASKS_CONFIG_PATH}: ${formatUnknownError(error)}`);
}
if (!response.ok) {
const errorMessage = await readResponseErrorMessage(response);
if (errorMessage === missingWorkspaceFileError) return missing();
const responseSummary = errorMessage === undefined ? `HTTP ${String(response.status)}` : `HTTP ${String(response.status)}: ${errorMessage}`;
return unavailable(`Unable to read ${TASKS_CONFIG_PATH}: ${responseSummary}`);
}
let body: unknown;
try {
body = await response.json();
} catch (error) {
return unavailable(`Invalid response while reading ${TASKS_CONFIG_PATH}: ${formatUnknownError(error)}`);
}
const file = parseWorkspaceFileResponse(body);
if (file === undefined) return unavailable(`Invalid response while reading ${TASKS_CONFIG_PATH}`);
if (file.binary) return unavailable(`${TASKS_CONFIG_PATH} must be a text file`);
if (file.truncated) return unavailable(`${TASKS_CONFIG_PATH} is too large and was truncated`);
@@ -56,19 +39,6 @@ export async function loadWorkspaceTasksConfig(
return { kind: "loaded", config: result.config, path: TASKS_CONFIG_PATH };
}
export function workspaceFileUrl(workspace: Workspace, path: string): string {
return `/api/projects/${encodeURIComponent(workspace.projectId)}/workspaces/${encodeURIComponent(workspace.id)}/file?path=${encodeURIComponent(path)}`;
}
export function parseWorkspaceFileResponse(value: unknown): WorkspaceFileResponse | undefined {
if (!isRecord(value)) return undefined;
const content = value["content"];
const truncated = value["truncated"];
const binary = value["binary"];
if (typeof content !== "string" || typeof truncated !== "boolean" || typeof binary !== "boolean") return undefined;
return { content, truncated, binary };
}
function missing(): WorkspaceTasksConfigLoadResult {
return {
kind: "missing",
@@ -86,21 +56,10 @@ function unavailable(detail: string): WorkspaceTasksConfigLoadResult {
};
}
async function readResponseErrorMessage(response: Response): Promise<string | undefined> {
try {
const body: unknown = await response.json();
if (!isRecord(body)) return undefined;
const error = body["error"];
return typeof error === "string" ? error : undefined;
} catch {
return undefined;
}
function errorMessage(error: unknown): string | undefined {
return error instanceof Error ? error.message : undefined;
}
function formatUnknownError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}