mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +01:00
## Thinking Path > - Paperclip manages agent work through tasks, projects, and routines. > - Daily QA must exercise the staging app through normal browser and API surfaces. > - A schedule must create real work and retain its result and history. > - Pausing that schedule must prevent the next invocation. > - The routine journey in #18652 passed in QA, while its separate completion journey has not passed. > - This PR delivers the routine test independently and preserves its measured flow and assertions. ## Linked Issues or Issue Description Refs: #18652. This is the routine-only delivery from that PR. The original completion implementation and its failed attempts remain on #18652. ## What Changed - Add the target-independent `routine-scheduled-run` journey with one native run and seven required checks. - Start from the measured routine source `ac4efb7c6f4782ea806661683c656fd2b4cc3189`. Keep its request, seven success assertions, one native-run limit, and 65-second pause check. - Bound each observed GET by the original remaining journey deadline. Preserve the normal API timeout for other callers. Keep the original routine error if final trigger disabling also fails, save a closed secondary receipt, and fail an otherwise successful journey when disabling fails. - Narrow the shared output-reader type to its one-argument GET capability without changing its runtime code. - Register only this case in the catalog, types, feature associations, dispatcher, and definition hash. Keep the strict catalog expectation in sync. - Retain the real Routines component controls and the existing project-flow coverage for the shared browser helper. - Document resource cleanup and the 65-second pause observation. Preserve the current routine map content. ## Verification - The original source passed [QA staging run 41567574185](https://pages.paperclip.ing/evals-qa-smoke-41567574185-1/). TestOps launch 1657 / result 3431 maps to case 718. All seven checks and five phases passed. One scheduler-bound native run, saved output attribution, the full pause window, and cleanup were verified. - Current source `8f62137f130f59fd3e39276e3d60185ddff24517` passed [fresh QA staging run 41582706162](https://pages.paperclip.ing/evals-qa-smoke-41582706162-1/) against Core `09162fe893b464dd2ccdafd14bd3ef1611202a86`. TestOps launch 1855. All seven checks, five phases, and all three workflow jobs passed. The run used one scheduler-bound native turn and observed 65.352 seconds after pause and completion. Canonical evidence collection and normal cleanup completed, with no active operations and the owned agent terminal. - This fresh pass covers the reviewed deadline and failure-reporting changes in the current source. The earlier ac4 pass and the byte-preserving b940 extraction remain historical records under their original conditions; neither failed attempts nor prior source conditions are regraded. - Causal controls: six assertions failed on the old behavior, then all 62 focused controls passed after the repair. Local: 834 related support tests, 41 shared chat/output-reader tests, and 26 unchanged real component controls passed. Runner E2E typecheck, recursive typecheck, build, maps, token gates, and `git diff --check` passed. The temporary output-reader type mismatch exposed by typecheck was corrected by narrowing the capability type. - The first restricted build/typecheck invocations failed because local tsx IPC was denied. A restricted support run also lacked the report CLI output. The same source passed with normal owned local IPC allowed; the original logs remain retained. - The original head's hosted e2e shard 2/8 failed at the unchanged mobile preference frame-count test: expected more than five frames, observed five. The cause is unknown. Its original log remains retained; it is not labeled a proven flake. No CI rerun was requested. - Current-head full CI run 41580423918, attempt 1, passed. The fresh review is 5/5 with zero unresolved threads. The broad local suite was not repeated. The earlier full local run recorded in #18643 had four failures and did not reach 164 serialized groups; this PR does not claim an all-local pass. - Prior failed routine attempts remain recorded. They exposed composer focus, collapsed schedule controls, pending browser events, and Markdown serialization problems. The measured later pass does not regrade those attempts. ## Risks - The shared browser-event helper also serves task creation and project creation. Its regression tests and existing consumers remain included. - The staging pass qualifies the captured source and configuration. The coordinator retains source merging, daily pinning, and plan selection. - The repository test and the unpassed completion test are not registered by this PR. ## Model Used OpenAI GPT-6 through Codex, with tool use and code execution. The exact deployment ID and context window are not exposed by this session. ## 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 issue/PR references) --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1155 lines
36 KiB
TypeScript
1155 lines
36 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import type { AnchorHTMLAttributes, ComponentProps, ReactNode } from "react";
|
|
import { flushSync } from "react-dom";
|
|
import { createRoot } from "react-dom/client";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import type { FolderListResult, Issue, RoutineListItem } from "@paperclipai/shared";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { Routines, buildRoutineGroups, buildRoutineSections, sortRoutines } from "./Routines";
|
|
|
|
let currentSearch = "";
|
|
|
|
async function act(callback: () => void | Promise<void>) {
|
|
let result: void | Promise<void> = undefined;
|
|
flushSync(() => {
|
|
result = callback();
|
|
});
|
|
await result;
|
|
}
|
|
|
|
const navigateMock = vi.fn();
|
|
const routinesListMock = vi.fn<(companyId: string) => Promise<RoutineListItem[]>>();
|
|
const foldersListMock = vi.fn<(companyId: string, kind: string) => Promise<FolderListResult>>();
|
|
const issuesListMock = vi.fn<(companyId: string, filters?: Record<string, unknown>) => Promise<Issue[]>>();
|
|
const markdownEditorRenderMock = vi.fn((props: { mentions?: Array<{ id: string; name: string }> }) => props);
|
|
const issuesListRenderMock = vi.fn(({ issues }: { issues: Issue[] }) => (
|
|
<div data-testid="issues-list">{issues.map((issue) => issue.title).join(", ")}</div>
|
|
));
|
|
const inlineEntitySelectorRenderMock = vi.fn((props: { options?: Array<{ id: string }> }) => props);
|
|
const pickerMode = vi.hoisted(() => ({ real: false }));
|
|
|
|
vi.mock("@/lib/router", () => ({
|
|
Navigate: ({ to }: { to: string }) => <a data-redirect href={to}>Redirect</a>,
|
|
Link: ({ to, children, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { to: string; children: ReactNode }) => (
|
|
<a href={to} {...props}>
|
|
{children}
|
|
</a>
|
|
),
|
|
useNavigate: () => navigateMock,
|
|
useLocation: () => ({ pathname: "/routines", search: currentSearch ? `?${currentSearch}` : "", hash: "" }),
|
|
useSearchParams: () => [new URLSearchParams(currentSearch), vi.fn()],
|
|
}));
|
|
|
|
vi.mock("../context/CompanyContext", () => ({
|
|
useCompany: () => ({ selectedCompanyId: "company-1" }),
|
|
}));
|
|
|
|
vi.mock("../context/BreadcrumbContext", () => ({
|
|
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
|
|
}));
|
|
|
|
vi.mock("../context/ToastContext", () => ({
|
|
useToastActions: () => ({ pushToast: vi.fn() }),
|
|
}));
|
|
|
|
vi.mock("../api/routines", () => ({
|
|
routinesApi: {
|
|
list: (companyId: string) => routinesListMock(companyId),
|
|
create: vi.fn(),
|
|
update: vi.fn(),
|
|
run: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock("../api/folders", () => ({
|
|
foldersApi: {
|
|
list: (companyId: string, kind: string) => foldersListMock(companyId, kind),
|
|
create: vi.fn(),
|
|
update: vi.fn(),
|
|
delete: vi.fn(),
|
|
moveItem: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock("../api/issues", () => ({
|
|
issuesApi: {
|
|
list: (companyId: string, filters?: Record<string, unknown>) => issuesListMock(companyId, filters),
|
|
update: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock("../api/agents", () => ({
|
|
agentsApi: {
|
|
list: vi.fn(async () => [
|
|
{
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
name: "Agent One",
|
|
role: "engineer",
|
|
title: null,
|
|
status: "active",
|
|
reportsTo: null,
|
|
capabilities: null,
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
contextMode: "thin",
|
|
budgetMonthlyCents: 0,
|
|
spentMonthlyCents: 0,
|
|
lastHeartbeatAt: null,
|
|
icon: "code",
|
|
metadata: null,
|
|
createdAt: new Date("2026-04-01T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-04-01T00:00:00.000Z"),
|
|
urlKey: "agent-one",
|
|
pauseReason: null,
|
|
pausedAt: null,
|
|
permissions: null,
|
|
},
|
|
{
|
|
id: "agent-2",
|
|
companyId: "company-1",
|
|
name: "Agent Two",
|
|
role: "engineer",
|
|
title: null,
|
|
status: "active",
|
|
reportsTo: null,
|
|
capabilities: null,
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
contextMode: "thin",
|
|
budgetMonthlyCents: 0,
|
|
spentMonthlyCents: 0,
|
|
lastHeartbeatAt: null,
|
|
icon: "code",
|
|
metadata: null,
|
|
createdAt: new Date("2026-04-01T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-04-01T00:00:00.000Z"),
|
|
urlKey: "agent-two",
|
|
pauseReason: null,
|
|
pausedAt: null,
|
|
permissions: null,
|
|
},
|
|
]),
|
|
},
|
|
}));
|
|
|
|
vi.mock("../api/projects", () => ({
|
|
projectsApi: {
|
|
list: vi.fn(async () => [
|
|
{
|
|
id: "project-1",
|
|
companyId: "company-1",
|
|
urlKey: "project-alpha",
|
|
goalId: null,
|
|
goalIds: [],
|
|
goals: [],
|
|
name: "Project Alpha",
|
|
description: null,
|
|
status: "in_progress",
|
|
leadAgentId: null,
|
|
targetDate: null,
|
|
color: "#22c55e",
|
|
pauseReason: null,
|
|
pausedAt: null,
|
|
archivedAt: null,
|
|
executionWorkspacePolicy: null,
|
|
codebase: null,
|
|
workspaces: [],
|
|
primaryWorkspace: null,
|
|
createdAt: new Date("2026-04-01T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-04-01T00:00:00.000Z"),
|
|
},
|
|
{
|
|
id: "project-2",
|
|
companyId: "company-1",
|
|
urlKey: "project-beta",
|
|
goalId: null,
|
|
goalIds: [],
|
|
goals: [],
|
|
name: "Project Beta",
|
|
description: null,
|
|
status: "in_progress",
|
|
leadAgentId: null,
|
|
targetDate: null,
|
|
color: "#38bdf8",
|
|
pauseReason: null,
|
|
pausedAt: null,
|
|
archivedAt: null,
|
|
executionWorkspacePolicy: null,
|
|
codebase: null,
|
|
workspaces: [],
|
|
primaryWorkspace: null,
|
|
createdAt: new Date("2026-04-01T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-04-01T00:00:00.000Z"),
|
|
},
|
|
{
|
|
id: "project-archived",
|
|
companyId: "company-1",
|
|
urlKey: "project-archived",
|
|
goalId: null,
|
|
goalIds: [],
|
|
goals: [],
|
|
name: "Archived Project",
|
|
description: null,
|
|
status: "completed",
|
|
leadAgentId: null,
|
|
targetDate: null,
|
|
color: "#94a3b8",
|
|
pauseReason: null,
|
|
pausedAt: null,
|
|
archivedAt: new Date("2026-04-02T00:00:00.000Z"),
|
|
executionWorkspacePolicy: null,
|
|
codebase: null,
|
|
workspaces: [],
|
|
primaryWorkspace: null,
|
|
createdAt: new Date("2026-04-01T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-04-01T00:00:00.000Z"),
|
|
},
|
|
]),
|
|
},
|
|
}));
|
|
|
|
vi.mock("../api/access", () => ({
|
|
accessApi: {
|
|
listUserDirectory: vi.fn(async () => ({
|
|
users: [
|
|
{
|
|
principalId: "user-1",
|
|
status: "active",
|
|
user: {
|
|
name: "Taylor",
|
|
email: "taylor@example.com",
|
|
image: null,
|
|
},
|
|
},
|
|
],
|
|
})),
|
|
},
|
|
}));
|
|
|
|
vi.mock("../api/instanceSettings", () => ({
|
|
instanceSettingsApi: {
|
|
getExperimental: vi.fn(async () => ({ enableIsolatedWorkspaces: false })),
|
|
},
|
|
}));
|
|
|
|
vi.mock("../api/heartbeats", () => ({
|
|
heartbeatsApi: {
|
|
liveRunsForCompany: vi.fn(async () => []),
|
|
},
|
|
}));
|
|
|
|
vi.mock("../components/IssuesList", () => ({
|
|
IssuesList: (props: { issues: Issue[] }) => issuesListRenderMock(props),
|
|
}));
|
|
|
|
vi.mock("../components/PageTabBar", () => ({
|
|
PageTabBar: ({ items }: { items: Array<{ label: string }> }) => (
|
|
<div>{items.map((item) => item.label).join(", ")}</div>
|
|
),
|
|
}));
|
|
|
|
vi.mock("@/components/ui/tabs", () => ({
|
|
Tabs: ({ children }: { children: unknown }) => <div>{children as never}</div>,
|
|
TabsContent: ({ children }: { children: unknown }) => <div>{children as never}</div>,
|
|
}));
|
|
|
|
vi.mock("../components/MarkdownEditor", () => ({
|
|
MarkdownEditor: (props: { mentions?: Array<{ id: string; name: string }> }) => {
|
|
markdownEditorRenderMock(props);
|
|
return <div data-testid="markdown-editor" />;
|
|
},
|
|
}));
|
|
|
|
vi.mock("../components/InlineEntitySelector", async importOriginal => {
|
|
const actual = await importOriginal<typeof import("../components/InlineEntitySelector")>();
|
|
const { forwardRef } = await import("react");
|
|
return { InlineEntitySelector: forwardRef<HTMLButtonElement, ComponentProps<typeof actual.InlineEntitySelector>>((props, ref) => {
|
|
inlineEntitySelectorRenderMock(props);
|
|
return pickerMode.real ? <actual.InlineEntitySelector {...props} ref={ref} /> : <button type="button">selector</button>;
|
|
}) };
|
|
});
|
|
|
|
vi.mock("../components/RoutineRunVariablesDialog", () => ({
|
|
RoutineRunVariablesDialog: () => null,
|
|
routineRunNeedsConfiguration: () => false,
|
|
}));
|
|
|
|
vi.mock("../components/RoutineVariablesEditor", () => ({
|
|
RoutineVariablesEditor: () => null,
|
|
RoutineVariablesHint: () => null,
|
|
}));
|
|
|
|
vi.mock("../components/AgentIconPicker", () => ({
|
|
AgentIcon: () => <span data-testid="agent-icon" />,
|
|
}));
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
function createRoutine(overrides: Partial<RoutineListItem>): RoutineListItem {
|
|
return {
|
|
id: "routine-1",
|
|
companyId: "company-1",
|
|
projectId: "project-1",
|
|
goalId: null,
|
|
parentIssueId: null,
|
|
responsibleUserId: null,
|
|
title: "Routine title",
|
|
description: null,
|
|
assigneeAgentId: "agent-1",
|
|
priority: "medium",
|
|
status: "active",
|
|
concurrencyPolicy: "coalesce_if_active",
|
|
catchUpPolicy: "skip_missed",
|
|
activityGatePolicy: "always",
|
|
activityGateScope: "company",
|
|
variables: [],
|
|
latestRevisionId: null,
|
|
latestRevisionNumber: 1,
|
|
createdByAgentId: null,
|
|
createdByUserId: null,
|
|
updatedByAgentId: null,
|
|
updatedByUserId: null,
|
|
lastTriggeredAt: null,
|
|
lastEnqueuedAt: null,
|
|
createdAt: new Date("2026-04-01T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-04-01T00:00:00.000Z"),
|
|
triggers: [],
|
|
lastRun: null,
|
|
activeIssue: null,
|
|
folderId: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createIssue(overrides: Partial<Issue> = {}): Issue {
|
|
return {
|
|
id: "issue-1",
|
|
identifier: "PAP-1000",
|
|
companyId: "company-1",
|
|
projectId: "project-1",
|
|
projectWorkspaceId: null,
|
|
goalId: null,
|
|
parentId: null,
|
|
title: "Routine execution issue",
|
|
description: null,
|
|
status: "todo",
|
|
priority: "medium",
|
|
reviewPolicy: null,
|
|
assigneeAgentId: "agent-1",
|
|
assigneeUserId: null,
|
|
responsibleUserId: null,
|
|
createdByAgentId: null,
|
|
createdByUserId: null,
|
|
issueNumber: 1000,
|
|
originKind: "routine_execution",
|
|
originId: "routine-1",
|
|
originRunId: null,
|
|
requestDepth: 0,
|
|
billingCode: null,
|
|
assigneeAdapterOverrides: null,
|
|
executionWorkspaceId: null,
|
|
executionWorkspacePreference: null,
|
|
executionWorkspaceSettings: null,
|
|
checkoutRunId: null,
|
|
executionRunId: null,
|
|
executionAgentNameKey: null,
|
|
executionLockedAt: null,
|
|
startedAt: null,
|
|
completedAt: null,
|
|
cancelledAt: null,
|
|
hiddenAt: null,
|
|
createdAt: new Date("2026-04-01T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-04-01T00:00:00.000Z"),
|
|
labels: [],
|
|
labelIds: [],
|
|
myLastTouchAt: null,
|
|
lastExternalCommentAt: null,
|
|
lastActivityAt: new Date("2026-04-01T00:00:00.000Z"),
|
|
isUnreadForMe: false,
|
|
...overrides,
|
|
workMode: overrides.workMode ?? "standard",
|
|
};
|
|
}
|
|
|
|
async function flush() {
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
|
}
|
|
|
|
describe("Routines page", () => {
|
|
let container: HTMLDivElement;
|
|
|
|
beforeEach(() => {
|
|
pickerMode.real = false;
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
currentSearch = "";
|
|
navigateMock.mockReset();
|
|
routinesListMock.mockReset();
|
|
foldersListMock.mockReset();
|
|
foldersListMock.mockResolvedValue({
|
|
kind: "routine",
|
|
folders: [],
|
|
allCount: 0,
|
|
unfiledCount: 0,
|
|
});
|
|
issuesListMock.mockReset();
|
|
markdownEditorRenderMock.mockClear();
|
|
issuesListRenderMock.mockClear();
|
|
inlineEntitySelectorRenderMock.mockClear();
|
|
localStorage.clear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
container.remove();
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
it("groups routines by project using project names for the section labels", () => {
|
|
const groups = buildRoutineGroups(
|
|
[
|
|
createRoutine({ id: "routine-1", title: "Morning sync", projectId: "project-1" }),
|
|
createRoutine({ id: "routine-2", title: "Weekly digest", projectId: "project-2", assigneeAgentId: "agent-2" }),
|
|
],
|
|
"project",
|
|
new Map([
|
|
["project-1", { name: "Project Alpha" }],
|
|
["project-2", { name: "Project Beta" }],
|
|
]),
|
|
new Map([
|
|
["agent-1", { name: "Agent One" }],
|
|
["agent-2", { name: "Agent Two" }],
|
|
]),
|
|
new Map(),
|
|
);
|
|
|
|
expect(groups.map((group) => group.label)).toEqual(["Project Alpha", "Project Beta"]);
|
|
expect(groups[0]?.items.map((item) => item.title)).toEqual(["Morning sync"]);
|
|
expect(groups[1]?.items.map((item) => item.title)).toEqual(["Weekly digest"]);
|
|
});
|
|
|
|
it("keeps built-in routines in their own section after configured groups", () => {
|
|
const groups = buildRoutineSections(
|
|
[
|
|
createRoutine({
|
|
id: "routine-1",
|
|
title: "Reflection review",
|
|
projectId: "project-1",
|
|
originKind: "built_in_agent_bundle",
|
|
originId: "reflection-coach:recent-agent-reflection",
|
|
}),
|
|
createRoutine({ id: "routine-2", title: "Morning sync", projectId: "project-1" }),
|
|
],
|
|
"project",
|
|
new Map([["project-1", { name: "Project Alpha" }]]),
|
|
new Map([["agent-1", { name: "Agent One" }]]),
|
|
new Map(),
|
|
);
|
|
|
|
expect(groups.map((group) => group.label)).toEqual(["Project Alpha", "Built-in routines"]);
|
|
expect(groups[0]?.items.map((item) => item.title)).toEqual(["Morning sync"]);
|
|
expect(groups[1]?.items.map((item) => item.title)).toEqual(["Reflection review"]);
|
|
});
|
|
|
|
it("groups routines by folder using folder names and Unfiled labels", () => {
|
|
const groups = buildRoutineGroups(
|
|
[
|
|
createRoutine({ id: "routine-1", title: "RPI review", folderId: "folder-rpi" }),
|
|
createRoutine({ id: "routine-2", title: "Unfiled sweep", folderId: null }),
|
|
createRoutine({ id: "routine-3", title: "Test summary", folderId: "folder-test" }),
|
|
],
|
|
"folder",
|
|
new Map(),
|
|
new Map(),
|
|
new Map([
|
|
["folder-rpi", { name: "RPI" }],
|
|
["folder-test", { name: "Test" }],
|
|
]),
|
|
);
|
|
|
|
expect(groups.map((group) => group.label)).toEqual(["RPI", "Test", "Unfiled"]);
|
|
expect(groups[0]?.items.map((item) => item.title)).toEqual(["RPI review"]);
|
|
expect(groups[1]?.items.map((item) => item.title)).toEqual(["Test summary"]);
|
|
expect(groups[2]?.items.map((item) => item.title)).toEqual(["Unfiled sweep"]);
|
|
});
|
|
|
|
it("orders folder groups by folder position before label and keeps Unfiled after folders", () => {
|
|
const groups = buildRoutineGroups(
|
|
[
|
|
createRoutine({ id: "routine-1", title: "Beta routine", folderId: "folder-beta" }),
|
|
createRoutine({ id: "routine-2", title: "Loose routine", folderId: null }),
|
|
createRoutine({ id: "routine-3", title: "Alpha routine", folderId: "folder-alpha" }),
|
|
],
|
|
"folder",
|
|
new Map(),
|
|
new Map(),
|
|
new Map([
|
|
["folder-alpha", { name: "Alpha", position: 20 }],
|
|
["folder-beta", { name: "Beta", position: 10 }],
|
|
]),
|
|
);
|
|
|
|
expect(groups.map((group) => group.label)).toEqual(["Beta", "Alpha", "Unfiled"]);
|
|
expect(groups.map((group) => group.key)).toEqual(["folder-beta", "folder-alpha", "__unfiled"]);
|
|
});
|
|
|
|
it("keeps built-in routines in their own section after folder groups", () => {
|
|
const groups = buildRoutineSections(
|
|
[
|
|
createRoutine({ id: "routine-1", title: "RPI review", folderId: "folder-rpi" }),
|
|
createRoutine({
|
|
id: "routine-2",
|
|
title: "Reflection review",
|
|
folderId: "folder-rpi",
|
|
originKind: "built_in_agent_bundle",
|
|
originId: "reflection-coach:recent-agent-reflection",
|
|
}),
|
|
createRoutine({ id: "routine-3", title: "Unfiled sweep", folderId: null }),
|
|
],
|
|
"folder",
|
|
new Map(),
|
|
new Map(),
|
|
new Map([["folder-rpi", { name: "RPI" }]]),
|
|
);
|
|
|
|
expect(groups.map((group) => group.label)).toEqual(["RPI", "Unfiled", "Built-in routines"]);
|
|
expect(groups[0]?.items.map((item) => item.title)).toEqual(["RPI review"]);
|
|
expect(groups[1]?.items.map((item) => item.title)).toEqual(["Unfiled sweep"]);
|
|
expect(groups[2]?.items.map((item) => item.title)).toEqual(["Reflection review"]);
|
|
});
|
|
|
|
it("sorts routines by selected field and direction without mutating the source list", () => {
|
|
const routines = [
|
|
createRoutine({
|
|
id: "routine-1",
|
|
title: "Weekly digest",
|
|
createdAt: new Date("2026-04-01T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-04-03T00:00:00.000Z"),
|
|
lastRun: {
|
|
id: "run-1",
|
|
companyId: "company-1",
|
|
routineId: "routine-1",
|
|
triggerId: null,
|
|
source: "manual",
|
|
status: "succeeded",
|
|
triggeredAt: new Date("2026-04-02T00:00:00.000Z"),
|
|
idempotencyKey: null,
|
|
triggerPayload: null,
|
|
dispatchFingerprint: null,
|
|
linkedIssueId: null,
|
|
coalescedIntoRunId: null,
|
|
failureReason: null,
|
|
completedAt: null,
|
|
createdAt: new Date("2026-04-02T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-04-02T00:00:00.000Z"),
|
|
linkedIssue: null,
|
|
trigger: null,
|
|
},
|
|
}),
|
|
createRoutine({
|
|
id: "routine-2",
|
|
title: "Morning sync",
|
|
createdAt: new Date("2026-04-02T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-04-04T00:00:00.000Z"),
|
|
lastRun: null,
|
|
}),
|
|
];
|
|
|
|
expect(sortRoutines(routines, "title", "asc").map((routine) => routine.title)).toEqual([
|
|
"Morning sync",
|
|
"Weekly digest",
|
|
]);
|
|
expect(sortRoutines(routines, "updated", "desc").map((routine) => routine.id)).toEqual([
|
|
"routine-2",
|
|
"routine-1",
|
|
]);
|
|
expect(sortRoutines(routines, "lastRun", "desc").map((routine) => routine.id)).toEqual([
|
|
"routine-1",
|
|
"routine-2",
|
|
]);
|
|
expect(routines.map((routine) => routine.id)).toEqual(["routine-1", "routine-2"]);
|
|
});
|
|
|
|
it("renders the routines sort control before the group control", async () => {
|
|
routinesListMock.mockResolvedValue([]);
|
|
issuesListMock.mockResolvedValue([]);
|
|
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
},
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Routines />
|
|
</QueryClientProvider>,
|
|
);
|
|
await flush();
|
|
});
|
|
|
|
let sortButton = container.querySelector<HTMLButtonElement>('button[title="Sort"]');
|
|
let groupButton = container.querySelector<HTMLButtonElement>('button[title="Group"]');
|
|
for (let attempts = 0; attempts < 5 && (!sortButton || !groupButton); attempts += 1) {
|
|
await act(async () => {
|
|
await flush();
|
|
});
|
|
sortButton = container.querySelector<HTMLButtonElement>('button[title="Sort"]');
|
|
groupButton = container.querySelector<HTMLButtonElement>('button[title="Group"]');
|
|
}
|
|
|
|
expect(sortButton).not.toBeNull();
|
|
expect(groupButton).not.toBeNull();
|
|
expect(sortButton!.compareDocumentPosition(groupButton!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("defaults the routines list to folder mode with inline folder sections", async () => {
|
|
foldersListMock.mockResolvedValue({
|
|
kind: "routine",
|
|
allCount: 3,
|
|
unfiledCount: 1,
|
|
folders: [
|
|
{
|
|
id: "folder-rpi",
|
|
companyId: "company-1",
|
|
kind: "routine",
|
|
parentId: null,
|
|
name: "RPI",
|
|
slug: "rpi",
|
|
systemKey: null,
|
|
path: "rpi",
|
|
depth: 1,
|
|
color: null,
|
|
position: 0,
|
|
itemCount: 1,
|
|
createdAt: new Date("2026-07-01T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-07-01T00:00:00.000Z"),
|
|
},
|
|
{
|
|
id: "folder-test",
|
|
companyId: "company-1",
|
|
kind: "routine",
|
|
parentId: null,
|
|
name: "Test",
|
|
slug: "test",
|
|
systemKey: null,
|
|
path: "test",
|
|
depth: 1,
|
|
color: null,
|
|
position: 1,
|
|
itemCount: 1,
|
|
createdAt: new Date("2026-07-01T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-07-01T00:00:00.000Z"),
|
|
},
|
|
],
|
|
});
|
|
routinesListMock.mockResolvedValue([
|
|
createRoutine({ id: "routine-1", title: "RPI review", folderId: "folder-rpi", projectId: "project-1" }),
|
|
createRoutine({ id: "routine-2", title: "Unfiled sweep", folderId: null, projectId: "project-1" }),
|
|
createRoutine({ id: "routine-3", title: "Test summary", folderId: "folder-test", projectId: "project-2" }),
|
|
]);
|
|
issuesListMock.mockResolvedValue([]);
|
|
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
},
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Routines />
|
|
</QueryClientProvider>,
|
|
);
|
|
await flush();
|
|
});
|
|
|
|
for (let attempts = 0; attempts < 5 && !container.textContent?.includes("Unfiled sweep"); attempts += 1) {
|
|
await act(async () => {
|
|
await flush();
|
|
});
|
|
}
|
|
|
|
const sectionLabels = Array.from(container.querySelectorAll("[data-routine-section-label]"))
|
|
.map((element) => element.textContent);
|
|
expect(sectionLabels).toEqual(["RPI", "Test", "Unfiled"]);
|
|
|
|
const text = container.textContent ?? "";
|
|
expect(text.indexOf("RPI review")).toBeLessThan(text.indexOf("Test summary"));
|
|
expect(text.indexOf("Test summary")).toBeLessThan(text.indexOf("Unfiled sweep"));
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("renders built-in routines in a dedicated section on the routines tab", async () => {
|
|
routinesListMock.mockResolvedValue([
|
|
createRoutine({
|
|
id: "routine-1",
|
|
title: "Morning sync",
|
|
projectId: "project-1",
|
|
}),
|
|
createRoutine({
|
|
id: "routine-2",
|
|
title: "Reflection review",
|
|
projectId: null,
|
|
originKind: "built_in_agent_bundle",
|
|
originId: "reflection-coach:recent-agent-reflection",
|
|
}),
|
|
]);
|
|
issuesListMock.mockResolvedValue([]);
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
},
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Routines />
|
|
</QueryClientProvider>,
|
|
);
|
|
await flush();
|
|
});
|
|
|
|
for (let attempts = 0; attempts < 5 && !container.textContent?.includes("Built-in routines"); attempts += 1) {
|
|
await act(async () => {
|
|
await flush();
|
|
});
|
|
}
|
|
|
|
const text = container.textContent ?? "";
|
|
expect(text.indexOf("Morning sync")).toBeLessThan(text.indexOf("Built-in routines"));
|
|
expect(text.indexOf("Built-in routines")).toBeLessThan(text.indexOf("Reflection review"));
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("filters to Unfiled and shows the empty-folder state with a create CTA", async () => {
|
|
foldersListMock.mockResolvedValue({
|
|
kind: "routine",
|
|
allCount: 2,
|
|
unfiledCount: 1,
|
|
folders: [
|
|
{
|
|
id: "folder-reporting",
|
|
companyId: "company-1",
|
|
kind: "routine",
|
|
parentId: null,
|
|
name: "Reporting",
|
|
slug: "reporting",
|
|
systemKey: null,
|
|
path: "reporting",
|
|
depth: 1,
|
|
color: "#6366f1",
|
|
position: 0,
|
|
itemCount: 1,
|
|
createdAt: new Date("2026-07-01T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-07-01T00:00:00.000Z"),
|
|
},
|
|
{
|
|
id: "folder-empty",
|
|
companyId: "company-1",
|
|
kind: "routine",
|
|
parentId: null,
|
|
name: "Empty folder",
|
|
slug: "empty-folder",
|
|
systemKey: null,
|
|
path: "empty-folder",
|
|
depth: 1,
|
|
color: null,
|
|
position: 1,
|
|
itemCount: 0,
|
|
createdAt: new Date("2026-07-01T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-07-01T00:00:00.000Z"),
|
|
},
|
|
],
|
|
});
|
|
routinesListMock.mockResolvedValue([
|
|
createRoutine({ id: "routine-1", title: "Filed digest", folderId: "folder-reporting" }),
|
|
createRoutine({ id: "routine-2", title: "Loose routine", folderId: null }),
|
|
]);
|
|
issuesListMock.mockResolvedValue([]);
|
|
|
|
currentSearch = "folder=unfiled";
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
},
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Routines />
|
|
</QueryClientProvider>,
|
|
);
|
|
await flush();
|
|
});
|
|
for (let attempts = 0; attempts < 5 && !container.textContent?.includes("Loose routine"); attempts += 1) {
|
|
await act(async () => {
|
|
await flush();
|
|
});
|
|
}
|
|
|
|
// Unfiled filter: only the folderless routine renders; the rail still lists folders.
|
|
expect(container.textContent).toContain("Loose routine");
|
|
expect(container.textContent).not.toContain("Filed digest");
|
|
expect(container.textContent).toContain("Reporting");
|
|
expect(container.textContent).toContain("Empty folder");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
|
|
// Remount filtered to the empty folder: empty state + create-into-folder CTA.
|
|
currentSearch = "folder=folder-empty";
|
|
const secondRoot = createRoot(container);
|
|
await act(async () => {
|
|
secondRoot.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Routines />
|
|
</QueryClientProvider>,
|
|
);
|
|
await flush();
|
|
});
|
|
for (let attempts = 0; attempts < 5 && !container.textContent?.includes("This folder is empty"); attempts += 1) {
|
|
await act(async () => {
|
|
await flush();
|
|
});
|
|
}
|
|
|
|
expect(container.textContent).toContain("This folder is empty");
|
|
expect(container.textContent).toContain("New routine in this folder");
|
|
|
|
await act(async () => {
|
|
secondRoot.unmount();
|
|
});
|
|
});
|
|
|
|
it("hides archived routines from the routines list", async () => {
|
|
routinesListMock.mockResolvedValue([
|
|
createRoutine({ id: "routine-1", title: "Morning sync", status: "active" }),
|
|
createRoutine({ id: "routine-2", title: "Archived cleanup", status: "archived" }),
|
|
]);
|
|
issuesListMock.mockResolvedValue([]);
|
|
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
},
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Routines />
|
|
</QueryClientProvider>,
|
|
);
|
|
await flush();
|
|
});
|
|
|
|
for (let attempts = 0; attempts < 5 && !container.textContent?.includes("Morning sync"); attempts += 1) {
|
|
await act(async () => {
|
|
await flush();
|
|
});
|
|
}
|
|
|
|
const text = container.textContent ?? "";
|
|
expect(text).toContain("Morning sync");
|
|
expect(text).not.toContain("Archived cleanup");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("searches routine titles and descriptions and restores results when cleared", async () => {
|
|
routinesListMock.mockResolvedValue([
|
|
createRoutine({ id: "routine-1", title: "Morning sync", status: "active" }),
|
|
createRoutine({ id: "routine-2", title: "Weekly digest", description: "Review company activity", status: "active" }),
|
|
]);
|
|
issuesListMock.mockResolvedValue([]);
|
|
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
},
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Routines />
|
|
</QueryClientProvider>,
|
|
);
|
|
await flush();
|
|
});
|
|
|
|
for (let attempts = 0; attempts < 5 && !container.textContent?.includes("Morning sync"); attempts += 1) {
|
|
await act(async () => {
|
|
await flush();
|
|
});
|
|
}
|
|
|
|
const input = container.querySelector<HTMLInputElement>('input[aria-label="Search routines"]')!;
|
|
const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
|
async function search(value: string) {
|
|
await act(async () => {
|
|
setValue.call(input, value);
|
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
});
|
|
}
|
|
await search("company review");
|
|
expect(container.textContent).toContain("Weekly digest");
|
|
expect(container.textContent).not.toContain("Morning sync");
|
|
await search("missing");
|
|
expect(container.textContent).toContain("No routines match your search.");
|
|
await search("");
|
|
expect(container.textContent).toContain("Weekly digest");
|
|
expect(container.textContent).toContain("Morning sync");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("shows an outlined row-level run now button on the routines table", async () => {
|
|
routinesListMock.mockResolvedValue([createRoutine({ id: "routine-1", title: "Morning sync" })]);
|
|
issuesListMock.mockResolvedValue([]);
|
|
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
},
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Routines />
|
|
</QueryClientProvider>,
|
|
);
|
|
await flush();
|
|
});
|
|
|
|
let runNowButton = Array.from(container.querySelectorAll("button")).find((button) =>
|
|
button.textContent?.includes("Run now"),
|
|
);
|
|
for (let attempts = 0; attempts < 5 && !runNowButton; attempts += 1) {
|
|
await act(async () => {
|
|
await flush();
|
|
});
|
|
runNowButton = Array.from(container.querySelectorAll("button")).find((button) =>
|
|
button.textContent?.includes("Run now"),
|
|
);
|
|
}
|
|
|
|
expect(runNowButton).toBeTruthy();
|
|
expect(runNowButton?.getAttribute("data-variant")).toBe("outline");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("opens the project picker after selecting the responsible agent", async () => {
|
|
pickerMode.real = true;
|
|
routinesListMock.mockResolvedValue([]);
|
|
issuesListMock.mockResolvedValue([]);
|
|
const root = createRoot(container), queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
const button = (text: string) => Array.from(document.querySelectorAll<HTMLButtonElement>("button"))
|
|
.find(element => element.textContent?.trim() === text)!;
|
|
try {
|
|
await act(async () => { root.render(<QueryClientProvider client={queryClient}><Routines /></QueryClientProvider>); await flush(); });
|
|
await vi.waitFor(() => expect(button("Create routine")).toBeTruthy());
|
|
await act(async () => { button("Create routine").click(); await flush(); });
|
|
await act(async () => { button("Responsible").click(); await flush(); });
|
|
await act(async () => { button("Agent One").click(); await new Promise(resolve => requestAnimationFrame(resolve)); await flush(); });
|
|
// Responsible selection already opens Project through its real focus path.
|
|
expect(document.querySelector('input[placeholder="Search projects..."]')).not.toBeNull();
|
|
await act(async () => { button("Project Alpha").click(); await flush(); });
|
|
expect(button("Project Alpha")).toBeTruthy();
|
|
} finally { await act(() => root.unmount()); queryClient.clear(); }
|
|
});
|
|
|
|
it("excludes archived projects from the create composer project selector", async () => {
|
|
routinesListMock.mockResolvedValue([]);
|
|
issuesListMock.mockResolvedValue([]);
|
|
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
},
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Routines />
|
|
</QueryClientProvider>,
|
|
);
|
|
await flush();
|
|
});
|
|
|
|
let createButton = Array.from(container.querySelectorAll("button")).find((button) =>
|
|
button.textContent?.includes("Create routine"),
|
|
);
|
|
for (let attempts = 0; attempts < 5 && !createButton; attempts += 1) {
|
|
await act(async () => {
|
|
await flush();
|
|
});
|
|
createButton = Array.from(container.querySelectorAll("button")).find((button) =>
|
|
button.textContent?.includes("Create routine"),
|
|
);
|
|
}
|
|
|
|
await act(async () => {
|
|
createButton?.click();
|
|
await flush();
|
|
});
|
|
|
|
const projectSelectorCall = inlineEntitySelectorRenderMock.mock.calls.find(([props]) => {
|
|
const ids = (props.options ?? []).map((option) => option.id);
|
|
return ids.includes("project-1") && ids.includes("project-2");
|
|
});
|
|
|
|
expect(projectSelectorCall).toBeTruthy();
|
|
expect(projectSelectorCall?.[0].options?.map((option) => option.id)).toEqual(["project-1", "project-2"]);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("passes company mention options to the routine description editor", async () => {
|
|
routinesListMock.mockResolvedValue([]);
|
|
issuesListMock.mockResolvedValue([]);
|
|
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
},
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Routines />
|
|
</QueryClientProvider>,
|
|
);
|
|
await flush();
|
|
});
|
|
|
|
let createButton = Array.from(container.querySelectorAll("button")).find((button) =>
|
|
button.textContent?.includes("Create routine"),
|
|
);
|
|
for (let attempts = 0; attempts < 5 && !createButton; attempts += 1) {
|
|
await act(async () => {
|
|
await flush();
|
|
});
|
|
createButton = Array.from(container.querySelectorAll("button")).find((button) =>
|
|
button.textContent?.includes("Create routine"),
|
|
);
|
|
}
|
|
|
|
expect(createButton).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
createButton?.click();
|
|
await flush();
|
|
});
|
|
|
|
for (let attempts = 0; attempts < 5; attempts += 1) {
|
|
const hasMentionOptions = markdownEditorRenderMock.mock.calls.some(([props]) => (props.mentions ?? []).length > 0);
|
|
if (hasMentionOptions) break;
|
|
await act(async () => {
|
|
await flush();
|
|
});
|
|
}
|
|
|
|
const callsWithMentions = markdownEditorRenderMock.mock.calls
|
|
.map(([props]) => props.mentions ?? [])
|
|
.filter((mentions) => mentions.length > 0);
|
|
|
|
expect(callsWithMentions.at(-1)?.map((mention) => mention.id)).toEqual([
|
|
"user:user-1",
|
|
"agent:agent-1",
|
|
"agent:agent-2",
|
|
"project:project-1",
|
|
"project:project-2",
|
|
]);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("links View all tasks to the routine-filtered task list", async () => {
|
|
routinesListMock.mockResolvedValue([]);
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
await act(async () => {
|
|
root.render(<QueryClientProvider client={queryClient}><Routines /></QueryClientProvider>);
|
|
await flush();
|
|
});
|
|
const link = Array.from(container.querySelectorAll("a")).find(anchor => anchor.textContent === "View all tasks");
|
|
expect(link?.getAttribute("href")).toBe("/issues?view=all&originKind=routine_execution&sortField=created&sortDir=desc");
|
|
expect(container.textContent).not.toContain("View all runs");
|
|
await act(() => root.unmount());
|
|
queryClient.clear();
|
|
});
|
|
|
|
it("keeps the list focused on routines and links run history to Audit", async () => {
|
|
currentSearch = "tab=runs";
|
|
routinesListMock.mockResolvedValue([createRoutine({ id: "routine-1" })]);
|
|
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
},
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Routines />
|
|
</QueryClientProvider>,
|
|
);
|
|
await flush();
|
|
});
|
|
|
|
expect(container.querySelector('a[data-redirect][href="/activity/runs"]'))
|
|
.not.toBeNull();
|
|
expect(issuesListMock).not.toHaveBeenCalled();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
});
|