Files
PaperClipAI/ui/src/components/routine-sections/editable-sections.test.tsx
T
DottaandPaperclip e5713d95c6 test(e2e): add scheduled routine journey (#18728)
## 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>
2026-11-02 23:25:56 -06:00

180 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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();
});
});