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>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-11-02 23:25:56 -06:00
1 parent 4461905b92
commit e5713d95c6
25 files changed
+820 -28

No files matched your search

+2
View File
@@ -96,3 +96,5 @@ Sources: Source: [RoutineRunVariablesDialog.tsx](../../ui/src/components/Routine
### Documentation scope: Routines, cases, and status cards
All three capabilities have recipes. Trigger/provider/timezone combinations, custom case schemas and summarizer quality need deeper cases.
The target-independent Product E2E journey `routine-scheduled-run` covers browser creation, an actual scheduled invocation, run-attributed saved output, history after reload, and no new invocation across the next minute boundary after pause. Source: `tests/runner-e2e/product-journeys/routine-schedule-flow.ts`. Registration is not evidence of a passing deployed run.
+8
View File
@@ -23,6 +23,7 @@ never start a server or depend on a tenant filesystem.
| Journey | Required starting state | Observed outcome |
| --- | --- | --- |
| `routine-scheduled-run` | Native agent; one work run; an owned project and routine | Browser-create a routine, enable its minute schedule, verify one scheduled invocation and run-attributed saved output, then pause and observe the next schedule boundary with no new work |
| `file-input-answer` | Native agent; one work run; synthetic CSV uploaded through the composer | Verify original uploaded bytes, independently calculated accepted-row count and units, a run-attributed task document after reload, and preservation of the input attachment. The task prompt contains neither the random file reference nor the answers |
| `inbox-triage` | Signed-in board user; a separately authenticated QA human author; a fresh self-assigned task; zero model runs | Mark an unread task read, archive it through Mine, reload both views, verify unchanged work status, then observe a real Blocked transition resurface it through the normal archive policy |
| `task-create` | Company and signed-in board user | Create a task through the shared production composer, assign it to yourself, reopen and reload it, verify the saved prompt and no agent work |
@@ -472,3 +473,10 @@ A candidate's presence in this source catalog is not qualification. It enters th
saved daily TestOps plan only after an inspected real QA Cloud pass including
usage, artifact/decision evidence and cleanup. The daily driver runs that saved
plan against the registered QA tenant; it does not substitute a local instance.
The scheduled-routine pause oracle uses a full 65 seconds of monotonic elapsed
time after both pause acknowledgement and work completion. Its enable receipt
uses the server's timestamp; client and server wall clocks are never compared.
The journey retries only transient read transport failures within the existing
phase and journey deadlines. Private read-failure receipts retain those
observations. Mutations, user messages and agent work are never replayed.
+1
View File
@@ -635,3 +635,4 @@ authority. Provider credentials still enter through encrypted fixture secrets.
Local rolling smoke receipts explicitly exclude active-agent and hosted proof.
The personal inbox journey requires a separately authenticated, registered QA human author. Its target provider exposes one controlled comment operation on the exact campaign-owned task. The public oracle independently reads the saved comment and verifies its distinct human author, exact content, company, task, and absence of an agent run. The private provider closes the isolated author context during evidence-first cleanup. No provider or native run is allowed for this browser-only journey; agent cross-task execution guards remain intact.
The `routine-scheduled-run` journey uses one campaign-owned routine, project, and scheduled task. It enables a minute schedule only for the bounded observation. Cleanup must pause the routine, disable its triggers, and reconcile scheduler-created tasks before stopping agent work. Private recovery repeats these checks after a lost response. Retain task and routine history; never leave a recurring trigger enabled after an attempt.
+1 -1
View File
@@ -126,7 +126,7 @@ describe("runner E2E catalog", () => {
});
it("exports deployment journeys separately from the model matrix and default selection", () => {
expect(productJourneys.map(journey => journey.id)).toEqual(["inbox-triage", "file-input-answer", "task-create", "project-task-context", "ask-question", "question-answer-resume", "task-properties", "task-document-persistence", "task-search-filter", "plan-revise-accept", "build-revise", "protected-actions", "create-skill-studio", "stop-redirect", "followup-while-running", "hire-delegate-reuse", "cloud-onboarding"]);
expect(productJourneys.map(journey => journey.id)).toEqual(["routine-scheduled-run", "inbox-triage", "file-input-answer", "task-create", "project-task-context", "ask-question", "question-answer-resume", "task-properties", "task-document-persistence", "task-search-filter", "plan-revise-accept", "build-revise", "protected-actions", "create-skill-studio", "stop-redirect", "followup-while-running", "hire-delegate-reuse", "cloud-onboarding"]);
expect(runnerSuites.some(suite => suite.id === "product-journeys")).toBe(false);
expect(runnerMatrix.some(execution => execution.suite.id === "product-journeys")).toBe(false);
expect(selectRunnerExecutions(parseRunnerSelectors(["--all"])).some(execution => execution.suite.id === "product-journeys")).toBe(false);
+1 -1
View File
@@ -152,7 +152,7 @@ export function assertChatExecutionOutput(
/** A requested output document may have a descriptive key; a copied plan is not output. */
export async function readChatOutputDocument(
api: Pick<RunnerApi, "get">,
api: { get<T>(route: string): Promise<T> },
issueId: string,
marker: string,
): Promise<ChatOutputDocument> {
@@ -143,6 +143,16 @@ describe("live connection coverage", () => {
});
describe("target and selected credential boundaries", () => {
it("passes a bounded GET timeout and preserves the default for other readers", async () => {
const fetch = vi.fn(async () => ({ ok: () => true, json: async () => ({ passed: true }) }));
const api = new ConnectionApi({ fetch } as any, "https://qa.test.paperclip.app");
await api.get("/api/health", { timeoutMs: 17 });
expect(fetch).toHaveBeenLastCalledWith("https://qa.test.paperclip.app/api/health", expect.objectContaining({ timeout: 17 }));
await api.get("/api/health");
expect(fetch).toHaveBeenLastCalledWith("https://qa.test.paperclip.app/api/health", expect.objectContaining({ timeout: 30_000 }));
await expect(api.get("/api/health", { timeoutMs: 0 })).rejects.toThrow("invalid_api_timeout");
expect(fetch).toHaveBeenCalledTimes(2);
});
it("keeps scoped requests on tenant APIs and bounds extended probe timeouts", async () => {
const fetch = vi.fn(async (_url: string, _options: unknown) => ({ ok: () => true, json: async () => ({ passed: true }) }));
const api = new ConnectionApi({ fetch } as any, "https://qa.test.paperclip.app");
+1 -1
View File
@@ -29,7 +29,7 @@ export class ConnectionApi {
throw new ConnectionFailure("api_request_failed");
}
}
async get<T = any>(route: string): Promise<T> { return (await this.response(route)).json(); }
async get<T = any>(route: string, options?: { timeoutMs?: number }): Promise<T> { return (await this.response(route, "GET", undefined, options)).json(); }
async post<T = any>(route: string, data?: unknown, options?: { timeoutMs?: number }): Promise<T> { return (await this.response(route, "POST", data, options)).json(); }
async patch<T = any>(route: string, data: unknown): Promise<T> { return (await this.response(route, "PATCH", data)).json(); }
async delete(route: string) { await this.response(route, "DELETE"); }
+1
View File
@@ -19,6 +19,7 @@ function associate(suites: readonly string[], cases: Record<string, readonly str
}
associate(["product-journeys"], {
"routine-scheduled-run": ["routines/definition", "routines/triggers", "routines/history", "routines/management"],
"file-input-answer": ["documents-artifacts/attachments", "documents-artifacts/documents"],
"inbox-triage": ["inbox-search/views", "inbox-search/triage"], "task-create": ["tasks/create"], "ask-question": ["questions-and-approvals/questions"],
"question-answer-resume": ["questions-and-approvals/questions", "questions-and-approvals/continuation"],
@@ -20,7 +20,7 @@ import { agentJourneyRuns } from "./catalog.js";
import { JourneyError, type JourneyContext, type JourneyEvidence, type TargetSession } from "./types.js";
type Row = Record<string, any>;
type AgentJourney = Exclude<keyof typeof agentJourneyRuns, "ask-question" | "question-answer-resume" | "followup-while-running" | "file-input-answer">;
type AgentJourney = Exclude<keyof typeof agentJourneyRuns, "ask-question" | "question-answer-resume" | "followup-while-running" | "file-input-answer" | "routine-scheduled-run">;
interface TaskState { currentIssue: Row & { id: string; status: string }; taskRuns: Array<Row & { status: string }>; comments: Row[]; interactions: Array<Row & { id: string; status: string }>; attachments: StoryDelivery[] }
interface Options { deadlineAt: number; directory: string; capture(id: string, label: string, file: string): Promise<void> }
const active = (run: Row) => ["queued", "running"].includes(run.status);
@@ -2,6 +2,7 @@ import { agentJourneyRuns } from "./success-contract.js";
export { agentJourneyRuns, agentJourneyRunCountValid } from "./success-contract.js";
import type { JourneyId, TargetConfig } from "./types.js";
export const productJourneys = [
{ id: "routine-scheduled-run", label: "Run a scheduled routine once and verify pause and saved history", requires: ["company", "board-user", "task-write", "agent"], capabilities: ["routines/definition", "routines/triggers", "routines/history", "routines/management"], paid: true },
{ id: "inbox-triage", label: "Read, archive and resurface an owned inbox task", requires: ["company", "board-user", "task-write", "inbox-author"], capabilities: ["inbox-search/views", "inbox-search/triage"], paid: false },
{ id: "file-input-answer", label: "Upload a reference file and verify the agent uses its contents", requires: ["company", "board-user", "task-write", "agent"], capabilities: ["documents-artifacts/attachments", "documents-artifacts/documents"], paid: true },
{ id: "task-create", label: "Create a task for yourself and reopen it", requires: ["company", "board-user", "task-write"], capabilities: ["tasks/create"], paid: false },
+5 -2
View File
@@ -17,6 +17,7 @@ import { projectTaskContext } from "./project-context-flow.js";
import { runAgentJourney } from "./agent-flows.js";
import { runActiveFollowup } from "./active-followup-flow.js";
import { runClarificationJourney } from "./clarification-flow.js";
import { runScheduledRoutine } from "./routine-schedule-flow.js";
import { runInputFile } from "./input-file-flow.js";
import { inboxTriage } from "./inbox-triage-flow.js";
import { JourneyError, type JourneyContext, type JourneyEvidence, type JourneyOutcome, type TargetLifecycleInput, type TargetProvider, type TargetSession } from "./types.js";
@@ -110,7 +111,7 @@ export async function launchProductJourneys(args: string[], repositoryRoot: stri
try { sha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repositoryRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch { /* Packaged harnesses may not include Git metadata. */ }
const source = resolveRunnerE2ESource({ sha, ref: null, workflowRunUrl: null });
const definitionHash = createHash("sha256").update(JSON.stringify(productJourneys));
for (const name of ["catalog.ts", "input-file-flow.ts", "input-file-evidence.ts", "inbox-triage-flow.ts", "inbox-triage-evidence.ts", "inbox-triage-fixture.ts", "inbox-author-contract.ts", "task-properties.ts", "task-search-filter.ts", "task-search-evidence.ts", "basic-flows.ts", "basic-evidence.ts", "task-document-flow.ts", "task-document-evidence.ts", "project-context-flow.ts", "project-context-readback.ts", "project-context-evidence.ts", "agent-flows.ts", "hire-delegate-flow.ts", "hire-delegate-composer.ts", "hire-delegate-evidence.ts", "active-followup-flow.ts", "active-followup-evidence.ts", "stop-redirect-evidence.ts", "clarification-flow.ts", "clarification-evidence.ts", "../question-resume-ui.ts", "../question-resume-scoring.ts", "../runtime-question-readiness.ts", "../chat-flow.ts", "../continuation-readiness.ts", "../continuation-cases.ts", "success-contract.ts", "product-screenshot.ts", "cloud-onboarding.ts", "onboarding-connect.ts", "onboarding-first-task.ts", "workspace-deferral.ts", "onboarding-pre-acceptance.ts", "artifact-evidence.ts", "fixtures.ts", "launch.ts", "types.ts", "collection.ts", "local-target.ts", "../../../ui/src/lib/markdown-reply-content.ts", "../../../ui/src/lib/issue-reference.ts", "../../../ui/src/lib/company-routes.ts", "../../../ui/package.json"]) definitionHash.update(await readFile(path.join(import.meta.dirname, name)));
for (const name of ["observation-read.ts", "../connection-config.ts", "catalog.ts", "../user-actions.ts", "../connection-target.ts", "routine-schedule-flow.ts", "routine-schedule-evidence.ts", "input-file-flow.ts", "input-file-evidence.ts", "inbox-triage-flow.ts", "inbox-triage-evidence.ts", "inbox-triage-fixture.ts", "inbox-author-contract.ts", "task-properties.ts", "task-search-filter.ts", "task-search-evidence.ts", "basic-flows.ts", "basic-evidence.ts", "task-document-flow.ts", "task-document-evidence.ts", "project-context-flow.ts", "project-context-readback.ts", "project-context-evidence.ts", "agent-flows.ts", "hire-delegate-flow.ts", "hire-delegate-composer.ts", "hire-delegate-evidence.ts", "active-followup-flow.ts", "active-followup-evidence.ts", "stop-redirect-evidence.ts", "clarification-flow.ts", "clarification-evidence.ts", "../question-resume-ui.ts", "../question-resume-scoring.ts", "../runtime-question-readiness.ts", "../chat-flow.ts", "../continuation-readiness.ts", "../continuation-cases.ts", "success-contract.ts", "product-screenshot.ts", "cloud-onboarding.ts", "onboarding-connect.ts", "onboarding-first-task.ts", "workspace-deferral.ts", "onboarding-pre-acceptance.ts", "artifact-evidence.ts", "fixtures.ts", "launch.ts", "types.ts", "collection.ts", "local-target.ts", "../../../ui/src/lib/markdown-reply-content.ts", "../../../ui/src/lib/issue-reference.ts", "../../../ui/src/lib/company-routes.ts", "../../../ui/package.json"]) definitionHash.update(await readFile(path.join(import.meta.dirname, name)));
for (const file of ["catalog.ts", "api.ts", "run-observations.ts", "native-task-reply.ts", "native-event-envelope.ts", "user-actions.ts", "plan-revision-flow.ts", "skill-studio-flow.ts", "protected-action-flow.ts", "story-artifact.ts", "stop-redirect-flow.ts", "everyday-cases.ts", "everyday-delivery.ts", "chat-hardening.ts", "everyday-decisions.ts", "connection-guidance-evidence.ts", "everyday-artifact.py"]) definitionHash.update(await readFile(path.join(import.meta.dirname, "..", file)));
definitionHash.update(runnerSuites.flatMap(suite => suite.tasks).find(task => task.id === "ask-question")!.buildPrompt("definition"));
const suiteDefinitionHash = definitionHash.digest("hex");
@@ -206,7 +207,9 @@ export async function launchProductJourneys(args: string[], repositoryRoot: stri
result.provider = ready.agent.provider;
result.model = ready.agent.model;
result.runtimeMode = ready.agent.runtimeMode;
if (id === "file-input-answer") await runInputFile(ready, session!, campaignId, evidence,
if (id === "routine-scheduled-run") await runScheduledRoutine(ready, session!, campaignId, evidence,
{ deadlineAt, directory: path.join(outputDir, "private-stories", id) });
else if (id === "file-input-answer") await runInputFile(ready, session!, campaignId, evidence,
{ deadlineAt, directory: path.join(outputDir, "private-stories", id) });
else if (id === "ask-question") await askQuestion(ready, session!, campaignId, evidence);
else if (id === "followup-while-running") await runActiveFollowup(ready, session!, campaignId, evidence,
@@ -0,0 +1,89 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ConnectionFailure } from "../connection-config.js";
import { createObservedReader } from "./observation-read.js";
afterEach(() => vi.useRealTimers());
describe("bounded observation reads", () => {
it("rejects a successful in-flight read that finishes after the original deadline", async () => {
vi.useFakeTimers();
const get = vi.fn(() => new Promise(resolve => setTimeout(() => resolve({ id: "late" }), 75)));
const reader = createObservedReader({ get } as any, Date.now() + 50);
const result = reader.get("/api/issues/owned").then(value => ({ value, error: undefined }), error => ({ error }));
await vi.advanceTimersByTimeAsync(75);
expect((await result).error).toMatchObject({ code: "observation_read_deadline" });
expect(get).toHaveBeenCalledTimes(1);
});
it("ends an in-flight read at the original deadline even when the transport does not settle", async () => {
vi.useFakeTimers();
const get = vi.fn(() => new Promise(() => {}));
let failure: unknown;
const result = createObservedReader({ get } as any, Date.now() + 50).get("/api/issues/owned").catch(error => { failure = error; });
await vi.advanceTimersByTimeAsync(50);
expect(failure).toMatchObject({ code: "observation_read_deadline" });
await result;
expect(get).toHaveBeenCalledTimes(1);
});
it("passes the remaining deadline to the transport without extending its normal timeout", async () => {
const get = vi.fn().mockResolvedValue({ id: "owned" });
await createObservedReader({ get }, Date.now() + 50).get("/api/issues/owned");
expect(get.mock.calls[0]?.[1]?.timeoutMs).toBeGreaterThan(0);
expect(get.mock.calls[0]?.[1]?.timeoutMs).toBeLessThanOrEqual(50);
await createObservedReader({ get }, Date.now() + 100_000).get("/api/issues/owned");
expect(get.mock.calls[1]?.[1]).toEqual({ timeoutMs: 30_000 });
});
it.each(["api_request_failed", "api_status_408", "api_status_429", "api_status_502", "api_status_503", "api_status_504"])("retains and retries %s without replaying work", async code => {
vi.useFakeTimers();
const get = vi.fn().mockRejectedValueOnce(new ConnectionFailure(code)).mockResolvedValue({ id: "owned" });
const reader = createObservedReader({ get }, Date.now() + 2000), result = reader.get("/api/issues/owned");
await vi.advanceTimersByTimeAsync(1000);
await expect(result).resolves.toEqual({ id: "owned" });
expect(get).toHaveBeenCalledTimes(2);
expect(reader.failures).toEqual([{ route: "/api/issues/owned", code }]);
});
it.each(["api_status_401", "api_status_403", "api_status_404", "api_status_409", "api_status_422", "api_status_500", "invalid_api_route"])("preserves %s as a failure", async code => {
const error = new ConnectionFailure(code), get = vi.fn().mockRejectedValue(error);
const reader = createObservedReader({ get }, Date.now() + 2000);
await expect(reader.get("/api/issues/owned")).rejects.toBe(error);
expect(get).toHaveBeenCalledTimes(1);
expect(reader.failures).toEqual(code === "invalid_api_route" ? [] : [{ route: "/api/issues/owned", code }]);
});
it("saves a fatal status receipt to private evidence without retrying", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "qa-observation-evidence-"));
const error = new ConnectionFailure("api_status_422"), get = vi.fn().mockRejectedValue(error);
const reader = createObservedReader({ get }, Date.now() + 2000);
try {
await expect(reader.get("/api/issues/owned/documents/result")).rejects.toBe(error);
await reader.preserve(directory);
expect(JSON.parse(await readFile(path.join(directory, "observation-read-failures.json"), "utf8"))).toEqual({
failures: [{ route: "/api/issues/owned/documents/result", code: "api_status_422" }],
});
expect(get).toHaveBeenCalledTimes(1);
} finally { await rm(directory, { recursive: true, force: true }); }
});
it("excludes arbitrary error text from the closed receipt", async () => {
const error = new ConnectionFailure("api_status_422 body contains private values"), get = vi.fn().mockRejectedValue(error);
const reader = createObservedReader({ get }, Date.now() + 2000);
await expect(reader.get("/api/issues/owned")).rejects.toBe(error);
expect(reader.failures).toEqual([]);
expect(get).toHaveBeenCalledTimes(1);
});
it("keeps a shorter phase deadline when that phase begins", async () => {
vi.useFakeTimers(); let deadlineAt = Date.now() + 100;
const get = vi.fn().mockRejectedValue(new ConnectionFailure("api_status_504"));
const reader = createObservedReader({ get }, () => deadlineAt), result = reader.get("/api/issues/owned");
const assertion = expect(result).rejects.toMatchObject({ code: "observation_read_deadline" });
await vi.advanceTimersByTimeAsync(100); await assertion;
expect(get).toHaveBeenCalledTimes(1);
});
it("fails at the original deadline instead of turning missing observations into a pass", async () => {
vi.useFakeTimers();
const get = vi.fn().mockRejectedValue(new ConnectionFailure("api_status_503"));
const reader = createObservedReader({ get }, Date.now() + 500), result = reader.get("/api/issues/owned");
const assertion = expect(result).rejects.toMatchObject({ code: "observation_read_deadline" });
await vi.advanceTimersByTimeAsync(500); await assertion;
expect(get).toHaveBeenCalledTimes(1); expect(reader.failures).toHaveLength(1);
});
});
@@ -0,0 +1,44 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { ConnectionFailure } from "../connection-config.js";
import type { ConnectionApi } from "../connection-target.js";
import { JourneyError } from "./types.js";
/** Retry only read transport failures within the original journey deadline.
* Never replay user input, native work, or a mutating API call. */
export function createObservedReader(api: Pick<ConnectionApi, "get">, deadlineAt: number | (() => number)) {
const deadline = () => typeof deadlineAt === "function" ? deadlineAt() : deadlineAt;
const failures: Array<{ route: string; code: string }> = [];
return {
failures,
async get<T = any>(route: string): Promise<T> {
while (Date.now() < deadline()) {
const remaining = deadline() - Date.now();
if (remaining < 1) break;
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const expired = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new JourneyError("failed", "observation_read_deadline")), remaining);
});
const value = await Promise.race([api.get<T>(route, { timeoutMs: Math.min(30_000, remaining) }), expired]);
if (Date.now() >= deadline()) throw new JourneyError("failed", "observation_read_deadline");
return value;
}
catch (error) {
if (!(error instanceof ConnectionFailure)) throw error;
const code = error.message;
if (code === "api_request_failed" || /^api_status_[1-5][0-9]{2}$/.test(code)) failures.push({ route, code });
if (!(code === "api_request_failed" || /^api_status_(408|429|502|503|504)$/.test(code))) throw error;
const remaining = deadline() - Date.now();
if (remaining > 0) await new Promise(resolve => setTimeout(resolve, Math.min(1000, remaining)));
}
finally { clearTimeout(timer); }
}
throw new JourneyError("failed", "observation_read_deadline");
},
async preserve(directory: string) {
await mkdir(directory, { recursive: true, mode: 0o700 });
await writeFile(path.join(directory, "observation-read-failures.json"), JSON.stringify({ failures }, null, 2), { mode: 0o600 });
},
};
}
@@ -1,5 +1,5 @@
import { expect } from "@playwright/test";
import { createTaskThroughUi } from "../user-actions.js";
import { createTaskThroughUi, withBrowserEvent } from "../user-actions.js";
import { projectContextEvidencePasses, type ProjectContextObservation } from "./project-context-evidence.js";
import { openProjectTaskThroughList, reopenProjectThroughList } from "./project-context-readback.js";
import { JourneyError, type JourneyContext, type JourneyEvidence, type TargetSession } from "./types.js";
@@ -15,10 +15,10 @@ export async function projectTaskContext(context: JourneyContext, session: Targe
const dialog = page.getByRole("dialog");
await dialog.getByRole("textbox", { name: "Project name", exact: true }).fill(projectName);
await session.own({ kind: "project", name: projectName, journeyId });
const responsePromise = page.waitForResponse(response => response.request().method() === "POST"
&& new URL(response.url()).pathname === `/api/companies/${company.id}/projects`);
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
const response = await responsePromise;
const response = await withBrowserEvent(
() => page.waitForResponse(response => response.request().method() === "POST"
&& new URL(response.url()).pathname === `/api/companies/${company.id}/projects`),
() => dialog.getByRole("button", { name: "Create project", exact: true }).click());
if (response.status() !== 201) throw new JourneyError("failed", "project_ui_creation_failed");
const createdProject = await response.json();
if (typeof createdProject.id !== "string" || !createdProject.id || createdProject.companyId !== company.id || createdProject.name !== projectName) {
@@ -0,0 +1,141 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { createHash } from "node:crypto";
import { afterEach, expect, it, vi } from "vitest";
import { runScheduledRoutine } from "./routine-schedule-flow.js";
import type { JourneyContext, JourneyEvidence, TargetSession } from "./types.js";
// Browser rendering is covered by the real Routines component tests. This entry
// control keeps the API reads and the production attribution grade intact.
vi.mock("@playwright/test", () => ({ expect: Object.assign(() => ({ toBeVisible: async () => {} }), {
poll: (load: () => Promise<unknown>) => ({ toBe: async (value: unknown) => expect(await load()).toBe(value) }),
}) }));
type Row = Record<string, any>;
const directories: string[] = [];
afterEach(async () => { await Promise.all(directories.splice(0).map(directory => rm(directory, { recursive: true, force: true }))); });
async function fixture(fault: "body" | "document-author" | "revision-run" | "creation-description" | "creation-project" | null = null, disableFailure = false) {
const campaignId = "journey-1-campaign", expectedBody = `scheduled-${campaignId}\nSchedule check complete.`;
const agent = { id: "agent", name: "Owned QA agent", provider: "opencode", model: "configured-model", runtimeMode: "native" };
const project = { id: "project", companyId: "company", name: `Journey QA routine project ${campaignId}` };
const trigger = { id: "trigger", companyId: "company", routineId: "routine", kind: "schedule", cronExpression: "* * * * *",
timezone: "America/Chicago", enabled: false, updatedAt: new Date(60_000).toISOString() };
const invocation = { id: "invocation", companyId: "company", routineId: "routine", triggerId: "trigger", source: "schedule",
status: "completed", linkedIssueId: "task", routineRevisionId: "routine-revision", triggeredAt: new Date(61_000).toISOString() };
const task = { id: "task", identifier: "QA-1", companyId: "company", projectId: "project", assigneeAgentId: agent.id,
status: "done", originKind: "routine_execution", originId: "routine", originRunId: "invocation" };
const run = { id: "native-run", companyId: "company", issueId: task.id, agentId: agent.id, status: "succeeded", runtimeMode: "native",
contextSnapshot: { paperclipEnvironment: { id: "environment" } },
runnerProfileJson: { nativeExecutionInput: { provider: { kind: agent.provider, model: agent.model } } } };
const body = fault === "body" ? expectedBody.slice(0, -1) : expectedBody;
const document = { id: "document", companyId: "company", issueId: task.id, latestRevisionId: "revision", body,
createdByAgentId: fault === "document-author" ? "foreign-agent" : agent.id, createdByUserId: null };
const revision = { id: "revision", documentId: document.id, body, createdByAgentId: agent.id,
createdByRunId: fault === "revision-run" ? "foreign-run" : run.id };
const reads: string[] = [];
let instructions = "", pastes = 0, triggerCreated = false, responseResolver: ((value: unknown) => void) | undefined;
const routine = () => ({ id: "routine", companyId: "company", title: `Journey QA scheduled routine ${campaignId}`,
projectId: fault === "creation-project" ? "foreign-project" : project.id, assigneeAgentId: agent.id,
createdByUserId: "user", createdByAgentId: null,
description: fault === "creation-description" ? instructions.replaceAll("`", "\\`") : instructions,
concurrencyPolicy: "always_enqueue", triggers: triggerCreated ? [trigger] : [] });
const locator = (name = ""): any => ({ first() { return this; }, getByRole: (_role: string, options: { name?: string } = {}) => locator(options.name),
getByPlaceholder: () => locator(), locator: () => ({
fill: async (value: string) => { instructions = value; }, focus: async () => {},
// The real MDX browser discriminator covers serialization. Keep this unit
// editor boundary separate from the production API reads and grade.
evaluate: async (_action: unknown, text?: string) => {
if (text === undefined) return false;
pastes += 1; instructions = text;
},
}),
fill: async () => {}, click: async () => {
if (name === "Enable trigger") { trigger.enabled = true; responseResolver?.({ status: () => 200, json: async () => ({ ...trigger }) }); }
if (name === "Pause trigger") trigger.enabled = false;
if (name === "Create routine") responseResolver?.({ status: () => 201, json: async () => routine() });
} });
const api = { origin: "https://qa.example.test", async post(route: string) {
if (route === "/api/companies/company/projects") return project;
expect(route).toBe("/api/routines/routine/triggers"); triggerCreated = true; return { trigger: { ...trigger } };
}, async patch(route: string, input: Row) {
if (route === "/api/routine-triggers/trigger") {
if (disableFailure) throw new Error("Private transport error must not replace attribution");
trigger.enabled = input.enabled;
}
},
async get(route: string) {
reads.push(route);
if (route === "/api/routines/routine") return routine();
if (route === "/api/routines/routine/runs") return [invocation];
if (route === "/api/issues/task") return task;
if (route === "/api/issues/task/runs") return [{ runId: run.id }];
if (route === "/api/heartbeat-runs/native-run") return run;
if (route === "/api/issues/task/comments?order=asc" || route === "/api/issues/task/interactions") return [];
if (route === "/api/issues/task/documents/scheduled-summary") return document;
if (route === "/api/issues/task/documents/scheduled-summary/revisions/revision") return revision;
throw new Error(`Unexpected fixture read ${route}`);
} };
const page = { getByRole: (_role: string, options: { name?: string } = {}) => locator(options.name), getByPlaceholder: () => locator(),
async goto(url: string) { if (url.endsWith("/routines/routine/runs")) throw new Error("Stop the positive control before the real pause window"); },
waitForResponse: () => new Promise(resolve => { responseResolver = resolve; }) };
const context = { api, page, company: { id: "company", issuePrefix: "QA" }, user: { id: "user" }, agent, environmentId: "environment" } as unknown as JourneyContext;
const directory = await mkdtemp(path.join(os.tmpdir(), "routine-attribution-")); directories.push(directory);
const evidence = { checks: [] } as unknown as JourneyEvidence;
return { expectedBody, body, instructions: () => instructions, pastes: () => pastes, evidence, trigger, directory, reads,
run: () => runScheduledRoutine(context, { async own() {} } as unknown as TargetSession, campaignId, evidence,
{ directory, deadlineAt: Date.now() + 10_000 }) };
}
it.each(["creation-description", "creation-project"] as const)("retains closed %s evidence before the unchanged creation failure", async fault => {
const f = await fixture(fault);
await expect(f.run()).rejects.toMatchObject({ code: "routine_browser_created" });
const receipt = JSON.parse(await readFile(path.join(f.directory, "scheduled-routine-creation.json"), "utf8"));
expect(receipt.matches).toEqual({ company: true, title: true, project: fault !== "creation-project", assignee: true,
userCreator: true, agentCreatorNull: true, description: fault !== "creation-description", concurrency: true, zeroTriggers: true });
expect(receipt.expectedDescription.sha256).toBe(createHash("sha256").update(f.instructions()).digest("hex"));
expect(receipt.savedDescription.backslashCount).toBe(fault === "creation-description" ? 6 : 0);
expect(JSON.stringify(receipt)).not.toContain("Schedule check complete");
expect(f.evidence.checks).toEqual([{ id: "routine-browser-created", passed: false }]);
expect(f.reads.some(route => route.includes("heartbeat-runs"))).toBe(false);
});
it.each(["body", "document-author", "revision-run"] as const)("retains closed %s attribution evidence before preserving the failed grade", async fault => {
const f = await fixture(fault);
await expect(f.run()).rejects.toMatchObject({ code: "routine_result_attributed" });
const receipt = JSON.parse(await readFile(path.join(f.directory, "scheduled-routine-result.json"), "utf8"));
expect(receipt.matches).toMatchObject({ documentExactBody: fault !== "body", documentAuthor: fault !== "document-author", revisionRun: fault !== "revision-run" });
expect(Object.values(receipt.matches).every(Boolean)).toBe(false);
expect(receipt.documentBody.sha256).toBe(createHash("sha256").update(f.body).digest("hex"));
expect(receipt.revision.createdByRunId).toBe(fault === "revision-run" ? "foreign-run" : "native-run");
expect(JSON.stringify(receipt)).not.toContain("Schedule check complete");
expect(f.evidence.checks.at(-1)).toEqual({ id: "routine-result-attributed", passed: false });
expect(f.trigger.enabled).toBe(false);
});
it("keeps the real journey attribution failure when final trigger disabling also fails", async () => {
const f = await fixture("body", true);
await expect(f.run()).rejects.toMatchObject({ code: "routine_result_attributed" });
expect(f.evidence.checks.at(-1)).toEqual({ id: "routine-result-attributed", passed: false });
const receipt = JSON.parse(await readFile(path.join(f.directory, "scheduled-routine-cleanup-failure.json"), "utf8"));
expect(receipt).toMatchObject({ code: "routine_trigger_disable_failed", primaryFailurePreserved: true });
expect(JSON.stringify(receipt)).not.toContain("Private transport");
});
it("sends the unchanged exact two-line body as a delimited instruction and accepts valid attribution", async () => {
const f = await fixture();
await expect(f.run()).rejects.toMatchObject({ code: "routine_open_history_failed" });
const block = f.instructions().match(/```text\n([\s\S]*?)\n```/);
expect(block?.[1]).toBe(f.expectedBody);
expect(f.instructions()).toContain("including the final period");
expect(f.instructions()).toContain("document body.\n\n```text\n");
expect(f.instructions()).toContain("\n```\n\nThen finish the task.");
expect(f.pastes()).toBe(1);
const creation = JSON.parse(await readFile(path.join(f.directory, "scheduled-routine-creation.json"), "utf8"));
expect(Object.values(creation.matches).every(Boolean)).toBe(true);
expect(creation.savedDescription.sha256).toBe(createHash("sha256").update(f.instructions()).digest("hex"));
const receipt = JSON.parse(await readFile(path.join(f.directory, "scheduled-routine-result.json"), "utf8"));
expect(Object.values(receipt.matches).every(Boolean)).toBe(true);
expect(f.evidence.checks.at(-1)).toEqual({ id: "routine-result-attributed", passed: true });
expect(f.trigger.enabled).toBe(false);
});
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { scheduledInvocationMatches, schedulePauseMatches } from "./routine-schedule-evidence.js";
const expected = { companyId: "company", routineId: "routine", triggerId: "trigger", projectId: "project", agentId: "agent", enabledAt: 60_000 };
const run = { id: "run", companyId: "company", routineId: "routine", triggerId: "trigger", source: "schedule", status: "completed",
linkedIssueId: "issue", routineRevisionId: "revision", triggeredAt: new Date(61_000).toISOString() };
const task = { id: "issue", companyId: "company", projectId: "project", assigneeAgentId: "agent", originKind: "routine_execution",
originId: "routine", originRunId: "run", status: "done" };
describe("scheduled routine evidence", () => {
it("accepts an actual scheduled invocation bound to its execution task", () => expect(scheduledInvocationMatches([run], task, expected)).toBe(true));
it("rejects manual, stale, missing, duplicate and foreign invocation evidence", () => {
for (const patch of [{ source: "manual" }, { source: "api" }, { companyId: "foreign" }, { routineId: "foreign" },
{ triggerId: "foreign" }, { routineRevisionId: null }, { triggeredAt: new Date(59_000).toISOString() }, { linkedIssueId: "foreign" }]) {
expect(scheduledInvocationMatches([{ ...run, ...patch }], task, expected)).toBe(false);
}
expect(scheduledInvocationMatches([], task, expected)).toBe(false);
expect(scheduledInvocationMatches([run, run], task, expected)).toBe(false);
for (const patch of [{ companyId: "foreign" }, { projectId: "foreign" }, { assigneeAgentId: "foreign" },
{ originKind: "manual" }, { originId: "foreign" }, { originRunId: "foreign" }]) {
expect(scheduledInvocationMatches([run], { ...task, ...patch }, expected)).toBe(false);
}
});
it("requires the paused trigger to span the next complete scheduler boundary after work", () => {
const good = { enabled: false, pausedAt: 65_000, completedAt: 130_000, observedUntil: 195_000, first: [run], final: [run] };
expect(schedulePauseMatches(good)).toBe(true);
for (const patch of [{ enabled: true }, { enabled: undefined }, { observedUntil: 194_999 }, { observedUntil: 185_000 }, { final: [] },
{ final: [run, run] }, { final: [{ ...run, id: "new" }] }, { final: [{ ...run, status: "issue_created" }] }]) {
expect(schedulePauseMatches({ ...good, ...patch })).toBe(false);
}
});
});
@@ -0,0 +1,36 @@
export interface ScheduledInvocation {
id: string; companyId: string; routineId: string; triggerId: string | null; source: string;
status: string; linkedIssueId: string | null; routineRevisionId?: string | null; triggeredAt: string;
}
export interface ScheduledTask {
id: string; companyId: string; projectId: string; assigneeAgentId: string;
originKind: string; originId: string; originRunId: string; status: string;
}
export interface ScheduledExpectation {
companyId: string; routineId: string; triggerId: string; projectId: string; agentId: string; enabledAt: number;
}
/** Manual Run now and an unrelated successful task are not scheduler evidence. */
export function scheduledInvocationMatches(rows: ScheduledInvocation[], task: ScheduledTask, expected: ScheduledExpectation) {
if (!Array.isArray(rows) || rows.length !== 1) return false;
const run = rows[0]!;
return typeof run.id === "string" && run.id.length > 0 && run.companyId === expected.companyId
&& run.routineId === expected.routineId && run.triggerId === expected.triggerId && run.source === "schedule"
&& typeof run.routineRevisionId === "string" && run.routineRevisionId.length > 0
&& Number.isFinite(Date.parse(run.triggeredAt)) && Date.parse(run.triggeredAt) >= expected.enabledAt
&& run.linkedIssueId === task.id && task.companyId === expected.companyId && task.projectId === expected.projectId
&& task.assigneeAgentId === expected.agentId && task.originKind === "routine_execution"
&& task.originId === expected.routineId && task.originRunId === run.id;
}
/** Monotonic elapsed time covers a full server minute regardless of clock offset. */
export const schedulePauseObservationMs = 65_000;
export function schedulePauseMatches(input: {
enabled: unknown; pausedAt: number; completedAt: number; observedUntil: number;
first: ScheduledInvocation[]; final: ScheduledInvocation[];
}) {
const elapsed = input.observedUntil - Math.max(input.pausedAt, input.completedAt);
return input.enabled === false && Number.isFinite(input.pausedAt) && Number.isFinite(input.completedAt)
&& Number.isFinite(input.observedUntil) && elapsed >= schedulePauseObservationMs && input.first.length === 1 && input.final.length === 1
&& input.first[0]!.id === input.final[0]!.id && input.final[0]!.status === "completed";
}
@@ -0,0 +1,73 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { enableRoutineScheduleTrigger, routineUiStep, withRoutineTriggerCleanup } from "./routine-schedule-flow.js";
import { JourneyError } from "./types.js";
describe("routine trigger cleanup failure reporting", () => {
it.each([false, true])("retains the original grade with a secondary disable failure (primary=%s)", async primary => {
const directory = await mkdtemp(path.join(os.tmpdir(), "routine-cleanup-"));
const original = new JourneyError("failed", "routine_result_attributed");
const patch = vi.fn().mockRejectedValue(new Error("private URL and form data"));
try {
const result = withRoutineTriggerCleanup({ api: { patch }, triggerRoute: "/api/routine-triggers/owned", directory,
run: async () => { if (primary) throw original; return "completed"; } });
if (primary) await expect(result).rejects.toBe(original);
else await expect(result).rejects.toMatchObject({ outcome: "failed", code: "routine_trigger_disable_failed" });
const receipt = JSON.parse(await readFile(path.join(directory, "scheduled-routine-cleanup-failure.json"), "utf8"));
expect(receipt).toMatchObject({ code: "routine_trigger_disable_failed", primaryFailurePreserved: primary });
expect(JSON.stringify(receipt)).not.toMatch(/private|form data/);
expect(patch).toHaveBeenCalledExactlyOnceWith("/api/routine-triggers/owned", { enabled: false });
} finally { await rm(directory, { recursive: true, force: true }); }
});
it("keeps the successful outcome when disabling the exact trigger succeeds", async () => {
const patch = vi.fn().mockResolvedValue({ enabled: false });
expect(await withRoutineTriggerCleanup({ api: { patch }, triggerRoute: "/api/routine-triggers/owned", directory: "/unused",
run: async () => "completed" })).toBe("completed");
expect(patch).toHaveBeenCalledExactlyOnceWith("/api/routine-triggers/owned", { enabled: false });
});
});
describe("routine UI failure evidence", () => {
it("retains a closed step code without browser messages or form values", async () => {
const error = await routineUiStep("select_project", async () => {
throw new Error("Timeout at https://private.example/?token=secret with form value private-project");
}).catch(value => value);
expect(error).toBeInstanceOf(JourneyError);
expect(error).toMatchObject({ outcome: "failed", code: "routine_select_project_failed", message: "routine_select_project_failed" });
expect(JSON.stringify(error)).not.toMatch(/private|token|secret/);
});
it("keeps a semantic journey failure instead of replacing its evidence code", async () => {
const original = new JourneyError("failed", "routine_schedule_enable_failed");
await expect(routineUiStep("enable_trigger", async () => { throw original; })).rejects.toBe(original);
});
it("returns the original successful browser response without replaying the action", async () => {
let count = 0;
const response = { status: 201 };
expect(await routineUiStep("submit", async () => { count += 1; return response; })).toBe(response);
expect(count).toBe(1);
});
});
describe("schedule trigger controls", () => {
it("opens the collapsed schedule editor before its single enable action", async () => {
let expanded = false, clicks = 0, resolveResponse!: (value: any) => void;
const response = { status: () => 200 };
const pending = new Promise<any>(resolve => { resolveResponse = resolve; });
const schedule = { getByRole: (_role: string, options: { name: string }) => ({ click: async () => {
if (options.name === "Edit schedule") { expanded = true; return; }
if (options.name !== "Enable trigger" || !expanded) throw new Error("schedule control is not rendered");
clicks += 1;
resolveResponse(response);
} }) };
const page = { getByRole: (role: string, options: { name: string }) => {
if (role === "region" && options.name === "Schedule trigger") return schedule;
return schedule.getByRole(role, options);
}, waitForResponse: () => pending } as unknown as Parameters<typeof enableRoutineScheduleTrigger>[0];
expect(await enableRoutineScheduleTrigger(page, "/api/routine-triggers/owned-trigger")).toBe(response);
expect(expanded).toBe(true);
expect(clicks).toBe(1);
});
});
@@ -0,0 +1,233 @@
import { createObservedReader } from "./observation-read.js";
import { createHash } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import { ConnectionFailure } from "../connection-config.js";
import { fillTaskPrompt, withBrowserEvent } from "../user-actions.js";
import { validateAgentTaskState, waitFor } from "./agent-flows.js";
import { openDocumentAuthoring } from "./task-document-flow.js";
import { scheduledInvocationMatches, schedulePauseMatches, schedulePauseObservationMs, type ScheduledInvocation, type ScheduledTask } from "./routine-schedule-evidence.js";
import { JourneyError, type JourneyContext, type JourneyEvidence, type TargetSession } from "./types.js";
type Row = Record<string, any>;
const journeyId = "routine-scheduled-run" as const;
type RoutineUiStep = "open_composer" | "select_responsible" | "select_project" | "fill_instructions" | "submit" | "enable_trigger" | "pause_trigger" | "open_history" | "reopen_result";
export async function routineUiStep<T>(step: RoutineUiStep, action: () => Promise<T>): Promise<T> {
try { return await action(); }
catch (error) {
if (error instanceof JourneyError) throw error;
// Browser failures can contain form values and URLs. Retain only this closed step code.
throw new JourneyError("failed", `routine_${step}_failed`);
}
}
export async function enableRoutineScheduleTrigger(page: Pick<Page, "getByRole" | "waitForResponse">, triggerRoute: string) {
const schedule = page.getByRole("region", { name: "Schedule trigger", exact: true });
// The enable/pause controls are children of the collapsed schedule editor.
await schedule.getByRole("button", { name: "Edit schedule", exact: true }).click();
return withBrowserEvent(
() => page.waitForResponse(response => response.request().method() === "PATCH"
&& new URL(response.url()).pathname === triggerRoute),
() => schedule.getByRole("button", { name: "Enable trigger", exact: true }).click());
}
export async function withRoutineTriggerCleanup<T>(input: {
api: Pick<JourneyContext["api"], "patch">; triggerRoute: string; directory: string; run(): Promise<T>;
}): Promise<T> {
let primary: { error: unknown } | undefined;
try { return await input.run(); }
catch (error) { primary = { error }; throw error; }
finally {
try { await input.api.patch(input.triggerRoute, { enabled: false }); }
catch (error) {
const failure = new JourneyError("failed", "routine_trigger_disable_failed");
try {
await mkdir(input.directory, { recursive: true, mode: 0o700 });
await writeFile(path.join(input.directory, "scheduled-routine-cleanup-failure.json"), JSON.stringify({
schema: "paperclip.product-journey.routine-cleanup-failure/v1", code: failure.code,
triggerRoute: input.triggerRoute, primaryFailurePreserved: Boolean(primary),
transportCode: error instanceof ConnectionFailure && /^(api_request_failed|api_status_[1-5][0-9]{2})$/.test(error.message)
? error.message : null,
}, null, 2), { mode: 0o600 });
} catch {
if (primary) throw primary.error;
throw failure;
}
// The outer lifecycle still grades cleanup and reconciles the owned trigger.
if (!primary) throw failure;
}
}
}
export async function runScheduledRoutine(context: JourneyContext, session: TargetSession, campaignId: string,
evidence: JourneyEvidence, options: { deadlineAt: number; directory: string }) {
const { api, page, company, user, agent } = context;
const observations = createObservedReader(api, options.deadlineAt);
const readObserved = observations.get;
try {
if (!agent || agent.runtimeMode !== "native" || !context.environmentId) throw new JourneyError("unsupported", "native_agent_and_environment_required");
const title = `Journey QA scheduled routine ${campaignId}`, projectName = `Journey QA routine project ${campaignId}`;
const check = (id: string, passed: boolean) => {
evidence.checks.push({ id, passed });
if (!passed) throw new JourneyError("failed", id.replaceAll("-", "_"));
};
const bodyReceipt = (body: unknown) => typeof body === "string" ? { byteSize: Buffer.byteLength(body),
sha256: createHash("sha256").update(body).digest("hex"), trimmedSha256: createHash("sha256").update(body.trim()).digest("hex"),
lineFeedCount: body.split("\n").length - 1, backslashCount: body.split("\\").length - 1 } : null;
await session.own({ kind: "project", name: projectName, journeyId });
const project = await api.post<Row>(`/api/companies/${company.id}/projects`, { name: projectName, status: "planned" });
await session.own({ kind: "project", name: projectName, id: project.id, journeyId });
const key = "scheduled-summary", marker = `scheduled-${campaignId}`;
const expectedBody = `${marker}\nSchedule check complete.`;
const prompt = `Prepare the scheduled status check. Save a task document with key ${key} and title Scheduled check. `
+ "Copy exactly the two lines inside this block, including the final period. Do not include the fences in the document body.\n\n"
+ `\`\`\`text\n${expectedBody}\n\`\`\`\n\n`
+ "Then finish the task. Do not create other tasks or agents.";
await routineUiStep("open_composer", async () => {
await page.goto(`${api.origin}/${company.issuePrefix}/routines`, { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "Create routine", exact: true }).first().click();
});
const dialog = page.getByRole("dialog");
await routineUiStep("select_responsible", async () => {
await dialog.getByPlaceholder("Routine title", { exact: true }).fill(title);
await dialog.getByRole("button", { name: "Responsible", exact: true }).click();
await page.getByPlaceholder("Search responsible...", { exact: true }).fill(agent.name);
await page.getByRole("button", { name: agent.name, exact: true }).click();
});
await routineUiStep("select_project", async () => {
// Selecting Responsible focuses Project and opens its picker. Clicking
// its trigger again closes the picker before the search field can fill.
const projectSearch = page.getByPlaceholder("Search projects...", { exact: true });
await expect(projectSearch).toBeVisible();
await projectSearch.fill(projectName);
await page.getByRole("button", { name: projectName, exact: true }).click();
});
await routineUiStep("fill_instructions", () => fillTaskPrompt(dialog.locator('[contenteditable="true"]'), prompt));
await session.own({ kind: "routine", name: title, journeyId });
const response = await routineUiStep("submit", () => withBrowserEvent(
() => page.waitForResponse(response => response.request().method() === "POST"
&& new URL(response.url()).pathname === `/api/companies/${company.id}/routines`),
() => dialog.getByRole("button", { name: "Create routine", exact: true }).click()));
if (response.status() !== 201) throw new JourneyError("failed", "routine_ui_creation_failed");
const routine = await response.json();
if (typeof routine.id !== "string") throw new JourneyError("failed", "routine_creation_receipt_missing");
await session.own({ kind: "routine", name: title, id: routine.id, journeyId });
const route = `/api/routines/${routine.id}`;
// Fixture policy makes duplicate scheduler work observable instead of coalescing it.
await api.patch(route, { concurrencyPolicy: "always_enqueue" });
const saved = await readObserved<Row>(route);
const creationMatches = { company: saved.companyId === company.id, title: saved.title === title,
project: saved.projectId === project.id, assignee: saved.assigneeAgentId === agent.id,
userCreator: saved.createdByUserId === user.id, agentCreatorNull: saved.createdByAgentId === null,
description: saved.description === prompt, concurrency: saved.concurrencyPolicy === "always_enqueue", zeroTriggers: saved.triggers.length === 0 };
await mkdir(options.directory, { recursive: true, mode: 0o700 });
await writeFile(path.join(options.directory, "scheduled-routine-creation.json"), JSON.stringify({
schema: "paperclip.product-journey.routine-creation/v1", matches: creationMatches,
expectedDescription: bodyReceipt(prompt), savedDescription: bodyReceipt(saved.description),
expected: { companyId: company.id, titleSha256: bodyReceipt(title)!.sha256, projectId: project.id, agentId: agent.id, userId: user.id },
saved: { id: saved.id, companyId: saved.companyId, titleSha256: bodyReceipt(saved.title)?.sha256,
projectId: saved.projectId, assigneeAgentId: saved.assigneeAgentId, createdByUserId: saved.createdByUserId,
createdByAgentId: saved.createdByAgentId, concurrencyPolicy: saved.concurrencyPolicy, triggerCount: saved.triggers.length,
createdAt: saved.createdAt, updatedAt: saved.updatedAt },
}, null, 2), { mode: 0o600 });
check("routine-browser-created", Object.values(creationMatches).every(Boolean));
const createdTrigger = await api.post<Row>(`${route}/triggers`, { kind: "schedule", label: `QA schedule ${campaignId}`,
cronExpression: "* * * * *", timezone: "America/Chicago", enabled: false });
const trigger = createdTrigger.trigger;
if (!trigger?.id || trigger.companyId !== company.id || trigger.routineId !== routine.id || trigger.kind !== "schedule"
|| trigger.cronExpression !== "* * * * *" || trigger.timezone !== "America/Chicago" || trigger.enabled !== false) {
throw new JourneyError("failed", "routine_schedule_fixture_mismatch");
}
const triggerRoute = `/api/routine-triggers/${trigger.id}`;
const readTrigger = async () => {
const detail = await readObserved<Row>(route);
const matching = detail.triggers?.filter((row: Row) => row.id === trigger.id);
if (!Array.isArray(matching) || matching.length !== 1 || matching[0].companyId !== company.id || matching[0].routineId !== routine.id) {
throw new JourneyError("failed", "routine_trigger_readback_mismatch");
}
return matching[0] as Row;
};
const runs = () => readObserved<ScheduledInvocation[]>(`${route}/runs`);
let pausedAt = 0;
await withRoutineTriggerCleanup({ api, triggerRoute, directory: options.directory, run: async () => {
const enabledResponse = await routineUiStep("enable_trigger", async () => {
await page.goto(`${api.origin}/${company.issuePrefix}/routines/${routine.id}/triggers`, { waitUntil: "domcontentloaded" });
return enableRoutineScheduleTrigger(page, triggerRoute);
});
if (enabledResponse.status() !== 200) throw new JourneyError("failed", "routine_schedule_enable_failed");
const enabledTrigger = await enabledResponse.json();
const enabledAt = Date.parse(enabledTrigger?.updatedAt);
if (enabledTrigger?.id !== trigger.id || enabledTrigger.companyId !== company.id || enabledTrigger.routineId !== routine.id
|| enabledTrigger.enabled !== true || !Number.isFinite(enabledAt)) throw new JourneyError("failed", "routine_schedule_enable_receipt_missing");
await expect.poll(async () => (await readTrigger()).enabled).toBe(true);
const first = await waitFor(options.deadlineAt, runs, rows => rows.length > 0 && Boolean(rows[0]?.linkedIssueId), "scheduled_invocation_missing");
await routineUiStep("pause_trigger", () => page.getByRole("button", { name: "Pause trigger", exact: true }).click());
await expect.poll(async () => (await readTrigger()).enabled).toBe(false);
pausedAt = performance.now();
const task = await readObserved<ScheduledTask & { title: string }>(`/api/issues/${first[0]!.linkedIssueId}`);
await session.own({ kind: "issue", name: task.title, id: task.id, journeyId });
const expected = { companyId: company.id, routineId: routine.id, triggerId: trigger.id, projectId: project.id, agentId: agent.id, enabledAt };
check("routine-scheduler-created-execution", scheduledInvocationMatches(first, task, expected));
const taskRoute = `/api/issues/${task.id}`;
const state = await waitFor(options.deadlineAt, async () => {
const [currentIssue, summaries, comments, interactions] = await Promise.all([
readObserved<Row & { id: string; status: string }>(taskRoute), readObserved<Row[]>(`${taskRoute}/runs`), readObserved<Row[]>(`${taskRoute}/comments?order=asc`),
readObserved<Array<Row & { id: string; status: string }>>(`${taskRoute}/interactions`),
]);
const taskRuns = await Promise.all(summaries.map(row => readObserved<Row & { status: string }>(`/api/heartbeat-runs/${row.runId}`)));
const value = { currentIssue, taskRuns, comments, interactions, attachments: [] };
validateAgentTaskState(context, task.id, value, 1);
return value;
}, value => value.currentIssue.status === "done" && value.taskRuns.length === 1 && value.taskRuns[0]!.status === "succeeded"
&& value.interactions.every(row => row.status !== "pending"), "routine_task_not_completed");
const completedAt = performance.now();
const document = await readObserved<Row>(`${taskRoute}/documents/${key}`);
const revision = await readObserved<Row>(`${taskRoute}/documents/${key}/revisions/${document.latestRevisionId}`);
const matches = { documentCompany: document.companyId === company.id, documentIssue: document.issueId === task.id,
documentExactBody: document.body?.trim() === expectedBody, documentAuthor: document.createdByAgentId === agent.id,
documentUserNull: document.createdByUserId === null, revisionDocument: revision.documentId === document.id,
revisionExactBody: revision.body?.trim() === expectedBody, revisionAuthor: revision.createdByAgentId === agent.id,
revisionRun: revision.createdByRunId === state.taskRuns[0]!.id };
// A failed grade must retain its attribution inputs without the prompt or output body.
await mkdir(options.directory, { recursive: true, mode: 0o700 });
await writeFile(path.join(options.directory, "scheduled-routine-result.json"), JSON.stringify({
schema: "paperclip.product-journey.routine-result/v1", matches, expectedBody: bodyReceipt(expectedBody),
documentBody: bodyReceipt(document.body), revisionBody: bodyReceipt(revision.body),
document: { id: document.id, companyId: document.companyId, issueId: document.issueId,
latestRevisionId: document.latestRevisionId, createdByAgentId: document.createdByAgentId,
createdByUserId: document.createdByUserId, createdAt: document.createdAt, updatedAt: document.updatedAt },
revision: { id: revision.id, documentId: revision.documentId, createdByAgentId: revision.createdByAgentId,
createdByRunId: revision.createdByRunId, createdAt: revision.createdAt },
run: { id: state.taskRuns[0]!.id, status: state.taskRuns[0]!.status, nativePhase: state.taskRuns[0]!.nativePhase,
agentId: state.taskRuns[0]!.agentId, companyId: state.taskRuns[0]!.companyId,
environmentId: state.taskRuns[0]!.contextSnapshot?.paperclipEnvironment?.id,
provider: state.taskRuns[0]!.runnerProfileJson?.nativeExecutionInput?.provider?.kind,
model: state.taskRuns[0]!.runnerProfileJson?.nativeExecutionInput?.provider?.model },
}, null, 2), { mode: 0o600 });
check("routine-result-attributed", Object.values(matches).every(Boolean));
if (typeof state.currentIssue.identifier !== "string" || !state.currentIssue.identifier) throw new JourneyError("failed", "routine_task_identifier_missing");
await routineUiStep("open_history", async () => {
await page.goto(`${api.origin}/${company.issuePrefix}/routines/${routine.id}/runs`, { waitUntil: "domcontentloaded" });
await page.reload();
await expect(page.getByRole("link", { name: state.currentIssue.identifier, exact: false }).first()).toBeVisible();
});
const final = await waitFor(options.deadlineAt, async () => ({ rows: await runs(), now: performance.now() }), value => {
if (value.rows.length !== 1) throw new JourneyError("failed", "routine_duplicate_invocation");
return value.rows[0]?.status === "completed" && value.now >= Math.max(pausedAt, completedAt) + schedulePauseObservationMs;
}, "routine_pause_not_observed");
check("routine-history-preserved", scheduledInvocationMatches(final.rows, await readObserved(taskRoute), expected));
check("routine-pause-prevented-next-run", schedulePauseMatches({ enabled: (await readTrigger()).enabled,
pausedAt, completedAt, observedUntil: final.now, first, final: final.rows }));
await routineUiStep("reopen_result", async () => {
await page.goto(`${api.origin}/${company.issuePrefix}/issues/${state.currentIssue.identifier}`, { waitUntil: "domcontentloaded" });
await page.reload();
await openDocumentAuthoring(context);
await expect(page.locator(`#document-${key}`)).toContainText(marker);
});
check("routine-result-reopened", (await readObserved<Row>(`${taskRoute}/documents/${key}`)).latestRevisionId === document.latestRevisionId);
check("configured-model-and-environment", true);
await mkdir(options.directory, { recursive: true, mode: 0o700 });
await writeFile(path.join(options.directory, "scheduled-routine-checks.json"), JSON.stringify({ routineId: routine.id,
triggerId: trigger.id, invocationId: first[0]!.id, taskId: task.id, runId: state.taskRuns[0]!.id, enabledServerAt: enabledAt, pausedMonotonicAt: pausedAt, completedMonotonicAt: completedAt,
observedUntilMonotonicAt: final.now, documentId: document.id, revisionId: revision.id }, null, 2), { mode: 0o600 });
} });
} finally { await observations.preserve(options.directory); }
}
@@ -1,5 +1,5 @@
/** Closed, target-independent success requirements used by dispatch and retained evidence. */
export const agentJourneyRuns = { "file-input-answer": 1, "ask-question": 1, "plan-revise-accept": 3, "build-revise": 2,
export const agentJourneyRuns = { "routine-scheduled-run": 1, "file-input-answer": 1, "ask-question": 1, "plan-revise-accept": 3, "build-revise": 2,
"followup-while-running": 2, "protected-actions": 4, "create-skill-studio": 1, "stop-redirect": 3, "question-answer-resume": 3, "hire-delegate-reuse": 7 } as const;
export function agentJourneyRunCountValid(id: string, count: unknown): boolean {
@@ -9,6 +9,7 @@ export function agentJourneyRunCountValid(id: string, count: unknown): boolean {
return (count as number) >= minimum && (count as number) <= maximum;
}
export const journeyRequiredChecks: Record<string, readonly string[]> = {
"routine-scheduled-run": ["routine-browser-created", "routine-scheduler-created-execution", "routine-result-attributed", "routine-history-preserved", "routine-pause-prevented-next-run", "routine-result-reopened", "configured-model-and-environment"],
"inbox-triage": ["inbox-external-human-activity", "inbox-read-persisted", "inbox-archive-persisted", "inbox-archive-preserved-task-status", "inbox-relevant-activity-resurfaced", "no-agent-work"],
"file-input-answer": ["input-upload-bytes-preserved", "input-content-used-correctly", "input-result-attributed-to-run", "input-result-reopened", "original-input-retained", "configured-model-and-environment"],
"task-properties": ["assignment-persisted", "no-agent-work"],
+2 -2
View File
@@ -3,7 +3,7 @@ import type { ConnectionApi } from "../connection-target.js";
export type JourneyOutcome = "passed" | "failed" | "blocked" | "unsupported";
export type JourneyId = "task-properties" | "task-create" | "project-task-context" | "task-document-persistence" | "ask-question" | "cloud-onboarding"
| "question-answer-resume" | "task-search-filter" | "followup-while-running" | "plan-revise-accept" | "build-revise" | "protected-actions" | "create-skill-studio" | "stop-redirect" | "hire-delegate-reuse" | "file-input-answer" | "inbox-triage";
| "question-answer-resume" | "task-search-filter" | "followup-while-running" | "plan-revise-accept" | "build-revise" | "protected-actions" | "create-skill-studio" | "stop-redirect" | "hire-delegate-reuse" | "file-input-answer" | "inbox-triage" | "routine-scheduled-run";
export type TargetMode = "managed-self-hosted" | "existing-cloud" | "new-cloud";
export interface DeploymentIdentity {
kind: "self-hosted" | "cloud";
@@ -112,7 +112,7 @@ export interface TargetSession {
/** Publish the observed finalization failure without releasing resources or leases. */
checkpointEvidence?(input?: TargetLifecycleInput): Promise<void>;
/** Durable intent before creation; receipt immediately after creation. */
own(resource: { kind: "issue" | "project" | "agent" | "connection" | "tool-connection" | "skill"; name: string; id?: string; journeyId?: JourneyId;
own(resource: { kind: "issue" | "project" | "agent" | "connection" | "tool-connection" | "skill" | "routine"; name: string; id?: string; journeyId?: JourneyId;
freshChat?: { companyId: string; userId: string; leadAgentId: string; absent: true } }): Promise<void>;
}
export interface TargetProvider {
+61
View File
@@ -0,0 +1,61 @@
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { setImmediate as nextTurn } from "node:timers/promises";
import { describe, expect, it } from "vitest";
import { withBrowserEvent } from "./user-actions.js";
describe("browser event ownership", () => {
it.each(["event-first", "action-first", "synchronous-action"])("handles both original failures when %s rejects", mode => {
// A separate strict Node process makes a detached rejection an actual process
// failure, matching a live launch rather than relying on a mocked listener.
const script = `
import assert from 'node:assert/strict';
import { setTimeout as delay } from 'node:timers/promises';
import { withBrowserEvent } from ${JSON.stringify(new URL("./user-actions.ts", import.meta.url).href)};
const mode = ${JSON.stringify(mode)};
const eventError = new Error('event_failure'), actionError = new Error('action_failure');
let observed = 0, acted = 0;
const event = () => { observed++; return delay(mode === 'event-first' ? 0 : 25).then(() => { throw eventError; }); };
const action = () => {
acted++;
if (mode === 'synchronous-action') throw actionError;
return delay(mode === 'action-first' ? 0 : 25).then(() => { throw actionError; });
};
await withBrowserEvent(event, action).then(() => assert.fail('must fail'), error => {
assert.equal(error, mode === 'event-first' ? eventError : actionError);
});
await delay(60);
assert.equal(observed, 1); assert.equal(acted, 1);
`;
const result = spawnSync(process.execPath, ["--unhandled-rejections=strict", "--import",
fileURLToPath(new URL("../../cli/node_modules/tsx/dist/loader.mjs", import.meta.url)), "--input-type=module", "-e", script],
{ encoding: "utf8", timeout: 10_000 });
expect(result.error).toBeUndefined();
expect(result.status, result.stderr).toBe(0);
});
it("settles a late initiating action before failure cleanup can start", async () => {
const order: string[] = [], original = new Error("event_timeout");
let actionStarted!: () => void, releaseAction!: () => void;
const started = new Promise<void>(resolve => { actionStarted = resolve; });
const held = new Promise<void>(resolve => { releaseAction = resolve; });
const completed = withBrowserEvent(async () => { await started; throw original; }, async () => {
actionStarted();
await held;
order.push("late-mutation");
}).catch(error => { order.push("cleanup"); return error; });
await started;
try {
// Let the observation rejection propagate while the action is still held.
await nextTurn();
expect(order).toEqual([]);
} finally { releaseAction(); }
expect(await completed).toBe(original);
expect(order).toEqual(["late-mutation", "cleanup"]);
});
it("returns the original event after its single initiating action completes", async () => {
const order: string[] = [], response = { status: 201 };
const result = await withBrowserEvent(async () => { order.push("observe"); return response; }, async () => { order.push("action"); });
expect(result).toBe(response);
expect(order).toEqual(["observe", "action"]);
});
});
+25 -8
View File
@@ -1,5 +1,21 @@
import { expect, type Locator, type Page } from "@playwright/test";
/** Own a browser event and its initiating action in the same error boundary. */
export async function withBrowserEvent<T>(observe: () => Promise<T>, action: () => Promise<unknown>): Promise<T> {
// Attach both rejection handlers before the action can fail or the event can
// time out. A synchronous action failure must own the pending event too.
const observed = observe(), initiated = Promise.resolve().then(action);
try {
const [event] = await Promise.all([observed, initiated]);
return event;
} catch (error) {
// A failed observation must not let a late click mutate owned resources
// after normal cleanup has begun. Keep the first failure and settle both.
await Promise.allSettled([observed, initiated]);
throw error;
}
}
/** Rich editors interpret pasted Markdown; fill inserts literal paragraph text. */
export async function fillTaskPrompt(editor: Locator, prompt: string): Promise<void> {
if (!/^ {0,3}(?:`{3,}|~{3,})/m.test(prompt)
@@ -95,14 +111,15 @@ export async function createTaskThroughUi(input: {
await input.page.getByText(input.projectName, { exact: true }).last().click();
}
if (input.attachments?.length) {
const chooser = input.page.waitForEvent("filechooser");
const upload = dialog.getByRole("button", { name: "Upload", exact: true });
if (await upload.count()) await upload.click();
else {
await dialog.getByRole("button", { name: "Add to composer", exact: true }).click();
await input.page.getByRole("menuitem", { name: /^Files and images/ }).click();
}
await (await chooser).setFiles([...input.attachments]);
const chooser = await withBrowserEvent(() => input.page.waitForEvent("filechooser"), async () => {
const upload = dialog.getByRole("button", { name: "Upload", exact: true });
if (await upload.count()) await upload.click();
else {
await dialog.getByRole("button", { name: "Add to composer", exact: true }).click();
await input.page.getByRole("menuitem", { name: /^Files and images/ }).click();
}
});
await chooser.setFiles([...input.attachments]);
// Upload finishes as part of task creation; the dialog retains the selected files.
}
// The prompt-only composer generates a provisional title which the provider
@@ -146,6 +146,22 @@ describe("TriggersSection", () => {
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();
+30 -6
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import type { AnchorHTMLAttributes, ReactNode } from "react";
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";
@@ -27,6 +27,7 @@ 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>,
@@ -261,12 +262,14 @@ vi.mock("../components/MarkdownEditor", () => ({
},
}));
vi.mock("../components/InlineEntitySelector", () => ({
InlineEntitySelector: (props: { options?: Array<{ id: string }> }) => {
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 <button type="button">selector</button>;
},
}));
return pickerMode.real ? <actual.InlineEntitySelector {...props} ref={ref} /> : <button type="button">selector</button>;
}) };
});
vi.mock("../components/RoutineRunVariablesDialog", () => ({
RoutineRunVariablesDialog: () => null,
@@ -381,6 +384,7 @@ describe("Routines page", () => {
let container: HTMLDivElement;
beforeEach(() => {
pickerMode.real = false;
container = document.createElement("div");
document.body.appendChild(container);
currentSearch = "";
@@ -970,6 +974,26 @@ describe("Routines page", () => {
});
});
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([]);