Files
fusion/packages/dashboard/app/components/__tests__/ScheduleForm.test.tsx
gsxdsm e9e63d8e0f consolidate/capacity: --strict was red on main (my #2621), 14 stale baselines, routines seeding a deleted column, worktrees-off audit (#2652)
Capacity unit consolidation. Three coherent themes, small commits
inside.

## Census before/after (`node scripts/lifecycle-column-census.mjs`)

| | before | after |
|---|---:|---:|
| triage column guards (the bar) | 10 | **10** |
| `--strict` on main | ❌ **RED** | ✅ green |
| baseline staleness | 14 files stale | **0** |

This branch does **not** move the triage bar — its remaining 10 are
moves.ts (dies with the flag), the dashboard cluster, and one deliberate
site. It fixes the instrument that measures the bar, plus a live defect
the comparison count cannot see.

---

## 1. `--strict` was RED on clean `origin/main`, and it was my fault

```
packages/dashboard/src/routes/register-task-workflow-routes.ts: 22 -> 23
```

My merged #2621 added a v1-IR pre-WIP fallback answering a greptile P1
and shipped no marker or baseline update, so the program's measuring
instrument has been failing on main since it landed.

Fixed **at the site** with a `DELIBERATE-LITERAL` marker, not by bumping
the baseline. That branch runs only when the IR declares no columns and
no nodes, so there is no role to resolve — `resolveLifecycleColumns`
returns nothing and the legacy pre-implementation ids are the only
pre-WIP signal that exists there. It is *unconvertible*, not unfinished;
the sibling `else` two lines down is the trait path for every IR that
can answer. A rise that is genuinely correct belongs where a reader will
see it.

## 2. The baseline was stale for 14 files — a hole, not cosmetics

A stale allowance lets converted guards return while the check stays
green. Measured gaps:

```
self-healing.ts          allows 126, tree has 111
executor.ts              allows 112, tree has 104
moves.ts                 allows  44, tree has  39
default-workflow-hooks   allows  25, tree has   7
mission-feature-sync     allows   5, tree has   0
MissionControlPanel      allows   4, tree has   0        (+8 more)
```

**Only two of the fourteen are mine.** The other twelve are
already-merged conversions by other workers where nobody re-recorded.
Re-recorded all fourteen here rather than waiting for twelve PRs,
because until it happens the ratchet is not holding the 779 it exists to
hold. Flagging it plainly: those drops are other people's work being
locked in, not mine being claimed.

## 3. Routines created tasks into the column U11 deleted

The routine editor's "Target Column" defaulted to `triage`. That value
is submitted as the create step's `taskColumn`, and an **explicit**
column bypasses the workflow entry-column resolution added for
column-less creates (#2589) — so every routine saved with the untouched
default seeded its tasks into a column the board does not declare.

Defaulting to `todo` would be the same mistake one column over: a custom
workflow declaring no `todo` is seeded into an undeclared column just as
surely, because an explicit column overrides entry resolution whatever
its value. So the default sends **nothing** and each workflow's own
intake resolution decides.

The `triage` **option** is removed too, not merely un-defaulted — fixing
the initializer alone left the operator able to pick the deleted column
one click away, and it was the option labelled "Planning", the name the
merged `todo` column now displays. Removing it retires that label
inversion as well.

Found by scanning **membership** forms rather than comparisons: the
comparison census cannot see a `?? "triage"` default, so no count showed
this and nobody was looking. Revert-proof — restoring the default fails
with *"the default must not name a column at all"*.

## 4. "Worktrees off is INERT" had one unaudited reader

The constraint was that `maxWorktrees` become genuinely inert, "not set
very high and not skipped by convention". `resolveWorktreeCapacityLimit`
returns `null` for that, and its unit tests can only prove the
**resolver** is right — they cannot see a second reader, which is the
only way the constraint breaks.

Audited every `maxWorktrees` read that bounds anything. **Exactly two:**
`scheduler.ts` (the admission gate, via the resolver, single call site,
optional gate snapshot) and `self-healing.ts`'s `enforceWorktreeCap` —
`(settings.maxWorktrees ?? 4) * 2`, a **raw** read.

The second is **not a bug** and is left alone: it bounds worktree
*directories on disk* and only removes *idle* ones. Worktrees still
exist in OFF mode, so that bound must keep applying or idle directories
accumulate unbounded. Recorded consequence: in OFF mode the number still
governs disk retention while gating no admission — an edge you scoped
out. The note says explicitly **not** to unify the two readers: routing
hygiene through the resolver returns `null` in OFF mode and silently
removes the disk bound, which is a leak dressed as a simplification.

New ratchet requires every file bounding on `maxWorktrees` to be named
with a reason, and rejects a **stale** allowlist entry. Proven by
injecting `active >= (settings.maxWorktrees ?? 4)` into
`hybrid-executor.ts`.

---

## Deliberately NOT included

- **My own census script.** #2633 landed the canonical one, and it is
better than mine — an AST classifier *plus* an independent text
classifier with `--compare`, and a baseline that fails on unrecorded
**drops** as well as rises. Mine only caught rises. I deleted mine
rather than ship a second measuring instrument; three copies of "strip
comments" is the drift shape this program keeps paying for, so the
worktree ratchet now imports #2633's `stripComments`.
- **My TaskContextMenu fix.** Superseded, and by a better answer: main's
`isPureIntakeColumn` (intake *without* hold) keeps the merged Planning
column shown and suppresses only a bare Ideas capture, which resolves
the exact hold-lane objection coderabbit raised against my version. I
briefly clobbered that merged work by checking my old file out
wholesale, caught it in the diff, and reverted.

## Verification

`pnpm lint` clean · core + dashboard `tsc` clean · census suite 23/23 ·
worktree ratchet 8/8 · RoutineEditor 49/49 ·
`routes-task-retry-planning-column` 16/16 · `lifecycle-column-census
--strict` exits 0.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---

## Added after review (all four greptile threads were real, and two of
them mattered)

**The routine fix was half a fix.** `routine-runner.ts:515` *and*
`cron-runner.ts:982` both did `column: (step.taskColumn as Column) ||
"triage"` **after** the step is read, so every routine — including ones
saved through the fixed editor — still created tasks into the deleted
column. Both now omit it.

**The advanced steps editor MANUFACTURED the defect.**
`ScheduleStepsEditor.tsx` had three `triage` defaults: the new-step
template (`:64`), the per-step initializer (`:95`), and the select still
offering it (`:344`). So the path I had *not* fixed produced the bug by
default, on fresh data. Template names no column; initializer coerces a
persisted `triage`; `triage` removed from the options; empty submits
`undefined`.

**Four pre-existing tests pinned the defect** and are rewritten to the
corrected invariant rather than appeased:

| test | asserted |
|---|---|
| `cron-runner`: "defaults column to triage when taskColumn is not set"
| `column: "triage"` |
| `ScheduleStepsEditor`: "adds a create-task step..." | `taskColumn`
toBe `"triage"` |
| `ScheduleStepsEditor`: "allows saving create-task step..." | the
legacy column is **resubmitted** |
| plus the explicit-column case added beside each, so the fix cannot
swallow a deliberate choice |

**The allowlist hole was the worst finding.** `AUDITED_BOUNDS` was keyed
by FILE, so every bounding expression in an allowlisted file was exempt
— a second raw bound in `scheduler.ts` stayed green, the one case that
ratchet exists for. Per-expression now, and making it so **immediately
surfaced a real second bound the file-level version was hiding**
(`maxWorktreesGate.used >= maxWorktreesGate.limit`, safe by construction
since the snapshot is `undefined` in OFF mode). Proven by injection.

## Found while re-reading my own deletion, not reported

A **rendered tooltip** still named a deleted cap. The "Queued to plan"
badge read *"planning starts when a concurrency slot frees up
(maxConcurrent / globalMaxConcurrent)"*. The cross-project cap is gone —
capacity is two numbers per project — so it told operators their
planning waited on a limiter they can no longer find a setting for.
Names the surviving dimension only now.

## Coding (Ideas): enforcing #2651 rather than repeating it

I took the unowned coding-ideas IR merge, concluded it must not be done,
then found **#2651 had already implemented, reverted and documented
exactly that** — with better grounding than my own argument. It added no
test, so nothing stops the next person reaching the same dead end.

So this ships their reasoning as a ratchet, not a second opinion: triage
discovery keys on the column's `autoTriage`, so a merged column is
either never scanned (cards sit on a bootstrap stub until the **capacity
hold** releases them, sending **unplanned** work into in-progress —
worse than stalling) or scanning wins and the manual gate is gone. Their
scope caveat is kept: `autoTriage` is a general trait field, so only
*this preset's* collapse is dead, not manual intake as a concept. The
registry does not reject the merged shape, which is why prose was not
enough.

## Verification (re-run)

`pnpm lint` clean · core + engine + dashboard-app `tsc` clean ·
`lifecycle-column-census --strict` exits 0 ("every file matches its
baseline exactly") · routine-runner 24/24 · cron-runner 156/156 ·
ScheduleStepsEditor 41/41 · RoutineEditor 49/49 · worktree +
coding-ideas 12/12. TaskCard has 2 failures **pre-existing on main** —
confirmed identical with my changes stashed.

---

## Bears directly on the closing bar: this PR already removes the
67-guard ratchet slack

Measured on current `origin/main` with the census itself:

```
tree total: 787   baseline total: 854   SLACK: 67

FILES ABOVE BASELINE (1):
   +1  packages/dashboard/src/routes/register-task-workflow-routes.ts  (22 -> 23)

FILES BELOW BASELINE: 13, totalling 68 unrecorded conversions
   -18  core/default-workflow-hooks.ts (25->7)   -15  engine/self-healing.ts (126->111)
    -8  engine/executor.ts (112->104)             -5  core/task-store/moves.ts (44->39)
    -5  engine/mission-feature-sync.ts (5->0)     -4  core/live-agent-count.ts (10->6)
```

**The slack is not regression — it is 13 files of merged conversions
nobody re-recorded**, against exactly **one** rise. This PR re-records
the baseline **854 → 782 across 140 files**, which closes it.

**And the "+3 that slipped in" is +1, and it is mine.**
`register-task-workflow-routes.ts 22 → 23` is the v1-IR pre-WIP fallback
my #2621 added; it is justified (that branch runs only when the IR
declares no columns or nodes, so there is no role to resolve) but it
shipped with no marker and no baseline update — which is why `--strict`
has been **red on main since it merged**. Fixed here at the site with a
`DELIBERATE-LITERAL` marker rather than by bumping the baseline, because
a rise that is genuinely correct belongs where a reader will see it.

Sequencing note for the auto-lowering change: if this lands first, that
work is purely the mechanism (auto-lower, or fail with tighten
instructions) rather than a cleanup, and the two re-records will not
collide in the same file.

Also worth carrying into that mechanism, from building the same guard
here: **`--update` must refuse to RAISE.** An earlier version of mine
wrote current counts verbatim, so a developer who added a literal and
ran the documented update command locked the regression in as the new
ceiling — the mirror of the high-water problem. Lowering can be
unattended; raising should be a hand edit with the reason recorded.

## Third piece of residue from my own deletion

`updateGlobalConcurrency` in the dashboard API client PUT to
`/api/global-concurrency`, a route removed when the machine-wide cap
went. Zero callers; the only reference was the `legacy.ts` barrel
re-export. Deleted both. `fetchGlobalConcurrency` **survives on
purpose** — the GET route remains and serves live utilization telemetry
to the footer and Command Center; nothing gates on it.

That is the third: after the second raw `maxWorktrees` reader and the
"Queued to plan" tooltip. A deletion is not finished when the
enforcement goes — the client, the label and the tooltip outlive it.

---

## Re-greened the dashboard API tests: 117 failures on main, ONE root
cause

These would have polluted the closing verification pass, and nobody
owned them.

`api()` builds headers via `new Headers(...)` and returns
`Object.fromEntries(headers.entries())` — and `Headers.entries()`
**lowercases every key**, so the object reaching `fetch` is
`content-type`, not `Content-Type`. `ab87d0d80` then added
`x-fusion-client: dashboard-ui` for run-audit attribution. Both changes
are correct; neither is visible at a call site, so **114 assertions
across 7 files** kept asserting the old shape and went red together.

Fixed by naming the shape **once** in `app/test/apiRequestHeaders.ts`
rather than patching 114 literals — restating a shared fact 114 times is
what made a two-line client change look like 117 failures. Deliberately
not a loose `objectContaining`: these tests are the only thing pinning
that the attribution header is sent *at all*.

**117 → 4.** The remaining 4 are unrelated pre-existing CSS failures
(`task-detail-modal-tablet-width` ×3, `space-token-defined` ×1) —
confirmed identical on clean main with my changes stashed.

### A gap this surfaced, recorded not papered over

Three routes failed in the *opposite* direction — they send the old
shape because they call `fetch()` **directly**, bypassing `api()`, so
they never get the attribution header. `client.ts` claims the opposite:

> "Applied once here rather than per-call so no future mutation route
has to remember it."

That does not hold for a route that bypasses the helper it is applied
in. **Measured in `app/api/`: 8 files make direct `fetch()` calls and 7
include mutations (POST/DELETE)** — among them `ai-sessions.ts`'s
DELETE, which is the same class as the four-delete incident the header
was added for. So the attribution fix has a hole in exactly its
motivating case.

Not fixed here: routing those onto `api()` is a behaviour change across
the API layer and belongs to its owner, not to a test re-green. Those
assertions use a separate `API_JSON_HEADERS_NO_ATTRIBUTION` constant so
the gap stays **visible** — if a route is later moved onto `api()`, its
test fails and points at the note explaining why.

---

## This branch takes the triage bar 10 → 5, and makes `--strict` green

`node scripts/lifecycle-column-census.mjs` on this branch reports
**triage 5**, against **10** on `origin/main`. The five removed are the
ScheduleStepsEditor template/initializer/option and the RoutineEditor
default/option — the automation paths that were creating tasks into the
deleted column.

**`--strict` was also RED on clean main, twice over, and both causes
were the same mistake:** a thorough written rationale the tool cannot
read, because the marker was not where the census looks. The census
reads a comparison node's **leading comments**; a `DELIBERATE-LITERAL`
in the JSDoc above the enclosing function or declaration does not reach
the comparison inside it.

| site | why it is legitimate | why the tool could not see it |
|---|---|---|
| `columnRoles.ts:80` `isHoldColumnRole` | degrades to `columnId ===
"todo"` only when a column has **no resolved traits** — identical in
kind to `LEGACY_PRE_IMPLEMENTATION_COLUMN_IDS` directly above, which
escapes counting only because a Set is a membership form | rationale
written, **no marker token** |
| `MissionControlPanel.tsx` ×3 | the SDLC funnel **alias table** — maps
`to-do`/`ready`/`review`/`shipped` onto one display stage with an
explicit `other` bucket, and nothing branches on it | marker in the
JSDoc; the comparisons are arrow bodies **inside the array literal**,
which it does not reach |

The second only surfaced because converting the `triage` stage to a Set
removed its count and exposed the siblings — red gate, justification
sitting three lines above, unreachable.

Both are markers, no behaviour change. Neither is a conversion
candidate: resolving the funnel table to traits would **drop the
non-column aliases it exists to accept**.

**For the auto-lowering work:** the marker-placement rule is now the
recurring trap — three instances, three different authors, including me.
A marker that does not register is indistinguishable from no marker, and
the failure mode is a red gate with a written explanation nobody can act
on. If the census accepted a marker anywhere in the enclosing
declaration's comments, none of the three would have happened.

Baseline re-recorded per the tool's own instruction ("Re-record the
baseline in the SAME PR that lowered the count").


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **Bug Fixes**
- Task “Actions” menus no longer appear on bare cards in the Planning
column.
- Routines, scheduled tasks, and create-task steps now respect each
board’s configured workflow intake column instead of using a retired
default.
- Legacy tasks saved with the retired intake column are migrated to
automatic workflow resolution.
- Target-column selection now offers only “Automatic (workflow intake)”
and “Planning,” removing the obsolete option.
- Capacity/planning messaging and related UI tooltip text were
clarified; concurrency cap updates are managed per project.

- **Tests**
- Added/updated coverage for workflow intake resolution, create-task
target column behavior (including legacy coercion), capacity safeguards,
and API request consistency.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 03:10:35 -07:00

1257 lines
52 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.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { ScheduleForm } from "../ScheduleForm";
import type { ScheduledTask } from "@fusion/core";
// Mock lucide-react
vi.mock("lucide-react", () => ({
Globe: () => <span data-testid="icon-globe">🌍</span>,
Folder: () => <span data-testid="icon-folder">📁</span>,
GripVertical: () => <span data-testid="icon-grip">⋮⋮</span>,
Plus: () => <span data-testid="icon-plus">+</span>,
Pencil: () => <span data-testid="icon-pencil">✎</span>,
Trash2: () => <span data-testid="icon-trash">🗑</span>,
CheckCircle: () => <span data-testid="icon-check">✓</span>,
XCircle: () => <span data-testid="icon-x">✗</span>,
ChevronDown: () => <span data-testid="icon-down">▼</span>,
ChevronUp: () => <span data-testid="icon-up">▲</span>,
Sparkles: () => <span data-testid="icon-sparkles">✨</span>,
Terminal: () => <span data-testid="icon-terminal">⌨</span>,
ArrowUpDown: () => <span data-testid="icon-arrow">↕</span>,
ListPlus: () => <span data-testid="icon-list-plus">➕</span>,
}));
// Mock @fusion/core to provide the runtime tool catalog used by ScheduleForm.
vi.mock("@fusion/core", () => ({
AUTOMATION_SELECTABLE_TOOLS: ["Read", "Bash", "Edit", "Write", "Grep", "Find", "Ls"],
}));
// Mock api
const mockFetchModels = vi.fn().mockResolvedValue({
models: [
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet", reasoning: false, contextWindow: 200000 },
],
favoriteProviders: [],
favoriteModels: [],
});
vi.mock("../api", () => ({
fetchModels: (...args: unknown[]) => mockFetchModels(...args),
}));
// Mock CustomModelDropdown
vi.mock("../CustomModelDropdown", () => ({
CustomModelDropdown: ({ id, value, onChange, disabled, models, showThinkingLevel, thinkingLevel, onThinkingLevelChange }: any) => (
<div data-testid={`${id}-mock`}>
<select
data-testid="model-dropdown"
value={value || ""}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
>
<option value="">Use default</option>
{models?.map((m: any) => (
<option key={`${m.provider}/${m.id}`} value={`${m.provider}/${m.id}`}>
{m.name}
</option>
))}
</select>
{showThinkingLevel && (
<select
aria-label={`${id} thinking level`}
data-testid={`${id}-thinking-level`}
value={thinkingLevel || ""}
onChange={(e) => onThinkingLevelChange?.(e.target.value)}
>
<option value="">Default (off)</option>
<option value="off">Off</option>
<option value="high">High</option>
</select>
)}
</div>
),
}));
function makeSchedule(overrides: Partial<ScheduledTask> = {}): ScheduledTask {
return {
id: "test-id",
name: "Test Schedule",
description: "A test schedule",
scheduleType: "daily",
cronExpression: "0 0 * * *",
command: "echo hello",
enabled: true,
runCount: 0,
runHistory: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
describe("ScheduleForm", () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
const onCancel = vi.fn();
beforeEach(async () => {
vi.clearAllMocks();
// Reset the mock to ensure it's fresh for each test
mockFetchModels.mockClear();
});
describe("create mode", () => {
it("renders with empty fields for a new schedule", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByText("New Schedule")).toBeDefined();
expect(screen.getByLabelText("Name")).toHaveProperty("value", "");
expect(screen.getByLabelText("Command")).toHaveProperty("value", "");
});
it("shows 'Create Schedule' submit button text", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByText("Create Schedule")).toBeDefined();
});
it("defaults schedule type to daily", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
const select = screen.getByLabelText("Schedule") as HTMLSelectElement;
expect(select.value).toBe("daily");
});
it("shows type toggle buttons in simple mode", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByRole("radio", { name: "Command" })).toBeDefined();
expect(screen.getByRole("radio", { name: "AI Prompt" })).toBeDefined();
});
it("shows command input when Command type is selected in simple mode", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
// Command radio should be selected by default
expect(screen.getByRole("radio", { name: "Command" })).toHaveAttribute("aria-checked", "true");
// Command input should be visible
expect(screen.getByLabelText("Command")).toBeDefined();
});
it("shows prompt textarea when AI Prompt type is selected in simple mode", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
// Click on AI Prompt button
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
// AI Prompt should now be checked
expect(screen.getByRole("radio", { name: "AI Prompt" })).toHaveAttribute("aria-checked", "true");
// Prompt textarea should be visible
expect(screen.getByLabelText("Prompt")).toBeDefined();
// Command input should not be visible
expect(screen.queryByLabelText("Command")).toBeNull();
});
it("shows validation error when prompt is empty on submit", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
// Fill name
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "AI Job" } });
// Switch to AI Prompt mode
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
// Submit without entering prompt
fireEvent.click(screen.getByText("Create Schedule"));
// Should show prompt validation error
expect(screen.getByText("Prompt is required")).toBeDefined();
expect(onSubmit).not.toHaveBeenCalled();
});
it("submits single ai-prompt step when simple mode uses AI Prompt", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
// Fill name
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "AI Job" } });
// Switch to AI Prompt mode
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
// Enter prompt
fireEvent.change(screen.getByLabelText("Prompt"), { target: { value: "Summarize recent commits" } });
// Submit
fireEvent.click(screen.getByText("Create Schedule"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
name: "AI Job",
command: "",
steps: expect.arrayContaining([
expect.objectContaining({
type: "ai-prompt",
name: "AI Job",
prompt: "Summarize recent commits",
}),
]),
}),
);
});
});
it("omits thinkingLevel by default in simple AI Prompt mode", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "AI Job" } });
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
expect(screen.getByTestId("schedule-model-thinking-level")).toBeDefined();
fireEvent.change(screen.getByLabelText("Prompt"), { target: { value: "Summarize recent commits" } });
fireEvent.click(screen.getByText("Create Schedule"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
steps: [expect.objectContaining({ thinkingLevel: undefined })],
}),
);
});
});
it("submits explicit thinkingLevel from simple AI Prompt mode", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "AI Job" } });
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
fireEvent.change(screen.getByTestId("schedule-model-thinking-level"), { target: { value: "high" } });
fireEvent.change(screen.getByLabelText("Prompt"), { target: { value: "Summarize recent commits" } });
fireEvent.click(screen.getByText("Create Schedule"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
steps: [expect.objectContaining({ thinkingLevel: "high" })],
}),
);
});
});
it("clears inherited thinkingLevel when editing a simple AI Prompt schedule", async () => {
const schedule = makeSchedule({
command: "",
steps: [
{
id: "step-1",
type: "ai-prompt",
name: "AI Job",
prompt: "Summarize recent commits",
thinkingLevel: "high",
},
],
});
render(<ScheduleForm schedule={schedule} onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByTestId("schedule-model-thinking-level")).toHaveValue("high");
fireEvent.change(screen.getByTestId("schedule-model-thinking-level"), { target: { value: "" } });
fireEvent.click(screen.getByText("Save Changes"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
steps: [expect.objectContaining({ thinkingLevel: undefined })],
}),
);
});
});
it("shares thinkingLevel state between simple AI Prompt and Create Task selectors", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
fireEvent.change(screen.getByTestId("schedule-model-thinking-level"), { target: { value: "high" } });
fireEvent.click(screen.getByRole("radio", { name: "Create Task" }));
expect(screen.getByTestId("schedule-task-model-thinking-level")).toHaveValue("high");
});
it("shows all automation tools checked by default in simple AI Prompt mode", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
for (const tool of ["Read", "Bash", "Edit", "Write", "Grep", "Find", "Ls"]) {
expect(screen.getByLabelText(tool)).toHaveProperty("checked", true);
}
});
it("submits undefined allowedTools when every simple AI Prompt tool is selected", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "AI Job" } });
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
fireEvent.change(screen.getByLabelText("Prompt"), { target: { value: "Summarize recent commits" } });
fireEvent.click(screen.getByText("Create Schedule"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
steps: [expect.objectContaining({ allowedTools: undefined })],
}),
);
});
});
it("submits a restricted allowedTools array from simple AI Prompt mode", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "AI Job" } });
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
fireEvent.change(screen.getByLabelText("Prompt"), { target: { value: "Summarize recent commits" } });
fireEvent.click(screen.getByLabelText("Bash"));
fireEvent.click(screen.getByText("Create Schedule"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
steps: [expect.objectContaining({ allowedTools: ["Read", "Edit", "Write", "Grep", "Find", "Ls"] })],
}),
);
});
});
it("submits an explicit empty allowedTools array when simple AI Prompt tools are cleared", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "AI Job" } });
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
fireEvent.change(screen.getByLabelText("Prompt"), { target: { value: "Summarize recent commits" } });
fireEvent.click(screen.getByRole("button", { name: "Clear" }));
fireEvent.click(screen.getByText("Create Schedule"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
steps: [expect.objectContaining({ allowedTools: [] })],
}),
);
});
});
it("submits with model provider and model ID when provided in simple AI Prompt mode", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
// Fill name
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "AI Job" } });
// Switch to AI Prompt mode
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
// Enter prompt
fireEvent.change(screen.getByLabelText("Prompt"), { target: { value: "Summarize recent commits" } });
// The model dropdown is present (optional field)
expect(screen.getByTestId("model-dropdown")).toBeDefined();
// Submit - model is optional, so this should work
await act(async () => {
fireEvent.click(screen.getByText("Create Schedule"));
});
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
steps: expect.arrayContaining([
expect.objectContaining({
type: "ai-prompt",
prompt: "Summarize recent commits",
}),
]),
}),
);
});
});
it("shows error when only one of model provider/model ID is set in simple AI Prompt mode", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
// Fill name
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "AI Job" } });
// Switch to AI Prompt mode
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
// Enter prompt
fireEvent.change(screen.getByLabelText("Prompt"), { target: { value: "Summarize recent commits" } });
// Set only model provider (not model ID) via the dropdown's internal state
// The CustomModelDropdown is mocked, so we need to test the validation path differently
// Since the dropdown sets both when a value is selected, we test the validation by
// directly manipulating the state through the onChange callback
// For this test, we verify the model dropdown is present
expect(screen.getByTestId("model-dropdown")).toBeDefined();
// Submit with both fields empty - should pass validation
fireEvent.click(screen.getByText("Create Schedule"));
// Should not show model consistency error
expect(screen.queryByText("Both model provider and model ID must be set")).toBeNull();
});
it("restores AI Prompt simple type when editing schedule with single ai-prompt step", () => {
const schedule = makeSchedule({
steps: [
{
id: "step-1",
type: "ai-prompt",
name: "AI Schedule",
prompt: "Summarize this",
modelProvider: "openai",
modelId: "gpt-4o",
},
],
command: "",
});
render(<ScheduleForm schedule={schedule} onSubmit={onSubmit} onCancel={onCancel} />);
// AI Prompt radio should be selected
expect(screen.getByRole("radio", { name: "AI Prompt" })).toHaveAttribute("aria-checked", "true");
// Prompt textarea should be populated
expect(screen.getByLabelText("Prompt")).toHaveProperty("value", "Summarize this");
// Command input should not be visible
expect(screen.queryByLabelText("Command")).toBeNull();
// Model dropdown should be present
expect(screen.getByTestId("model-dropdown")).toBeDefined();
});
it("restores restricted tool selection when editing a simple AI Prompt schedule", async () => {
const schedule = makeSchedule({
steps: [
{
id: "step-1",
type: "ai-prompt",
name: "AI Schedule",
prompt: "Summarize this",
allowedTools: ["Read", "Grep"],
},
],
command: "",
});
render(<ScheduleForm schedule={schedule} onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByLabelText("Read")).toHaveProperty("checked", true);
expect(screen.getByLabelText("Grep")).toHaveProperty("checked", true);
expect(screen.getByLabelText("Bash")).toHaveProperty("checked", false);
fireEvent.click(screen.getByText("Save Changes"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
steps: [expect.objectContaining({ allowedTools: ["Read", "Grep"] })],
}),
);
});
});
});
describe("edit mode", () => {
it("populates fields from existing schedule", () => {
const schedule = makeSchedule({ name: "My Job", command: "npm test" });
render(<ScheduleForm schedule={schedule} onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByText("Edit Schedule")).toBeDefined();
expect(screen.getByLabelText("Name")).toHaveProperty("value", "My Job");
expect(screen.getByLabelText("Command")).toHaveProperty("value", "npm test");
});
it("shows 'Save Changes' submit button text", () => {
const schedule = makeSchedule();
render(<ScheduleForm schedule={schedule} onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByText("Save Changes")).toBeDefined();
});
it("restores command simple type when editing schedule with command (no steps)", () => {
const schedule = makeSchedule({ command: "npm test" });
render(<ScheduleForm schedule={schedule} onSubmit={onSubmit} onCancel={onCancel} />);
// Command radio should be selected
expect(screen.getByRole("radio", { name: "Command" })).toHaveAttribute("aria-checked", "true");
// Command input should be visible and populated
expect(screen.getByLabelText("Command")).toHaveProperty("value", "npm test");
// Prompt textarea should not be visible
expect(screen.queryByLabelText("Prompt")).toBeNull();
});
});
describe("validation", () => {
it("shows error when name is empty on submit", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "echo hi" } });
fireEvent.click(screen.getByText("Create Schedule"));
expect(screen.getByText("Name is required")).toBeDefined();
expect(onSubmit).not.toHaveBeenCalled();
});
it("shows error when command is empty on submit", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Job" } });
fireEvent.click(screen.getByText("Create Schedule"));
expect(screen.getByText("Command is required")).toBeDefined();
expect(onSubmit).not.toHaveBeenCalled();
});
it("shows error for invalid cron expression with custom type", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Job" } });
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "echo hi" } });
fireEvent.change(screen.getByLabelText("Schedule"), { target: { value: "custom" } });
fireEvent.change(screen.getByLabelText("Cron Expression"), { target: { value: "invalid" } });
fireEvent.click(screen.getByText("Create Schedule"));
expect(screen.getByText(/Invalid cron format/)).toBeDefined();
expect(onSubmit).not.toHaveBeenCalled();
});
it("shows error for empty cron expression with custom type", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Job" } });
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "echo hi" } });
fireEvent.change(screen.getByLabelText("Schedule"), { target: { value: "custom" } });
// Clear the cron field
fireEvent.change(screen.getByLabelText("Cron Expression"), { target: { value: "" } });
fireEvent.click(screen.getByText("Create Schedule"));
expect(screen.getByText("Cron expression is required for custom schedules")).toBeDefined();
});
it("sets aria-invalid on fields with errors", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.click(screen.getByText("Create Schedule"));
expect(screen.getByLabelText("Name").getAttribute("aria-invalid")).toBe("true");
expect(screen.getByLabelText("Command").getAttribute("aria-invalid")).toBe("true");
});
});
describe("cron expression auto-fill", () => {
it("auto-fills cron expression for preset types", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
const cronField = screen.getByLabelText("Cron Expression") as HTMLInputElement;
// Default is daily
expect(cronField.value).toBe("0 0 * * *");
expect(cronField.disabled).toBe(true);
});
it("enables cron field when custom type is selected", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Schedule"), { target: { value: "custom" } });
const cronField = screen.getByLabelText("Cron Expression") as HTMLInputElement;
expect(cronField.disabled).toBe(false);
});
it("updates cron expression when changing preset type", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Schedule"), { target: { value: "hourly" } });
const cronField = screen.getByLabelText("Cron Expression") as HTMLInputElement;
expect(cronField.value).toBe("0 * * * *");
});
it("auto-fills cron expression for every15Minutes preset", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Schedule"), { target: { value: "every15Minutes" } });
const cronField = screen.getByLabelText("Cron Expression") as HTMLInputElement;
expect(cronField.value).toBe("*/15 * * * *");
});
it("auto-fills cron expression for every6Hours preset", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Schedule"), { target: { value: "every6Hours" } });
const cronField = screen.getByLabelText("Cron Expression") as HTMLInputElement;
expect(cronField.value).toBe("0 */6 * * *");
});
it("auto-fills cron expression for weekdays preset", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Schedule"), { target: { value: "weekdays" } });
const cronField = screen.getByLabelText("Cron Expression") as HTMLInputElement;
expect(cronField.value).toBe("0 9 * * 1-5");
});
});
describe("submission", () => {
it("calls onSubmit with correct data for valid form", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "My Job" } });
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "echo hello" } });
fireEvent.click(screen.getByText("Create Schedule"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
name: "My Job",
command: "echo hello",
scheduleType: "daily",
enabled: true,
}),
);
});
});
it("includes cronExpression only for custom type", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Job" } });
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "cmd" } });
fireEvent.click(screen.getByText("Create Schedule"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({ cronExpression: undefined }),
);
});
});
it("includes cronExpression for custom type", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Job" } });
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "cmd" } });
fireEvent.change(screen.getByLabelText("Schedule"), { target: { value: "custom" } });
fireEvent.change(screen.getByLabelText("Cron Expression"), { target: { value: "0 */6 * * *" } });
fireEvent.click(screen.getByText("Create Schedule"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({ cronExpression: "0 */6 * * *", scheduleType: "custom" }),
);
});
});
it("submits command as empty string when using AI Prompt mode", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "AI Job" } });
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
fireEvent.change(screen.getByLabelText("Prompt"), { target: { value: "Summarize commits" } });
fireEvent.click(screen.getByText("Create Schedule"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
command: "",
}),
);
});
});
it("does not include steps when using Command mode", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Command Job" } });
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "echo hello" } });
fireEvent.click(screen.getByText("Create Schedule"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
steps: undefined,
}),
);
});
});
it("generates unique step ID for AI prompt step", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "AI Job" } });
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
fireEvent.change(screen.getByLabelText("Prompt"), { target: { value: "Test prompt" } });
fireEvent.click(screen.getByText("Create Schedule"));
await waitFor(() => {
const call = onSubmit.mock.calls[0][0];
expect(call.steps).toBeDefined();
expect(call.steps.length).toBe(1);
expect(call.steps[0].id).toBeDefined();
// UUID format check (8-4-4-4-12 hex pattern)
expect(call.steps[0].id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$|^step-\d+-[a-z0-9]+$/);
});
});
});
describe("cancel", () => {
it("calls onCancel when Cancel button is clicked", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.click(screen.getByText("Cancel"));
expect(onCancel).toHaveBeenCalled();
});
});
describe("scope toggle", () => {
it("renders scope toggle buttons", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByRole("radio", { name: "🌍Global" })).toBeDefined();
expect(screen.getByRole("radio", { name: "📁Project" })).toBeDefined();
});
it("clicking Global scope button calls onScopeChange with 'global'", () => {
const onScopeChange = vi.fn();
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} scope="project" onScopeChange={onScopeChange} />);
fireEvent.click(screen.getByRole("radio", { name: "🌍Global" }));
expect(onScopeChange).toHaveBeenCalledWith("global");
});
it("clicking Project scope button calls onScopeChange with 'project'", () => {
const onScopeChange = vi.fn();
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} scope="global" projectId="test-project" onScopeChange={onScopeChange} />);
fireEvent.click(screen.getByRole("radio", { name: "📁Project" }));
expect(onScopeChange).toHaveBeenCalledWith("project");
});
it("scope toggle updates visual active state", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} scope="global" projectId="test-project" />);
// Global should be active initially
expect(screen.getByRole("radio", { name: "🌍Global" })).toHaveAttribute("aria-checked", "true");
expect(screen.getByRole("radio", { name: "📁Project" })).toHaveAttribute("aria-checked", "false");
// Click Project button
fireEvent.click(screen.getByRole("radio", { name: "📁Project" }));
// Project should now be active
expect(screen.getByRole("radio", { name: "📁Project" })).toHaveAttribute("aria-checked", "true");
expect(screen.getByRole("radio", { name: "🌍Global" })).toHaveAttribute("aria-checked", "false");
});
it("Project button is disabled when no projectId", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} scope="global" />);
expect(screen.getByRole("radio", { name: "📁Project" })).toBeDisabled();
});
it("scope buttons are disabled when editing existing schedule with scope", () => {
const schedule = makeSchedule({ scope: "global" });
render(<ScheduleForm schedule={schedule} onSubmit={onSubmit} onCancel={onCancel} scope="global" />);
expect(screen.getByRole("radio", { name: "🌍Global" })).toBeDisabled();
expect(screen.getByRole("radio", { name: "📁Project" })).toBeDisabled();
});
it("submit uses localScope when user changes scope", async () => {
const onScopeChange = vi.fn();
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} scope="global" projectId="test-project" onScopeChange={onScopeChange} />);
// Fill name and command
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "My Job" } });
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "echo hello" } });
// Switch to project scope
fireEvent.click(screen.getByRole("radio", { name: "📁Project" }));
// Submit
fireEvent.click(screen.getByText("Create Schedule"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
scope: "project",
}),
);
});
});
it("localScope syncs when formScope prop changes", () => {
const { rerender } = render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} scope="global" projectId="test-project" />);
// Global should be active initially
expect(screen.getByRole("radio", { name: "🌍Global" })).toHaveAttribute("aria-checked", "true");
// Change prop to project scope
rerender(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} scope="project" projectId="test-project" />);
// Project should now be active
expect(screen.getByRole("radio", { name: "📁Project" })).toHaveAttribute("aria-checked", "true");
});
it("shows project scope description when localScope is project", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} scope="project" projectId="test-project" />);
expect(screen.getByText(/scoped to the current project/)).toBeDefined();
});
it("shows global scope description when localScope is global", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} scope="global" />);
expect(screen.getByText(/created at global scope/)).toBeDefined();
});
it("shows no project description when no projectId and no schedule", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByText(/No active project/)).toBeDefined();
});
});
describe("multi-step mode", () => {
it("switches to Multi-Step mode and adds a command step", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
// Fill in basic info
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "My Multi-Step" } });
// Switch to Multi-Step mode
fireEvent.click(screen.getByText("Multi-Step"));
// Add a command step
fireEvent.click(screen.getByText("Add Command Step"));
// Step editor should be open
expect(screen.getByText("Save Step")).toBeDefined();
// Fill in step details - use placeholder to find the command field
fireEvent.change(screen.getByLabelText("Step Name"), { target: { value: "Run Tests" } });
fireEvent.change(screen.getByPlaceholderText("e.g. npm test"), { target: { value: "npm test" } });
// Save the step
fireEvent.click(screen.getByText("Save Step"));
// Step should be visible in the list
expect(screen.getByText("Run Tests")).toBeDefined();
});
it("adds an AI prompt step", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "AI Schedule" } });
fireEvent.click(screen.getByText("Multi-Step"));
// Add an AI prompt step
fireEvent.click(screen.getByText("Add AI Prompt Step"));
// Fill in step details
fireEvent.change(screen.getByLabelText("Step Name"), { target: { value: "Summarize Results" } });
fireEvent.change(screen.getByPlaceholderText("e.g. Summarize the test results and highlight any failures"), {
target: { value: "Summarize test output" }
});
// Save the step
fireEvent.click(screen.getByText("Save Step"));
// Step should be visible
expect(screen.getByText("Summarize Results")).toBeDefined();
});
it("prevents submission with incomplete steps (missing command)", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Incomplete Schedule" } });
fireEvent.click(screen.getByText("Multi-Step"));
// Add a step but don't fill in the command
fireEvent.click(screen.getByText("Add Command Step"));
fireEvent.change(screen.getByLabelText("Step Name"), { target: { value: "Run Tests" } });
// Note: Not filling in the command field
// Try to save step - this should fail validation
fireEvent.click(screen.getByText("Save Step"));
expect(screen.getByText("Command is required")).toBeDefined();
// Cancel the step editor - click the Cancel button in the step editor (not the form Cancel)
const cancelButtons = screen.getAllByText("Cancel");
// First Cancel is in the step editor, second is the form Cancel
fireEvent.click(cancelButtons[0]!);
// Try to submit the form - should show error about incomplete steps
fireEvent.click(screen.getByText("Create Schedule"));
expect(screen.getByText(/Step 1: Command is required/)).toBeDefined();
expect(onSubmit).not.toHaveBeenCalled();
});
it("prevents submission when steps are being edited", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Editing Schedule" } });
fireEvent.click(screen.getByText("Multi-Step"));
// Add a step and keep editor open
fireEvent.click(screen.getByText("Add Command Step"));
fireEvent.change(screen.getByLabelText("Step Name"), { target: { value: "Run Tests" } });
fireEvent.change(screen.getByPlaceholderText("e.g. npm test"), { target: { value: "npm test" } });
// Don't save - keep editor open
// Try to submit the form
fireEvent.click(screen.getByText("Create Schedule"));
// Should show editing error
expect(screen.getByText(/Please save or cancel all step edits/)).toBeDefined();
expect(onSubmit).not.toHaveBeenCalled();
});
it("successfully creates a multi-step schedule with valid steps", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Complete Multi-Step" } });
fireEvent.click(screen.getByText("Multi-Step"));
// Add first step
fireEvent.click(screen.getByText("Add Command Step"));
fireEvent.change(screen.getByLabelText("Step Name"), { target: { value: "Build" } });
fireEvent.change(screen.getByPlaceholderText("e.g. npm test"), { target: { value: "npm run build" } });
fireEvent.click(screen.getByText("Save Step"));
// Add second step
fireEvent.click(screen.getByText("Add AI Prompt Step"));
fireEvent.change(screen.getByLabelText("Step Name"), { target: { value: "Review" } });
fireEvent.change(screen.getByPlaceholderText("e.g. Summarize the test results and highlight any failures"), {
target: { value: "Review the build output" }
});
fireEvent.click(screen.getByText("Save Step"));
// Submit the form
fireEvent.click(screen.getByText("Create Schedule"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
name: "Complete Multi-Step",
steps: expect.arrayContaining([
expect.objectContaining({ name: "Build", type: "command", command: "npm run build" }),
expect.objectContaining({ name: "Review", type: "ai-prompt", prompt: "Review the build output" }),
]),
}),
);
});
});
it("edits an existing multi-step schedule", () => {
const schedule = makeSchedule({
steps: [
{ id: "step-1", type: "command", name: "Build", command: "npm run build" },
],
});
render(<ScheduleForm schedule={schedule} onSubmit={onSubmit} onCancel={onCancel} />);
// Should be in Multi-Step mode by default when schedule has steps
expect(screen.getByText("Steps (1)")).toBeDefined();
expect(screen.getByText("Build")).toBeDefined();
// Add another step
fireEvent.click(screen.getByText("Add Command Step"));
fireEvent.change(screen.getByLabelText("Step Name"), { target: { value: "Test" } });
fireEvent.change(screen.getByPlaceholderText("e.g. npm test"), { target: { value: "npm test" } });
fireEvent.click(screen.getByText("Save Step"));
// Should show both steps
expect(screen.getByText("Steps (2)")).toBeDefined();
expect(screen.getByText("Build")).toBeDefined();
expect(screen.getByText("Test")).toBeDefined();
});
});
describe("simple mode AI Prompt edge cases", () => {
it("can switch between Command and AI Prompt without losing form state", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
// Fill in command mode
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Test Job" } });
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "echo hello" } });
// Switch to AI Prompt mode
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
// Enter prompt
fireEvent.change(screen.getByLabelText("Prompt"), { target: { value: "Summarize this" } });
// Switch back to Command mode
fireEvent.click(screen.getByRole("radio", { name: "Command" }));
// Command should be visible again
expect(screen.getByLabelText("Command")).toBeDefined();
// Switch back to AI Prompt - prompt should still be there
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
expect(screen.getByLabelText("Prompt")).toHaveProperty("value", "Summarize this");
});
it("submits with trimmed prompt", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "AI Job" } });
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
fireEvent.change(screen.getByLabelText("Prompt"), { target: { value: " Summarize commits " } });
fireEvent.click(screen.getByText("Create Schedule"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
steps: expect.arrayContaining([
expect.objectContaining({
prompt: "Summarize commits", // trimmed
}),
]),
}),
);
});
});
it("handles AI prompt schedule with model but no modelProvider/modelId separate fields", async () => {
// When editing a schedule where the step has modelProvider/modelId,
// the form should correctly populate and submit them
const schedule = makeSchedule({
steps: [
{
id: "step-1",
type: "ai-prompt",
name: "AI Schedule",
prompt: "Do something",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
},
],
command: "",
});
render(<ScheduleForm schedule={schedule} onSubmit={onSubmit} onCancel={onCancel} />);
// Form should be in AI Prompt mode
expect(screen.getByRole("radio", { name: "AI Prompt" })).toHaveAttribute("aria-checked", "true");
// Submit without changes
fireEvent.click(screen.getByText("Save Changes"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
steps: expect.arrayContaining([
expect.objectContaining({
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
}),
]),
}),
);
});
});
});
describe("simple mode create-task", () => {
it("shows Create Task radio button in simple mode", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByRole("radio", { name: "Create Task" })).toBeDefined();
expect(screen.getByRole("radio", { name: "Command" })).toBeDefined();
expect(screen.getByRole("radio", { name: "AI Prompt" })).toBeDefined();
});
it("shows task creation fields when Create Task type is selected", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
// Click Create Task radio
fireEvent.click(screen.getByRole("radio", { name: "Create Task" }));
// Create Task radio should be checked
expect(screen.getByRole("radio", { name: "Create Task" })).toHaveAttribute("aria-checked", "true");
// Task title input should be visible
expect(screen.getByLabelText("Task Title (optional)")).toBeDefined();
// Task description textarea should be visible
expect(screen.getByLabelText("Task Description (required)")).toBeDefined();
// Target column select should be visible
expect(screen.getByLabelText("Target Column")).toBeDefined();
// Executor model dropdown should be visible
expect(screen.getByTestId("model-dropdown")).toBeDefined();
// Command input should NOT be visible
expect(screen.queryByLabelText("Command")).toBeNull();
// Prompt textarea should NOT be visible
expect(screen.queryByLabelText("Prompt")).toBeNull();
});
it("shows validation error when task description is empty on submit", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
// Fill name
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Create Task Schedule" } });
// Switch to Create Task mode
fireEvent.click(screen.getByRole("radio", { name: "Create Task" }));
// Submit without task description
fireEvent.click(screen.getByText("Create Schedule"));
// Should show description validation error
expect(screen.getByText("Task description is required")).toBeDefined();
expect(onSubmit).not.toHaveBeenCalled();
});
it("submits default and explicit thinkingLevel in simple Create Task mode", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Create Task Schedule" } });
fireEvent.click(screen.getByRole("radio", { name: "Create Task" }));
expect(screen.getByTestId("schedule-task-model-thinking-level")).toHaveValue("");
fireEvent.change(screen.getByTestId("schedule-task-model-thinking-level"), { target: { value: "high" } });
fireEvent.change(screen.getByLabelText("Task Description (required)"), {
target: { value: "Check npm dependencies for security vulnerabilities" },
});
fireEvent.click(screen.getByText("Create Schedule"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
steps: [expect.objectContaining({ type: "create-task", thinkingLevel: "high" })],
}),
);
});
});
it("submits single create-task step when simple mode uses Create Task", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
// Fill name
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Create Task Schedule" } });
// Switch to Create Task mode
fireEvent.click(screen.getByRole("radio", { name: "Create Task" }));
// Fill task description
fireEvent.change(screen.getByLabelText("Task Description (required)"), {
target: { value: "Check npm dependencies for security vulnerabilities" }
});
// Select target column (triage is default)
// Submit
fireEvent.click(screen.getByText("Create Schedule"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
name: "Create Task Schedule",
command: "",
steps: expect.arrayContaining([
expect.objectContaining({
type: "create-task",
name: "Create Task Schedule",
taskDescription: "Check npm dependencies for security vulnerabilities",
/*
FNXC:Automations 2026-07-30-23:55 (greptile #2652 — this pinned the defect):
Was `taskColumn: "triage"`. U11 deletes that column from the default workflow, and an
EXPLICIT column bypasses the workflow entry-column resolution used for column-less
creates, so the old default seeded tasks into a column the board does not declare.
The corrected invariant is that the form sends NO column and each workflow resolves
its own intake.
*/
taskColumn: undefined,
}),
]),
}),
);
});
});
it("submits with task title and model when provided", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
// Fill name
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Create Task Schedule" } });
// Switch to Create Task mode
fireEvent.click(screen.getByRole("radio", { name: "Create Task" }));
// Fill task title
fireEvent.change(screen.getByLabelText("Task Title (optional)"), {
target: { value: "Review Dependencies" }
});
// Fill task description
fireEvent.change(screen.getByLabelText("Task Description (required)"), {
target: { value: "Check npm dependencies for security vulnerabilities" }
});
// Select To Do column
fireEvent.change(screen.getByLabelText("Target Column"), { target: { value: "todo" } });
// Submit - model is optional
fireEvent.click(screen.getByText("Create Schedule"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
steps: expect.arrayContaining([
expect.objectContaining({
type: "create-task",
taskTitle: "Review Dependencies",
taskDescription: "Check npm dependencies for security vulnerabilities",
taskColumn: "todo",
}),
]),
}),
);
});
});
it("restores Create Task simple type when editing schedule with single create-task step", () => {
const schedule = makeSchedule({
steps: [
{
id: "step-1",
type: "create-task",
name: "Create Task Schedule",
taskTitle: "Review Dependencies",
taskDescription: "Check npm dependencies for security vulnerabilities",
taskColumn: "todo",
modelProvider: "openai",
modelId: "gpt-4o",
},
],
command: "",
});
render(<ScheduleForm schedule={schedule} onSubmit={onSubmit} onCancel={onCancel} />);
// Create Task radio should be selected
expect(screen.getByRole("radio", { name: "Create Task" })).toHaveAttribute("aria-checked", "true");
// Task title should be populated
expect(screen.getByLabelText("Task Title (optional)")).toHaveProperty("value", "Review Dependencies");
// Task description should be populated
expect(screen.getByLabelText("Task Description (required)")).toHaveProperty("value", "Check npm dependencies for security vulnerabilities");
// Target column should be To Do
expect(screen.getByLabelText("Target Column")).toHaveProperty("value", "todo");
// Command input should not be visible
expect(screen.queryByLabelText("Command")).toBeNull();
// Prompt textarea should not be visible
expect(screen.queryByLabelText("Prompt")).toBeNull();
});
it("can switch between all three simple types without losing form state", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
// Fill in Command mode
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Test Schedule" } });
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "echo hello" } });
// Switch to AI Prompt mode
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
// Enter prompt
fireEvent.change(screen.getByLabelText("Prompt"), { target: { value: "Summarize this" } });
// Switch to Create Task mode
fireEvent.click(screen.getByRole("radio", { name: "Create Task" }));
// Enter task description
fireEvent.change(screen.getByLabelText("Task Description (required)"), {
target: { value: "Check dependencies" }
});
// Switch back to AI Prompt mode - prompt should be preserved
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
expect(screen.getByLabelText("Prompt")).toHaveProperty("value", "Summarize this");
// Switch back to Create Task mode - description should be preserved
fireEvent.click(screen.getByRole("radio", { name: "Create Task" }));
expect(screen.getByLabelText("Task Description (required)")).toHaveProperty("value", "Check dependencies");
});
});
});