mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
test(storybook): add production examples for feature map UI gaps (#16196)
## Thinking Path > - Paperclip manages AI agents and their work. > - The feature map records the user-facing capabilities. > - Storybook uses the same feature index. > - Step 2 found 49 UI capabilities with no authored examples. > - This pull request adds shipped-component examples with isolated fixtures. > - The catalog records each example's states and remaining production checks. ## Linked Issues or Issue Description Refs: #16167. The verification inventory is merged; this follow-up now targets master. ## What Changed - Add 37 suites and 257 scenarios for all 49 UI capabilities from the step-2 inventory. Keep existing stories and IDs. - Use shipped pages and components. Simulate API replies, sockets, props, and callbacks at their boundaries. - Activate story API fixtures before the application providers read data. Block unregistered API mutations and restore owned storage/fetch state. - Add browser checks for rendering, declared play assertions, fixture reset, and selected mobile/theme states. Update the catalog and verification inventory. ## Verification - `pnpm -r typecheck`: passed. - `pnpm build`: passed. - UI Vitest suite: 730 files and 8,208 tests passed. Fixture boundary tests: 4 passed. - Map/catalog checks, 27 generator/map tests, and token gates: passed. - `pnpm build-storybook`: passed. All 2,248 previous built entry IDs remain present. - Pre-reconciliation static browser suite: 260 checks passed (257 scenarios, token fixture reset, and two mobile/theme cases). All non-play stories assert an explicit subject state; 129 play functions passed. - `pnpm test:run` could not complete on this Mac. Embedded PostgreSQL exhausted host shared-memory allocation. The run also reported native-runner test timeouts. Only this task's verified runner was stopped; other jobs and host settings were preserved. - Reconciled onto merged master `252d9b5077`. The six draft stories and new same-browser reset regression passed (7 checks); 16 draft-persistence tests and UI typecheck passed. Static merge verification: 32 affected-subject/draft checks passed, including same-browser reset. Rebuilt Storybook retains all 2,248 original IDs in its 2,517-entry index. Four additional play-result checks passed: visible onboarding reset, pending probe, filtered-empty runs, and optimistic queued-response removal. UI typecheck and 27 catalog/map tests also passed. Final static browser suite on `3fab35df8d912935fa86ce0de7d32d07e2b5f4c3`: all 261 checks passed (257 scenarios plus fixture-reset and mobile checks). Latest head `3fab35df8d912935fa86ce0de7d32d07e2b5f4c3`: all 55 CI/status checks passed; the two optional Storybook workflow jobs were skipped. Greptile 5/5 with no unresolved threads. - No pinned Linux visual-baseline comparison or real provider journey is claimed. ## Risks Fixtures can drift from production contracts. The catalog records inspected sources and their fingerprints. Capability links provide initial isolated UI coverage; they do not certify every recipe variant, real permissions, persistence, or provider behavior. The new examples expose existing UI limits. Plugin discovery failure stays on Loading. Document and environment-list reads lack dedicated failure feedback; Cases and Inbox fall back to empty views after errors. Goal alignment has display views but no assignment control. These findings remain visible in the inventory. ## Model Used OpenAI GPT-6 via Codex. The exact runtime deployment ID and context window are not exposed in this session. Used code generation, shell execution, and browser verification. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
252d9b5077
commit
c8efdbfba4
54 files changed
+6759
-73
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { activatePreparedFixtures } from "../support/capability-gaps/api";
|
||||
import { useLayoutEffect, useState, type ReactNode } from "react";
|
||||
import type { Preview } from "@storybook/react-vite";
|
||||
import { MINIMAL_VIEWPORTS } from "storybook/viewport";
|
||||
@@ -764,6 +765,7 @@ function StorybookProviders({
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
installStorybookApiFixtures();
|
||||
activatePreparedFixtures();
|
||||
}
|
||||
|
||||
const [viewportReady, setViewportReady] = useState(() => !initialViewportWidth || window.parent === window || window.innerWidth === initialViewportWidth);
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Routes, Route } from "@/lib/router";
|
||||
import { CliAuthPage } from "@/pages/CliAuth";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import {
|
||||
prepareFixtureApi,
|
||||
type FixtureState,
|
||||
} from "../../support/capability-gaps/api";
|
||||
function CliAuthorization() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/cli-auth/:id" element={<CliAuthPage />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
const meta = {
|
||||
id: "feature-cli-authorization",
|
||||
title:
|
||||
"Getting started and access/Login, invitations, and access/CLI authorization",
|
||||
component: CliAuthorization,
|
||||
parameters: {
|
||||
initialEntries: ["/cli-auth/challenge-gap?token=fixture-code"],
|
||||
layout: "padded",
|
||||
},
|
||||
beforeEach: ({ parameters }) => {
|
||||
let status = parameters.status ?? "pending";
|
||||
return prepareFixtureApi(
|
||||
[
|
||||
{
|
||||
path: "/api/cli-auth/challenges/challenge-gap",
|
||||
read: () => ({
|
||||
id: "challenge-gap",
|
||||
status,
|
||||
command: "paperclipai auth login",
|
||||
clientName: "Review workstation",
|
||||
requestedAccess: "board",
|
||||
requestedCompanyId: "company-storybook",
|
||||
requestedCompanyName: "Acme Research",
|
||||
approvedAt: null,
|
||||
expiresAt: "2099-01-01T00:00:00Z",
|
||||
requiresSignIn: false,
|
||||
canApprove: !parameters.blocked,
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/api/cli-auth/challenges/challenge-gap/approve",
|
||||
method: "POST",
|
||||
read: () => {
|
||||
status = "approved";
|
||||
return {
|
||||
approved: true,
|
||||
status,
|
||||
userId: "user-storybook",
|
||||
keyId: "fixture-key",
|
||||
expiresAt: "2099-01-01T00:00:00Z",
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/api/cli-auth/challenges/challenge-gap/cancel",
|
||||
method: "POST",
|
||||
read: () => {
|
||||
status = "cancelled";
|
||||
return { cancelled: true, status };
|
||||
},
|
||||
},
|
||||
],
|
||||
parameters.fixtureState as FixtureState,
|
||||
).cleanup;
|
||||
},
|
||||
} satisfies Meta<typeof CliAuthorization>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Pending: Story = {};
|
||||
export const Loading: Story = { parameters: { fixtureState: "loading" } };
|
||||
export const Expired: Story = { parameters: { status: "expired" } };
|
||||
export const Unavailable: Story = { parameters: { fixtureState: "failure" } };
|
||||
export const PermissionBlocked: Story = {
|
||||
parameters: { blocked: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
await expect(
|
||||
await within(canvasElement).findByRole("button", {
|
||||
name: "Approve CLI access",
|
||||
}),
|
||||
).toBeDisabled();
|
||||
},
|
||||
};
|
||||
export const Approved: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await c.findByRole("button", { name: "Approve CLI access" }),
|
||||
);
|
||||
await c.findByRole("heading", { name: "CLI access approved" });
|
||||
},
|
||||
};
|
||||
export const Approving: Story = {
|
||||
parameters: { fixtureState: "saving" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await c.findByRole("button", { name: "Approve CLI access" }),
|
||||
);
|
||||
await expect(
|
||||
await c.findByRole("button", { name: "Approving..." }),
|
||||
).toBeDisabled();
|
||||
},
|
||||
};
|
||||
export const ApprovalFailed: Story = {
|
||||
parameters: { fixtureState: "mutation-failure" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await c.findByRole("button", { name: "Approve CLI access" }),
|
||||
);
|
||||
await c.findByText("The request failed. Please try again.");
|
||||
},
|
||||
};
|
||||
export const Cancelled: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await userEvent.click(await c.findByRole("button", { name: "Cancel" }));
|
||||
await c.findByRole("heading", { name: "CLI auth challenge cancelled" });
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ToastViewport } from "@/components/ToastViewport";
|
||||
import { CompanyAccess } from "@/pages/CompanyAccess";
|
||||
import { expect, userEvent, within, waitFor } from "storybook/test";
|
||||
import {
|
||||
prepareFixtureApi,
|
||||
type FixtureState,
|
||||
} from "../../support/capability-gaps/api";
|
||||
let member = {
|
||||
id: "member-gap",
|
||||
companyId: "company-storybook",
|
||||
principalType: "user",
|
||||
principalId: "user-review",
|
||||
status: "active",
|
||||
membershipRole: "operator",
|
||||
user: {
|
||||
id: "user-review",
|
||||
email: "reviewer@example.test",
|
||||
name: "Release reviewer",
|
||||
image: null,
|
||||
},
|
||||
grants: [],
|
||||
createdAt: "2026-10-12T12:00:00Z",
|
||||
updatedAt: "2026-10-12T12:00:00Z",
|
||||
};
|
||||
function MembershipHost() {
|
||||
return (
|
||||
<>
|
||||
<CompanyAccess />
|
||||
<ToastViewport />
|
||||
</>
|
||||
);
|
||||
}
|
||||
const meta = {
|
||||
id: "feature-membership",
|
||||
title:
|
||||
"Getting started and access/Login, invitations, and access/Organization membership",
|
||||
component: MembershipHost,
|
||||
parameters: { layout: "padded" },
|
||||
beforeEach: ({ parameters }) => {
|
||||
member = { ...member, membershipRole: "operator", status: "active" };
|
||||
return prepareFixtureApi(
|
||||
[
|
||||
{
|
||||
path: "/api/companies/company-storybook/members",
|
||||
read: () => ({
|
||||
members: parameters.fixtureState === "empty" ? [] : [member],
|
||||
access: {
|
||||
currentUserRole: "owner",
|
||||
canManageMembers: true,
|
||||
canInviteUsers: true,
|
||||
canApproveJoinRequests: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/api/companies/company-storybook/join-requests",
|
||||
read: () => [],
|
||||
},
|
||||
{
|
||||
path: "/api/companies/company-storybook/members/member-gap",
|
||||
method: "PATCH",
|
||||
read: ({ body }) => {
|
||||
member = { ...member, ...body };
|
||||
return member;
|
||||
},
|
||||
},
|
||||
],
|
||||
parameters.fixtureState as FixtureState,
|
||||
).cleanup;
|
||||
},
|
||||
} satisfies Meta<typeof MembershipHost>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Members: Story = {};
|
||||
export const Loading: Story = { parameters: { fixtureState: "loading" } };
|
||||
export const Empty: Story = { parameters: { fixtureState: "empty" } };
|
||||
export const Failed: Story = { parameters: { fixtureState: "failure" } };
|
||||
export const PermissionDenied: Story = {
|
||||
parameters: { fixtureState: "permission" },
|
||||
};
|
||||
export const UpdateRole: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole("button", { name: "Edit" }),
|
||||
);
|
||||
const c = within(document.body);
|
||||
await userEvent.selectOptions(
|
||||
c.getByRole("combobox", { name: "Organization role" }),
|
||||
"viewer",
|
||||
);
|
||||
await userEvent.click(c.getByRole("button", { name: "Save member" }));
|
||||
await waitFor(() => expect(member.membershipRole).toBe("viewer"));
|
||||
await waitFor(() =>
|
||||
expect(c.queryByRole("dialog")).not.toBeInTheDocument(),
|
||||
);
|
||||
},
|
||||
};
|
||||
export const SaveFailed: Story = {
|
||||
parameters: { fixtureState: "mutation-failure" },
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole("button", { name: "Edit" }),
|
||||
);
|
||||
const c = within(document.body);
|
||||
await userEvent.selectOptions(
|
||||
c.getByRole("combobox", { name: "Organization role" }),
|
||||
"viewer",
|
||||
);
|
||||
await userEvent.click(c.getByRole("button", { name: "Save member" }));
|
||||
await c.findByText("The request failed. Please try again.");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Routes, Route } from "@/lib/router";
|
||||
import { AuthPage } from "@/pages/Auth";
|
||||
import { Companies } from "@/pages/Companies";
|
||||
import { expect, userEvent, within, waitFor } from "storybook/test";
|
||||
import { storybookAuthSession } from "../../fixtures/paperclipData";
|
||||
import {
|
||||
prepareFixtureApi,
|
||||
type FixtureState,
|
||||
} from "../../support/capability-gaps/api";
|
||||
function SignIn() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/auth" element={<AuthPage />} />
|
||||
<Route path="/companies" element={<Companies />} />
|
||||
<Route path="/PAP/companies" element={<Companies />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
const meta = {
|
||||
id: "feature-sign-in",
|
||||
title: "Getting started and access/Login, invitations, and access/Sign in",
|
||||
component: SignIn,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
initialEntries: ["/auth?next=/companies"],
|
||||
},
|
||||
beforeEach: ({ parameters }) => {
|
||||
let session: unknown = null;
|
||||
return prepareFixtureApi(
|
||||
[
|
||||
{
|
||||
path: "/api/health",
|
||||
state: "ready",
|
||||
read: () => ({
|
||||
status: "ok",
|
||||
deploymentMode: "authenticated",
|
||||
authReady: true,
|
||||
}),
|
||||
},
|
||||
{ path: "/api/auth/get-session", read: () => session },
|
||||
{
|
||||
path: "/api/auth/sign-in/email",
|
||||
method: "POST",
|
||||
read: () => {
|
||||
session = storybookAuthSession;
|
||||
return { user: storybookAuthSession.user, token: "fixture-only" };
|
||||
},
|
||||
},
|
||||
],
|
||||
parameters.fixtureState as FixtureState,
|
||||
).cleanup;
|
||||
},
|
||||
} satisfies Meta<typeof SignIn>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const SignedOut: Story = {};
|
||||
export const Loading: Story = { parameters: { fixtureState: "loading" } };
|
||||
export const SessionFailed: Story = { parameters: { fixtureState: "failure" } };
|
||||
async function submit(canvasElement: HTMLElement) {
|
||||
const c = within(canvasElement);
|
||||
const email = await c.findByRole("textbox", { name: "Email" });
|
||||
await userEvent.clear(email);
|
||||
await userEvent.click(email);
|
||||
await userEvent.paste("reviewer@example.test");
|
||||
const password = c.getByLabelText("Password");
|
||||
await userEvent.clear(password);
|
||||
await userEvent.click(password);
|
||||
await userEvent.paste("fixture-password");
|
||||
await waitFor(() => {
|
||||
expect(c.getByRole("textbox", { name: "Email" })).toHaveValue(
|
||||
"reviewer@example.test",
|
||||
);
|
||||
expect(c.getByLabelText("Password")).toHaveValue("fixture-password");
|
||||
expect(c.getByRole("button", { name: "Sign In" })).toHaveAttribute(
|
||||
"aria-disabled",
|
||||
"false",
|
||||
);
|
||||
});
|
||||
await userEvent.click(c.getByRole("button", { name: "Sign In" }));
|
||||
}
|
||||
export const SignInFailed: Story = {
|
||||
parameters: { fixtureState: "mutation-failure" },
|
||||
play: async ({ canvasElement }) => {
|
||||
await submit(canvasElement);
|
||||
await within(canvasElement).findByText(
|
||||
"The request failed. Please try again.",
|
||||
);
|
||||
},
|
||||
};
|
||||
export const SigningIn: Story = {
|
||||
parameters: { fixtureState: "saving" },
|
||||
play: async ({ canvasElement }) => {
|
||||
await submit(canvasElement);
|
||||
await expect(
|
||||
await within(canvasElement).findByRole("button", { name: "Working…" }),
|
||||
).toBeDisabled();
|
||||
},
|
||||
};
|
||||
export const SignedIn: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await submit(canvasElement);
|
||||
await within(canvasElement).findByRole("button", {
|
||||
name: "New Organization",
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, within } from "storybook/test";
|
||||
import { AuditRuns } from "@/pages/audit/AuditRuns";
|
||||
import {
|
||||
storybookLiveRuns,
|
||||
storybookAgents,
|
||||
} from "../../fixtures/paperclipData";
|
||||
import {
|
||||
prepareFixtureApi,
|
||||
type FixtureState,
|
||||
} from "../../support/capability-gaps/api";
|
||||
const meta = {
|
||||
id: "feature-run-audit",
|
||||
title:
|
||||
"Oversight and operation/Dashboards and audit trails/Execution history",
|
||||
component: AuditRuns,
|
||||
args: { companyId: "company-storybook" },
|
||||
parameters: { layout: "padded" },
|
||||
beforeEach: ({ parameters }) =>
|
||||
prepareFixtureApi(
|
||||
[
|
||||
{
|
||||
path: "/api/companies/company-storybook/heartbeat-runs",
|
||||
read: () =>
|
||||
parameters.fixtureState === "empty"
|
||||
? []
|
||||
: [
|
||||
{
|
||||
...storybookLiveRuns[0],
|
||||
id: "run-gap",
|
||||
agentId: storybookAgents[0].id,
|
||||
status: "succeeded",
|
||||
invocationSource: "on_demand",
|
||||
startedAt: "2026-10-12T12:00:00Z",
|
||||
finishedAt: "2026-10-12T12:01:00Z",
|
||||
resultJson: { summary: "Reviewed the release checklist" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
parameters.fixtureState as FixtureState,
|
||||
).cleanup,
|
||||
} satisfies Meta<typeof AuditRuns>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const CompletedRun: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await within(canvasElement).findByText("Reviewed the release checklist");
|
||||
},
|
||||
};
|
||||
export const Loading: Story = { parameters: { fixtureState: "loading" } };
|
||||
export const Empty: Story = { parameters: { fixtureState: "empty" } };
|
||||
export const Failed: Story = {
|
||||
parameters: { fixtureState: "failure" },
|
||||
play: async ({ canvasElement }) => {
|
||||
await within(canvasElement).findByText(
|
||||
"The request failed. Please try again.",
|
||||
);
|
||||
},
|
||||
};
|
||||
export const PermissionDenied: Story = {
|
||||
parameters: { fixtureState: "permission" },
|
||||
play: async ({ canvasElement }) => {
|
||||
await within(canvasElement).findByText(
|
||||
"You do not have permission to perform this action.",
|
||||
);
|
||||
},
|
||||
};
|
||||
export const FilterNoMatches: Story = {
|
||||
parameters: { initialEntries: ["/PAP/audit?runStatus=failed"] },
|
||||
play: async ({ canvasElement }) => {
|
||||
await expect(
|
||||
await within(canvasElement).findByRole("button", {
|
||||
name: /Clear filters/,
|
||||
}),
|
||||
).toBeEnabled();
|
||||
const c = within(canvasElement);
|
||||
await expect(
|
||||
await c.findByText("No runs match these filters."),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
c.queryByText("Reviewed the release checklist"),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { BoardChat } from "@/pages/BoardChat";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import { storybookIssues } from "../../fixtures/paperclipData";
|
||||
import {
|
||||
prepareFixtureApi,
|
||||
type FixtureState,
|
||||
} from "../../support/capability-gaps/api";
|
||||
const meta = {
|
||||
id: "feature-conference-room",
|
||||
title:
|
||||
"Tasks and conversations/Agent conversations and project handoff/Conference Room",
|
||||
component: BoardChat,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Shipped Conference Room with isolated conversation receipts and SSE. This does not execute a provider or verify server access.",
|
||||
},
|
||||
},
|
||||
},
|
||||
beforeEach: ({ parameters }) => {
|
||||
let comments: unknown[] = [];
|
||||
return prepareFixtureApi(
|
||||
[
|
||||
{
|
||||
path: "/api/companies/company-storybook/issues",
|
||||
read: () => [
|
||||
{
|
||||
...storybookIssues[0],
|
||||
id: "conference-gap",
|
||||
title: "Board Operations",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: "/api/issues/conference-gap/comments", read: () => comments },
|
||||
{ path: "/api/issues/conference-gap/feedback-votes", read: () => [] },
|
||||
{
|
||||
path: "/api/board/chat/stream",
|
||||
method: "POST",
|
||||
read: ({ body }) => {
|
||||
comments = [
|
||||
{
|
||||
id: "comment-human",
|
||||
issueId: "conference-gap",
|
||||
companyId: "company-storybook",
|
||||
authorUserId: "user-storybook",
|
||||
authorAgentId: null,
|
||||
body: String(body.message),
|
||||
createdAt: "2026-10-12T12:00:00Z",
|
||||
updatedAt: "2026-10-12T12:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "comment-agent",
|
||||
issueId: "conference-gap",
|
||||
companyId: "company-storybook",
|
||||
authorUserId: null,
|
||||
authorAgentId: "agent-codex",
|
||||
body: "The release is ready for review.",
|
||||
createdAt: "2026-10-12T12:00:01Z",
|
||||
updatedAt: "2026-10-12T12:00:01Z",
|
||||
},
|
||||
];
|
||||
return new Response(
|
||||
'data: {"type":"chunk","text":"The release is ready for review."}\n\ndata: {"type":"done"}\n\n',
|
||||
{ headers: { "Content-Type": "text/event-stream" } },
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
parameters.fixtureState as FixtureState,
|
||||
).cleanup;
|
||||
},
|
||||
} satisfies Meta<typeof BoardChat>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Empty: Story = {};
|
||||
async function send(canvasElement: HTMLElement) {
|
||||
const c = within(canvasElement);
|
||||
await userEvent.type(await c.findByRole("textbox"), "Review the release", {
|
||||
delay: 15,
|
||||
});
|
||||
await userEvent.click(c.getByRole("button", { name: "Send message" }));
|
||||
}
|
||||
export const Responded: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await send(canvasElement);
|
||||
await expect(
|
||||
await within(canvasElement).findByText(
|
||||
"The release is ready for review.",
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
export const Sending: Story = {
|
||||
parameters: { fixtureState: "saving" },
|
||||
play: async ({ canvasElement }) => {
|
||||
await send(canvasElement);
|
||||
await within(canvasElement).findByText("Connecting...");
|
||||
},
|
||||
};
|
||||
export const SendFailed: Story = {
|
||||
parameters: { fixtureState: "mutation-failure" },
|
||||
play: async ({ canvasElement }) => {
|
||||
await send(canvasElement);
|
||||
await within(canvasElement).findByText(
|
||||
"The board assistant is unavailable right now. Please try again in a moment.",
|
||||
);
|
||||
},
|
||||
};
|
||||
export const PermissionDenied: Story = {
|
||||
parameters: { fixtureState: "mutation-permission" },
|
||||
play: async ({ canvasElement }) => {
|
||||
await send(canvasElement);
|
||||
await within(canvasElement).findByText(
|
||||
"The board assistant is unavailable right now. Please try again in a moment.",
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { ToolCatalogEntry } from "@paperclipai/shared";
|
||||
import { ActionsSection } from "@/pages/apps/app-detail/PermissionsPanel";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
const entry: ToolCatalogEntry = {
|
||||
id: "catalog-gap",
|
||||
companyId: "company-storybook",
|
||||
applicationId: null,
|
||||
connectionId: "connection-gap",
|
||||
entryKind: "tool",
|
||||
toolName: "publish_release",
|
||||
title: "Publish release",
|
||||
description: "Publish the approved release notes.",
|
||||
inputSchema: { type: "object" },
|
||||
outputSchema: null,
|
||||
annotations: null,
|
||||
riskLevel: "write",
|
||||
isReadOnly: false,
|
||||
isWrite: true,
|
||||
isDestructive: false,
|
||||
status: "quarantined",
|
||||
addedAt: new Date("2026-10-12"),
|
||||
version: null,
|
||||
schemaHash: null,
|
||||
firstSeenAt: new Date("2026-10-12"),
|
||||
lastSeenAt: new Date("2026-10-12"),
|
||||
reviewedAt: null,
|
||||
reviewedByAgentId: null,
|
||||
reviewedByUserId: null,
|
||||
createdAt: new Date("2026-10-12"),
|
||||
updatedAt: new Date("2026-10-12"),
|
||||
};
|
||||
function CatalogReview({
|
||||
state = "ready",
|
||||
}: {
|
||||
state?: "ready" | "empty" | "refreshing" | "saving" | "readonly";
|
||||
}) {
|
||||
const [quarantined, setQuarantined] = useState(
|
||||
state === "empty" ? [] : [entry],
|
||||
);
|
||||
const [enabled, setEnabled] = useState<Set<string>>(new Set());
|
||||
const [refreshing, setRefreshing] = useState(state === "refreshing");
|
||||
return (
|
||||
<ActionsSection
|
||||
connectionId="connection-gap"
|
||||
appName="Release tools"
|
||||
readOnly={[]}
|
||||
canChange={
|
||||
state === "empty" || quarantined.length
|
||||
? []
|
||||
: [{ ...entry, status: "active" }]
|
||||
}
|
||||
quarantined={quarantined}
|
||||
enabledIds={enabled}
|
||||
askFirstIds={new Set()}
|
||||
disabled={state === "saving"}
|
||||
refreshPending={refreshing}
|
||||
canConfigure={state !== "readonly"}
|
||||
onReviewQuarantined={(ids) => {
|
||||
setEnabled(new Set(ids));
|
||||
setQuarantined([]);
|
||||
}}
|
||||
onRefreshActions={() => {
|
||||
setRefreshing(false);
|
||||
setQuarantined([entry]);
|
||||
}}
|
||||
onSetPermission={(ids, next) =>
|
||||
setEnabled(new Set(next === "allowed" ? ids : []))
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const meta = {
|
||||
id: "feature-catalog-review",
|
||||
title:
|
||||
"Apps, channels, and extensions/App access and action permissions/Review newly discovered actions",
|
||||
component: CatalogReview,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Shipped action section and quarantine reviewer with local catalog props. Discovery failures belong to the owning AppDetail API boundary; permissions and review persistence need production checks.",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof CatalogReview>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Quarantined: Story = {};
|
||||
export const Empty: Story = { args: { state: "empty" } };
|
||||
export const Refreshing: Story = { args: { state: "refreshing" } };
|
||||
export const Saving: Story = { args: { state: "saving" } };
|
||||
export const ReadOnly: Story = { args: { state: "readonly" } };
|
||||
export const ReviewAndAllow: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await userEvent.click(
|
||||
c.getByRole("switch", { name: "Publish release allowed" }),
|
||||
);
|
||||
await userEvent.click(c.getByRole("button", { name: "Save choices" }));
|
||||
await expect(c.queryByText("Review 1 new action")).not.toBeInTheDocument();
|
||||
await c.findByText("Publish release");
|
||||
},
|
||||
};
|
||||
export const LeaveBlocked: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await userEvent.click(c.getByRole("button", { name: "Save choices" }));
|
||||
await expect(c.queryByText("Review 1 new action")).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { BudgetIncident } from "@paperclipai/shared";
|
||||
import { expect, userEvent, within, waitFor } from "storybook/test";
|
||||
import type { ComponentProps } from "react";
|
||||
import { Costs } from "@/pages/Costs";
|
||||
import {
|
||||
prepareFixtureApi,
|
||||
type FixtureState,
|
||||
} from "../../support/capability-gaps/api";
|
||||
const incident: BudgetIncident = {
|
||||
id: "budget-gap",
|
||||
companyId: "company-storybook",
|
||||
policyId: "policy-gap",
|
||||
scopeType: "company",
|
||||
scopeId: "company-storybook",
|
||||
scopeName: "Acme Research",
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
windowStart: new Date("2026-10-01"),
|
||||
windowEnd: new Date("2026-11-01"),
|
||||
thresholdType: "hard",
|
||||
amountLimit: 10000,
|
||||
amountObserved: 12000,
|
||||
status: "open",
|
||||
approvalId: null,
|
||||
approvalStatus: null,
|
||||
resolvedAt: null,
|
||||
createdAt: new Date("2026-10-12"),
|
||||
updatedAt: new Date("2026-10-12"),
|
||||
};
|
||||
|
||||
let currentIncident: BudgetIncident;
|
||||
function BudgetHost(props: NonNullable<ComponentProps<typeof Costs>>) {
|
||||
return <Costs {...props} />;
|
||||
}
|
||||
const meta = {
|
||||
id: "feature-budget-resolution",
|
||||
title: "Oversight and operation/Costs and budgets/Resolve a budget incident",
|
||||
component: BudgetHost,
|
||||
args: { embedded: true, initialTab: "budgets", lockTab: true },
|
||||
parameters: { layout: "padded" },
|
||||
beforeEach: ({ parameters }) => {
|
||||
currentIncident = { ...incident };
|
||||
return prepareFixtureApi(
|
||||
[
|
||||
{
|
||||
path: "/api/companies/company-storybook/budgets/overview",
|
||||
read: () => ({
|
||||
companyId: "company-storybook",
|
||||
policies: [],
|
||||
activeIncidents:
|
||||
parameters.fixtureState === "empty" ||
|
||||
currentIncident.status !== "open"
|
||||
? []
|
||||
: [currentIncident],
|
||||
pausedAgentCount: parameters.fixtureState==="empty"?0:currentIncident.status === "open" ? 1 : 0,
|
||||
pausedProjectCount: 0,
|
||||
pendingApprovalCount: 0,
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/api/companies/company-storybook/budget-incidents/budget-gap/resolve",
|
||||
method: "POST",
|
||||
read: ({ body }) => {
|
||||
currentIncident = {
|
||||
...currentIncident,
|
||||
status: body.action === "keep_paused" ? "dismissed" : "resolved",
|
||||
resolvedAt: new Date(),
|
||||
amountLimit: Number(body.amount ?? currentIncident.amountLimit),
|
||||
};
|
||||
return currentIncident;
|
||||
},
|
||||
},
|
||||
],
|
||||
parameters.fixtureState as FixtureState,
|
||||
).cleanup;
|
||||
},
|
||||
} satisfies Meta<typeof BudgetHost>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const DecisionRequired: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await within(canvasElement).findByRole("button", {
|
||||
name: "Raise budget & resume",
|
||||
});
|
||||
},
|
||||
};
|
||||
export const Loading: Story = { parameters: { fixtureState: "loading" } };
|
||||
export const Empty: Story = { parameters: { fixtureState: "empty" } };
|
||||
export const LoadFailed: Story = { parameters: { fixtureState: "failure" } };
|
||||
export const PermissionDenied: Story = {
|
||||
parameters: { fixtureState: "permission" },
|
||||
};
|
||||
export const InvalidAmount: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await c.findByRole("button", { name: "Raise budget & resume" });
|
||||
const input = c.getByPlaceholderText("0.00");
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, "100", { delay: 15 });
|
||||
await expect(
|
||||
c.getByRole("button", { name: "Raise budget & resume" }),
|
||||
).toBeDisabled();
|
||||
},
|
||||
};
|
||||
export const RaiseAndResume: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await c.findByRole("button", { name: "Raise budget & resume" }),
|
||||
);
|
||||
await waitFor(() => expect(currentIncident.status).toBe("resolved"));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
c.queryByRole("button", { name: "Raise budget & resume" }),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
},
|
||||
};
|
||||
export const KeepPaused: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await c.findByRole("button", { name: "Keep paused" }),
|
||||
);
|
||||
await waitFor(() => expect(currentIncident.status).toBe("dismissed"));
|
||||
},
|
||||
};
|
||||
export const Saving: Story = {
|
||||
parameters: { fixtureState: "saving" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await c.findByRole("button", { name: "Raise budget & resume" }),
|
||||
);
|
||||
await expect(
|
||||
await c.findByRole("button", { name: "Applying..." }),
|
||||
).toBeDisabled();
|
||||
},
|
||||
};
|
||||
export const SaveFailed: Story = {
|
||||
parameters: { fixtureState: "mutation-failure" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await c.findByRole("button", { name: "Raise budget & resume" }),
|
||||
);
|
||||
await c.findByRole("alert");
|
||||
await expect(
|
||||
c.getByRole("button", { name: "Raise budget & resume" }),
|
||||
).toBeEnabled();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Cases } from "@/pages/Cases";
|
||||
import { within } from "storybook/test";
|
||||
import { installCaseFixtures } from "../../support/capability-gaps/cases";
|
||||
import type { FixtureState } from "../../support/capability-gaps/api";
|
||||
const meta = {
|
||||
id: "feature-case-browse",
|
||||
title: "Automation and structured work/Cases/Browse and hierarchy",
|
||||
component: Cases,
|
||||
parameters: { layout: "padded" },
|
||||
beforeEach: ({ parameters }) => {
|
||||
const key = "paperclip:cases:company-storybook:view";
|
||||
const previous = localStorage.getItem(key);
|
||||
localStorage.removeItem(key);
|
||||
const cleanup = installCaseFixtures(
|
||||
parameters.fixtureState as FixtureState,
|
||||
);
|
||||
return () => {
|
||||
cleanup();
|
||||
if (previous) localStorage.setItem(key, previous);
|
||||
else localStorage.removeItem(key);
|
||||
};
|
||||
},
|
||||
} satisfies Meta<typeof Cases>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const ReviewCases: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await within(canvasElement).findAllByText("Release readiness");
|
||||
},
|
||||
};
|
||||
export const Loading: Story = { parameters: { fixtureState: "loading" } };
|
||||
export const Empty: Story = { parameters: { fixtureState: "empty" } };
|
||||
export const Failed: Story = { parameters: { fixtureState: "failure" } };
|
||||
export const PermissionDenied: Story = {
|
||||
parameters: { fixtureState: "permission" },
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Routes, Route } from "@/lib/router";
|
||||
import { CaseDetail } from "@/pages/CaseDetail";
|
||||
import { CaseFieldsPanel } from "@/components/CaseFieldsPanel";
|
||||
import { IssueCasesPanel } from "@/components/IssueCasesPanel";
|
||||
import { instanceExperimentalSettingsSchema } from "@paperclipai/shared";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import {
|
||||
caseItem,
|
||||
installCaseFixtures,
|
||||
} from "../../support/capability-gaps/cases";
|
||||
import {
|
||||
prepareFixtureApi,
|
||||
type FixtureState,
|
||||
} from "../../support/capability-gaps/api";
|
||||
function CaseDetailHost() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/PAP/cases/:caseIdentifier" element={<CaseDetail />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
const meta = {
|
||||
id: "feature-case-detail",
|
||||
title:
|
||||
"Automation and structured work/Cases/Structure, history and task links",
|
||||
component: CaseDetailHost,
|
||||
parameters: { layout: "padded", initialEntries: ["/PAP/cases/PAP-C42"] },
|
||||
beforeEach: ({ parameters }) => {
|
||||
const cleanup = installCaseFixtures(
|
||||
parameters.fixtureState as FixtureState,
|
||||
);
|
||||
const flags = prepareFixtureApi([
|
||||
{
|
||||
path: "/api/instance/settings/experimental",
|
||||
read: () => ({
|
||||
...instanceExperimentalSettingsSchema.parse({}),
|
||||
enableCases: true,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
return () => {
|
||||
flags.cleanup();
|
||||
cleanup();
|
||||
};
|
||||
},
|
||||
} satisfies Meta<typeof CaseDetailHost>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Detail: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await within(canvasElement).findByRole("heading", {
|
||||
name: "Release readiness",
|
||||
});
|
||||
},
|
||||
};
|
||||
export const Loading: Story = { parameters: { fixtureState: "loading" } };
|
||||
export const Failed: Story = { parameters: { fixtureState: "failure" } };
|
||||
export const PermissionDenied: Story = {
|
||||
parameters: { fixtureState: "permission" },
|
||||
};
|
||||
export const ActivityHistory: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await userEvent.click(await c.findByRole("tab", { name: /Activity/ }));
|
||||
await c.findByText("Codex Coder");
|
||||
},
|
||||
};
|
||||
export const ArbitraryFields: Story = {
|
||||
render: () => <CaseFieldsPanel fields={caseItem.fields} />,
|
||||
};
|
||||
export const EmptyFields: Story = {
|
||||
render: () => <CaseFieldsPanel fields={{}} />,
|
||||
play: async ({ canvasElement }) => {
|
||||
await within(canvasElement).findByText("No fields set");
|
||||
},
|
||||
};
|
||||
export const LinkedFromTask: Story = {
|
||||
render: () => <IssueCasesPanel issueId="task-gap" />,
|
||||
play: async ({ canvasElement }) => {
|
||||
await expect(
|
||||
await within(canvasElement).findByRole("link", {
|
||||
name: /Release readiness/,
|
||||
}),
|
||||
).toHaveAttribute("href", "/PAP/cases/PAP-C42");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { IssueAttachment } from "@paperclipai/shared";
|
||||
import { TaskChatBubble } from "@/components/task-chat/TaskChatBubble";
|
||||
import { expect, within, waitFor } from "storybook/test";
|
||||
const attachment: IssueAttachment = {
|
||||
id: "attachment-gap",
|
||||
companyId: "company-storybook",
|
||||
issueId: "task-gap",
|
||||
issueCommentId: "message-gap",
|
||||
assetId: "asset-gap",
|
||||
contentPath: "/api/assets/asset-gap/content",
|
||||
provider: "paperclip",
|
||||
objectKey: "fixture/report.txt",
|
||||
contentType: "text/plain",
|
||||
byteSize: 42,
|
||||
sha256: "fixture-sha256",
|
||||
originalFilename: "release-report.txt",
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-storybook",
|
||||
createdAt: new Date("2026-10-12"),
|
||||
updatedAt: new Date("2026-10-12"),
|
||||
};
|
||||
const meta = {
|
||||
id: "feature-channel-attachments",
|
||||
title:
|
||||
"Apps, channels, and extensions/External chat and email/Task-linked files",
|
||||
component: TaskChatBubble,
|
||||
args: {
|
||||
item: {
|
||||
id: "message-gap",
|
||||
kind: "message",
|
||||
author: "human",
|
||||
text: "Here is the release report.",
|
||||
sourceChannel: "imessage-photon",
|
||||
},
|
||||
attachments: [attachment],
|
||||
},
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Production task bubble with the saved attachment receipt and channel attribution. Provider upload, delivery, download authority and replay require production tests.",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof TaskChatBubble>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const ReceivedFile: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await within(canvasElement).findByText("release-report.txt");
|
||||
},
|
||||
};
|
||||
export const NoFiles: Story = { args: { attachments: [] } };
|
||||
export const MultipleFiles: Story = {
|
||||
args: {
|
||||
attachments: [
|
||||
attachment,
|
||||
{
|
||||
...attachment,
|
||||
id: "attachment-two",
|
||||
assetId: "asset-two",
|
||||
contentPath: "/api/assets/asset-two/content",
|
||||
originalFilename: "checks.json",
|
||||
contentType: "application/json",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
export const MissingFilename: Story = {
|
||||
args: { attachments: [{ ...attachment, originalFilename: null }] },
|
||||
};
|
||||
export const OutboundFile: Story = {
|
||||
args: {
|
||||
item: {
|
||||
id: "message-gap",
|
||||
kind: "message",
|
||||
author: "agent",
|
||||
text: "I attached the reviewed release report.",
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const title = await within(canvasElement).findByText("release-report.txt");
|
||||
await waitFor(() => expect(title).toBeVisible());
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { CompanyImport } from "@/pages/CompanyImport";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import {
|
||||
prepareFixtureApi,
|
||||
type FixtureState,
|
||||
} from "../../support/capability-gaps/api";
|
||||
const files = {
|
||||
".paperclip.yaml": "schema: paperclip/v1\n",
|
||||
"agents/reviewer/AGENTS.md": "# Reviewer\n\nReview release evidence.",
|
||||
};
|
||||
const preview = {
|
||||
include: { company: true, agents: true, projects: true, issues: true },
|
||||
targetCompanyId: null,
|
||||
targetCompanyName: null,
|
||||
collisionStrategy: "rename",
|
||||
selectedAgentSlugs: ["reviewer"],
|
||||
plan: {
|
||||
companyAction: "create",
|
||||
agentPlans: [
|
||||
{
|
||||
slug: "reviewer",
|
||||
action: "create",
|
||||
plannedName: "Reviewer",
|
||||
existingAgentId: null,
|
||||
reason: null,
|
||||
},
|
||||
],
|
||||
projectPlans: [],
|
||||
issuePlans: [],
|
||||
},
|
||||
manifest: {
|
||||
agents: [
|
||||
{
|
||||
slug: "reviewer",
|
||||
name: "Reviewer",
|
||||
path: "agents/reviewer/AGENTS.md",
|
||||
adapterType: "claude_local",
|
||||
},
|
||||
],
|
||||
projects: [],
|
||||
issues: [],
|
||||
skills: [],
|
||||
company: null,
|
||||
},
|
||||
files,
|
||||
envInputs: [],
|
||||
warnings: [],
|
||||
errors: [],
|
||||
};
|
||||
const meta = {
|
||||
id: "feature-company-import",
|
||||
title: "Getting started and access/Companies and portability/Import preview",
|
||||
component: CompanyImport,
|
||||
parameters: { layout: "padded" },
|
||||
beforeEach: ({ parameters }) =>
|
||||
prepareFixtureApi(
|
||||
[
|
||||
{
|
||||
path: "/api/companies/import/preview",
|
||||
method: "POST",
|
||||
read: () => ({
|
||||
...preview,
|
||||
warnings: parameters.warning
|
||||
? ["Provider credentials are not included in this package."]
|
||||
: [],
|
||||
}),
|
||||
},
|
||||
],
|
||||
parameters.fixtureState as FixtureState,
|
||||
).cleanup,
|
||||
} satisfies Meta<typeof CompanyImport>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const ChooseSource: Story = {};
|
||||
async function previewPackage(canvasElement: HTMLElement) {
|
||||
const c = within(canvasElement);
|
||||
await userEvent.type(
|
||||
await c.findByPlaceholderText(
|
||||
"https://github.com/owner/repo/tree/main/company",
|
||||
),
|
||||
"https://github.com/example/release-team",
|
||||
{ delay: 15 },
|
||||
);
|
||||
await userEvent.click(c.getByRole("button", { name: "Preview import" }));
|
||||
}
|
||||
export const PreviewReady: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await previewPackage(canvasElement);
|
||||
await within(canvasElement).findByText("Reviewer");
|
||||
},
|
||||
};
|
||||
export const PreviewWithWarning: Story = {
|
||||
parameters: { warning: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
await previewPackage(canvasElement);
|
||||
await within(canvasElement).findByText(
|
||||
"Provider credentials are not included in this package.",
|
||||
);
|
||||
},
|
||||
};
|
||||
export const PreviewLoading: Story = {
|
||||
parameters: { fixtureState: "saving" },
|
||||
play: async ({ canvasElement }) => {
|
||||
await previewPackage(canvasElement);
|
||||
await expect(
|
||||
await within(canvasElement).findByRole("button", {
|
||||
name: "Previewing...",
|
||||
}),
|
||||
).toBeDisabled();
|
||||
},
|
||||
};
|
||||
export const PreviewFailed: Story = {
|
||||
parameters: { fixtureState: "mutation-failure" },
|
||||
play: async ({ canvasElement }) => {
|
||||
await previewPackage(canvasElement);
|
||||
await within(canvasElement).findByText(/The request failed/);
|
||||
},
|
||||
};
|
||||
export const PermissionDenied: Story = {
|
||||
parameters: { fixtureState: "mutation-permission" },
|
||||
play: async ({ canvasElement }) => {
|
||||
await previewPackage(canvasElement);
|
||||
await within(canvasElement).findByText(/You do not have permission/);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Companies } from "@/pages/Companies";
|
||||
import { expect, userEvent, within, waitFor } from "storybook/test";
|
||||
import { storybookCompanies } from "../../fixtures/paperclipData";
|
||||
import {
|
||||
prepareFixtureApi,
|
||||
type FixtureState,
|
||||
} from "../../support/capability-gaps/api";
|
||||
const meta = {
|
||||
id: "feature-company-selection",
|
||||
title:
|
||||
"Getting started and access/Companies and portability/Selection and archived organizations",
|
||||
component: Companies,
|
||||
parameters: { layout: "padded" },
|
||||
beforeEach: ({ parameters }) => {
|
||||
let companies =
|
||||
parameters.fixtureState === "empty"
|
||||
? []
|
||||
: [
|
||||
...storybookCompanies.map((c) => ({ ...c })),
|
||||
{
|
||||
...storybookCompanies[0],
|
||||
id: "archived-gap",
|
||||
name: "Archived Research",
|
||||
issuePrefix: "ARC",
|
||||
status: "archived",
|
||||
},
|
||||
];
|
||||
return prepareFixtureApi(
|
||||
[
|
||||
{ path: "/api/companies", read: () => companies },
|
||||
{ path: "/api/companies/stats", read: () => ({}) },
|
||||
{
|
||||
path: "/api/companies/archived-gap",
|
||||
method: "PATCH",
|
||||
read: ({ body }) => {
|
||||
companies = companies.map((c) =>
|
||||
c.id === "archived-gap" ? { ...c, ...body } : c,
|
||||
);
|
||||
return companies.find((c) => c.id === "archived-gap");
|
||||
},
|
||||
},
|
||||
],
|
||||
parameters.fixtureState as FixtureState,
|
||||
).cleanup;
|
||||
},
|
||||
} satisfies Meta<typeof Companies>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Organizations: Story = {};
|
||||
export const Loading: Story = { parameters: { fixtureState: "loading" } };
|
||||
export const Empty: Story = { parameters: { fixtureState: "empty" } };
|
||||
export const Failed: Story = { parameters: { fixtureState: "failure" } };
|
||||
export const PermissionDenied: Story = {
|
||||
parameters: { fixtureState: "permission" },
|
||||
};
|
||||
export const SelectOrganization: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
const card = await c.findByRole("button", { name: /Archived Research/ });
|
||||
await userEvent.click(card);
|
||||
await waitFor(() =>
|
||||
expect(localStorage.getItem("paperclip.selectedCompanyId")).toBe(
|
||||
"archived-gap",
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
export const Unarchive: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
const card = (await c.findByText("Archived Research")).closest(
|
||||
'[role="button"]',
|
||||
) as HTMLElement;
|
||||
await userEvent.click(
|
||||
card.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]')!,
|
||||
);
|
||||
await userEvent.click(
|
||||
await within(document.body).findByRole("menuitem", { name: "Unarchive" }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
const restored = within(canvasElement)
|
||||
.getByText("Archived Research")
|
||||
.closest('[role="button"]') as HTMLElement;
|
||||
expect(within(restored).getByText("active")).toBeVisible();
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { IssueDocumentsSection } from "@/components/IssueDocumentsSection";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import {
|
||||
storybookIssues,
|
||||
storybookIssueDocuments,
|
||||
} from "../../fixtures/paperclipData";
|
||||
import {
|
||||
prepareFixtureApi,
|
||||
type FixtureState,
|
||||
} from "../../support/capability-gaps/api";
|
||||
const meta = {
|
||||
id: "feature-document-revisions",
|
||||
title:
|
||||
"Tasks and conversations/Documents, attachments, and work products/Document revisions",
|
||||
component: IssueDocumentsSection,
|
||||
args: { issue: storybookIssues[0], canDeleteDocuments: true },
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Production document section. Loading and read errors currently yield the section's empty rendering; these examples expose that limitation. Revision authority and persisted concurrency require API tests.",
|
||||
},
|
||||
},
|
||||
},
|
||||
beforeEach: ({ parameters }) => {
|
||||
let doc = {
|
||||
...storybookIssueDocuments[0],
|
||||
body: "# Release plan\n\nRun the release checks.",
|
||||
latestRevisionNumber: 2,
|
||||
latestRevisionId: "revision-latest",
|
||||
};
|
||||
const revisions = [
|
||||
{
|
||||
id: "revision-latest",
|
||||
companyId: doc.companyId,
|
||||
documentId: doc.id,
|
||||
issueId: doc.issueId,
|
||||
key: doc.key,
|
||||
revisionNumber: 2,
|
||||
title: doc.title,
|
||||
format: "markdown",
|
||||
body: doc.body,
|
||||
changeSummary: "Add checks",
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-storybook",
|
||||
createdAt: "2026-10-12T12:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "revision-first",
|
||||
companyId: doc.companyId,
|
||||
documentId: doc.id,
|
||||
issueId: doc.issueId,
|
||||
key: doc.key,
|
||||
revisionNumber: 1,
|
||||
title: doc.title,
|
||||
format: "markdown",
|
||||
body: "# Original release plan",
|
||||
changeSummary: "Initial draft",
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-storybook",
|
||||
createdAt: "2026-10-11T12:00:00Z",
|
||||
},
|
||||
];
|
||||
return prepareFixtureApi(
|
||||
[
|
||||
{
|
||||
path: `/api/issues/${doc.issueId}/documents`,
|
||||
read: () => (parameters.fixtureState === "empty" ? [] : [doc]),
|
||||
},
|
||||
{
|
||||
path: `/api/issues/${doc.issueId}/documents/${doc.key}/revisions`,
|
||||
read: () => revisions,
|
||||
},
|
||||
{
|
||||
path: `/api/issues/${doc.issueId}/documents/${doc.key}`,
|
||||
read: ({ method, body }) => {
|
||||
if (method === "PUT")
|
||||
doc = {
|
||||
...doc,
|
||||
body: String(body.body),
|
||||
latestRevisionNumber: doc.latestRevisionNumber + 1,
|
||||
};
|
||||
return doc;
|
||||
},
|
||||
},
|
||||
{
|
||||
path: `/api/issues/${doc.issueId}/documents/${doc.key}/revisions/revision-first/restore`,
|
||||
method: "POST",
|
||||
read: () => {
|
||||
doc = {
|
||||
...doc,
|
||||
body: revisions[1].body,
|
||||
latestRevisionId: "revision-restored",
|
||||
latestRevisionNumber: 3,
|
||||
};
|
||||
revisions.unshift({
|
||||
...revisions[0],
|
||||
id: "revision-restored",
|
||||
revisionNumber: 3,
|
||||
body: doc.body,
|
||||
});
|
||||
return doc;
|
||||
},
|
||||
},
|
||||
],
|
||||
parameters.fixtureState as FixtureState,
|
||||
).cleanup;
|
||||
},
|
||||
} satisfies Meta<typeof IssueDocumentsSection>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Current: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await within(canvasElement).findByText("Run the release checks.");
|
||||
},
|
||||
};
|
||||
export const Empty: Story = { parameters: { fixtureState: "empty" } };
|
||||
export const Loading: Story = { parameters: { fixtureState: "loading" } };
|
||||
export const ReadFailed: Story = { parameters: { fixtureState: "failure" } };
|
||||
export const PermissionDenied: Story = {
|
||||
parameters: { fixtureState: "permission" },
|
||||
};
|
||||
export const Edit: Story = { args: { forceEditDocumentKey: "plan" } };
|
||||
|
||||
async function previousRevision(canvasElement: HTMLElement) {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole("button", { name: /rev 2/ }),
|
||||
);
|
||||
await userEvent.click(
|
||||
await within(document.body).findByRole("menuitemradio", { name: /rev 1/ }),
|
||||
);
|
||||
await within(canvasElement).findByText("Original release plan");
|
||||
}
|
||||
export const HistoricalPreview: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await previousRevision(canvasElement);
|
||||
await within(canvasElement).findByRole("button", {
|
||||
name: "Restore this revision",
|
||||
});
|
||||
},
|
||||
};
|
||||
export const Restored: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await previousRevision(canvasElement);
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByRole("button", {
|
||||
name: "Restore this revision",
|
||||
}),
|
||||
);
|
||||
await within(canvasElement).findByRole("button", { name: /rev 3/ });
|
||||
await within(canvasElement).findByText("Original release plan");
|
||||
},
|
||||
};
|
||||
export const RestoreFailed: Story = {
|
||||
parameters: { fixtureState: "mutation-failure" },
|
||||
play: async ({ canvasElement }) => {
|
||||
await previousRevision(canvasElement);
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByRole("button", {
|
||||
name: "Restore this revision",
|
||||
}),
|
||||
);
|
||||
await within(canvasElement).findByText(
|
||||
"The request failed. Please try again.",
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Routes, Route } from "@/lib/router";
|
||||
import { CompanyEnvironments } from "@/pages/CompanyEnvironments";
|
||||
import { ToastViewport } from "@/components/ToastViewport";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import { installEnvironmentFixture } from "../../support/capability-gaps/environments";
|
||||
import type { FixtureState } from "../../support/capability-gaps/api";
|
||||
function Environments() {
|
||||
return (
|
||||
<>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/PAP/company/settings/instance/environments"
|
||||
element={<CompanyEnvironments />}
|
||||
/>
|
||||
<Route
|
||||
path="/PAP/company/settings/instance/environments/:environmentId/edit"
|
||||
element={<CompanyEnvironments mode="edit" />}
|
||||
/>
|
||||
</Routes>
|
||||
<ToastViewport />
|
||||
</>
|
||||
);
|
||||
}
|
||||
const meta = {
|
||||
id: "feature-environment-images",
|
||||
title: "Projects and execution/Execution environments/Custom images",
|
||||
component: Environments,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
initialEntries: ["/PAP/company/settings/instance/environments"],
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Production environment admin page. API replies are fictional; no probe connects to a provider. List loading/errors currently fall back to empty saved environments rather than dedicated feedback.",
|
||||
},
|
||||
},
|
||||
},
|
||||
beforeEach: ({ parameters }) =>
|
||||
installEnvironmentFixture(
|
||||
parameters.fixtureState as FixtureState,
|
||||
parameters.image,
|
||||
parameters.unsupported,
|
||||
).cleanup,
|
||||
} satisfies Meta<typeof Environments>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const NoImage: Story = {
|
||||
parameters: {
|
||||
initialEntries: [
|
||||
"/PAP/company/settings/instance/environments/environment-gap/edit",
|
||||
],
|
||||
},
|
||||
};
|
||||
export const CapturedImage: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await within(canvasElement).findByText("Active template");
|
||||
},
|
||||
parameters: {
|
||||
image: "captured",
|
||||
initialEntries: [
|
||||
"/PAP/company/settings/instance/environments/environment-gap/edit",
|
||||
],
|
||||
},
|
||||
};
|
||||
export const ImageReadFailed: Story = {
|
||||
parameters: {
|
||||
image: "failed",
|
||||
initialEntries: [
|
||||
"/PAP/company/settings/instance/environments/environment-gap/edit",
|
||||
],
|
||||
},
|
||||
};
|
||||
export const UnsupportedProvider: Story = {
|
||||
parameters: {
|
||||
unsupported: true,
|
||||
initialEntries: [
|
||||
"/PAP/company/settings/instance/environments/environment-gap/edit",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const ImageLoading: Story = {
|
||||
parameters: {
|
||||
image: "loading",
|
||||
initialEntries: [
|
||||
"/PAP/company/settings/instance/environments/environment-gap/edit",
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
await within(canvasElement).findByText("Loading template setup...");
|
||||
},
|
||||
};
|
||||
export const ImagePermissionDenied: Story = {
|
||||
parameters: {
|
||||
image: "denied",
|
||||
initialEntries: [
|
||||
"/PAP/company/settings/instance/environments/environment-gap/edit",
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
await within(canvasElement).findByText(
|
||||
"You do not have permission to inspect this image.",
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Routes, Route } from "@/lib/router";
|
||||
import { CompanyEnvironments } from "@/pages/CompanyEnvironments";
|
||||
import { ToastViewport } from "@/components/ToastViewport";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import { installEnvironmentFixture } from "../../support/capability-gaps/environments";
|
||||
import type { FixtureState } from "../../support/capability-gaps/api";
|
||||
function Environments() {
|
||||
return (
|
||||
<>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/PAP/company/settings/instance/environments"
|
||||
element={<CompanyEnvironments />}
|
||||
/>
|
||||
<Route
|
||||
path="/PAP/company/settings/instance/environments/:environmentId/edit"
|
||||
element={<CompanyEnvironments mode="edit" />}
|
||||
/>
|
||||
</Routes>
|
||||
<ToastViewport />
|
||||
</>
|
||||
);
|
||||
}
|
||||
const meta = {
|
||||
id: "feature-execution-environments",
|
||||
title:
|
||||
"Projects and execution/Execution environments/Configuration, capabilities, and probes",
|
||||
component: Environments,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
initialEntries: ["/PAP/company/settings/instance/environments"],
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Production environment admin page. API replies are fictional; no probe connects to a provider. List loading/errors currently fall back to empty saved environments rather than dedicated feedback.",
|
||||
},
|
||||
},
|
||||
},
|
||||
beforeEach: ({ parameters }) =>
|
||||
installEnvironmentFixture(
|
||||
parameters.fixtureState as FixtureState,
|
||||
parameters.image,
|
||||
parameters.unsupported,
|
||||
).cleanup,
|
||||
} satisfies Meta<typeof Environments>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Configured: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await within(canvasElement).findByText("Release sandbox");
|
||||
},
|
||||
};
|
||||
export const Loading: Story = { parameters: { fixtureState: "loading" } };
|
||||
export const Empty: Story = { parameters: { fixtureState: "empty" } };
|
||||
export const Failed: Story = { parameters: { fixtureState: "failure" } };
|
||||
export const PermissionDenied: Story = {
|
||||
parameters: { fixtureState: "permission" },
|
||||
};
|
||||
export const UnsupportedProvider: Story = { parameters: { unsupported: true } };
|
||||
export const ProbeSucceeded: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole("button", {
|
||||
name: "Test provider",
|
||||
}),
|
||||
);
|
||||
await within(document.body).findAllByText("Provider connection succeeded.");
|
||||
},
|
||||
};
|
||||
export const Probing: Story = {
|
||||
parameters: { fixtureState: "saving" },
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole("button", {
|
||||
name: "Test provider",
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
await within(canvasElement).findByRole("button", { name: "Testing..." }),
|
||||
).toBeDisabled();
|
||||
},
|
||||
};
|
||||
export const ProbeFailed: Story = {
|
||||
parameters: { fixtureState: "mutation-failure" },
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole("button", {
|
||||
name: "Test provider",
|
||||
}),
|
||||
);
|
||||
await within(document.body).findAllByText(
|
||||
"The request failed. Please try again.",
|
||||
);
|
||||
},
|
||||
};
|
||||
export const EditConfiguration: Story = {
|
||||
parameters: {
|
||||
initialEntries: [
|
||||
"/PAP/company/settings/instance/environments/environment-gap/edit",
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type {
|
||||
ToolMcpGatewayToken,
|
||||
ToolMcpGatewayWithTokens,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
toolMcpGatewayAuthConfigSchema,
|
||||
toolMcpGatewayHeaderPolicySchema,
|
||||
toolMcpGatewayMetadataPolicySchema,
|
||||
toolMcpGatewayOnDemandToolsConfigSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { TokensPanel } from "@/pages/apps/gateways/panels/TokensPanel";
|
||||
import { gatewaysQueryKey } from "@/pages/apps/gateways/NewGatewayDialog";
|
||||
import { ToastViewport } from "@/components/ToastViewport";
|
||||
import {
|
||||
prepareFixtureApi,
|
||||
type FixtureState,
|
||||
} from "../../support/capability-gaps/api";
|
||||
const token: ToolMcpGatewayToken = {
|
||||
id: "token-gap",
|
||||
companyId: "company-storybook",
|
||||
gatewayId: "gateway-gap",
|
||||
name: "Preview client",
|
||||
tokenPrefix: "pcgw_fixture",
|
||||
subjectType: "gateway_client",
|
||||
subjectId: null,
|
||||
clientLabel: "Preview client",
|
||||
ownerNote: "Local preview",
|
||||
allowedActions: ["tools/list", "tools/call"],
|
||||
expiresAt: "2099-12-01T00:00:00Z",
|
||||
expiryOverrideReason: null,
|
||||
expiryOverrideByUserId: null,
|
||||
expiryOverrideByAgentId: null,
|
||||
expiryOverrideAt: null,
|
||||
lastUsedAt: null,
|
||||
revokedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-storybook",
|
||||
createdAt: "2026-10-12T12:00:00Z",
|
||||
updatedAt: "2026-10-12T12:00:00Z",
|
||||
};
|
||||
const gateway: ToolMcpGatewayWithTokens = {
|
||||
id: "gateway-gap",
|
||||
companyId: "company-storybook",
|
||||
gatewayPublicId: "preview-gateway",
|
||||
name: "Preview gateway",
|
||||
displaySlug: "preview",
|
||||
slug: "preview",
|
||||
description: null,
|
||||
status: "active",
|
||||
profileId: "profile-gap",
|
||||
defaultProfileMode: "gateway_only",
|
||||
contextScopeType: "none",
|
||||
contextScopeId: null,
|
||||
agentId: null,
|
||||
projectId: null,
|
||||
issueId: null,
|
||||
approvalIssueId: null,
|
||||
endpointPath: "/g/preview/mcp",
|
||||
authConfig: toolMcpGatewayAuthConfigSchema.parse({}),
|
||||
headerPolicy: toolMcpGatewayHeaderPolicySchema.parse({}),
|
||||
metadataPolicy: toolMcpGatewayMetadataPolicySchema.parse({}),
|
||||
onDemandToolsConfig: toolMcpGatewayOnDemandToolsConfigSchema.parse({}),
|
||||
metadata: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-storybook",
|
||||
archivedAt: null,
|
||||
createdAt: new Date("2026-10-12"),
|
||||
updatedAt: new Date("2026-10-12"),
|
||||
tokens: [token],
|
||||
clientSnippets: [],
|
||||
};
|
||||
function Tokens() {
|
||||
const query = useQuery({
|
||||
queryKey: gatewaysQueryKey("company-storybook"),
|
||||
queryFn: () => toolsApi.listGateways("company-storybook"),
|
||||
});
|
||||
if (!query.data) return null;
|
||||
return (
|
||||
<>
|
||||
<TokensPanel
|
||||
companyId="company-storybook"
|
||||
gateway={query.data.gateways[0]}
|
||||
/>
|
||||
<ToastViewport />
|
||||
</>
|
||||
);
|
||||
}
|
||||
const meta = {
|
||||
id: "feature-gateway-tokens",
|
||||
title:
|
||||
"Apps, channels, and extensions/Tool gateways and access profiles/Named gateway tokens",
|
||||
component: Tokens,
|
||||
parameters: { layout: "padded" },
|
||||
beforeEach: ({ parameters }) => {
|
||||
let current = {
|
||||
...gateway,
|
||||
tokens: parameters.empty
|
||||
? []
|
||||
: [
|
||||
{
|
||||
...token,
|
||||
expiresAt: parameters.expired
|
||||
? "2020-01-01T00:00:00Z"
|
||||
: token.expiresAt,
|
||||
},
|
||||
],
|
||||
};
|
||||
return prepareFixtureApi(
|
||||
[
|
||||
{
|
||||
path: "/api/companies/company-storybook/tools/gateways",
|
||||
read: () => ({ gateways: [current] }),
|
||||
},
|
||||
{
|
||||
path: "/api/tool-gateway/gateways/gateway-gap/tokens",
|
||||
method: "POST",
|
||||
read: ({ body }) => {
|
||||
const created = {
|
||||
...token,
|
||||
id: "issued-token",
|
||||
name: String(body.name),
|
||||
clientLabel: String(body.name),
|
||||
ownerNote: String(body.ownerNote),
|
||||
expiresAt: String(body.expiresAt),
|
||||
};
|
||||
current = { ...current, tokens: [created, ...current.tokens] };
|
||||
return { ...created, token: "pcgw_storybook_fixture_only" };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/api/tool-gateway/gateway-tokens/token-gap/revoke",
|
||||
method: "POST",
|
||||
read: () => {
|
||||
const revoked = { ...token, revokedAt: "2026-10-13T12:00:00Z" };
|
||||
current = {
|
||||
...current,
|
||||
tokens: current.tokens.map((row) =>
|
||||
row.id === token.id ? revoked : row,
|
||||
),
|
||||
};
|
||||
return revoked;
|
||||
},
|
||||
},
|
||||
],
|
||||
parameters.fixtureState as FixtureState,
|
||||
).cleanup;
|
||||
},
|
||||
} satisfies Meta<typeof Tokens>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const ActiveToken: Story = {};
|
||||
export const NoTokens: Story = { parameters: { empty: true } };
|
||||
export const ExpiredToken: Story = { parameters: { expired: true } };
|
||||
async function issue(canvasElement: HTMLElement) {
|
||||
const c = within(canvasElement);
|
||||
await userEvent.click(await c.findByRole("button", { name: "Issue token" }));
|
||||
await userEvent.clear(c.getByRole("textbox", { name: /Token name/ }));
|
||||
await userEvent.type(
|
||||
c.getByRole("textbox", { name: /Token name/ }),
|
||||
"Review client",
|
||||
{ delay: 15 },
|
||||
);
|
||||
await userEvent.click(
|
||||
within(canvasElement.querySelector("form")!).getByRole("button", {
|
||||
name: "Issue token",
|
||||
}),
|
||||
);
|
||||
}
|
||||
export const IssuedToken: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await issue(canvasElement);
|
||||
await within(document.body).findByText("Token issued");
|
||||
await within(canvasElement).findByText("pcgw_storybook_fixture_only");
|
||||
},
|
||||
};
|
||||
export const Issuing: Story = {
|
||||
parameters: { fixtureState: "saving" },
|
||||
play: async ({ canvasElement }) => {
|
||||
await issue(canvasElement);
|
||||
await expect(
|
||||
await within(canvasElement).findByRole("button", { name: "Issuing…" }),
|
||||
).toBeDisabled();
|
||||
},
|
||||
};
|
||||
export const IssueFailed: Story = {
|
||||
parameters: { fixtureState: "mutation-failure" },
|
||||
play: async ({ canvasElement }) => {
|
||||
await issue(canvasElement);
|
||||
await within(document.body).findByText("Token was not issued");
|
||||
},
|
||||
};
|
||||
export const PermissionDenied: Story = {
|
||||
parameters: { fixtureState: "mutation-permission" },
|
||||
play: async ({ canvasElement }) => {
|
||||
await issue(canvasElement);
|
||||
await within(document.body).findByText(
|
||||
"You do not have permission to perform this action.",
|
||||
);
|
||||
},
|
||||
};
|
||||
export const RevokeToken: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await c.findByRole("button", { name: /Token history/ }),
|
||||
);
|
||||
await userEvent.click(await c.findByRole("button", { name: "Revoke" }));
|
||||
await userEvent.type(
|
||||
c.getByPlaceholderText("Preview client"),
|
||||
"Preview client",
|
||||
{ delay: 15 },
|
||||
);
|
||||
await userEvent.click(c.getByRole("button", { name: "Revoke token" }));
|
||||
await within(document.body).findByText("Token revoked");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Routes, Route } from "@/lib/router";
|
||||
import { GoalDetail } from "@/pages/GoalDetail";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import {
|
||||
storybookGoals,
|
||||
storybookProjects,
|
||||
} from "../../fixtures/paperclipData";
|
||||
import {
|
||||
prepareFixtureApi,
|
||||
type FixtureState,
|
||||
} from "../../support/capability-gaps/api";
|
||||
function Goal() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/PAP/goals/:goalId" element={<GoalDetail />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
const goal = {
|
||||
...storybookGoals[0],
|
||||
id: "goal-gap",
|
||||
title: "Qualify the release",
|
||||
};
|
||||
const meta = {
|
||||
id: "feature-goal-alignment",
|
||||
title:
|
||||
"Projects and execution/Goals and work alignment/Aligned work and navigation",
|
||||
component: Goal,
|
||||
parameters: { layout: "padded", initialEntries: ["/PAP/goals/goal-gap"] },
|
||||
beforeEach: ({ parameters }) =>
|
||||
prepareFixtureApi(
|
||||
[
|
||||
{ path: "/api/goals/goal-gap", read: () => goal },
|
||||
{
|
||||
path: "/api/companies/company-storybook/goals",
|
||||
read: () =>
|
||||
parameters.fixtureState === "empty"
|
||||
? [goal]
|
||||
: [
|
||||
goal,
|
||||
{
|
||||
...goal,
|
||||
id: "child-goal-gap",
|
||||
title: "Verify provider access",
|
||||
parentId: goal.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/api/companies/company-storybook/projects",
|
||||
read: () =>
|
||||
parameters.fixtureState === "empty"
|
||||
? []
|
||||
: [
|
||||
{
|
||||
...storybookProjects[0],
|
||||
name: "Release workspace",
|
||||
goalId: goal.id,
|
||||
goalIds: [goal.id],
|
||||
goals: [{ id: goal.id, title: goal.title }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
parameters.fixtureState as FixtureState,
|
||||
).cleanup,
|
||||
} satisfies Meta<typeof Goal>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const GoalAndChildren: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await expect(
|
||||
await within(canvasElement).findByRole("link", {
|
||||
name: /Verify provider access/,
|
||||
}),
|
||||
).toHaveAttribute("href", "/PAP/goals/child-goal-gap");
|
||||
},
|
||||
};
|
||||
export const AlignedProject: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await userEvent.click(await c.findByRole("tab", { name: /Projects/ }));
|
||||
await c.findByText("Release workspace");
|
||||
},
|
||||
};
|
||||
export const EmptyRelationships: Story = {
|
||||
parameters: { fixtureState: "empty" },
|
||||
};
|
||||
export const Loading: Story = { parameters: { fixtureState: "loading" } };
|
||||
export const Failed: Story = { parameters: { fixtureState: "failure" } };
|
||||
export const PermissionDenied: Story = {
|
||||
parameters: { fixtureState: "permission" },
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { ComponentProps } from "react";
|
||||
import { Inbox } from "@/pages/Inbox";
|
||||
import { instanceExperimentalSettingsSchema } from "@paperclipai/shared";
|
||||
import { storybookIssues } from "../../fixtures/paperclipData";
|
||||
import {
|
||||
prepareFixtureApi,
|
||||
type FixtureState,
|
||||
} from "../../support/capability-gaps/api";
|
||||
function InboxHost(props: NonNullable<ComponentProps<typeof Inbox>>) {
|
||||
return <Inbox {...props} />;
|
||||
}
|
||||
const meta = {
|
||||
id: "feature-inbox-views",
|
||||
title:
|
||||
"Tasks and conversations/Inbox, decisions, and search/Personal task views",
|
||||
component: InboxHost,
|
||||
args: { tab: "mine" },
|
||||
parameters: { layout: "padded" },
|
||||
beforeEach: ({ parameters }) => {
|
||||
const api = prepareFixtureApi(
|
||||
[
|
||||
{
|
||||
path: "/api/companies/company-storybook/issues",
|
||||
read: () =>
|
||||
parameters.fixtureState === "empty" ? [] : storybookIssues,
|
||||
},
|
||||
{
|
||||
path: /^\/api\/companies\/company-storybook\/(approvals|join-requests|heartbeat-runs)$/,
|
||||
read: () => [],
|
||||
},
|
||||
],
|
||||
parameters.fixtureState as FixtureState,
|
||||
);
|
||||
const flags = prepareFixtureApi([
|
||||
{
|
||||
path: "/api/instance/settings/experimental",
|
||||
read: () => ({
|
||||
...instanceExperimentalSettingsSchema.parse({}),
|
||||
enableStreamlinedUi: true,
|
||||
enableCombinedInboxTasks: true,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
return () => {
|
||||
flags.cleanup();
|
||||
api.cleanup();
|
||||
};
|
||||
},
|
||||
} satisfies Meta<typeof InboxHost>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Mine: Story = { args: { tab: "mine" } };
|
||||
export const Recent: Story = { args: { tab: "recent" } };
|
||||
export const Unread: Story = { args: { tab: "unread" } };
|
||||
export const Blocked: Story = { args: { tab: "blocked" } };
|
||||
export const Empty: Story = {
|
||||
args: { tab: "mine" },
|
||||
parameters: { fixtureState: "empty" },
|
||||
};
|
||||
export const Loading: Story = { parameters: { fixtureState: "loading" } };
|
||||
export const Failed: Story = { parameters: { fixtureState: "failure" } };
|
||||
export const PermissionDenied: Story = {
|
||||
parameters: { fixtureState: "permission" },
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { LiveUpdatesProvider } from "@/context/LiveUpdatesProvider";
|
||||
import { ToastViewport } from "@/components/ToastViewport";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { expect, userEvent, within, waitFor } from "storybook/test";
|
||||
import {
|
||||
installLiveEventFixture,
|
||||
listenForFixtureSocket,
|
||||
publishFixtureEvent,
|
||||
} from "../../support/capability-gaps/live-events";
|
||||
function Publish({ eligible = true }: { eligible?: boolean }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
useEffect(() => listenForFixtureSocket(() => setReady(true)), []);
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
disabled={!ready}
|
||||
onClick={() =>
|
||||
publishFixtureEvent({
|
||||
id: 1,
|
||||
companyId: "company-storybook",
|
||||
type: "activity.logged",
|
||||
createdAt: new Date().toISOString(),
|
||||
toastEligible: eligible,
|
||||
payload: {
|
||||
entityType: "issue",
|
||||
entityId: "toast-task",
|
||||
action: "issue.created",
|
||||
actorType: "user",
|
||||
actorId: "another-user",
|
||||
actorName: "Release reviewer",
|
||||
details: {
|
||||
issueIdentifier: "PAP-42",
|
||||
issueTitle: "Review the release",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
Publish task update
|
||||
</Button>
|
||||
<ToastViewport />
|
||||
</>
|
||||
);
|
||||
}
|
||||
function TaskNotifications(props: { eligible?: boolean }) {
|
||||
return (
|
||||
<LiveUpdatesProvider>
|
||||
<Publish {...props} />
|
||||
</LiveUpdatesProvider>
|
||||
);
|
||||
}
|
||||
const meta = {
|
||||
id: "feature-personal-task-toasts",
|
||||
title:
|
||||
"Tasks and conversations/Inbox, decisions, and search/Personal task notifications",
|
||||
component: TaskNotifications,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
initialEntries: ["/PAP/dashboard"],
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Production socket consumer and toast viewport. Server eligibility metadata is simulated; persisted participation and mentions require multiplayer tests.",
|
||||
},
|
||||
},
|
||||
},
|
||||
beforeEach: installLiveEventFixture,
|
||||
} satisfies Meta<typeof TaskNotifications>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
async function publish(canvasElement: HTMLElement) {
|
||||
const button = await within(canvasElement).findByRole("button", {
|
||||
name: "Publish task update",
|
||||
});
|
||||
await waitFor(() => expect(button).toBeEnabled());
|
||||
await userEvent.click(button);
|
||||
}
|
||||
export const EligibleTask: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await publish(canvasElement);
|
||||
await within(document.body).findByText(/Release reviewer created/);
|
||||
},
|
||||
};
|
||||
export const UnrelatedTaskStaysQuiet: Story = {
|
||||
args: { eligible: false },
|
||||
play: async ({ canvasElement }) => {
|
||||
await publish(canvasElement);
|
||||
await expect(
|
||||
within(document.body).queryByText(/Release reviewer created/),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
export const VisibleTaskStaysQuiet: Story = {
|
||||
parameters: { initialEntries: ["/PAP/issues/toast-task"] },
|
||||
play: async ({ canvasElement }) => {
|
||||
await publish(canvasElement);
|
||||
await expect(
|
||||
within(document.body).queryByText(/Release reviewer created/),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
export const DuplicateDelivery: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await publish(canvasElement);
|
||||
await within(document.body).findByText(/Release reviewer created/);
|
||||
await publish(canvasElement);
|
||||
await expect(
|
||||
within(document.body).getAllByText(/Release reviewer created/),
|
||||
).toHaveLength(1);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent, within, waitFor } from "storybook/test";
|
||||
import { instanceGeneralSettingsSchema } from "@paperclipai/shared";
|
||||
import { InstanceGeneralSettings } from "@/pages/InstanceGeneralSettings";
|
||||
import {
|
||||
prepareFixtureApi,
|
||||
type FixtureState,
|
||||
} from "../../support/capability-gaps/api";
|
||||
let settings = instanceGeneralSettingsSchema.parse({});
|
||||
const meta = {
|
||||
id: "feature-backup-retention",
|
||||
title: "Oversight and operation/Instance operations/Backup retention",
|
||||
component: InstanceGeneralSettings,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Shipped general settings. Retention changes persist within this fixture; database backup and restoration require operator verification.",
|
||||
},
|
||||
},
|
||||
},
|
||||
beforeEach: ({ parameters }) => {
|
||||
settings = instanceGeneralSettingsSchema.parse({});
|
||||
const fixture = prepareFixtureApi(
|
||||
[
|
||||
{
|
||||
path: "/api/instance/settings/general",
|
||||
read: ({ method, body }) => {
|
||||
if (method === "PATCH")
|
||||
settings = instanceGeneralSettingsSchema.parse({
|
||||
...settings,
|
||||
...body,
|
||||
});
|
||||
return settings;
|
||||
},
|
||||
},
|
||||
],
|
||||
parameters.fixtureState as FixtureState,
|
||||
);
|
||||
return fixture.cleanup;
|
||||
},
|
||||
} satisfies Meta<typeof InstanceGeneralSettings>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const CurrentPolicy: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await within(canvasElement).findByRole("heading", {
|
||||
name: "Backup retention",
|
||||
});
|
||||
},
|
||||
};
|
||||
export const Loading: Story = {
|
||||
parameters: { fixtureState: "loading" },
|
||||
play: async ({ canvasElement }) => {
|
||||
await within(canvasElement).findByText("Loading general settings...");
|
||||
},
|
||||
};
|
||||
export const LoadFailed: Story = {
|
||||
parameters: { fixtureState: "failure" },
|
||||
play: async ({ canvasElement }) => {
|
||||
await within(canvasElement).findByText(
|
||||
"The request failed. Please try again.",
|
||||
);
|
||||
},
|
||||
};
|
||||
export const PermissionDenied: Story = {
|
||||
parameters: { fixtureState: "permission" },
|
||||
play: async ({ canvasElement }) => {
|
||||
await within(canvasElement).findByText(
|
||||
"You do not have permission to perform this action.",
|
||||
);
|
||||
},
|
||||
};
|
||||
export const SaveRetention: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
const button = await c.findByRole("button", { name: "14 days" });
|
||||
await userEvent.click(button);
|
||||
await waitFor(() => expect(settings.backupRetention.dailyDays).toBe(14));
|
||||
await waitFor(() => expect(button).toBeEnabled());
|
||||
await expect(button).toHaveClass("bg-accent");
|
||||
},
|
||||
};
|
||||
export const Saving: Story = {
|
||||
parameters: { fixtureState: "saving" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
const button = await c.findByRole("button", { name: "14 days" });
|
||||
await userEvent.click(button);
|
||||
await expect(button).toBeDisabled();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { PluginLauncherProvider } from "@/plugins/launchers";
|
||||
import { Sidebar } from "@/components/Sidebar";
|
||||
import { expect, userEvent, within, waitFor } from "storybook/test";
|
||||
import {
|
||||
prepareFixtureApi,
|
||||
type FixtureState,
|
||||
} from "../../support/capability-gaps/api";
|
||||
function SidebarPreferences() {
|
||||
return (
|
||||
<PluginLauncherProvider>
|
||||
<Sidebar />
|
||||
</PluginLauncherProvider>
|
||||
);
|
||||
}
|
||||
const meta = {
|
||||
id: "feature-sidebar-preferences",
|
||||
title:
|
||||
"Oversight and operation/Navigation, profile, and announcements/Sidebar preferences",
|
||||
component: SidebarPreferences,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Production sidebar with isolated saved order responses. Section collapse is session-local; recents are browser-local. No server persistence is claimed.",
|
||||
},
|
||||
},
|
||||
},
|
||||
beforeEach: ({ parameters }) =>
|
||||
prepareFixtureApi(
|
||||
[
|
||||
{
|
||||
path: "/api/sidebar-preferences/me",
|
||||
read: () => ({ orderedIds: ["company-storybook"], updatedAt: null }),
|
||||
},
|
||||
{
|
||||
path: "/api/companies/company-storybook/sidebar-preferences/me",
|
||||
read: () => ({ orderedIds: [], updatedAt: null }),
|
||||
},
|
||||
],
|
||||
parameters.fixtureState as FixtureState,
|
||||
).cleanup,
|
||||
} satisfies Meta<typeof SidebarPreferences>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const SavedOrder: Story = {};
|
||||
export const LoadingOrder: Story = { parameters: { fixtureState: "loading" } };
|
||||
export const OrderReadFailed: Story = {
|
||||
parameters: { fixtureState: "failure" },
|
||||
};
|
||||
export const OrderPermissionDenied: Story = {
|
||||
parameters: { fixtureState: "permission" },
|
||||
};
|
||||
export const CollapseWork: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await userEvent.click(await c.findByRole("button", { name: /Work/ }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
c.queryByRole("link", { name: "Dashboard" }),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent, within, waitFor } from "storybook/test";
|
||||
import {
|
||||
OnboardingWizard,
|
||||
ONBOARDING_STORAGE_KEY,
|
||||
} from "@/components/OnboardingWizard";
|
||||
import { useDialog } from "@/context/DialogContext";
|
||||
import { useCompanyListQuery } from "@/api/companies-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
seedOnboardingDraft,
|
||||
clearOnboardingDraft,
|
||||
} from "../../fixtures/onboardingDraft";
|
||||
function Resume() {
|
||||
const companies = useCompanyListQuery();
|
||||
const { openOnboarding } = useDialog();
|
||||
const [generation, setGeneration] = useState(0);
|
||||
useEffect(() => {
|
||||
if (companies.isSuccess)
|
||||
openOnboarding({ initialStep: 3, companyId: "company-storybook" });
|
||||
}, [companies.isSuccess, openOnboarding]);
|
||||
return (
|
||||
<>
|
||||
{companies.isSuccess ? <OnboardingWizard key={generation} /> : null}
|
||||
<Button
|
||||
className="fixed bottom-4 right-4 z-50"
|
||||
onClick={() => {
|
||||
setGeneration((n) => n + 1);
|
||||
openOnboarding({ initialStep: 3, companyId: "company-storybook" });
|
||||
}}
|
||||
>
|
||||
Reopen saved draft
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const meta = {
|
||||
id: "feature-onboarding-resume",
|
||||
title:
|
||||
"Getting started and access/Onboarding and first work/Resume a saved draft",
|
||||
component: Resume,
|
||||
parameters: { layout: "fullscreen" },
|
||||
beforeEach: ({ parameters }) => {
|
||||
seedOnboardingDraft();
|
||||
if (parameters.invalid)
|
||||
localStorage.setItem(ONBOARDING_STORAGE_KEY, "invalid JSON");
|
||||
if (parameters.otherCompany)
|
||||
localStorage.setItem(
|
||||
ONBOARDING_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
step: 3,
|
||||
createdCompanyId: "unavailable-company",
|
||||
agentName: "Another account's draft",
|
||||
}),
|
||||
);
|
||||
return clearOnboardingDraft;
|
||||
},
|
||||
} satisfies Meta<typeof Resume>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const SavedAgentName: Story = {
|
||||
play: async () => {
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(
|
||||
within(document.body).getByDisplayValue("Darnold"),
|
||||
).toBeInTheDocument(),
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
},
|
||||
};
|
||||
export const DraftAfterRemount: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const body = within(document.body);
|
||||
const input = await body.findByDisplayValue(
|
||||
"Darnold",
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
await userEvent.clear(input);
|
||||
await userEvent.click(input);
|
||||
await userEvent.paste("Release reviewer");
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
JSON.parse(localStorage.getItem(ONBOARDING_STORAGE_KEY) ?? "{}")
|
||||
.agentName,
|
||||
).toBe("Release reviewer"),
|
||||
);
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByRole("button", { name: "Reopen saved draft" }),
|
||||
);
|
||||
await body.findByDisplayValue("Release reviewer");
|
||||
},
|
||||
};
|
||||
export const MalformedDraft: Story = { parameters: { invalid: true } };
|
||||
export const UnavailableCompanyDraft: Story = {
|
||||
parameters: { otherCompany: true },
|
||||
play: async () => {
|
||||
const body = within(document.body);
|
||||
await expect(await body.findByTestId("onboarding-wizard")).toBeVisible();
|
||||
const name = await body.findByLabelText("Agent name");
|
||||
await waitFor(() => expect(name).toBeVisible(), { timeout: 10000 });
|
||||
await expect(name).toHaveValue("");
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
body.queryByDisplayValue("Another account's draft"),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { within } from "storybook/test";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Routes, Route } from "@/lib/router";
|
||||
import { Learnings } from "@/pages/Pipelines";
|
||||
import { installPipelineFixture } from "../../support/capability-gaps/pipelines";
|
||||
import type { FixtureState } from "../../support/capability-gaps/api";
|
||||
function Host() {
|
||||
return <Learnings />;
|
||||
}
|
||||
const meta = {
|
||||
id: "feature-pipeline-learnings",
|
||||
title:
|
||||
"Automation and structured work/Pipelines, review queues, and learnings/Learnings",
|
||||
component: Host,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
initialEntries: ["/PAP/learnings"],
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Shipped Learnings with isolated API receipts. Guards, durable transitions, automation execution and learning provenance require production tests.",
|
||||
},
|
||||
},
|
||||
},
|
||||
beforeEach: ({ parameters }) =>
|
||||
installPipelineFixture(parameters.fixtureState as FixtureState).cleanup,
|
||||
} satisfies Meta<typeof Host>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Ready: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await within(canvasElement).findByText(/Release reviewer approved/);
|
||||
},
|
||||
};
|
||||
export const Loading: Story = { parameters: { fixtureState: "loading" } };
|
||||
export const Empty: Story = { parameters: { fixtureState: "empty" } };
|
||||
export const Failed: Story = { parameters: { fixtureState: "failure" } };
|
||||
export const PermissionDenied: Story = {
|
||||
parameters: { fixtureState: "permission" },
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { within } from "storybook/test";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Routes, Route } from "@/lib/router";
|
||||
import { Pipelines } from "@/pages/Pipelines";
|
||||
import { installPipelineFixture } from "../../support/capability-gaps/pipelines";
|
||||
import type { FixtureState } from "../../support/capability-gaps/api";
|
||||
function Host() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/PAP/pipelines/:pipelineId" element={<Pipelines />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
const meta = {
|
||||
id: "feature-pipeline-board",
|
||||
title:
|
||||
"Automation and structured work/Pipelines, review queues, and learnings/Items",
|
||||
component: Host,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
initialEntries: ["/PAP/pipelines/pipeline-gap"],
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Shipped Pipelines with isolated API receipts. Guards, durable transitions, automation execution and learning provenance require production tests.",
|
||||
},
|
||||
},
|
||||
},
|
||||
beforeEach: ({ parameters }) =>
|
||||
installPipelineFixture(parameters.fixtureState as FixtureState).cleanup,
|
||||
} satisfies Meta<typeof Host>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Ready: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await within(canvasElement).findAllByText("Ship the release notes");
|
||||
},
|
||||
};
|
||||
export const Loading: Story = { parameters: { fixtureState: "loading" } };
|
||||
export const Empty: Story = { parameters: { fixtureState: "empty" } };
|
||||
export const Failed: Story = { parameters: { fixtureState: "failure" } };
|
||||
export const PermissionDenied: Story = {
|
||||
parameters: { fixtureState: "permission" },
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent, within, waitFor } from "storybook/test";
|
||||
import { Routes, Route } from "@/lib/router";
|
||||
import { ReviewQueue } from "@/pages/Pipelines";
|
||||
import { installPipelineFixture } from "../../support/capability-gaps/pipelines";
|
||||
import type { FixtureState } from "../../support/capability-gaps/api";
|
||||
function Host() {
|
||||
return <ReviewQueue />;
|
||||
}
|
||||
const meta = {
|
||||
id: "feature-pipeline-review",
|
||||
title:
|
||||
"Automation and structured work/Pipelines, review queues, and learnings/Review queue",
|
||||
component: Host,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
initialEntries: ["/PAP/review-queue"],
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Shipped ReviewQueue with isolated API receipts. Guards, durable transitions, automation execution and learning provenance require production tests.",
|
||||
},
|
||||
},
|
||||
},
|
||||
beforeEach: ({ parameters }) =>
|
||||
installPipelineFixture(parameters.fixtureState as FixtureState).cleanup,
|
||||
} satisfies Meta<typeof Host>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Ready: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await within(canvasElement).findAllByText("Ship the release notes");
|
||||
},
|
||||
};
|
||||
export const Loading: Story = { parameters: { fixtureState: "loading" } };
|
||||
export const Empty: Story = { parameters: { fixtureState: "empty" } };
|
||||
export const Failed: Story = { parameters: { fixtureState: "failure" } };
|
||||
export const PermissionDenied: Story = {
|
||||
parameters: { fixtureState: "permission" },
|
||||
};
|
||||
|
||||
export const Approved: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await c.findByText("Ship the release notes");
|
||||
await userEvent.click(c.getByRole("button", { name: /^Approve$/ }));
|
||||
await waitFor(() =>
|
||||
expect(c.queryByText("Ship the release notes")).not.toBeInTheDocument(),
|
||||
);
|
||||
},
|
||||
};
|
||||
export const ApprovalFailed: Story = {
|
||||
parameters: { fixtureState: "mutation-failure" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await c.findByText("Ship the release notes");
|
||||
await userEvent.click(c.getByRole("button", { name: /^Approve$/ }));
|
||||
await c.findByText("Needs attention");
|
||||
await c.findByText("Ship the release notes");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { within } from "storybook/test";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Routes, Route } from "@/lib/router";
|
||||
import { PipelineSettings } from "@/pages/PipelineSettings";
|
||||
import { installPipelineFixture } from "../../support/capability-gaps/pipelines";
|
||||
import type { FixtureState } from "../../support/capability-gaps/api";
|
||||
function Host() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/PAP/pipelines/:pipelineId/settings"
|
||||
element={<PipelineSettings />}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
const meta = {
|
||||
id: "feature-pipeline-stages",
|
||||
title:
|
||||
"Automation and structured work/Pipelines, review queues, and learnings/Stage settings",
|
||||
component: Host,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
initialEntries: ["/PAP/pipelines/pipeline-gap/settings"],
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Shipped PipelineSettings with isolated API receipts. Guards, durable transitions, automation execution and learning provenance require production tests.",
|
||||
},
|
||||
},
|
||||
},
|
||||
beforeEach: ({ parameters }) =>
|
||||
installPipelineFixture(parameters.fixtureState as FixtureState, {
|
||||
emptyStages: parameters.fixtureState === "empty",
|
||||
}).cleanup,
|
||||
} satisfies Meta<typeof Host>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Ready: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await within(canvasElement).findByDisplayValue("Release review");
|
||||
},
|
||||
};
|
||||
export const Loading: Story = { parameters: { fixtureState: "loading" } };
|
||||
export const Empty: Story = {
|
||||
parameters: { fixtureState: "empty" },
|
||||
play: async ({ canvasElement }) => {
|
||||
await within(canvasElement).findByText("No stages configured.");
|
||||
},
|
||||
};
|
||||
export const Failed: Story = { parameters: { fixtureState: "failure" } };
|
||||
export const PermissionDenied: Story = {
|
||||
parameters: { fixtureState: "permission" },
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import { PluginManager } from "@/pages/PluginManager";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Routes, Route } from "@/lib/router";
|
||||
import { PluginSettings } from "@/pages/PluginSettings";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import { installPluginFixtures } from "../../support/capability-gaps/plugins";
|
||||
import type { FixtureState } from "../../support/capability-gaps/api";
|
||||
function Settings() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/PAP/company/settings/instance/plugins"
|
||||
element={<PluginManager />}
|
||||
/>
|
||||
<Route
|
||||
path="/PAP/plugins/:pluginId/settings"
|
||||
element={<PluginSettings />}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
const meta = {
|
||||
id: "feature-plugin-configuration",
|
||||
title: "Apps, channels, and extensions/Plugins/Configuration",
|
||||
component: Settings,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
initialEntries: ["/PAP/plugins/plugin-gap/settings"],
|
||||
},
|
||||
beforeEach: ({ parameters }) =>
|
||||
installPluginFixtures(parameters.fixtureState as FixtureState),
|
||||
} satisfies Meta<typeof Settings>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Configured: Story = {};
|
||||
export const Loading: Story = { parameters: { fixtureState: "loading" } };
|
||||
export const Unavailable: Story = { parameters: { fixtureState: "failure" } };
|
||||
export const SaveConfiguration: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
const input = await c.findByDisplayValue("Release preview");
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, "Reviewed release", { delay: 15 });
|
||||
await userEvent.click(
|
||||
c.getByRole("button", { name: "Save Configuration" }),
|
||||
);
|
||||
await c.findByText("Configuration saved.");
|
||||
await expect(input).toHaveValue("Reviewed release");
|
||||
},
|
||||
};
|
||||
export const SaveFailed: Story = {
|
||||
parameters: { fixtureState: "mutation-failure" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
const input = await c.findByDisplayValue("Release preview");
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, "Reviewed release", { delay: 15 });
|
||||
await userEvent.click(
|
||||
c.getByRole("button", { name: "Save Configuration" }),
|
||||
);
|
||||
await c.findByText("The request failed. Please try again.");
|
||||
},
|
||||
};
|
||||
export const PermissionDenied: Story = {
|
||||
parameters: { fixtureState: "mutation-permission" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
const input = await c.findByDisplayValue("Release preview");
|
||||
await userEvent.type(input, " updated", { delay: 15 });
|
||||
await userEvent.click(
|
||||
c.getByRole("button", { name: "Save Configuration" }),
|
||||
);
|
||||
await c.findByText("You do not have permission to perform this action.");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
import { PluginSettings } from "@/pages/PluginSettings";
|
||||
import { PluginManager } from "@/pages/PluginManager";
|
||||
import { installPluginFixtures } from "../../support/capability-gaps/plugins";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Routes, Route } from "@/lib/router";
|
||||
import { PluginPage } from "@/pages/PluginPage";
|
||||
import { registerPluginReactComponent } from "@/plugins/slots";
|
||||
import { within } from "storybook/test";
|
||||
import {
|
||||
prepareFixtureApi,
|
||||
type FixtureState,
|
||||
} from "../../support/capability-gaps/api";
|
||||
function Contribution() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/PAP/company/settings/instance/plugins/:pluginId"
|
||||
element={<PluginSettings />}
|
||||
/>
|
||||
<Route
|
||||
path="/PAP/company/settings/instance/plugins"
|
||||
element={<PluginManager />}
|
||||
/>
|
||||
<Route path="/PAP/plugins/:pluginId" element={<PluginPage />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
function FixturePluginContent() {
|
||||
return <p>Isolated plugin contribution</p>;
|
||||
}
|
||||
const meta = {
|
||||
id: "feature-plugin-contributions",
|
||||
title: "Apps, channels, and extensions/Plugins/Contributed routes",
|
||||
component: Contribution,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
initialEntries: ["/PAP/plugins/contribution-gap"],
|
||||
},
|
||||
beforeEach: ({ parameters }) => {
|
||||
registerPluginReactComponent(
|
||||
"storybook.contribution",
|
||||
"FixturePluginContent",
|
||||
FixturePluginContent,
|
||||
);
|
||||
const baseCleanup = installPluginFixtures(
|
||||
"ready",
|
||||
false,
|
||||
"contribution-gap",
|
||||
);
|
||||
const fixture = prepareFixtureApi(
|
||||
[
|
||||
{
|
||||
path: "/api/plugins/ui-contributions",
|
||||
read: () =>
|
||||
parameters.empty
|
||||
? []
|
||||
: [
|
||||
{
|
||||
pluginId: "contribution-gap",
|
||||
pluginKey: "storybook.contribution",
|
||||
displayName: "Preview contribution",
|
||||
version: "1.0.0",
|
||||
uiEntryFile: "fixture.js",
|
||||
slots: [
|
||||
{
|
||||
type: "page",
|
||||
id: "fixture-page",
|
||||
displayName: "Preview",
|
||||
exportName: "FixturePluginContent",
|
||||
routePath: "fixture",
|
||||
},
|
||||
],
|
||||
launchers: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
parameters.fixtureState as FixtureState,
|
||||
);
|
||||
return () => {
|
||||
fixture.cleanup();
|
||||
baseCleanup();
|
||||
};
|
||||
},
|
||||
} satisfies Meta<typeof Contribution>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const MountedContribution: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await within(canvasElement).findByText("Isolated plugin contribution");
|
||||
},
|
||||
};
|
||||
export const Loading: Story = { parameters: { fixtureState: "loading" } };
|
||||
export const MissingContribution: Story = { parameters: { empty: true } };
|
||||
export const DiscoveryFailure: Story = {
|
||||
parameters: {
|
||||
fixtureState: "failure",
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"Current production PluginPage retains its Loading state after contribution discovery fails. This exposes a product error-state gap; it is not a passing failure UX.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import { PluginManager } from "@/pages/PluginManager";
|
||||
import { ToastViewport } from "@/components/ToastViewport";
|
||||
import { installPluginFixtures } from "../../support/capability-gaps/plugins";
|
||||
import type { FixtureState } from "../../support/capability-gaps/api";
|
||||
function Plugins() {
|
||||
return (
|
||||
<>
|
||||
<PluginManager />
|
||||
<ToastViewport />
|
||||
</>
|
||||
);
|
||||
}
|
||||
const meta = {
|
||||
id: "feature-plugin-management",
|
||||
title: "Apps, channels, and extensions/Plugins/Installation and lifecycle",
|
||||
component: Plugins,
|
||||
parameters: { layout: "padded" },
|
||||
beforeEach: ({ parameters }) =>
|
||||
installPluginFixtures(
|
||||
parameters.fixtureState as FixtureState,
|
||||
Boolean(parameters.empty),
|
||||
),
|
||||
} satisfies Meta<typeof Plugins>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Installed: Story = {};
|
||||
export const Empty: Story = { parameters: { empty: true } };
|
||||
export const Loading: Story = { parameters: { fixtureState: "loading" } };
|
||||
export const Failed: Story = { parameters: { fixtureState: "failure" } };
|
||||
export const PermissionDenied: Story = {
|
||||
parameters: { fixtureState: "permission" },
|
||||
};
|
||||
async function install() {
|
||||
const c = within(document.body);
|
||||
await userEvent.click(
|
||||
await c.findByRole("button", { name: "Install Plugin" }),
|
||||
);
|
||||
await userEvent.type(
|
||||
await c.findByRole("textbox"),
|
||||
"@paperclipai/storybook-fixture",
|
||||
{ delay: 15 },
|
||||
);
|
||||
await userEvent.click(c.getByRole("button", { name: "Install" }));
|
||||
}
|
||||
export const InstallPackage: Story = {
|
||||
parameters: { empty: true },
|
||||
play: async () => {
|
||||
await install();
|
||||
await within(document.body).findByText("Plugin installed successfully");
|
||||
await within(document.body).findByText("Preview plugin");
|
||||
},
|
||||
};
|
||||
export const Installing: Story = {
|
||||
parameters: { empty: true, fixtureState: "saving" },
|
||||
play: async () => {
|
||||
await install();
|
||||
await expect(
|
||||
await within(document.body).findByRole("button", {
|
||||
name: "Installing...",
|
||||
}),
|
||||
).toBeDisabled();
|
||||
},
|
||||
};
|
||||
export const InstallFailed: Story = {
|
||||
parameters: { empty: true, fixtureState: "mutation-failure" },
|
||||
play: async () => {
|
||||
await install();
|
||||
await within(document.body).findByText("Failed to install plugin");
|
||||
},
|
||||
};
|
||||
export const DisablePlugin: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await userEvent.click(await c.findByTitle("Disable"));
|
||||
await within(document.body).findByText("Plugin disabled");
|
||||
await c.findByTitle("Enable");
|
||||
},
|
||||
};
|
||||
export const UninstallPlugin: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(await within(canvasElement).findByTitle("Uninstall"));
|
||||
const c = within(document.body);
|
||||
await userEvent.click(c.getByRole("button", { name: "Uninstall" }));
|
||||
await c.findByText("Plugin uninstalled successfully");
|
||||
await expect(c.queryByText("Preview plugin")).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { IssueRecoveryAction } from "@paperclipai/shared";
|
||||
import { expect, userEvent, within, waitFor } from "storybook/test";
|
||||
import { IssueRecoveryActionCard } from "@/components/IssueRecoveryActionCard";
|
||||
import {
|
||||
DispositionRecoveryNotice,
|
||||
DispositionRecoveryProvider,
|
||||
} from "@/components/DispositionRecoveryNotice";
|
||||
import { storybookAgents } from "../../fixtures/paperclipData";
|
||||
const agent = storybookAgents[0];
|
||||
const action: IssueRecoveryAction = {
|
||||
id: "recovery-gap",
|
||||
companyId: agent.companyId,
|
||||
sourceIssueId: "task-gap",
|
||||
recoveryIssueId: null,
|
||||
kind: "deliberate_wait_without_target",
|
||||
status: "active",
|
||||
ownerType: "board",
|
||||
ownerAgentId: null,
|
||||
ownerUserId: null,
|
||||
previousOwnerAgentId: agent.id,
|
||||
returnOwnerAgentId: agent.id,
|
||||
cause: "unchanged_source_state_exhausted",
|
||||
fingerprint: "storybook-recovery-gap",
|
||||
evidence: {
|
||||
summary:
|
||||
"Automatic recovery exhausted two attempts without a durable next step.",
|
||||
},
|
||||
nextAction: "Inspect the outcome before deciding whether to retry.",
|
||||
wakePolicy: { type: "board_escalation" },
|
||||
monitorPolicy: null,
|
||||
attemptCount: 2,
|
||||
maxAttempts: 2,
|
||||
timeoutAt: null,
|
||||
lastAttemptAt: "2026-10-12T12:00:00Z",
|
||||
outcome: null,
|
||||
resolutionNote: null,
|
||||
resolvedAt: null,
|
||||
createdAt: "2026-10-12T12:00:00Z",
|
||||
updatedAt: "2026-10-12T12:00:00Z",
|
||||
};
|
||||
function Recovery({
|
||||
state = "ready",
|
||||
}: {
|
||||
state?: "ready" | "blocked" | "failure" | "saving" | "historical";
|
||||
}) {
|
||||
const [resolved, setResolved] = useState(false);
|
||||
return (
|
||||
<DispositionRecoveryProvider
|
||||
value={{
|
||||
issue: {
|
||||
status: resolved ? "todo" : "blocked",
|
||||
assigneeAgentId: agent.id,
|
||||
executionRunId: null,
|
||||
checkoutRunId: null,
|
||||
executionState: null,
|
||||
blockedBy: [],
|
||||
activeRecoveryAction: state === "historical" ? null : action,
|
||||
},
|
||||
agentMap: new Map([[agent.id, agent]]),
|
||||
unavailableReason:
|
||||
state === "blocked"
|
||||
? "You do not have permission to retry this task."
|
||||
: null,
|
||||
onRetry: async () => {
|
||||
if (state === "saving") return new Promise<void>(() => {});
|
||||
if (state === "failure")
|
||||
throw new Error("The recovery decision could not be saved.");
|
||||
setResolved(true);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DispositionRecoveryNotice
|
||||
defaultExpanded
|
||||
snapshot={{
|
||||
kind: "disposition_repair_escalated",
|
||||
actionId: action.id,
|
||||
attemptCount: 2,
|
||||
maxAttempts: 2,
|
||||
reason: "unchanged_source_state_exhausted",
|
||||
assigneeAgentId: agent.id,
|
||||
}}
|
||||
/>
|
||||
</DispositionRecoveryProvider>
|
||||
);
|
||||
}
|
||||
const meta = {
|
||||
id: "feature-recovery-gap-states",
|
||||
title: "Projects and execution/Recovery/Exhaustion and user decisions",
|
||||
component: Recovery,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Shipped recovery notice/card. Decisions alter local fixture state only. Bounded attempts, unknown-side-effect replay and real continuation require production tests.",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Recovery>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Exhausted: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await within(canvasElement).findByRole("button", { name: "Retry agent" });
|
||||
},
|
||||
};
|
||||
export const PermissionBlocked: Story = {
|
||||
args: { state: "blocked" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await expect(
|
||||
await c.findByRole("button", { name: "Retry agent" }),
|
||||
).toBeDisabled();
|
||||
await c.findByText("You do not have permission to retry this task.");
|
||||
},
|
||||
};
|
||||
export const RetryFailed: Story = {
|
||||
args: { state: "failure" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await c.findByRole("button", { name: "Retry agent" }),
|
||||
);
|
||||
await c.findByRole("alert");
|
||||
},
|
||||
};
|
||||
export const RetryPending: Story = {
|
||||
args: { state: "saving" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await c.findByRole("button", { name: "Retry agent" }),
|
||||
);
|
||||
const retry = await c.findByRole("button", { name: "Requesting retry…" });
|
||||
await waitFor(() => expect(retry).toBeDisabled());
|
||||
},
|
||||
};
|
||||
export const RetryAccepted: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await c.findByRole("button", { name: "Retry agent" }),
|
||||
);
|
||||
await c.findByRole("heading", { name: "Retry requested" });
|
||||
},
|
||||
};
|
||||
export const NoActiveRecovery: Story = {
|
||||
args: { state: "historical" },
|
||||
play: async ({ canvasElement }) => {
|
||||
await within(canvasElement).findByRole("heading", {
|
||||
name: "Agent needed attention",
|
||||
});
|
||||
},
|
||||
};
|
||||
export const UnknownOutcome: Story = {
|
||||
render: () => (
|
||||
<IssueRecoveryActionCard
|
||||
action={{
|
||||
...action,
|
||||
kind: "active_run_watchdog",
|
||||
cause: "external_execution_unknown",
|
||||
evidence: {
|
||||
summary:
|
||||
"The external request may have completed. Inspect the provider before replaying it.",
|
||||
},
|
||||
nextAction:
|
||||
"Inspect the external outcome; choose a recorded disposition.",
|
||||
}}
|
||||
/>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
await within(canvasElement).findByText(
|
||||
"The external request may have completed. Inspect the provider before replaying it.",
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Routes, Route } from "@/lib/router";
|
||||
import { RoutineDetail } from "@/pages/RoutineDetail";
|
||||
import { ToastViewport } from "@/components/ToastViewport";
|
||||
import { expect, userEvent, within, waitFor } from "storybook/test";
|
||||
import { baseRoutine } from "../../fixtures/routineWebhooks";
|
||||
import { storybookIssues } from "../../fixtures/paperclipData";
|
||||
import {
|
||||
prepareFixtureApi,
|
||||
type FixtureState,
|
||||
} from "../../support/capability-gaps/api";
|
||||
let submitted: Record<string, unknown> | null = null;
|
||||
function RunRoutine() {
|
||||
return (
|
||||
<>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/PAP/routines/:routineId/:section?"
|
||||
element={<RoutineDetail />}
|
||||
/>
|
||||
</Routes>
|
||||
<ToastViewport />
|
||||
</>
|
||||
);
|
||||
}
|
||||
const meta = {
|
||||
id: "feature-routine-run-now",
|
||||
title:
|
||||
"Automation and structured work/Routines, schedules, and triggers/Run now",
|
||||
component: RunRoutine,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
initialEntries: ["/PAP/routines/routine-gap"],
|
||||
},
|
||||
beforeEach: ({ parameters }) => {
|
||||
submitted = null;
|
||||
let tasks: unknown[] = [];
|
||||
return prepareFixtureApi(
|
||||
[
|
||||
{
|
||||
path: "/api/routines/routine-gap",
|
||||
read: () => ({
|
||||
...baseRoutine,
|
||||
id: "routine-gap",
|
||||
title: "Review a release",
|
||||
variables: [
|
||||
{
|
||||
name: "environment",
|
||||
label: "Environment",
|
||||
type: "text",
|
||||
required: true,
|
||||
defaultValue: "",
|
||||
options: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ path: "/api/routines/routine-gap/runs", read: () => [] },
|
||||
{ path: "/api/companies/company-storybook/issues", read: () => tasks },
|
||||
{
|
||||
path: "/api/routines/routine-gap/run",
|
||||
method: "POST",
|
||||
read: ({ body }) => {
|
||||
submitted = body;
|
||||
tasks = [
|
||||
{
|
||||
...storybookIssues[0],
|
||||
id: "run-task-gap",
|
||||
title: "Review a release in staging",
|
||||
originKind: "routine_execution",
|
||||
originId: "routine-gap",
|
||||
},
|
||||
];
|
||||
return {
|
||||
id: "routine-run-gap",
|
||||
companyId: "company-storybook",
|
||||
routineId: "routine-gap",
|
||||
status: "enqueued",
|
||||
linkedIssueId: "run-task-gap",
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
parameters.fixtureState as FixtureState,
|
||||
).cleanup;
|
||||
},
|
||||
} satisfies Meta<typeof RunRoutine>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Overview: Story = {};
|
||||
export const Loading: Story = { parameters: { fixtureState: "loading" } };
|
||||
export const Failed: Story = { parameters: { fixtureState: "failure" } };
|
||||
export const PermissionDenied: Story = {
|
||||
parameters: { fixtureState: "permission" },
|
||||
};
|
||||
async function openRun() {
|
||||
const c = within(document.body);
|
||||
await userEvent.click(await c.findByRole("button", { name: "Run now" }));
|
||||
return c;
|
||||
}
|
||||
export const RequiredInput: Story = {
|
||||
play: async () => {
|
||||
const c = await openRun();
|
||||
await expect(c.getByRole("button", { name: "Run routine" })).toBeDisabled();
|
||||
},
|
||||
};
|
||||
async function run() {
|
||||
const c = await openRun();
|
||||
await userEvent.type(
|
||||
within(c.getByRole("dialog")).getByRole("textbox"),
|
||||
"staging",
|
||||
{ delay: 15 },
|
||||
);
|
||||
await userEvent.click(c.getByRole("button", { name: "Run routine" }));
|
||||
}
|
||||
export const Started: Story = {
|
||||
play: async () => {
|
||||
await run();
|
||||
await within(document.body).findByText("Routine run started");
|
||||
await waitFor(() =>
|
||||
expect(submitted?.variables).toEqual({ environment: "staging" }),
|
||||
);
|
||||
},
|
||||
};
|
||||
export const Starting: Story = {
|
||||
parameters: { fixtureState: "saving" },
|
||||
play: async () => {
|
||||
await run();
|
||||
await expect(
|
||||
await within(document.body).findByRole("button", { name: "Running..." }),
|
||||
).toBeDisabled();
|
||||
},
|
||||
};
|
||||
export const StartFailed: Story = {
|
||||
parameters: { fixtureState: "mutation-failure" },
|
||||
play: async () => {
|
||||
await run();
|
||||
await within(document.body).findByText("Routine run failed");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { SecretProposalView } from "@paperclipai/shared";
|
||||
import { expect, userEvent, within, waitFor } from "storybook/test";
|
||||
import { ProposalsTab } from "@/pages/secrets/ProposalsTab";
|
||||
import { ToastViewport } from "@/components/ToastViewport";
|
||||
import {
|
||||
prepareFixtureApi,
|
||||
type FixtureState,
|
||||
} from "../../support/capability-gaps/api";
|
||||
const proposal: SecretProposalView = {
|
||||
id: "proposal-gap",
|
||||
companyId: "company-storybook",
|
||||
kind: "secret",
|
||||
status: "pending",
|
||||
justification: "The deploy task needs access to the GitHub API.",
|
||||
proposedName: "dev/github/token",
|
||||
proposedKey: "token",
|
||||
proposedDescription: null,
|
||||
valueFingerprintSha256: "abcdef0123456789abcdef",
|
||||
valueLength: 40,
|
||||
secretId: null,
|
||||
secretName: null,
|
||||
secretProposalId: null,
|
||||
secretProposalName: null,
|
||||
targetType: null,
|
||||
target: null,
|
||||
configPath: null,
|
||||
proposedBy: { id: "agent-coder", name: "Codex Coder", icon: "code" },
|
||||
originIssue: { id: "task-gap", key: "PAP-42", title: "Deploy preview" },
|
||||
originRunId: "run-gap",
|
||||
expiresAt: "2099-10-13T12:00:00Z",
|
||||
createdAt: "2026-10-12T12:00:00Z",
|
||||
resolvedByUserId: null,
|
||||
resolvedAt: null,
|
||||
resolutionReason: null,
|
||||
createdSecretId: null,
|
||||
appliedBindingConfigPath: null,
|
||||
viewerCanApprove: true,
|
||||
approveBlockReason: null,
|
||||
};
|
||||
let rows: SecretProposalView[] = [];
|
||||
function ProposalReview() {
|
||||
return (
|
||||
<>
|
||||
<ProposalsTab companyId="company-storybook" providerConfigs={[]} />
|
||||
<ToastViewport />
|
||||
</>
|
||||
);
|
||||
}
|
||||
const meta = {
|
||||
id: "feature-secret-proposal-review",
|
||||
title: "Apps, channels, and extensions/Secrets and proposals/Proposal review",
|
||||
component: ProposalReview,
|
||||
parameters: { layout: "padded" },
|
||||
beforeEach: ({ parameters }) => {
|
||||
rows =
|
||||
parameters.fixtureState === "empty"
|
||||
? []
|
||||
: [
|
||||
{
|
||||
...proposal,
|
||||
viewerCanApprove: !parameters.blocked,
|
||||
approveBlockReason: parameters.blocked
|
||||
? "Only an authorized secret administrator can approve this proposal."
|
||||
: null,
|
||||
},
|
||||
];
|
||||
return prepareFixtureApi(
|
||||
[
|
||||
{
|
||||
path: "/api/companies/company-storybook/secret-proposals",
|
||||
read: () => rows,
|
||||
},
|
||||
{
|
||||
path: /^\/api\/companies\/company-storybook\/secret-proposals\/proposal-gap\/(approve|reject)$/,
|
||||
method: "POST",
|
||||
read: ({ url, body }) => {
|
||||
const result = {
|
||||
...rows[0],
|
||||
status: url.pathname.endsWith("approve")
|
||||
? ("approved" as const)
|
||||
: ("rejected" as const),
|
||||
resolutionReason: String(body.reason ?? ""),
|
||||
resolvedAt: "2026-10-13T12:00:00Z",
|
||||
};
|
||||
rows = [];
|
||||
return result;
|
||||
},
|
||||
},
|
||||
],
|
||||
parameters.fixtureState as FixtureState,
|
||||
).cleanup;
|
||||
},
|
||||
} satisfies Meta<typeof ProposalReview>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Pending: Story = {};
|
||||
export const Loading: Story = { parameters: { fixtureState: "loading" } };
|
||||
export const Empty: Story = { parameters: { fixtureState: "empty" } };
|
||||
export const Failed: Story = { parameters: { fixtureState: "failure" } };
|
||||
export const PermissionBlocked: Story = {
|
||||
parameters: { blocked: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
await expect(
|
||||
await within(canvasElement).findByRole("button", { name: "Approve" }),
|
||||
).toBeDisabled();
|
||||
},
|
||||
};
|
||||
export const RejectProposal: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await userEvent.click(await c.findByRole("button", { name: "Reject" }));
|
||||
const body = within(document.body);
|
||||
await userEvent.type(
|
||||
await body.findByRole("textbox"),
|
||||
"The task can use the existing managed connection.",
|
||||
{ delay: 15 },
|
||||
);
|
||||
await userEvent.click(
|
||||
within(body.getByRole("dialog")).getByRole("button", { name: "Reject" }),
|
||||
);
|
||||
await waitFor(() => expect(rows).toHaveLength(0));
|
||||
await body.findByText("Proposal rejected");
|
||||
},
|
||||
};
|
||||
export const RejectFailed: Story = {
|
||||
parameters: { fixtureState: "mutation-failure" },
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole("button", { name: "Reject" }),
|
||||
);
|
||||
const body = within(document.body);
|
||||
await userEvent.type(
|
||||
await body.findByRole("textbox"),
|
||||
"Use the existing connection.",
|
||||
{ delay: 15 },
|
||||
);
|
||||
await userEvent.click(
|
||||
within(body.getByRole("dialog")).getByRole("button", { name: "Reject" }),
|
||||
);
|
||||
await body.findByText("The request failed. Please try again.");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import { Routes, Route } from "@/lib/router";
|
||||
import { SkillStudio } from "@/pages/SkillStudio";
|
||||
import { makeSkill } from "../../support/capability-gaps/skills";
|
||||
import {
|
||||
prepareFixtureApi,
|
||||
type FixtureState,
|
||||
} from "../../support/capability-gaps/api";
|
||||
function Studio() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/PAP/skills/studio/:skillId" element={<SkillStudio />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
const meta = {
|
||||
id: "feature-skill-test-runs",
|
||||
title: "Agents and reusable capabilities/Skills and Skill Studio/Test runs",
|
||||
component: Studio,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
initialEntries: ["/PAP/skills/studio/skill-gap"],
|
||||
},
|
||||
beforeEach: ({ parameters }) => {
|
||||
const skill = makeSkill();
|
||||
const run = {
|
||||
id: "run-gap",
|
||||
companyId: skill.companyId,
|
||||
skillId: skill.id,
|
||||
inputId: "input-gap",
|
||||
inputSnapshot: "Review this release.",
|
||||
skillVersionId: "version-gap",
|
||||
agentId: "agent-codex",
|
||||
agentConfigSnapshot: {},
|
||||
issueId: "task-gap",
|
||||
templateId: null,
|
||||
templateName: null,
|
||||
templateBody: null,
|
||||
renderedTemplateBody: null,
|
||||
harnessIssueDescription: "Review this release.",
|
||||
status: parameters.runStatus ?? "succeeded",
|
||||
outputDocumentKey: "result",
|
||||
outputSnapshot:
|
||||
parameters.runStatus === "running" || parameters.runStatus === "queued"
|
||||
? null
|
||||
: "The release checks passed.",
|
||||
error:
|
||||
parameters.runStatus === "failed"
|
||||
? "The provider could not complete the test."
|
||||
: null,
|
||||
deletedAt: null,
|
||||
supersededAt: null,
|
||||
harnessIssueExpiresAt: null,
|
||||
harnessIssueDeletedAt: null,
|
||||
createdAt: "2026-10-12T12:00:00Z",
|
||||
updatedAt: "2026-10-12T12:01:00Z",
|
||||
cost: {
|
||||
costCents: 2,
|
||||
inputTokens: 100,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 50,
|
||||
},
|
||||
taskExpired: false,
|
||||
};
|
||||
let runs = parameters.fixtureState === "empty" ? [] : [run];
|
||||
const detail = () => ({
|
||||
...run,
|
||||
skillVersion: {
|
||||
id: "version-gap",
|
||||
companyId: skill.companyId,
|
||||
companySkillId: skill.id,
|
||||
revisionNumber: 1,
|
||||
label: "Release check",
|
||||
releaseId: null,
|
||||
releaseName: null,
|
||||
releasedAt: null,
|
||||
fileInventory: [],
|
||||
authorAgentId: null,
|
||||
authorUserId: "user-storybook",
|
||||
createdAt: run.createdAt,
|
||||
},
|
||||
outputBody: run.outputSnapshot,
|
||||
harnessContent: {
|
||||
available: false,
|
||||
unavailableReason: "expired",
|
||||
documents: [],
|
||||
attachments: [],
|
||||
workProducts: [],
|
||||
},
|
||||
harnessIssue: null,
|
||||
documents: [],
|
||||
interactions: [],
|
||||
artifacts: [],
|
||||
});
|
||||
return prepareFixtureApi(
|
||||
[
|
||||
{
|
||||
path: "/api/companies/company-storybook/skills",
|
||||
read: () => [skill],
|
||||
},
|
||||
{
|
||||
path: "/api/companies/company-storybook/skills/skill-gap",
|
||||
read: () => skill,
|
||||
},
|
||||
{
|
||||
path: "/api/companies/company-storybook/skills/skill-gap/files",
|
||||
read: () => ({
|
||||
skillId: skill.id,
|
||||
path: "SKILL.md",
|
||||
content: skill.markdown,
|
||||
language: "markdown",
|
||||
editable: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/api/companies/company-storybook/skills/skill-gap/test-inputs",
|
||||
read: () =>
|
||||
parameters.fixtureState === "empty"
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: "input-gap",
|
||||
companyId: skill.companyId,
|
||||
skillId: skill.id,
|
||||
name: "Release review",
|
||||
content: "Review this release.",
|
||||
createdBy: null,
|
||||
deletedAt: null,
|
||||
createdAt: run.createdAt,
|
||||
updatedAt: run.updatedAt,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/api/companies/company-storybook/skill-test-run-templates",
|
||||
read: () => [],
|
||||
},
|
||||
{
|
||||
path: "/api/companies/company-storybook/skills/skill-gap/test-runs",
|
||||
read: ({ method }) => {
|
||||
if (method === "POST") {
|
||||
const queued = { ...run, id: "run-queued", status: "queued" };
|
||||
runs = [queued, ...runs];
|
||||
return queued;
|
||||
}
|
||||
return runs;
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/api/companies/company-storybook/skills/skill-gap/test-runs/run-gap",
|
||||
read: detail,
|
||||
},
|
||||
],
|
||||
parameters.fixtureState as FixtureState,
|
||||
).cleanup;
|
||||
},
|
||||
} satisfies Meta<typeof Studio>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const CompletedTest: Story = {};
|
||||
export const Queued: Story = { parameters: { runStatus: "queued" } };
|
||||
export const Running: Story = { parameters: { runStatus: "running" } };
|
||||
export const TestFailed: Story = { parameters: { runStatus: "failed" } };
|
||||
export const Empty: Story = { parameters: { fixtureState: "empty" } };
|
||||
export const Loading: Story = { parameters: { fixtureState: "loading" } };
|
||||
export const LoadFailed: Story = { parameters: { fixtureState: "failure" } };
|
||||
export const PermissionDenied: Story = {
|
||||
parameters: { fixtureState: "permission" },
|
||||
};
|
||||
|
||||
export const ResultDetails: Story = {
|
||||
parameters: { initialEntries: ["/PAP/skills/studio/skill-gap?run=run-gap"] },
|
||||
play: async ({ canvasElement }) => {
|
||||
await within(canvasElement).findByText("The release checks passed.");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,244 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Routes, Route } from "@/lib/router";
|
||||
import { StatusCards } from "@/pages/StatusCards";
|
||||
import { expect, userEvent, within, waitFor } from "storybook/test";
|
||||
import { makeStatusCard } from "../../support/capability-gaps/status-cards";
|
||||
import {
|
||||
prepareFixtureApi,
|
||||
type FixtureState,
|
||||
} from "../../support/capability-gaps/api";
|
||||
function StatusCardHost() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/status/:cardId?" element={<StatusCards />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
const meta = {
|
||||
id: "feature-status-card-states",
|
||||
title:
|
||||
"Automation and structured work/Status cards/Authoring, updates and archive",
|
||||
component: StatusCardHost,
|
||||
parameters: { layout: "padded", initialEntries: ["/status"] },
|
||||
beforeEach: ({ parameters }) => {
|
||||
let card = makeStatusCard({
|
||||
state: parameters.cardState ?? "active",
|
||||
generatingIssueId:
|
||||
parameters.cardState === "compiling" ? "summary-task-gap" : null,
|
||||
...(parameters.cardState === "compiling"
|
||||
? { queryCompiledAt: null, lastGeneratedAt: null }
|
||||
: {}),
|
||||
failureReason:
|
||||
parameters.cardState === "error"
|
||||
? "The summarizer could not finish this update."
|
||||
: null,
|
||||
summaryBody:
|
||||
parameters.cardState === "compiling"
|
||||
? null
|
||||
: "Build and tests passed. Provider qualification remains pending.",
|
||||
pendingChangeCount: parameters.stale ? 3 : 0,
|
||||
archivedAt: parameters.archived ? "2026-10-12T12:00:00Z" : null,
|
||||
});
|
||||
let rows = parameters.fixtureState === "empty" ? [] : [card];
|
||||
return prepareFixtureApi(
|
||||
[
|
||||
{
|
||||
path: "/api/companies/company-storybook/status-cards",
|
||||
read: ({ method, url, body }) => {
|
||||
if (method === "POST") {
|
||||
card = makeStatusCard({
|
||||
title: "New review card",
|
||||
generatingIssueId: "summary-task-gap",
|
||||
queryCompiledAt: null,
|
||||
state: "compiling",
|
||||
interestPrompt: String(body.interestPrompt),
|
||||
summaryBody: null,
|
||||
lastGeneratedAt: null,
|
||||
});
|
||||
rows = [card];
|
||||
return card;
|
||||
}
|
||||
return rows.filter(
|
||||
(row) =>
|
||||
Boolean(row.archivedAt) ===
|
||||
(url.searchParams.get("archived") === "true"),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/api/status-cards/card-gap",
|
||||
read: ({ method, body }) => {
|
||||
if (method === "PATCH") {
|
||||
card = {
|
||||
...card,
|
||||
archivedAt: body.archived ? "2026-10-13T12:00:00Z" : null,
|
||||
pendingChangeCount: body.archived ? 0 : 1,
|
||||
};
|
||||
rows = [card];
|
||||
}
|
||||
return card;
|
||||
},
|
||||
},
|
||||
{
|
||||
path: /^\/api\/status-cards\/card-gap\/(refresh|recompile)$/,
|
||||
method: "POST",
|
||||
read: () => {
|
||||
card = {
|
||||
...card,
|
||||
pendingChangeCount: 0,
|
||||
summaryBody: "All qualification checks passed. Ready for review.",
|
||||
lastGeneratedAt: "2026-10-13T12:00:00Z",
|
||||
};
|
||||
rows = [card];
|
||||
return card;
|
||||
},
|
||||
},
|
||||
{ path: "/api/status-cards/card-gap/updates", read: () => [] },
|
||||
{
|
||||
path: "/api/status-cards/card-gap/summary-revisions",
|
||||
read: () => [],
|
||||
},
|
||||
{
|
||||
path: "/api/status-cards/card-gap/dry-run",
|
||||
read: () => ({
|
||||
cardId: card.id,
|
||||
queryVersion: 1,
|
||||
queries: [],
|
||||
mentionedIssues: [],
|
||||
}),
|
||||
},
|
||||
],
|
||||
parameters.fixtureState as FixtureState,
|
||||
).cleanup;
|
||||
},
|
||||
} satisfies Meta<typeof StatusCardHost>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const CurrentSummary: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await within(canvasElement).findByText(
|
||||
"Build and tests passed. Provider qualification remains pending.",
|
||||
);
|
||||
},
|
||||
};
|
||||
export const Loading: Story = { parameters: { fixtureState: "loading" } };
|
||||
export const Empty: Story = { parameters: { fixtureState: "empty" } };
|
||||
export const LoadFailed: Story = { parameters: { fixtureState: "failure" } };
|
||||
export const PermissionDenied: Story = {
|
||||
parameters: { fixtureState: "permission" },
|
||||
};
|
||||
export const SettingUp: Story = { parameters: { cardState: "compiling" } };
|
||||
export const GenerationFailed: Story = { parameters: { cardState: "error" } };
|
||||
export const StaleSummary: Story = { parameters: { stale: true } };
|
||||
async function create() {
|
||||
const c = within(document.body);
|
||||
const button = (await c.findAllByRole("button", { name: "New card" }))[0];
|
||||
await waitFor(() => expect(button).toBeEnabled());
|
||||
await userEvent.click(button);
|
||||
await userEvent.type(
|
||||
await c.findByRole("textbox", {
|
||||
name: "What do you want to keep an eye on?",
|
||||
}),
|
||||
"Watch the release checks and summarize pending qualification.",
|
||||
{ delay: 15 },
|
||||
);
|
||||
const createButton = c.getByRole("button", { name: "Create card" });
|
||||
await waitFor(() => expect(createButton).toBeEnabled());
|
||||
await userEvent.click(createButton);
|
||||
}
|
||||
export const Created: Story = {
|
||||
parameters: { fixtureState: "empty" },
|
||||
play: async () => {
|
||||
await create();
|
||||
await within(document.body).findByText("New review card");
|
||||
},
|
||||
};
|
||||
export const CreateFailed: Story = {
|
||||
parameters: { fixtureState: "mutation-failure" },
|
||||
play: async () => {
|
||||
await create();
|
||||
await within(document.body).findByText(
|
||||
"The request failed. Please try again.",
|
||||
);
|
||||
},
|
||||
};
|
||||
export const Refreshed: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await c.findByRole("button", { name: "Refresh card" }),
|
||||
);
|
||||
await c.findByText("All qualification checks passed. Ready for review.");
|
||||
},
|
||||
};
|
||||
export const RefreshFailed: Story = {
|
||||
parameters: { fixtureState: "mutation-failure" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await c.findByRole("button", { name: "Refresh card" }),
|
||||
);
|
||||
await c.findByText("The request failed. Please try again.");
|
||||
},
|
||||
};
|
||||
export const Refreshing: Story = {
|
||||
parameters: { fixtureState: "saving" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await c.findByRole("button", { name: "Refresh card" }),
|
||||
);
|
||||
await expect(
|
||||
c.getByRole("button", { name: "Refresh card" }),
|
||||
).toBeDisabled();
|
||||
},
|
||||
};
|
||||
async function action(canvasElement: HTMLElement, name: string) {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole("button", { name: "Card actions" }),
|
||||
);
|
||||
await userEvent.click(
|
||||
await within(document.body).findByRole("menuitem", { name }),
|
||||
);
|
||||
}
|
||||
export const Inspection: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await action(canvasElement, "Open detail");
|
||||
await within(document.body).findByRole("dialog");
|
||||
},
|
||||
};
|
||||
export const Archive: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await action(canvasElement, "Archive");
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(canvasElement).queryByText(
|
||||
"Build and tests passed. Provider qualification remains pending.",
|
||||
),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
},
|
||||
};
|
||||
export const Archived: Story = {
|
||||
parameters: { archived: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await c.findByRole("button", { name: "Show archived (1)" }),
|
||||
);
|
||||
await c.findByRole("button", { name: "Restore" });
|
||||
},
|
||||
};
|
||||
export const Restored: Story = {
|
||||
parameters: { archived: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await c.findByRole("button", { name: "Show archived (1)" }),
|
||||
);
|
||||
await userEvent.click(c.getByRole("button", { name: "Restore" }));
|
||||
await c.findByText(
|
||||
"Build and tests passed. Provider qualification remains pending.",
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
saveDraft,
|
||||
loadDraft,
|
||||
clearDraft,
|
||||
saveDraftSubmission,
|
||||
loadDraftSubmission,
|
||||
} from "@/lib/composer-draft";
|
||||
import { expect, userEvent, within, waitFor } from "storybook/test";
|
||||
const first = "paperclip:storybook:gap-draft-a",
|
||||
second = "paperclip:storybook:gap-draft-b";
|
||||
function resetFixtureDraft(key: string) {
|
||||
clearDraft(key, loadDraftSubmission(key)?.attemptId);
|
||||
}
|
||||
function Drafts({ fail = false }: { fail?: boolean }) {
|
||||
const [task, setTask] = useState(first);
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setTask(task === first ? second : first)}>
|
||||
Switch task
|
||||
</Button>
|
||||
<TaskChatComposer
|
||||
key={task}
|
||||
draftKey={task}
|
||||
workMode="standard"
|
||||
onAdd={async () => {
|
||||
if (fail) throw new Error("The message could not be saved.");
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const meta = {
|
||||
id: "feature-composer-draft-recovery",
|
||||
title: "Tasks and conversations/Steering and queued messages/Draft recovery",
|
||||
component: Drafts,
|
||||
parameters: { layout: "padded" },
|
||||
beforeEach: ({ parameters }) => {
|
||||
resetFixtureDraft(first);
|
||||
resetFixtureDraft(second);
|
||||
if (!parameters.empty)
|
||||
saveDraft(first, "Review the release before publishing.");
|
||||
saveDraft(second, "Check provider qualification.");
|
||||
if (parameters.uncertain)
|
||||
saveDraftSubmission(first, {
|
||||
attemptId: "00000000-0000-4000-8000-000000000001",
|
||||
reviewed: false,
|
||||
});
|
||||
return () => {
|
||||
resetFixtureDraft(first);
|
||||
resetFixtureDraft(second);
|
||||
};
|
||||
},
|
||||
} satisfies Meta<typeof Drafts>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Restored: Story = {};
|
||||
export const Empty: Story = { parameters: { empty: true } };
|
||||
export const UncertainSubmission: Story = { parameters: { uncertain: true } };
|
||||
export const SwitchAndRestore: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
const editor = await c.findByRole("textbox");
|
||||
await userEvent.click(editor);
|
||||
await userEvent.paste(" Additional evidence.");
|
||||
await waitFor(
|
||||
() => expect(loadDraft(first)).toContain("Additional evidence."),
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
await userEvent.click(c.getByRole("button", { name: "Switch task" }));
|
||||
await c.findByText("Check provider qualification.");
|
||||
await userEvent.click(c.getByRole("button", { name: "Switch task" }));
|
||||
await c.findByText(/Additional evidence/);
|
||||
},
|
||||
};
|
||||
export const SendFailedKeepsDraft: Story = {
|
||||
args: { fail: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await userEvent.click(await c.findByRole("button", { name: /Send/ }));
|
||||
await waitFor(() =>
|
||||
expect(loadDraft(first)).toContain("Review the release"),
|
||||
);
|
||||
},
|
||||
};
|
||||
export const SentClearsDraft: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await userEvent.click(await c.findByRole("button", { name: /Send/ }));
|
||||
await waitFor(() => expect(loadDraft(first)).toBe(""));
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { IssueQueuedCommentQueue } from "@paperclipai/shared";
|
||||
import { expect, userEvent, within, waitFor } from "storybook/test";
|
||||
import { TaskChatQueuedMessages } from "@/components/task-chat/TaskChatQueuedMessages";
|
||||
const initialQueue: IssueQueuedCommentQueue = {
|
||||
issueId: "task-gap",
|
||||
queueId: "response-gap",
|
||||
state: "deferred",
|
||||
targetRunId: "run-gap",
|
||||
revision: "rev-1",
|
||||
protocol: "paperclip_runner_v1",
|
||||
steeringDisposition: "available",
|
||||
entries: [
|
||||
{
|
||||
position: 0,
|
||||
canEdit: false,
|
||||
canDiscard: false,
|
||||
source: {
|
||||
kind: "interaction",
|
||||
interactionId: "approval-gap",
|
||||
interactionKind: "request_confirmation",
|
||||
requiresFreshSession: true,
|
||||
},
|
||||
comment: {
|
||||
id: "response-comment",
|
||||
companyId: "company-storybook",
|
||||
issueId: "task-gap",
|
||||
authorType: "user",
|
||||
authorAgentId: null,
|
||||
authorUserId: "user-storybook",
|
||||
body: "Accepted: Review the migration\n\nApproved revision v2.",
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-10-12T12:00:00Z"),
|
||||
updatedAt: new Date("2026-10-12T12:00:00Z"),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
function ResponseQueue({
|
||||
state = "ready",
|
||||
}: {
|
||||
state?: "ready" | "empty" | "waiting" | "failure" | "saving" | "blocked";
|
||||
}) {
|
||||
const [queue, setQueue] = useState<IssueQueuedCommentQueue>({
|
||||
...initialQueue,
|
||||
queueId: state === "blocked" ? null : initialQueue.queueId,
|
||||
entries: state === "empty" ? [] : initialQueue.entries,
|
||||
executionWait:
|
||||
state === "waiting"
|
||||
? {
|
||||
reason: "remote_cleanup",
|
||||
message:
|
||||
"Waiting for the previous environment to stop. Your message will start automatically.",
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
const deliver = async () => {
|
||||
if (state === "failure")
|
||||
throw new Error("The response could not be delivered.");
|
||||
if (state === "saving") return new Promise<void>(() => {});
|
||||
setQueue((current) => ({
|
||||
...current,
|
||||
entries: [],
|
||||
executionWait: undefined,
|
||||
revision: "rev-2",
|
||||
}));
|
||||
};
|
||||
return (
|
||||
<TaskChatQueuedMessages
|
||||
queue={queue}
|
||||
onEdit={() => {
|
||||
throw new Error("Accepted responses cannot be edited.");
|
||||
}}
|
||||
onReorder={async () => {
|
||||
throw new Error("Accepted responses cannot be reordered.");
|
||||
}}
|
||||
onSteer={deliver}
|
||||
onInterrupt={deliver}
|
||||
onDiscard={async () => {
|
||||
throw new Error("Accepted responses cannot be discarded.");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const meta = {
|
||||
id: "feature-durable-response-queue",
|
||||
title:
|
||||
"Tasks and conversations/Steering and queued messages/Accepted response delivery",
|
||||
component: ResponseQueue,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Production queue with an immutable accepted response and remote-cleanup wait. Delivery uses local state; it does not prove persisted queues or runtime continuation.",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof ResponseQueue>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const AcceptedResponse: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await c.findByText("Accepted: Review the migration");
|
||||
await expect(
|
||||
c.getByRole("button", { name: /Reorder queued message/ }),
|
||||
).toBeDisabled();
|
||||
},
|
||||
};
|
||||
export const WaitingForCleanup: Story = {
|
||||
args: { state: "waiting" },
|
||||
play: async ({ canvasElement }) => {
|
||||
await within(canvasElement).findByText(
|
||||
"Waiting for the previous environment to stop. Your message will start automatically.",
|
||||
);
|
||||
},
|
||||
};
|
||||
export const AdmissionUnavailable: Story = {
|
||||
args: { state: "blocked" },
|
||||
play: async ({ canvasElement }) => {
|
||||
await expect(
|
||||
await within(canvasElement).findByRole("button", { name: "Interrupt" }),
|
||||
).toBeDisabled();
|
||||
},
|
||||
};
|
||||
export const Empty: Story = { args: { state: "empty" } };
|
||||
export const DeliveryFailed: Story = {
|
||||
args: { state: "failure" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await userEvent.click(await c.findByRole("button", { name: "Interrupt" }));
|
||||
await waitFor(() =>
|
||||
expect(c.getByText("Accepted: Review the migration")).toBeInTheDocument(),
|
||||
);
|
||||
await c.findByText("Couldn’t interrupt. Message is still queued.");
|
||||
},
|
||||
};
|
||||
export const Delivering: Story = {
|
||||
args: { state: "saving" },
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"The shipped queue removes accepted responses optimistically while delivery remains pending; it has no separate visible progress indicator.",
|
||||
},
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole("button", { name: "Interrupt" }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(canvasElement).queryByTestId("task-chat-queued-messages"),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
await expect(canvasElement).toBeEmptyDOMElement();
|
||||
},
|
||||
};
|
||||
export const Delivered: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await userEvent.click(await c.findByRole("button", { name: "Interrupt" }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
c.queryByText("Accepted: Review the migration"),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import { ExecutionWorkspaceCloseDialog } from "@/components/ExecutionWorkspaceCloseDialog";
|
||||
import { ToastViewport } from "@/components/ToastViewport";
|
||||
import {
|
||||
prepareFixtureApi,
|
||||
type FixtureState,
|
||||
} from "../../support/capability-gaps/api";
|
||||
function CloseWorkspace() {
|
||||
const [open, setOpen] = useState(true);
|
||||
return (
|
||||
<>
|
||||
<ExecutionWorkspaceCloseDialog
|
||||
workspaceId="workspace-gap"
|
||||
workspaceName="Feature review checkout"
|
||||
currentStatus="active"
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
/>
|
||||
<ToastViewport />
|
||||
</>
|
||||
);
|
||||
}
|
||||
const meta = {
|
||||
id: "feature-workspace-close",
|
||||
title:
|
||||
"Projects and execution/Workspaces, services, and files/Close readiness",
|
||||
component: CloseWorkspace,
|
||||
parameters: { layout: "padded" },
|
||||
beforeEach: ({ parameters }) =>
|
||||
prepareFixtureApi(
|
||||
[
|
||||
{
|
||||
path: "/api/execution-workspaces/workspace-gap/close-readiness",
|
||||
read: () => ({
|
||||
workspaceId: "workspace-gap",
|
||||
deliveryState: "unknown",
|
||||
isDestructiveCloseAllowed: !parameters.blocked,
|
||||
state: parameters.blocked ? "blocked" : "ready",
|
||||
linkedIssues: [],
|
||||
blockingReasons: parameters.blocked
|
||||
? ["A task is still running in this workspace."]
|
||||
: [],
|
||||
warnings: [],
|
||||
runtimeServices: [],
|
||||
plannedActions: [],
|
||||
isSharedWorkspace: false,
|
||||
isProjectPrimaryWorkspace: false,
|
||||
git: null,
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/api/execution-workspaces/workspace-gap",
|
||||
method: "PATCH",
|
||||
read: ({ body }) => ({
|
||||
id: "workspace-gap",
|
||||
companyId: "company-storybook",
|
||||
name: "Feature review checkout",
|
||||
...body,
|
||||
}),
|
||||
},
|
||||
],
|
||||
parameters.fixtureState as FixtureState,
|
||||
).cleanup,
|
||||
} satisfies Meta<typeof CloseWorkspace>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Ready: Story = {
|
||||
play: async () => {
|
||||
await within(document.body).findByText("Close is ready");
|
||||
},
|
||||
};
|
||||
export const Loading: Story = {
|
||||
parameters: { fixtureState: "loading" },
|
||||
play: async () => {
|
||||
await within(document.body).findByText(
|
||||
"Checking whether this workspace is safe to close...",
|
||||
);
|
||||
},
|
||||
};
|
||||
export const FailedInspection: Story = {
|
||||
parameters: { fixtureState: "failure" },
|
||||
play: async () => {
|
||||
await within(document.body).findByText(
|
||||
"The request failed. Please try again.",
|
||||
);
|
||||
},
|
||||
};
|
||||
export const PermissionDenied: Story = {
|
||||
parameters: { fixtureState: "permission" },
|
||||
play: async () => {
|
||||
await within(document.body).findByText(
|
||||
"You do not have permission to perform this action.",
|
||||
);
|
||||
},
|
||||
};
|
||||
export const BlockedByRunningTask: Story = {
|
||||
parameters: { blocked: true },
|
||||
play: async () => {
|
||||
const c = within(document.body);
|
||||
await c.findByText("Close is blocked");
|
||||
await expect(
|
||||
c.getByRole("button", { name: "Close workspace" }),
|
||||
).toBeDisabled();
|
||||
},
|
||||
};
|
||||
export const Closing: Story = {
|
||||
parameters: { fixtureState: "saving" },
|
||||
play: async () => {
|
||||
const c = within(document.body);
|
||||
await c.findByText("Close is ready");
|
||||
await userEvent.click(c.getByRole("button", { name: "Close workspace" }));
|
||||
await expect(
|
||||
c.getByRole("button", { name: "Close workspace" }),
|
||||
).toBeDisabled();
|
||||
},
|
||||
};
|
||||
export const Closed: Story = {
|
||||
play: async () => {
|
||||
const c = within(document.body);
|
||||
await c.findByText("Close is ready");
|
||||
await userEvent.click(c.getByRole("button", { name: "Close workspace" }));
|
||||
await c.findByText("Workspace closed");
|
||||
await expect(c.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
installFixtureApi,
|
||||
prepareFixtureApi,
|
||||
activatePreparedFixtures,
|
||||
} from "./api";
|
||||
let cleanup: (() => void) | undefined;
|
||||
afterEach(() => {
|
||||
cleanup?.();
|
||||
cleanup = undefined;
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
localStorage.clear();
|
||||
});
|
||||
describe("capability story API boundary", () => {
|
||||
it("activates story routes after preview defaults and restores those defaults", async () => {
|
||||
const preview = vi.fn(async () => Response.json({ source: "preview" }));
|
||||
const fixture = prepareFixtureApi([
|
||||
{ path: "/api/subject", read: () => ({ source: "story" }) },
|
||||
]);
|
||||
cleanup = fixture.cleanup;
|
||||
vi.stubGlobal("fetch", preview);
|
||||
activatePreparedFixtures();
|
||||
expect(await (await window.fetch("/api/subject")).json()).toEqual({
|
||||
source: "story",
|
||||
});
|
||||
expect(await (await window.fetch("/api/other")).json()).toEqual({
|
||||
source: "preview",
|
||||
});
|
||||
fixture.cleanup();
|
||||
cleanup = undefined;
|
||||
expect(window.fetch).toBe(preview);
|
||||
});
|
||||
it("blocks an unregistered mutation without calling the outer fetch", async () => {
|
||||
const outer = vi
|
||||
.spyOn(window, "fetch")
|
||||
.mockResolvedValue(Response.json({ unexpected: true }));
|
||||
cleanup = installFixtureApi([]).cleanup;
|
||||
const reply = await window.fetch("/api/companies/company-storybook", {
|
||||
method: "PATCH",
|
||||
body: "{}",
|
||||
});
|
||||
expect(reply.status).toBe(501);
|
||||
expect(outer).not.toHaveBeenCalled();
|
||||
});
|
||||
it("parses Request bodies and records the mutation before a simulated denial", async () => {
|
||||
const handler = vi.fn(() => ({ saved: true }));
|
||||
const fixture = installFixtureApi(
|
||||
[{ path: "/api/example", method: "POST", read: handler }],
|
||||
"mutation-permission",
|
||||
);
|
||||
cleanup = fixture.cleanup;
|
||||
const result = await window.fetch(
|
||||
new Request(`${location.origin}/api/example`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ value: 42 }),
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe(403);
|
||||
expect(fixture.requests[0].body).toEqual({ value: 42 });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
it("keeps prerequisite flags available during a subject error and restores fetch and company selection", async () => {
|
||||
const previous = window.fetch;
|
||||
localStorage.setItem("paperclip.selectedCompanyId", "original-company");
|
||||
const fixture = installFixtureApi(
|
||||
[
|
||||
{ path: "/api/flags", state: "ready", read: () => ({ enabled: true }) },
|
||||
{ path: "/api/subject", read: () => [] },
|
||||
],
|
||||
"failure",
|
||||
);
|
||||
cleanup = fixture.cleanup;
|
||||
expect(await (await window.fetch("/api/flags")).json()).toEqual({
|
||||
enabled: true,
|
||||
});
|
||||
expect((await window.fetch("/api/subject")).status).toBe(500);
|
||||
fixture.cleanup();
|
||||
cleanup = undefined;
|
||||
expect(window.fetch).toBe(previous);
|
||||
expect(localStorage.getItem("paperclip.selectedCompanyId")).toBe(
|
||||
"original-company",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
export type FixtureState =
|
||||
| "ready"
|
||||
| "loading"
|
||||
| "empty"
|
||||
| "failure"
|
||||
| "permission"
|
||||
| "saving"
|
||||
| "mutation-failure"
|
||||
| "mutation-permission";
|
||||
export interface FixtureRequest {
|
||||
url: URL;
|
||||
method: string;
|
||||
body: Record<string, unknown>;
|
||||
}
|
||||
export interface FixtureRoute {
|
||||
path: string | RegExp;
|
||||
method?: string;
|
||||
state?: FixtureState;
|
||||
read: (
|
||||
request: FixtureRequest,
|
||||
) => unknown | Response | Promise<unknown | Response>;
|
||||
}
|
||||
const pendingFixtures: Array<() => void> = [];
|
||||
export function activatePreparedFixtures() {
|
||||
for (const activate of pendingFixtures.splice(0)) activate();
|
||||
}
|
||||
export function prepareFixtureApi(
|
||||
routes: FixtureRoute[],
|
||||
state: FixtureState = "ready",
|
||||
) {
|
||||
return installFixtureApi(routes, state, true);
|
||||
}
|
||||
export function installFixtureApi(
|
||||
routes: FixtureRoute[],
|
||||
state: FixtureState = "ready",
|
||||
deferred = false,
|
||||
) {
|
||||
let previous = window.fetch;
|
||||
const selectedCompany = localStorage.getItem("paperclip.selectedCompanyId");
|
||||
localStorage.setItem("paperclip.selectedCompanyId", "company-storybook");
|
||||
const requests: FixtureRequest[] = [];
|
||||
const fixtureFetch: typeof fetch = async (input, init) => {
|
||||
const request = input instanceof Request ? input : null;
|
||||
const url = new URL(request?.url ?? String(input), location.origin);
|
||||
const method = (init?.method ?? request?.method ?? "GET").toUpperCase();
|
||||
const route = routes.find(
|
||||
(candidate) =>
|
||||
(typeof candidate.path === "string"
|
||||
? candidate.path === url.pathname
|
||||
: candidate.path.test(url.pathname)) &&
|
||||
(!candidate.method || candidate.method === method),
|
||||
);
|
||||
if (!route) {
|
||||
if (url.pathname.startsWith("/api/") && method !== "GET") {
|
||||
return Response.json(
|
||||
{ error: `No isolated fixture for ${method} ${url.pathname}` },
|
||||
{ status: 501 },
|
||||
);
|
||||
}
|
||||
return previous(input, init);
|
||||
}
|
||||
const routeState = route.state ?? state;
|
||||
const rawBody =
|
||||
init?.body ??
|
||||
(request && method !== "GET" ? await request.clone().text() : null);
|
||||
const body = rawBody
|
||||
? (JSON.parse(String(rawBody)) as Record<string, unknown>)
|
||||
: {};
|
||||
const fixtureRequest = { url, method, body };
|
||||
requests.push(fixtureRequest);
|
||||
if (
|
||||
(routeState === "loading" && method === "GET") ||
|
||||
(routeState === "saving" && method !== "GET")
|
||||
) {
|
||||
return new Promise<Response>(() => {});
|
||||
}
|
||||
if (
|
||||
routeState === "failure" ||
|
||||
routeState === "permission" ||
|
||||
(method !== "GET" &&
|
||||
(routeState === "mutation-failure" ||
|
||||
routeState === "mutation-permission"))
|
||||
) {
|
||||
const denied =
|
||||
routeState === "permission" || routeState === "mutation-permission";
|
||||
return Response.json(
|
||||
{
|
||||
error: denied
|
||||
? "You do not have permission to perform this action."
|
||||
: "The request failed. Please try again.",
|
||||
},
|
||||
{ status: denied ? 403 : 500 },
|
||||
);
|
||||
}
|
||||
const result = await route.read(fixtureRequest);
|
||||
return result instanceof Response ? result : Response.json(result);
|
||||
};
|
||||
let active = !deferred;
|
||||
const activate = () => {
|
||||
previous = window.fetch;
|
||||
window.fetch = fixtureFetch;
|
||||
active = true;
|
||||
};
|
||||
if (deferred) pendingFixtures.push(activate);
|
||||
else window.fetch = fixtureFetch;
|
||||
return {
|
||||
requests,
|
||||
cleanup: () => {
|
||||
const pendingIndex = pendingFixtures.indexOf(activate);
|
||||
if (pendingIndex >= 0) pendingFixtures.splice(pendingIndex, 1);
|
||||
if (active) window.fetch = previous;
|
||||
if (selectedCompany === null)
|
||||
localStorage.removeItem("paperclip.selectedCompanyId");
|
||||
else localStorage.setItem("paperclip.selectedCompanyId", selectedCompany);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { instanceExperimentalSettingsSchema } from "@paperclipai/shared";
|
||||
import type { CaseSummary, CaseDetail, CaseEvent } from "@/api/cases";
|
||||
import { prepareFixtureApi, type FixtureState } from "./api";
|
||||
export const caseItem: CaseSummary = {
|
||||
id: "case-gap",
|
||||
companyId: "company-storybook",
|
||||
projectId: null,
|
||||
caseNumber: 42,
|
||||
identifier: "PAP-C42",
|
||||
caseType: "release",
|
||||
key: "release-preview",
|
||||
title: "Release readiness",
|
||||
summary: "The release review record.",
|
||||
status: "in_review",
|
||||
fields: {
|
||||
version: "0.3.1",
|
||||
ready: false,
|
||||
checks: { build: "passed", tests: "pending" },
|
||||
task_identifiers: ["PAP-42"],
|
||||
},
|
||||
parentCaseId: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
completedAt: null,
|
||||
createdAt: "2026-10-12T12:00:00Z",
|
||||
updatedAt: "2026-10-12T12:00:00Z",
|
||||
};
|
||||
export const childCase: CaseSummary = {
|
||||
...caseItem,
|
||||
id: "child-gap",
|
||||
caseNumber: 43,
|
||||
identifier: "PAP-C43",
|
||||
title: "API qualification",
|
||||
parentCaseId: caseItem.id,
|
||||
status: "in_progress",
|
||||
};
|
||||
export const caseDetail: CaseDetail = {
|
||||
...caseItem,
|
||||
parent: null,
|
||||
labels: [],
|
||||
documents: [],
|
||||
attachments: [],
|
||||
issueLinks: [
|
||||
{
|
||||
id: "link-gap",
|
||||
caseId: caseItem.id,
|
||||
issueId: "task-gap",
|
||||
role: "work",
|
||||
createdAt: caseItem.createdAt,
|
||||
issue: {
|
||||
id: "task-gap",
|
||||
identifier: "PAP-42",
|
||||
title: "Qualify the release",
|
||||
status: "in_progress",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const event: CaseEvent = {
|
||||
id: "event-gap",
|
||||
caseId: caseItem.id,
|
||||
kind: "status_changed",
|
||||
actorType: "agent",
|
||||
actorAgentId: "agent-coder",
|
||||
actorUserId: null,
|
||||
runId: "run-gap",
|
||||
payload: { from: "in_progress", to: "in_review" },
|
||||
createdAt: caseItem.updatedAt,
|
||||
actorAgentName: "Codex Coder",
|
||||
issue: caseDetail.issueLinks[0].issue,
|
||||
};
|
||||
export function installCaseFixtures(state: FixtureState = "ready") {
|
||||
return prepareFixtureApi(
|
||||
[
|
||||
{
|
||||
path: "/api/instance/settings/experimental",
|
||||
state: "ready",
|
||||
read: () => ({
|
||||
...instanceExperimentalSettingsSchema.parse({}),
|
||||
enableCases: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/api/companies/company-storybook/cases",
|
||||
read: ({ url }) =>
|
||||
state === "empty"
|
||||
? []
|
||||
: url.searchParams.has("parent")
|
||||
? [childCase]
|
||||
: [caseItem, childCase],
|
||||
},
|
||||
{ path: /^\/api\/cases\/(PAP-C42|case-gap)$/, read: () => caseDetail },
|
||||
{
|
||||
path: /^\/api\/cases\/(PAP-C42|case-gap)\/events$/,
|
||||
read: () => (state === "empty" ? [] : [event]),
|
||||
},
|
||||
{
|
||||
path: "/api/issues/task-gap/cases",
|
||||
read: () =>
|
||||
state === "empty"
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: "reverse-link-gap",
|
||||
role: "work",
|
||||
createdAt: caseItem.createdAt,
|
||||
case: caseItem,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
state,
|
||||
).cleanup;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { Environment, EnvironmentCapabilities } from "@paperclipai/shared";
|
||||
import { prepareFixtureApi, type FixtureState } from "./api";
|
||||
export const environment: Environment = {
|
||||
id: "environment-gap",
|
||||
name: "Release sandbox",
|
||||
description: "Isolated release checks",
|
||||
driver: "sandbox",
|
||||
status: "active",
|
||||
config: { provider: "daytona", reuseLease: true },
|
||||
envVars: {},
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-10-12"),
|
||||
updatedAt: new Date("2026-10-12"),
|
||||
};
|
||||
const capabilities: EnvironmentCapabilities = {
|
||||
adapters: [],
|
||||
drivers: {
|
||||
local: "supported",
|
||||
ssh: "supported",
|
||||
sandbox: "supported",
|
||||
plugin: "unsupported",
|
||||
},
|
||||
sandboxProviders: {
|
||||
fake: {
|
||||
status: "unsupported",
|
||||
supportsLoginPty: false,
|
||||
supportsSavedProbe: false,
|
||||
supportsUnsavedProbe: false,
|
||||
supportsRunExecution: false,
|
||||
supportsReusableLeases: false,
|
||||
supportsInteractiveSetup: false,
|
||||
interactiveSetupConnectionTypes: [],
|
||||
supportsTemplateCapture: false,
|
||||
supportsTemplateDelete: false,
|
||||
},
|
||||
daytona: {
|
||||
status: "supported",
|
||||
supportsLoginPty: true,
|
||||
supportsSavedProbe: true,
|
||||
supportsUnsavedProbe: true,
|
||||
supportsRunExecution: true,
|
||||
supportsReusableLeases: true,
|
||||
supportsInteractiveSetup: true,
|
||||
interactiveSetupConnectionTypes: ["ssh"],
|
||||
supportsTemplateCapture: true,
|
||||
supportsTemplateDelete: true,
|
||||
displayName: "Daytona",
|
||||
},
|
||||
},
|
||||
};
|
||||
export function installEnvironmentFixture(
|
||||
state: FixtureState = "ready",
|
||||
image: "none" | "captured" | "failed" | "loading" | "denied" = "none",
|
||||
unsupported = false,
|
||||
) {
|
||||
let current = { ...environment };
|
||||
const template = {
|
||||
id: "template-gap",
|
||||
environmentId: environment.id,
|
||||
provider: "daytona",
|
||||
templateKind: "snapshot",
|
||||
templateRef: "fixture-template",
|
||||
sourceTemplateRef: null,
|
||||
sourceEnvironmentConfigFingerprint: "fixture-fingerprint",
|
||||
status: "active",
|
||||
createdByUserId: "user-storybook",
|
||||
createdByAgentId: null,
|
||||
capturedAt: "2026-10-12T12:00:00Z",
|
||||
lastUsedAt: null,
|
||||
supersededByTemplateId: null,
|
||||
metadata: null,
|
||||
createdAt: "2026-10-12T12:00:00Z",
|
||||
updatedAt: "2026-10-12T12:00:00Z",
|
||||
};
|
||||
return prepareFixtureApi(
|
||||
[
|
||||
{
|
||||
path: "/api/instance/settings/experimental",
|
||||
state: "ready",
|
||||
read: () => ({
|
||||
enableEnvironments: true,
|
||||
enableManagedSandboxOnly: false,
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/api/instance/settings",
|
||||
state: "ready",
|
||||
read: () => ({ defaultEnvironmentId: environment.id }),
|
||||
},
|
||||
{
|
||||
path: "/api/companies/company-storybook/environments",
|
||||
read: () => (state === "empty" ? [] : [current]),
|
||||
},
|
||||
{
|
||||
path: "/api/companies/company-storybook/environments/capabilities",
|
||||
read: () =>
|
||||
unsupported
|
||||
? {
|
||||
...capabilities,
|
||||
sandboxProviders: {
|
||||
fake: {
|
||||
status: "unsupported",
|
||||
supportsLoginPty: false,
|
||||
supportsSavedProbe: false,
|
||||
supportsUnsavedProbe: false,
|
||||
supportsRunExecution: false,
|
||||
supportsReusableLeases: false,
|
||||
supportsInteractiveSetup: false,
|
||||
interactiveSetupConnectionTypes: [],
|
||||
supportsTemplateCapture: false,
|
||||
supportsTemplateDelete: false,
|
||||
},
|
||||
daytona: {
|
||||
...capabilities.sandboxProviders.daytona,
|
||||
status: "unsupported",
|
||||
supportsSavedProbe: false,
|
||||
supportsTemplateCapture: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
: capabilities,
|
||||
},
|
||||
{
|
||||
path: "/api/environments/environment-gap/secret-refs",
|
||||
read: () => ({ refs: [] }),
|
||||
},
|
||||
{
|
||||
path: "/api/environments/environment-gap",
|
||||
method: "PATCH",
|
||||
read: ({ body }) => {
|
||||
current = { ...current, ...body };
|
||||
return current;
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/api/environments/environment-gap/probe",
|
||||
method: "POST",
|
||||
read: () => ({
|
||||
ok: true,
|
||||
driver: "sandbox",
|
||||
summary: "Provider connection succeeded.",
|
||||
details: {},
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/api/environments/environment-gap/custom-image-template",
|
||||
read: () =>
|
||||
image === "loading"
|
||||
? new Promise<Response>(() => {})
|
||||
: image === "denied"
|
||||
? Response.json(
|
||||
{
|
||||
error: "You do not have permission to inspect this image.",
|
||||
},
|
||||
{ status: 403 },
|
||||
)
|
||||
: image === "failed"
|
||||
? Response.json(
|
||||
{ error: "Image metadata is unavailable." },
|
||||
{ status: 503 },
|
||||
)
|
||||
: {
|
||||
activeTemplate: image === "captured" ? template : null,
|
||||
activeTemplateMatchesConfig: true,
|
||||
activeSession: null,
|
||||
latestSession: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
state,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { LiveEvent } from "@paperclipai/shared";
|
||||
const sockets: FixtureSocket[] = [];
|
||||
const listeners = new Set<() => void>();
|
||||
class FixtureSocket {
|
||||
static CONNECTING = 0;
|
||||
static OPEN = 1;
|
||||
static CLOSING = 2;
|
||||
static CLOSED = 3;
|
||||
readyState = 0;
|
||||
onopen: ((event: Event) => void) | null = null;
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
onclose: ((event: CloseEvent) => void) | null = null;
|
||||
onerror: ((event: Event) => void) | null = null;
|
||||
constructor() {
|
||||
sockets.push(this);
|
||||
queueMicrotask(() => {
|
||||
if (this.readyState === 3) return;
|
||||
this.readyState = 1;
|
||||
this.onopen?.(new Event("open"));
|
||||
for (const listener of listeners) listener();
|
||||
});
|
||||
}
|
||||
close() {
|
||||
this.readyState = 3;
|
||||
}
|
||||
send() {}
|
||||
}
|
||||
export function listenForFixtureSocket(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
if (sockets.some((s) => s.readyState === 1)) listener();
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
export function publishFixtureEvent(event: LiveEvent) {
|
||||
for (const socket of sockets)
|
||||
if (socket.readyState === 1)
|
||||
socket.onmessage?.(
|
||||
new MessageEvent("message", { data: JSON.stringify(event) }),
|
||||
);
|
||||
}
|
||||
export function installLiveEventFixture() {
|
||||
const previous = window.WebSocket;
|
||||
sockets.splice(0);
|
||||
listeners.clear();
|
||||
window.WebSocket = FixtureSocket as unknown as typeof WebSocket;
|
||||
return () => {
|
||||
for (const socket of sockets) socket.close();
|
||||
sockets.splice(0);
|
||||
listeners.clear();
|
||||
window.WebSocket = previous;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import type {
|
||||
PipelineDetail,
|
||||
PipelineCase,
|
||||
PipelineReviewCaseRow,
|
||||
PipelineCompanyCaseEvent,
|
||||
} from "@/api/pipelines";
|
||||
import { prepareFixtureApi, type FixtureState } from "./api";
|
||||
export const pipeline: PipelineDetail = {
|
||||
id: "pipeline-gap",
|
||||
companyId: "company-storybook",
|
||||
key: "release",
|
||||
name: "Release review",
|
||||
description: "Check and approve a release.",
|
||||
projectId: null,
|
||||
enforceTransitions: true,
|
||||
archivedAt: null,
|
||||
stageCount: 3,
|
||||
openCaseCount: 1,
|
||||
createdAt: "2026-10-12",
|
||||
updatedAt: "2026-10-12",
|
||||
stages: [
|
||||
{
|
||||
id: "stage-work",
|
||||
pipelineId: "pipeline-gap",
|
||||
key: "work",
|
||||
name: "Prepare",
|
||||
kind: "working",
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
id: "stage-review",
|
||||
pipelineId: "pipeline-gap",
|
||||
key: "review",
|
||||
name: "Review",
|
||||
kind: "review",
|
||||
position: 1,
|
||||
config: {
|
||||
approveToStageKey: "done",
|
||||
requestChangesToStageKey: "work",
|
||||
requireRequestChangesReason: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "stage-done",
|
||||
pipelineId: "pipeline-gap",
|
||||
key: "done",
|
||||
name: "Done",
|
||||
kind: "done",
|
||||
position: 2,
|
||||
},
|
||||
],
|
||||
transitions: [
|
||||
{ fromStageId: "stage-work", toStageId: "stage-review" },
|
||||
{ fromStageId: "stage-review", toStageId: "stage-done" },
|
||||
],
|
||||
};
|
||||
export function installPipelineFixture(
|
||||
state: FixtureState = "ready",
|
||||
options: { emptyStages?: boolean } = {},
|
||||
) {
|
||||
let item: PipelineCase = {
|
||||
id: "case-pipeline-gap",
|
||||
companyId: "company-storybook",
|
||||
pipelineId: pipeline.id,
|
||||
stageId: "stage-review",
|
||||
caseKey: "REL-1",
|
||||
title: "Ship the release notes",
|
||||
version: 1,
|
||||
fields: { release: "2026.1013.0" },
|
||||
createdAt: "2026-10-12",
|
||||
updatedAt: "2026-10-12",
|
||||
};
|
||||
let review = true;
|
||||
const detail = () => ({
|
||||
case: item,
|
||||
stage: pipeline.stages.find((s) => s.id === item.stageId),
|
||||
pipeline,
|
||||
allowedNextStages: [pipeline.stages[2]],
|
||||
links: [],
|
||||
blockers: [],
|
||||
blocks: [],
|
||||
childrenSummary: {
|
||||
childCount: 0,
|
||||
terminalChildCount: 0,
|
||||
loadedChildren: 0,
|
||||
},
|
||||
});
|
||||
const event: PipelineCompanyCaseEvent = {
|
||||
id: "event-gap",
|
||||
companyId: "company-storybook",
|
||||
caseId: item.id,
|
||||
type: "review_decided",
|
||||
actorType: "user",
|
||||
actorUserId: "user-storybook",
|
||||
fromStageId: "stage-review",
|
||||
toStageId: "stage-done",
|
||||
fromStage: pipeline.stages[1],
|
||||
toStage: pipeline.stages[2],
|
||||
payload: {
|
||||
decision: "approve",
|
||||
actorName: "Release reviewer",
|
||||
reason: "All release checks passed.",
|
||||
},
|
||||
createdAt: "2026-10-12T12:00:00Z",
|
||||
updatedAt: "2026-10-12T12:00:00Z",
|
||||
case: {
|
||||
id: item.id,
|
||||
caseKey: item.caseKey ?? null,
|
||||
title: item.title,
|
||||
terminalKind: null,
|
||||
},
|
||||
pipeline: { id: pipeline.id, key: pipeline.key, name: pipeline.name },
|
||||
};
|
||||
const reviewRow = (): PipelineReviewCaseRow => ({
|
||||
case: item,
|
||||
pipeline: { id: pipeline.id, key: pipeline.key, name: pipeline.name },
|
||||
stage: pipeline.stages[1],
|
||||
reviewConfig: {
|
||||
approveToStageKey: "done",
|
||||
requestChangesToStageKey: "work",
|
||||
requireRequestChangesReason: true,
|
||||
},
|
||||
});
|
||||
return prepareFixtureApi(
|
||||
[
|
||||
{
|
||||
path: "/api/companies/company-storybook/pipelines",
|
||||
read: () => (state === "empty" ? [] : [pipeline]),
|
||||
},
|
||||
{
|
||||
path: "/api/pipelines/pipeline-gap",
|
||||
read: () =>
|
||||
options.emptyStages
|
||||
? { ...pipeline, stages: [], stageCount: 0, transitions: [] }
|
||||
: pipeline,
|
||||
},
|
||||
{
|
||||
path: "/api/pipelines/pipeline-gap/cases",
|
||||
read: () =>
|
||||
state === "empty" ? [] : [{ case: item, stage: detail().stage }],
|
||||
},
|
||||
{
|
||||
path: "/api/pipelines/pipeline-gap/health",
|
||||
read: () => ({ warnings: [], pipelineId: pipeline.id }),
|
||||
},
|
||||
{
|
||||
path: "/api/pipelines/pipeline-gap/intake-form",
|
||||
read: () => ({
|
||||
pipelineId: pipeline.id,
|
||||
stageId: "stage-work",
|
||||
fields: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/api/companies/company-storybook/pipelines-attention",
|
||||
read: () => ({
|
||||
suggestions: [],
|
||||
reviews: [],
|
||||
headsUp: [],
|
||||
counts: { suggestions: 0, reviews: 0, headsUp: 0 },
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/api/companies/company-storybook/review-cases",
|
||||
read: () => (state === "empty" || !review ? [] : [reviewRow()]),
|
||||
},
|
||||
{ path: "/api/cases/case-pipeline-gap", read: detail },
|
||||
{
|
||||
path: /^\/api\/cases\/case-pipeline-gap\/(children|events|issue-links)$/,
|
||||
read: () => [],
|
||||
},
|
||||
{
|
||||
path: "/api/cases/case-pipeline-gap/review",
|
||||
method: "POST",
|
||||
read: ({ body }) => {
|
||||
review = false;
|
||||
item = {
|
||||
...item,
|
||||
stageId: body.decision === "approve" ? "stage-done" : "stage-work",
|
||||
version: 2,
|
||||
};
|
||||
return detail();
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/api/companies/company-storybook/case-events",
|
||||
read: () => ({
|
||||
items: state === "empty" ? [] : [event],
|
||||
pagination: {
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
nextOffset: null,
|
||||
hasMore: false,
|
||||
order: "desc",
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
state,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { prepareFixtureApi, type FixtureState } from "./api";
|
||||
export function installPluginFixtures(
|
||||
state: FixtureState = "ready",
|
||||
empty = false,
|
||||
pluginId = "plugin-gap",
|
||||
) {
|
||||
let plugin = {
|
||||
id: pluginId,
|
||||
pluginKey: "storybook.fixture",
|
||||
packageName: "@paperclipai/storybook-fixture",
|
||||
version: "1.0.0",
|
||||
status: "ready",
|
||||
categories: ["automation"],
|
||||
manifestJson: {
|
||||
displayName: "Preview plugin",
|
||||
version: "1.0.0",
|
||||
description: "An isolated plugin fixture.",
|
||||
capabilities: [],
|
||||
instanceConfigSchema: {
|
||||
type: "object",
|
||||
properties: { label: { type: "string", title: "Label" } },
|
||||
required: ["label"],
|
||||
},
|
||||
},
|
||||
lastError: null as string | null,
|
||||
packagePath: null,
|
||||
createdAt: "2026-10-12T12:00:00Z",
|
||||
updatedAt: "2026-10-12T12:00:00Z",
|
||||
};
|
||||
let rows = empty ? [] : [plugin];
|
||||
let configJson: Record<string, unknown> = { label: "Release preview" };
|
||||
const health = {
|
||||
pluginId: plugin.id,
|
||||
status: "ready",
|
||||
healthy: true,
|
||||
checks: [],
|
||||
};
|
||||
return prepareFixtureApi(
|
||||
[
|
||||
{ path: "/api/plugins", read: () => rows },
|
||||
{ path: "/api/plugins/examples", read: () => [] },
|
||||
{ path: "/api/plugins/ui-contributions", read: () => [] },
|
||||
{
|
||||
path: "/api/plugins/install",
|
||||
method: "POST",
|
||||
read: () => {
|
||||
rows = [plugin];
|
||||
return plugin;
|
||||
},
|
||||
},
|
||||
{
|
||||
path: `/api/plugins/${pluginId}`,
|
||||
read: ({ method }) => {
|
||||
if (method === "DELETE") {
|
||||
rows = [];
|
||||
return { ok: true };
|
||||
}
|
||||
return plugin;
|
||||
},
|
||||
},
|
||||
{
|
||||
path: new RegExp(`^/api/plugins/${pluginId}/(enable|disable)$`),
|
||||
method: "POST",
|
||||
read: ({ url }) => {
|
||||
plugin = {
|
||||
...plugin,
|
||||
status: url.pathname.endsWith("disable") ? "disabled" : "ready",
|
||||
lastError: url.pathname.endsWith("disable")
|
||||
? "Disabled by operator"
|
||||
: null,
|
||||
};
|
||||
rows = [plugin];
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
{ path: `/api/plugins/${pluginId}/health`, read: () => health },
|
||||
{ path: `/api/plugins/${pluginId}/logs`, read: () => [] },
|
||||
{
|
||||
path: `/api/plugins/${pluginId}/dashboard`,
|
||||
read: () => ({
|
||||
pluginId: plugin.id,
|
||||
worker: null,
|
||||
recentJobRuns: [],
|
||||
recentWebhookDeliveries: [],
|
||||
health,
|
||||
checkedAt: "2026-10-12T12:00:00Z",
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: `/api/plugins/${pluginId}/config`,
|
||||
read: ({ method, body }) => {
|
||||
if (method === "POST")
|
||||
configJson = body.configJson as Record<string, unknown>;
|
||||
return {
|
||||
id: "config-gap",
|
||||
pluginId: plugin.id,
|
||||
companyId: "company-storybook",
|
||||
configJson,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
path: `/api/plugins/${pluginId}/config/test`,
|
||||
method: "POST",
|
||||
read: () => ({ valid: true }),
|
||||
},
|
||||
],
|
||||
state,
|
||||
).cleanup;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { CompanySkillDetail } from "@paperclipai/shared";
|
||||
export function makeSkill(
|
||||
overrides: Partial<CompanySkillDetail> = {},
|
||||
): CompanySkillDetail {
|
||||
return {
|
||||
id: "skill-gap",
|
||||
companyId: "company-storybook",
|
||||
key: "paperclip/demo-skill",
|
||||
slug: "demo-skill",
|
||||
name: "Demo Skill",
|
||||
description: "A demo skill.",
|
||||
markdown:
|
||||
"---\nname: Demo Skill\ndescription: Existing\n---\n\n# Demo Skill\n",
|
||||
sourceType: "local_path",
|
||||
sourceLocator: null,
|
||||
sourceRef: null,
|
||||
trustLevel: "markdown_only",
|
||||
compatibility: "compatible",
|
||||
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
|
||||
iconUrl: null,
|
||||
color: null,
|
||||
tagline: "Existing tagline",
|
||||
authorName: null,
|
||||
homepageUrl: null,
|
||||
categories: ["engineering"],
|
||||
sharingScope: "company",
|
||||
publicShareToken: null,
|
||||
forkedFromSkillId: null,
|
||||
forkedFromCompanyId: null,
|
||||
starCount: 0,
|
||||
installCount: 0,
|
||||
forkCount: 0,
|
||||
currentVersionId: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-01-02T00:00:00Z"),
|
||||
attachedAgentCount: 0,
|
||||
usedByAgents: [],
|
||||
editable: true,
|
||||
editableReason: null,
|
||||
sourceLabel: "Local",
|
||||
sourceBadge: "local",
|
||||
sourcePath: null,
|
||||
currentVersion: null,
|
||||
starredByCurrentActor: false,
|
||||
existingForks: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { StatusCard } from "@paperclipai/shared";
|
||||
import type { StatusCardView } from "@/pages/StatusCards/types";
|
||||
export function makeStatusCard(
|
||||
overrides: Partial<StatusCardView> = {},
|
||||
): StatusCardView {
|
||||
const card: StatusCard = {
|
||||
id: "card-gap",
|
||||
companyId: "company-storybook",
|
||||
createdByUserId: null,
|
||||
createdByAgentId: null,
|
||||
title: "Release readiness",
|
||||
titlePinned: false,
|
||||
interestPrompt: "issues that help ship the new install subcommand",
|
||||
queries: [],
|
||||
queryVersion: 1,
|
||||
queryCompiledAt: "2026-07-22T10:00:00.000Z",
|
||||
queryCompiledByAgentId: null,
|
||||
agentId: null,
|
||||
refreshPolicy: {
|
||||
mode: "interval",
|
||||
intervalMinutes: 15,
|
||||
triggers: {
|
||||
statusTransitions: true,
|
||||
membershipChanges: true,
|
||||
humanComments: true,
|
||||
assigneeChanges: true,
|
||||
anyUpdate: false,
|
||||
},
|
||||
},
|
||||
state: "active",
|
||||
pendingChangeCount: 0,
|
||||
lastChangeAt: null,
|
||||
fingerprint: null,
|
||||
fingerprintAt: null,
|
||||
mentionedIssueIds: [],
|
||||
documentId: null,
|
||||
lastUpdateRunKind: "full",
|
||||
lastGeneratedAt: "2026-07-22T11:00:00.000Z",
|
||||
lastModel: "claude-haiku",
|
||||
generatingIssueId: null,
|
||||
failureReason: null,
|
||||
nextEvalAt: null,
|
||||
archivedAt: null,
|
||||
archivedByUserId: null,
|
||||
archivedByAgentId: null,
|
||||
createdAt: "2026-07-22T09:00:00.000Z",
|
||||
updatedAt: "2026-07-22T11:00:00.000Z",
|
||||
};
|
||||
return {
|
||||
...card,
|
||||
summaryBody: "All on track. Next: review the deep-link fix, then merge.",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user