Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
384 changes: 0 additions & 384 deletions src/profiles/web/dist/assets/index-A7Dw9JEC.js

This file was deleted.

1 change: 0 additions & 1 deletion src/profiles/web/dist/assets/index-CYtEXweO.css

This file was deleted.

1 change: 1 addition & 0 deletions src/profiles/web/dist/assets/index-NlIXOAvu.css

Large diffs are not rendered by default.

384 changes: 384 additions & 0 deletions src/profiles/web/dist/assets/index-QRvtM2rd.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions src/profiles/web/dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ProFiles</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<script type="module" crossorigin src="/assets/index-A7Dw9JEC.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CYtEXweO.css">
<script type="module" crossorigin src="/assets/index-QRvtM2rd.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-NlIXOAvu.css">
<link rel="manifest" href="/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
<body class="antialiased selection:bg-indigo-500 selection:text-white">
<div id="root"></div>
Expand Down
2 changes: 1 addition & 1 deletion src/profiles/web/dist/sw.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 30 additions & 7 deletions src/profiles/web/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,32 @@ import type {
WorkflowSessionState,
} from "./types";

/**
* Error carrying the HTTP status so callers can react to specific codes —
* e.g. 404 from the workflow session routes means the server-side session
* expired or was purged (TTL), and the UI can offer recovery (#79).
*/
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = "ApiError";
this.status = status;
}
}

/** Build an ApiError from a failed Response, preferring the body's detail. */
async function apiError(res: Response, fallback: string): Promise<ApiError> {
let detail = fallback;
try {
const body = await res.json();
if (body?.detail) detail = body.detail;
} catch {
// keep the fallback message
}
return new ApiError(detail, res.status);
}

/** Fetch UI events recorded after *afterId* (config handoff notifications). */
export async function fetchNotifications(afterId: number): Promise<{ events: ExternalEvent[] }> {
const res = await fetch(`/api/notifications?after=${afterId}`);
Expand Down Expand Up @@ -325,16 +351,13 @@ export async function startWorkflowSession(params: {
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
if (!res.ok) {
const errorData = await res.json().catch(() => ({ detail: "Failed to start workflow session" }));
throw new Error(errorData.detail || "Failed to start workflow session");
}
if (!res.ok) throw await apiError(res, "Failed to start workflow session");
return res.json();
}

export async function fetchWorkflowSession(sessionId: string): Promise<WorkflowSessionState> {
const res = await fetch(`/api/workflow/${sessionId}`);
if (!res.ok) throw new Error("Failed to fetch workflow session");
if (!res.ok) throw await apiError(res, "Failed to fetch workflow session");
return res.json();
}

Expand All @@ -347,13 +370,13 @@ export async function respondWorkflowSession(
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action }),
});
if (!res.ok) throw new Error("Failed to respond to workflow prompt");
if (!res.ok) throw await apiError(res, "Failed to respond to workflow prompt");
return res.json();
}

/** Abort a still-interactive workflow session (modal closed mid-workflow). */
export async function abortWorkflowSession(sessionId: string): Promise<WorkflowSessionState> {
const res = await fetch(`/api/workflow/${sessionId}/abort`, { method: "POST" });
if (!res.ok) throw new Error("Failed to abort workflow session");
if (!res.ok) throw await apiError(res, "Failed to abort workflow session");
return res.json();
}
185 changes: 185 additions & 0 deletions src/profiles/web/src/components/WorkflowRunnerModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -254,3 +254,188 @@ describe("gate key hints", () => {
expect(ack.querySelector("kbd")).toBeTruthy();
});
});

describe("copy buttons (#78)", () => {
function mockClipboard(): ReturnType<typeof vi.fn> {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
return writeText;
}

it("copies the interactive command text and flips to Copied", async () => {
// userEvent.setup() patches navigator.clipboard with its own stub:
// install our mock AFTER setup or it is silently replaced (#78 tests).
const user = userEvent.setup();
const writeText = mockClipboard();
renderModal();
const copy = await screen.findByRole("button", { name: "Copy content" });
await user.click(copy);
await waitFor(() => expect(writeText).toHaveBeenCalledWith("cmd-1"));
expect(await screen.findByRole("button", { name: "Copied" })).toBeTruthy();
});

it("copies the finished-session command output", async () => {
const user = userEvent.setup();
const writeText = mockClipboard();
startSpy.mockResolvedValue(
makeSession({ status: "completed", current_index: 2, last_output: "out-line" }),
);
renderModal();
const copy = await screen.findByRole("button", { name: "Copy content" });
await user.click(copy);
await waitFor(() => expect(writeText).toHaveBeenCalledWith("out-line"));
});

it("swallows clipboard failures silently (webview without clipboard API)", async () => {
const user = userEvent.setup();
Object.defineProperty(navigator, "clipboard", {
value: { writeText: vi.fn().mockRejectedValue(new Error("nope")) },
configurable: true,
});
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
renderModal();
const copy = await screen.findByRole("button", { name: "Copy content" });
await user.click(copy);
// Still labelled Copy: the copy did not happen, and no error UI appears.
expect(await screen.findByRole("button", { name: "Copy content" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "Copied" })).toBeNull();
consoleSpy.mockRestore();
});
});

describe("session-expired recovery (#79)", () => {
function goneError(): Error {
return Object.assign(new Error("Session not found"), { status: 404 });
}

it("switches to the recovery panel when respond 404s", async () => {
const user = userEvent.setup();
respondSpy.mockRejectedValue(goneError());
renderModal();
const exec = await screen.findByRole("button", { name: "Execute Step" });
await user.click(exec);

expect(await screen.findByText(/has expired or ended/i)).toBeTruthy();
// Dead footer gates are replaced by recovery actions.
expect(screen.queryByRole("button", { name: "Execute Step" })).toBeNull();
expect(screen.queryByRole("button", { name: /launch now/i })).toBeNull();
expect(screen.getByRole("button", { name: /restart workflow/i })).toBeTruthy();
// Raw error kept for diagnostics behind the collapsible.
expect(screen.getByText(/Raw error/i)).toBeTruthy();
// Sidebar shows the explicit unavailable state.
expect(screen.getByText(/Session unavailable/i)).toBeTruthy();
expect(screen.queryByText(/Loading steps/i)).toBeNull();
});

it("restart re-issues start with identical props and clears the error", async () => {
const user = userEvent.setup();
respondSpy.mockRejectedValueOnce(goneError());
renderModal();
const exec = await screen.findByRole("button", { name: "Execute Step" });
await user.click(exec);
await screen.findByText(/has expired or ended/i);

await user.click(screen.getByRole("button", { name: /restart workflow/i }));
await waitFor(() => expect(startSpy).toHaveBeenCalledTimes(2));
expect(startSpy).toHaveBeenLastCalledWith({
directory: "C:/d",
filename: "build.mttl",
release: "1.0",
args: undefined,
});
// The session is interactive again and the error strip is gone.
await screen.findByRole("button", { name: "Execute Step" });
expect(screen.queryByText(/has expired or ended/i)).toBeNull();
});

it("non-404 respond errors keep the gates and show the pinned banner", async () => {
const user = userEvent.setup();
respondSpy.mockRejectedValueOnce(new Error("boom"));
renderModal();
const exec = await screen.findByRole("button", { name: "Execute Step" });
await user.click(exec);
expect(await screen.findByText("boom")).toBeTruthy();
// Not a session-gone error: gates stay available.
expect(screen.getByRole("button", { name: "Execute Step" })).toBeTruthy();
expect(screen.queryByRole("button", { name: /restart workflow/i })).toBeNull();
});

it("start failure shows the pinned banner and an explicit sidebar state", async () => {
startSpy.mockRejectedValue(
Object.assign(new Error("No matching workflow found for file"), { status: 400 }),
);
renderModal();
expect(await screen.findByText(/No matching workflow/i)).toBeTruthy();
expect(screen.getByText(/Session unavailable/i)).toBeTruthy();
expect(screen.queryByText(/Loading steps/i)).toBeNull();
});
});

describe("step sidebar semantics (#80)", () => {
it("renders a semantic list with visible status words and aria-current", async () => {
renderModal();
const step1 = await screen.findByRole("button", { name: /#1/ });
expect(screen.getByRole("list")).toBeTruthy();
// Status is text, not color-only (WCAG 1.4.1): current step is pending.
expect(step1.textContent).toContain("pending");
expect(screen.getByRole("button", { name: /#0/ }).textContent).toContain("done");
expect(step1.getAttribute("aria-current")).toBe("step");
expect(screen.getByRole("button", { name: /#0/ }).getAttribute("aria-current")).toBeNull();
});

it("uses a roving tabindex and moves selection with arrow keys", async () => {
renderModal();
const step0 = await screen.findByRole("button", { name: /#0/ });
const step1 = screen.getByRole("button", { name: /#1/ });
// Single Tab stop: only the inspected row is tabbable.
expect(step0.getAttribute("tabindex")).toBe("-1");
expect(step1.getAttribute("tabindex")).toBe("0");

step1.focus();
step1.dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true, cancelable: true }),
);
await waitFor(() => {
const nowCurrent = screen.getByRole("button", { name: /#0/ });
expect(nowCurrent.getAttribute("aria-current")).toBe("step");
expect(document.activeElement).toBe(nowCurrent);
});
});

it("footer status hint is a live region", async () => {
renderModal();
const hint = await screen.findByText(/Action required to proceed/i);
expect(hint.getAttribute("aria-live")).toBe("polite");
});
});

describe("keyboard guard residuals (#82)", () => {
it("Enter closes the runner when finished and no button holds focus", async () => {
const { onClose } = renderModal();
const user = userEvent.setup();
respondSpy.mockResolvedValue(makeSession({ status: "completed", current_index: 2 }));
await user.click(await screen.findByRole("button", { name: "Execute Step" }));
await screen.findByRole("button", { name: "Close Runner" });
(document.activeElement as HTMLElement | null)?.blur?.();
document.body.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }),
);
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
});

it("gate keys are ignored while focus is in a text input", async () => {
renderModal();
await screen.findByRole("button", { name: "Execute Step" });
const input = document.createElement("input");
document.body.appendChild(input);
input.focus();
input.dispatchEvent(
new KeyboardEvent("keydown", { key: "y", bubbles: true, cancelable: true }),
);
expect(respondSpy).not.toHaveBeenCalled();
input.remove();
});
});
Loading
Loading