mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 01:50:13 +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>
180 lines
10 KiB
TypeScript
180 lines
10 KiB
TypeScript
// @vitest-environment jsdom
|
||
import { act } from "react";
|
||
import { createRoot, type Root } from "react-dom/client";
|
||
import { MemoryRouter } from "react-router-dom";
|
||
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||
import type { RoutineDetail, RoutineTrigger } from "@paperclipai/shared";
|
||
import { BreadcrumbProvider } from "@/context/BreadcrumbContext";
|
||
import { queryKeys } from "@/lib/queryKeys";
|
||
import { TriggersSection } from "./editable-sections";
|
||
import { RoutineDetailContext, type RoutineDetailContextValue, type SecretMessage } from "./context";
|
||
|
||
const api = vi.hoisted(() => ({ get: vi.fn(), createTrigger: vi.fn(), updateTrigger: vi.fn() }));
|
||
vi.mock("@/api/routines", () => ({ routinesApi: api }));
|
||
vi.mock("@/context/SidebarContext", () => ({ useSidebar: () => ({ isMobile: false, setSidebarOpen: vi.fn() }) }));
|
||
vi.mock("../MarkdownEditor", () => ({ MarkdownEditor: () => null }));
|
||
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
|
||
|
||
let container: HTMLDivElement;
|
||
let root: Root;
|
||
let client: QueryClient;
|
||
let routine: RoutineDetail;
|
||
function button(label: string) {
|
||
const found = [...container.querySelectorAll<HTMLButtonElement>("button")].find((item) => item.textContent?.trim() === label);
|
||
if (!found) throw new Error(`Button not found: ${label}`);
|
||
return found;
|
||
}
|
||
async function click(label: string) { await act(async () => button(label).click()); }
|
||
async function choose(label: string) {
|
||
const option = [...container.querySelectorAll("label")].find((item) => item.textContent?.includes(label));
|
||
expect(option).toBeTruthy();
|
||
await act(async () => option!.click());
|
||
}
|
||
function Harness({ secretMessage, contextRoutine }: { secretMessage?: SecretMessage; contextRoutine?: RoutineDetail }) {
|
||
const { data } = useQuery({ queryKey: queryKeys.routines.detail(routine.id), queryFn: api.get });
|
||
const value = { routine: contextRoutine ?? data ?? routine, routineId: routine.id, companyId: routine.companyId, secretMessage, setSecretMessage: vi.fn() } as unknown as RoutineDetailContextValue;
|
||
return <RoutineDetailContext.Provider value={value}><TriggersSection /></RoutineDetailContext.Provider>;
|
||
}
|
||
async function render(secretMessage?: SecretMessage, contextRoutine?: RoutineDetail) {
|
||
await act(async () => root.render(<MemoryRouter initialEntries={["/routines/routine-1/triggers"]}>
|
||
<QueryClientProvider client={client}><BreadcrumbProvider><Harness secretMessage={secretMessage} contextRoutine={contextRoutine} /></BreadcrumbProvider></QueryClientProvider>
|
||
</MemoryRouter>));
|
||
}
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
sessionStorage.clear();
|
||
container = document.createElement("div");
|
||
document.body.appendChild(container);
|
||
root = createRoot(container);
|
||
client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
|
||
routine = { id: "routine-1", companyId: "company-1", title: "Verify deployment", status: "active", triggers: [] } as unknown as RoutineDetail;
|
||
api.get.mockImplementation(async () => routine);
|
||
api.createTrigger.mockImplementation(async (_id, input) => {
|
||
const trigger = { id: "trigger-1", enabled: true, webhookUrl: "https://paperclip.example/api/routine-triggers/public/0123456789abcdef01234567/fire", ...input } as RoutineTrigger;
|
||
routine = { ...routine, triggers: [...routine.triggers, trigger] };
|
||
return { trigger, secretMaterial: { webhookUrl: trigger.webhookUrl, webhookSecret: "one-time-secret" } };
|
||
});
|
||
api.updateTrigger.mockImplementation(async (id, patch) => {
|
||
routine = { ...routine, triggers: routine.triggers.map((trigger) => trigger.id === id ? { ...trigger, ...patch } : trigger) };
|
||
return routine.triggers.find((trigger) => trigger.id === id);
|
||
});
|
||
});
|
||
afterEach(async () => {
|
||
await act(async () => root.unmount());
|
||
client.clear();
|
||
container.remove();
|
||
});
|
||
|
||
describe("TriggersSection", () => {
|
||
it("offers schedule and webhook choices before creating a trigger", async () => {
|
||
await render();
|
||
await click("Add trigger");
|
||
expect(button("Continue").disabled).toBe(true);
|
||
expect(container.textContent).toContain("On a schedule");
|
||
await choose("When another app sends a webhook");
|
||
expect(button("Continue").disabled).toBe(false);
|
||
expect(api.createTrigger).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("creates a pending webhook, shows credentials, and activates only when setup finishes", async () => {
|
||
await render();
|
||
await click("Add trigger");
|
||
await choose("When another app sends a webhook");
|
||
await click("Continue");
|
||
expect(api.createTrigger).toHaveBeenCalledWith("routine-1", { kind: "webhook", signingMode: "app_webhook", setupPending: true });
|
||
expect(container.textContent).toContain("one-time-secret");
|
||
expect(container.textContent).toContain("signing secret field");
|
||
expect(button("Copy for your agent")).toBeTruthy();
|
||
expect(JSON.stringify(Object.values(sessionStorage))).not.toContain("one-time-secret");
|
||
await click("Check connection");
|
||
expect(container.textContent).toContain("This won’t start the routine");
|
||
expect(api.updateTrigger).not.toHaveBeenCalled();
|
||
await click("Finish without checking");
|
||
expect(api.updateTrigger).toHaveBeenCalledWith("trigger-1", { setupPending: false });
|
||
expect(button("Add trigger")).toBeTruthy();
|
||
expect(container.textContent).not.toContain("one-time-secret");
|
||
});
|
||
|
||
it("warns about private URLs without blocking webhook setup or completion", async () => {
|
||
routine.triggers = [{ id: "trigger-1", kind: "webhook", enabled: true, setupPending: true, signingMode: "bearer", webhookUrl: "https://paperclip.internal/webhook" }] as RoutineTrigger[];
|
||
await render();
|
||
await click("Resume setup");
|
||
expect(container.textContent).toContain("This webhook URL appears to be private");
|
||
expect(container.querySelector('a[href="https://docs.paperclip.ing/reference/deploy/https/"]')).not.toBeNull();
|
||
expect(button("Check connection").disabled).toBe(false);
|
||
await click("Check connection");
|
||
expect(container.textContent).toContain("This webhook URL appears to be private");
|
||
expect(button("Finish without checking").disabled).toBe(false);
|
||
await click("Finish without checking");
|
||
expect(api.updateTrigger).toHaveBeenCalledWith("trigger-1", { setupPending: false });
|
||
await click("Edit webhook");
|
||
expect(container.textContent).toContain("This webhook URL appears to be private");
|
||
});
|
||
|
||
it("shows polled connection results even when routine context is stale", async () => {
|
||
routine.triggers = [{ id: "trigger-1", kind: "webhook", enabled: true, setupPending: true, signingMode: "bearer", webhookUrl: "https://paperclip.example/webhook" }] as RoutineTrigger[];
|
||
await render(undefined, routine);
|
||
await click("Resume setup");
|
||
await click("Check connection");
|
||
expect(container.textContent).toContain("Waiting to verify");
|
||
for (const status of ["rejected", "received"] as const) {
|
||
routine = { ...routine, triggers: [{ ...routine.triggers[0], lastWebhookDelivery: { status, test: true, receivedAt: new Date().toISOString() } }] };
|
||
await act(async () => { await client.invalidateQueries({ queryKey: queryKeys.routines.detail(routine.id) }); });
|
||
await vi.waitFor(() => expect(container.textContent).toContain(status === "received" ? "Authentication passed. No routine run or task was created." : "Go back to Connect your app, update the key"));
|
||
}
|
||
expect(button("Finish setup")).toBeTruthy();
|
||
});
|
||
|
||
it("shows restored one-time credentials on the triggers screen", async () => {
|
||
await render({ title: "Webhook key restored", entries: [{ webhookUrl: "https://paperclip.example/webhook", webhookSecret: "restored-secret" }] });
|
||
expect(container.textContent).toContain("restored-secret");
|
||
expect(container.querySelector('[aria-label="Copy Secret key"]')).not.toBeNull();
|
||
expect(button("Done")).toBeTruthy();
|
||
});
|
||
|
||
it("creates a schedule on confirmation and starts a fresh wizard afterward", async () => {
|
||
await render();
|
||
await click("Add trigger");
|
||
await choose("On a schedule");
|
||
await click("Continue");
|
||
await click("Review schedule");
|
||
expect(api.createTrigger).not.toHaveBeenCalled();
|
||
await click("Add schedule");
|
||
expect(api.createTrigger).toHaveBeenCalledWith("routine-1", expect.objectContaining({ kind: "schedule", cronExpression: "0 9 * * 1-5" }));
|
||
await click("Add trigger");
|
||
expect(button("Continue").disabled).toBe(true);
|
||
});
|
||
|
||
it("reveals schedule enable and pause actions only after opening the editor", async () => {
|
||
routine.triggers = [{ id: "schedule-1", kind: "schedule", enabled: false, cronExpression: "* * * * *", timezone: "America/Chicago" }] as RoutineTrigger[];
|
||
await render();
|
||
expect(container.querySelector('section[aria-label="Schedule trigger"]')).not.toBeNull();
|
||
expect(() => button("Enable trigger")).toThrow("Button not found");
|
||
await click("Edit schedule");
|
||
expect(button("Close").getAttribute("aria-expanded")).toBe("true");
|
||
await click("Enable trigger");
|
||
expect(api.updateTrigger).toHaveBeenNthCalledWith(1, "schedule-1", { enabled: true });
|
||
expect(button("Pause trigger")).toBeTruthy();
|
||
await click("Pause trigger");
|
||
expect(api.updateTrigger).toHaveBeenNthCalledWith(2, "schedule-1", { enabled: false });
|
||
expect(api.updateTrigger).toHaveBeenCalledTimes(2);
|
||
expect(button("Enable trigger")).toBeTruthy();
|
||
});
|
||
|
||
it("blocks saving an invalid custom cron in the inline schedule editor", async () => {
|
||
routine.triggers = [{ id: "schedule-1", kind: "schedule", enabled: true, cronExpression: "0 8-18/2 * * 1-5", timezone: "UTC" }] as RoutineTrigger[];
|
||
await render();
|
||
await click("Edit schedule");
|
||
const input = container.querySelector<HTMLInputElement>('input[aria-label="Cron expression"]')!;
|
||
expect(input).not.toBeNull();
|
||
await act(async () => {
|
||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "0 8-18/2 *");
|
||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||
});
|
||
expect(container.textContent).toContain("Use exactly 5 fields");
|
||
expect(button("Save schedule").disabled).toBe(true);
|
||
expect(api.updateTrigger).not.toHaveBeenCalled();
|
||
});
|
||
});
|