diff --git a/README.md b/README.md index cc91bd1..fb88689 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,10 @@ Tasks are stored in JSON config files: Global config takes precedence on ID collision. Project configs cannot set `skipPermissions`. +The global state directory (schedules, logs, history — default `~/.claude`) can be +relocated by setting `CLAUDE_SCHEDULER_STATE_DIR`; OS registration (launchd/cron) +always uses the real home. This is primarily used to isolate state in tests. + --- ## Development @@ -117,7 +121,7 @@ Global config takes precedence on ID collision. Project configs cannot set `skip ```bash npm install npm test # Unit/integration tests -npm run test:e2e # E2E tests via Claude CLI subprocess (~9 min) +npm run test:e2e # Agentry E2E (local-only; needs claude CLI + sibling ../agentry) npm run lint # ESLint with typescript-eslint npm run typecheck # Type checking only npm run build # TypeScript compilation @@ -131,7 +135,7 @@ CI runs automatically on every push and PR to `main` (lint, typecheck, test on N The test suite has two tiers: - **Unit/Integration** (`npm test`) — fast tests covering library functions with no external dependencies. -- **E2E** (`npm run test:e2e`) — subprocess tests that invoke each plugin command through `claude --plugin-dir`. Requires the `claude` CLI to be installed and takes ~9 minutes. Skipped automatically if the CLI is not available. E2E tests use temp directories with fixture data and assert on output patterns (not exact strings) to handle Claude's non-deterministic phrasing. +- **E2E** (`npm run test:e2e`) — [agentry](https://github.com/dortort/agentry)-based tests that drive each read command through a real Claude agent (the plugin is loaded via `--plugin-dir`) and assert on output patterns (not exact strings) to handle Claude's non-deterministic phrasing. **Local-only** — not run in CI. Requires the `claude` CLI, `pnpm`, and a sibling `../agentry` checkout (build it once with `pnpm -r build`). The harness lives in [`e2e/`](e2e/README.md) as a self-contained pnpm project so it never touches the npm-managed root. Scenarios are made deterministic by pointing `CLAUDE_SCHEDULER_STATE_DIR` at a per-scenario sandbox (auth-safe — `$HOME` is untouched); mutating commands are excluded since their OS registration can't be isolated. ### Releasing diff --git a/commands/history.md b/commands/history.md index 4103e83..a6b40ea 100644 --- a/commands/history.md +++ b/commands/history.md @@ -1,6 +1,6 @@ --- allowed-tools: - - Bash(cat ~/.claude/execution-history.jsonl *) + - Bash(cat *) - Bash(tail *) --- @@ -10,9 +10,12 @@ View execution history for scheduled tasks. ## Data Sources +The state directory is `$CLAUDE_SCHEDULER_STATE_DIR` when set, otherwise +`~/.claude`; in Bash read it as `"${CLAUDE_SCHEDULER_STATE_DIR:-$HOME/.claude}"`. + | Data | Path | |------|------| -| Execution history | `~/.claude/execution-history.jsonl` | +| Execution history | `${CLAUDE_SCHEDULER_STATE_DIR:-~/.claude}/execution-history.jsonl` | History record format (one JSON object per line): ```json @@ -26,7 +29,7 @@ Fields: `taskId`, `taskName`, `status` (`success`|`failure`|`timeout`), `started ### Step 1 — Read history ```bash -cat ~/.claude/execution-history.jsonl 2>/dev/null || echo NO_HISTORY +cat "${CLAUDE_SCHEDULER_STATE_DIR:-$HOME/.claude}/execution-history.jsonl" 2>/dev/null || echo NO_HISTORY ``` ### Step 2 — Empty state diff --git a/commands/list.md b/commands/list.md index dc20c89..aedcaa9 100644 --- a/commands/list.md +++ b/commands/list.md @@ -2,6 +2,8 @@ allowed-tools: - Read - Glob + - Bash(cat *) + - Bash(tail *) - Bash(~/.claude/bin/claude-scheduler-cli *) --- @@ -11,13 +13,15 @@ List all scheduled tasks with their status, schedule, and next run time. ## Data Sources -All data comes from these exact paths — do NOT read any source files: +All data comes from these exact paths — do NOT read any source files. The state +directory is `$CLAUDE_SCHEDULER_STATE_DIR` when set, otherwise `~/.claude`; in +Bash always read it as `"${CLAUDE_SCHEDULER_STATE_DIR:-$HOME/.claude}"`. | Data | Path | |------|------| -| Global config | `~/.claude/schedules.json` | +| Global config | `${CLAUDE_SCHEDULER_STATE_DIR:-~/.claude}/schedules.json` | | Project config | `.claude/schedules.json` (optional) | -| Execution history | `~/.claude/execution-history.jsonl` | +| Execution history | `${CLAUDE_SCHEDULER_STATE_DIR:-~/.claude}/execution-history.jsonl` | Config format: ```json @@ -31,9 +35,9 @@ History format: one JSON object per line — fields: `taskId`, `taskName`, `stat ### Step 1 — Load all data in parallel Issue simultaneously: -1. `Read ~/.claude/schedules.json` -2. `Read .claude/schedules.json` (ignore if missing) -3. `Bash: tail -50 ~/.claude/execution-history.jsonl 2>/dev/null || echo NO_HISTORY` +1. `Bash: cat "${CLAUDE_SCHEDULER_STATE_DIR:-$HOME/.claude}/schedules.json" 2>/dev/null || echo NO_GLOBAL_CONFIG` +2. `Bash: cat .claude/schedules.json 2>/dev/null || echo NO_PROJECT_CONFIG` (ignore if missing) +3. `Bash: tail -50 "${CLAUDE_SCHEDULER_STATE_DIR:-$HOME/.claude}/execution-history.jsonl" 2>/dev/null || echo NO_HISTORY` ### Step 2 — Derive merged task list @@ -52,12 +56,15 @@ Then stop. ### Step 4 — Humanize cron expressions and compute next runs -Use a single `node -e` call with the compiled modules (never `src/`): +Use a single call to the compiled CLI (never `src/`): ```bash ~/.claude/bin/claude-scheduler-cli humanize --tasks '[{"id":"...","cron":"...","timezone":"..."}]' ``` +If that binary is not present, fall back to showing each task's raw cron +expression (e.g. `0 9 * * *`) and omit the next-run time. + ### Step 5 — Output table For each task display: diff --git a/commands/logs.md b/commands/logs.md index 10c804d..2d393f7 100644 --- a/commands/logs.md +++ b/commands/logs.md @@ -11,14 +11,17 @@ View stdout and stderr logs for a scheduled task. ## Data Sources +The state directory is `$CLAUDE_SCHEDULER_STATE_DIR` when set, otherwise +`~/.claude`; in Bash read it as `"${CLAUDE_SCHEDULER_STATE_DIR:-$HOME/.claude}"`. + | Data | Path pattern | |------|--------------| -| Global config | `~/.claude/schedules.json` | +| Global config | `${CLAUDE_SCHEDULER_STATE_DIR:-~/.claude}/schedules.json` | | Project config | `.claude/schedules.json` (optional) | -| stdout log (macOS) | `~/.claude/logs/.out.log` | -| stderr log (macOS) | `~/.claude/logs/.err.log` | -| combined log (Linux) | `~/.claude/logs/.log` | -| status marker | `~/.claude/logs/.status` | +| stdout log (macOS) | `${CLAUDE_SCHEDULER_STATE_DIR:-~/.claude}/logs/.out.log` | +| stderr log (macOS) | `${CLAUDE_SCHEDULER_STATE_DIR:-~/.claude}/logs/.err.log` | +| combined log (Linux) | `${CLAUDE_SCHEDULER_STATE_DIR:-~/.claude}/logs/.log` | +| status marker | `${CLAUDE_SCHEDULER_STATE_DIR:-~/.claude}/logs/.status` | Config format: ```json @@ -31,7 +34,10 @@ Status marker content examples: `success`, `failure:exit-1`, `failure:timeout` ### Step 1 — Find the task -Read `~/.claude/schedules.json` (and `.claude/schedules.json` if present) directly as JSON — no `node -e` needed. +Read the config as JSON — no `node -e` needed: +- `Bash: cat "${CLAUDE_SCHEDULER_STATE_DIR:-$HOME/.claude}/schedules.json" 2>/dev/null || echo NO_GLOBAL_CONFIG` +- `Bash: cat .claude/schedules.json 2>/dev/null || echo NO_PROJECT_CONFIG` (if present) + Match the user's input against task `id` (exact) or `name` (case-insensitive). If not found: `Task "" not found. Run /scheduler:list to see available tasks.` @@ -41,12 +47,12 @@ If not found: `Task "" not found. Run /scheduler:list to see available ta With the resolved ``, issue all reads simultaneously: **macOS:** -1. `Bash: tail -50 ~/.claude/logs/.out.log 2>/dev/null || echo NO_STDOUT` -2. `Bash: tail -50 ~/.claude/logs/.err.log 2>/dev/null || echo NO_STDERR` -3. `Bash: cat ~/.claude/logs/.status 2>/dev/null || echo NO_STATUS` +1. `Bash: tail -50 "${CLAUDE_SCHEDULER_STATE_DIR:-$HOME/.claude}/logs/.out.log" 2>/dev/null || echo NO_STDOUT` +2. `Bash: tail -50 "${CLAUDE_SCHEDULER_STATE_DIR:-$HOME/.claude}/logs/.err.log" 2>/dev/null || echo NO_STDERR` +3. `Bash: cat "${CLAUDE_SCHEDULER_STATE_DIR:-$HOME/.claude}/logs/.status" 2>/dev/null || echo NO_STATUS` **Linux:** replace steps 1+2 with: -1. `Bash: tail -50 ~/.claude/logs/.log 2>/dev/null || echo NO_LOG` +1. `Bash: tail -50 "${CLAUDE_SCHEDULER_STATE_DIR:-$HOME/.claude}/logs/.log" 2>/dev/null || echo NO_LOG` If the user requests "full" or "all", use `cat` instead of `tail -50`. diff --git a/commands/status.md b/commands/status.md index 8c50424..241a7e1 100644 --- a/commands/status.md +++ b/commands/status.md @@ -2,6 +2,8 @@ allowed-tools: - Read - Glob + - Bash(cat *) + - Bash(tail *) - Bash(launchctl list *) - Bash(crontab -l) - Bash(ls ~/Library/LaunchAgents/com.claude-scheduler.*.plist *) @@ -14,14 +16,16 @@ Show the health status of the scheduling system and individual tasks. ## Data Sources -All checks use these exact paths — do NOT read any source files: +All checks use these exact paths — do NOT read any source files. The state +directory is `$CLAUDE_SCHEDULER_STATE_DIR` when set, otherwise `~/.claude`; in +Bash always read it as `"${CLAUDE_SCHEDULER_STATE_DIR:-$HOME/.claude}"`. | Data | Path | |------|------| -| Global config | `~/.claude/schedules.json` | +| Global config | `${CLAUDE_SCHEDULER_STATE_DIR:-~/.claude}/schedules.json` | | Project config | `.claude/schedules.json` (optional, only if present) | -| Execution history | `~/.claude/execution-history.jsonl` | -| macOS plist files | `~/Library/LaunchAgents/com.claude-scheduler.*.plist` | +| Execution history | `${CLAUDE_SCHEDULER_STATE_DIR:-~/.claude}/execution-history.jsonl` | +| macOS plist files | `~/Library/LaunchAgents/com.claude-scheduler.*.plist` (OS-managed; always real home) | Config format (`schedules.json`): ```json @@ -38,9 +42,9 @@ macOS plist label format: `com.claude-scheduler.` Issue ALL of the following reads/commands simultaneously in one batch: -1. `Read ~/.claude/schedules.json` (global config) -2. `Read .claude/schedules.json` (project config — ignore if missing) -3. `Bash: tail -20 ~/.claude/execution-history.jsonl 2>/dev/null || echo NO_HISTORY` +1. `Bash: cat "${CLAUDE_SCHEDULER_STATE_DIR:-$HOME/.claude}/schedules.json" 2>/dev/null || echo NO_GLOBAL_CONFIG` (global config) +2. `Bash: cat .claude/schedules.json 2>/dev/null || echo NO_PROJECT_CONFIG` (project config — ignore if missing) +3. `Bash: tail -20 "${CLAUDE_SCHEDULER_STATE_DIR:-$HOME/.claude}/execution-history.jsonl" 2>/dev/null || echo NO_HISTORY` 4. `Bash: ls ~/Library/LaunchAgents/com.claude-scheduler.*.plist 2>/dev/null || echo NO_PLISTS` (macOS) OR `Bash: crontab -l 2>/dev/null | grep '#claude-scheduler' || echo NO_ENTRIES` (Linux — check with `uname -s` first if platform is unknown) 5. `Bash: launchctl list 2>/dev/null | grep com.claude-scheduler || echo NO_ENTRIES` (macOS only) @@ -49,9 +53,11 @@ Issue ALL of the following reads/commands simultaneously in one batch: Merge global + project tasks (project tasks override global tasks with the same `id`). -### Step 3 — Empty state (no tasks) +### Step 3 — Empty state (no configured tasks) -If the merged task list is empty or both config files are missing, output: +If the merged task list is empty (or both config files are missing), the block +below is the PRIMARY output and MUST always be shown — even when orphaned OS +registrations exist. Never replace it with an issues-only report. ``` Scheduler Status @@ -65,7 +71,9 @@ No tasks are currently scheduled. Run /scheduler:add to create your first task. ``` -Then stop — do not attempt further per-task checks. +You MAY append a brief "Orphaned registrations" note afterward if plist or +launchctl entries exist with no matching config, but the `Tasks: none configured` +line must appear first. Do not perform per-task status checks. ### Step 4 — Per-task status table diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..0930b30 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,75 @@ +# Scheduler E2E (agentry) + +End-to-end tests that drive the scheduler's slash commands through a **real Claude +agent** using [agentry](https://github.com/dortort/agentry) ("Playwright for AI +Agents"), then assert on the agent's reply. These replace the old Vitest-subprocess +suite. + +This is a **self-contained pnpm project**, deliberately isolated from the repo's +npm-managed root so `npm ci` in CI never has to resolve agentry's `workspace:*` +dependencies. It is **local-only** and never runs in CI. + +## Prerequisites + +- The `claude` CLI on your `PATH`, authenticated. +- `pnpm`. +- A sibling checkout of agentry at `../agentry`, built once: + ```bash + cd ../agentry && pnpm install && pnpm -r build + ``` + agentry is linked via `file:` in `package.json`; the `pnpm.overrides` there + rewrite its internal `workspace:*` deps to the sibling paths. + +## Running + +From the repo root: + +```bash +npm run test:e2e # installs e2e deps, then runs all scenarios (live) +``` + +Or from this directory: + +```bash +pnpm install +pnpm test # all scenarios +pnpm exec tsx node_modules/agentry/src/bin.ts test --mode dry # $0 discovery check +pnpm exec tsx node_modules/agentry/src/bin.ts test --grep "not found" # one scenario +``` + +`agentry.config.ts` runs in `live` mode (real agent, ~$0.04/scenario). No cassettes +are recorded or committed. Because a real model varies its phrasing and path run to +run, assertions on free text are inherently a little flaky; `retries: 2` is set so a +flaky scenario re-runs before failing (state isolation, below, is deterministic — only +the model's wording is not). + +## How it works + +- `commands.agentry.ts` — the scenarios (`test`/`test.describe` from `agentry`). +- `fixtures.ts` — seeds a scenario's isolated state dir with sample scheduler data. +- `helpers.ts` — `SCHEDULER_ROOT` (the `--plugin-dir` target), `runOpts()`, and + tolerant text assertions over the agent's reply. + +Each scenario gets a fresh sandbox as its working directory; `agent.run(cmd, opts)` +spawns `claude --plugin-dir -p ` so the scheduler plugin is loaded. + +### State isolation (determinism) + +The scheduler otherwise reads **global** state from `~/.claude` and +`~/Library/LaunchAgents`, which a cwd sandbox can't isolate. To make the scenarios +deterministic, each run sets **`CLAUDE_SCHEDULER_STATE_DIR`** (via agentry's per-run +`env`) to a `state/` subdir of the sandbox — the read commands (`status`, `list`, +`history`, `logs`) resolve their paths from it, and the fixtures seed into it. This +is auth-safe: it does **not** touch `$HOME`, so the `claude` CLI keeps its normal +credentials (unlike a `$HOME` remap, which drops OAuth auth). + +Not isolated by the state dir: OS registration (`~/Library/LaunchAgents`, launchctl). +The read commands only consult those cosmetically, and the assertions don't depend on +them. + +### Not covered: mutating commands + +`/scheduler:add` (and the other mutating commands) are intentionally **not** exercised +here: they perform real OS registration (launchd/cron) and executor installation that +`CLAUDE_SCHEDULER_STATE_DIR` can't relocate, so running them would mutate the host. +They remain covered by the unit/integration suite (`npm test`). diff --git a/e2e/agentry.config.ts b/e2e/agentry.config.ts new file mode 100644 index 0000000..beb2d67 --- /dev/null +++ b/e2e/agentry.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'agentry'; + +export default defineConfig({ + testDir: '.', + mode: 'live', + use: { agent: 'claude', model: 'claude-haiku-4-5' }, + timeout: 120_000, + // Live agent runs vary in phrasing/path; retry a flaky scenario before failing. + retries: 2, + budget: { perTest: { usd: 1 } }, +}); diff --git a/e2e/commands.agentry.ts b/e2e/commands.agentry.ts new file mode 100644 index 0000000..b2ce9c5 --- /dev/null +++ b/e2e/commands.agentry.ts @@ -0,0 +1,92 @@ +/** + * E2E: drive the scheduler plugin's slash commands through a real Claude agent + * via agentry, and assert on the agent's textual reply. + * + * Each scenario redirects the scheduler's global state to its sandbox via + * CLAUDE_SCHEDULER_STATE_DIR (see helpers.runOpts), so empty/populated states + * are deterministic and isolated from the real ~/.claude. + * + * Local-only (not in CI). Requires the `claude` CLI. Run: `npm run test:e2e`. + * The mutating `/scheduler:add` command is intentionally not covered here — it + * performs real OS registration (launchd/cron) that the state dir can't isolate. + */ +import { test } from 'agentry'; +import { runOpts, agentText, assertContainsAny, assertNotContains } from './helpers.js'; +import { seedPopulated } from './fixtures.js'; + +test.describe('E2E Scheduler Commands', () => { + test.describe('Empty State', () => { + test('/scheduler:status — reports no tasks', async ({ agent, workspace }) => { + const out = agentText(await agent.run('/scheduler:status', runOpts(workspace))); + // "no corresponding config" covers the case where the machine has orphaned + // OS registrations but the (isolated) config is empty. + assertContainsAny(out, [ + 'none configured', + 'no tasks', + '0 tasks', + 'no scheduled', + 'no configured tasks', + 'no corresponding config', + ]); + assertContainsAny(out, ['macos', 'darwin', 'launchd', 'mac']); + }); + + test('/scheduler:list — reports no tasks', async ({ agent, workspace }) => { + const out = agentText(await agent.run('/scheduler:list', runOpts(workspace))); + assertContainsAny(out, ['no scheduled tasks', 'no tasks', '0 tasks', 'none']); + assertContainsAny(out, ['/scheduler:add', 'add', 'create', 'schedule']); + }); + + test('/scheduler:history — reports no history', async ({ agent, workspace }) => { + const out = agentText(await agent.run('/scheduler:history', runOpts(workspace))); + assertContainsAny(out, [ + 'no execution history', + 'no history', + "haven't been executed", + 'no executions', + 'no records', + 'none', + ]); + }); + }); + + test.describe('Populated State', () => { + test('/scheduler:status — shows task info', async ({ agent, workspace }) => { + await seedPopulated(workspace); + const out = agentText(await agent.run('/scheduler:status', runOpts(workspace))); + assertContainsAny(out, ['e2e-daily-review', 'E2E Daily Review']); + assertNotContains(out, ['none configured']); + }); + + test('/scheduler:list — shows task with schedule', async ({ agent, workspace }) => { + await seedPopulated(workspace); + const out = agentText(await agent.run('/scheduler:list', runOpts(workspace))); + assertContainsAny(out, ['E2E Daily Review', 'e2e-daily-review']); + assertContainsAny(out, ['daily', '9:00', '9 am', '09:00', '0 9 * * *']); + }); + + test('/scheduler:history — shows execution records', async ({ agent, workspace }) => { + await seedPopulated(workspace); + const out = agentText(await agent.run('/scheduler:history', runOpts(workspace))); + assertContainsAny(out, ['e2e-daily-review', 'E2E Daily Review', 'success', 'failure', 'execution']); + }); + + test('/scheduler:logs e2e-daily-review — shows log content', async ({ agent, workspace }) => { + await seedPopulated(workspace); + const out = agentText(await agent.run('/scheduler:logs e2e-daily-review', runOpts(workspace))); + assertContainsAny(out, [ + 'Reviewing recent commits', + 'Found 3 commits', + 'Review complete', + 'recent commits for project', + ]); + assertNotContains(out, ['not found', 'no logs']); + }); + + test('/scheduler:logs nonexistent-task — reports not found', async ({ agent, workspace }) => { + await seedPopulated(workspace); + const out = agentText(await agent.run('/scheduler:logs nonexistent-task', runOpts(workspace))); + assertContainsAny(out, ['not found', 'no task', 'no logs', 'does not exist', 'unknown']); + }); + }); +}); diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 0000000..d080553 --- /dev/null +++ b/e2e/fixtures.ts @@ -0,0 +1,74 @@ +import type { Sandbox } from 'agentry'; +import { STATE_SUBDIR } from './helpers.js'; + +/** + * Seed a sandbox's isolated state dir (CLAUDE_SCHEDULER_STATE_DIR) with the + * sample data the "populated" scenarios expect: one task (`e2e-daily-review`), + * a two-record execution history, and its log files. + */ +export async function seedPopulated(workspace: Sandbox): Promise { + const dir = workspace.dir; + const p = (rel: string) => `${STATE_SUBDIR}/${rel}`; + + const schedulesConfig = { + version: 1, + tasks: [ + { + id: 'e2e-daily-review', + name: 'E2E Daily Review', + enabled: true, + trigger: { type: 'cron', expression: '0 9 * * *', timezone: 'local' }, + execution: { + command: 'Review recent commits', + workingDirectory: dir, + timeout: 300, + skipPermissions: false, + }, + tags: [], + createdAt: '2026-01-15T10:00:00.000Z', + updatedAt: '2026-01-15T10:00:00.000Z', + }, + ], + }; + await workspace.write(p('schedules.json'), JSON.stringify(schedulesConfig, null, 2)); + + const successRecord = { + id: 'exec-001', + taskId: 'e2e-daily-review', + taskName: 'E2E Daily Review', + project: dir, + startedAt: '2026-01-16T09:00:00.000Z', + completedAt: '2026-01-16T09:01:30.000Z', + status: 'success', + triggeredBy: 'scheduled', + duration: 90000, + exitCode: 0, + }; + const failureRecord = { + id: 'exec-002', + taskId: 'e2e-daily-review', + taskName: 'E2E Daily Review', + project: dir, + startedAt: '2026-01-17T09:00:00.000Z', + completedAt: '2026-01-17T09:00:45.000Z', + status: 'failure', + triggeredBy: 'scheduled', + duration: 45000, + exitCode: 1, + error: 'Command exited with code 1', + }; + await workspace.write( + p('execution-history.jsonl'), + `${JSON.stringify(successRecord)}\n${JSON.stringify(failureRecord)}\n`, + ); + + await workspace.write( + p('logs/e2e-daily-review.out.log'), + 'Reviewing recent commits for project\nFound 3 commits in the last 24 hours\nReview complete\n', + ); + await workspace.write( + p('logs/e2e-daily-review.err.log'), + 'Warning: large diff detected in commit abc123\n', + ); + await workspace.write(p('logs/e2e-daily-review.status'), 'success'); +} diff --git a/e2e/helpers.ts b/e2e/helpers.ts new file mode 100644 index 0000000..a8d2acd --- /dev/null +++ b/e2e/helpers.ts @@ -0,0 +1,51 @@ +import path from 'node:path'; +import type { AgentRunExtra, RunRecord, Sandbox } from 'agentry'; + +/** Repo root, used as the Claude Code `--plugin-dir` so the scheduler plugin loads. */ +export const SCHEDULER_ROOT = path.resolve(import.meta.dirname, '..'); + +/** Sandbox-relative subdir used as the scheduler's isolated global state directory. */ +export const STATE_SUBDIR = 'state'; + +/** Absolute state dir for a scenario's sandbox (what CLAUDE_SCHEDULER_STATE_DIR points at). */ +export function stateDir(workspace: Sandbox): string { + return path.join(workspace.dir, STATE_SUBDIR); +} + +/** + * Run options for a scenario: load the scheduler plugin, skip interactive + * approvals, and redirect the scheduler's global state to the sandbox so the + * command reads/writes are isolated from the real `~/.claude`. + */ +export function runOpts(workspace: Sandbox): AgentRunExtra { + return { + pluginDir: SCHEDULER_ROOT, + permissionMode: 'bypassPermissions', + env: { CLAUDE_SCHEDULER_STATE_DIR: stateDir(workspace) }, + }; +} + +/** All assistant text from a run, joined — the analog of the old stdout+stderr capture. */ +export function agentText(rec: RunRecord): string { + return rec.assistantMessages.map((m) => m.payload.text).join('\n'); +} + +/** Assert the output contains at least one of the alternatives (case-insensitive). */ +export function assertContainsAny(output: string, alternatives: string[]): void { + const lower = output.toLowerCase(); + if (!alternatives.some((alt) => lower.includes(alt.toLowerCase()))) { + throw new Error( + `Expected output to contain at least one of [${alternatives.map((a) => `"${a}"`).join(', ')}] but none were found.\nOutput:\n${output}`, + ); + } +} + +/** Assert the output does NOT contain any of the patterns (case-insensitive). */ +export function assertNotContains(output: string, patterns: string[]): void { + const lower = output.toLowerCase(); + for (const pattern of patterns) { + if (lower.includes(pattern.toLowerCase())) { + throw new Error(`Expected output NOT to contain "${pattern}" but it was found.\nOutput:\n${output}`); + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..a3e57bf --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,20 @@ +{ + "name": "scheduler-e2e", + "private": true, + "type": "module", + "description": "Agentry-based end-to-end tests for @dortort/scheduler (local-only; consumes ../../agentry).", + "scripts": { + "test": "tsx node_modules/agentry/src/bin.ts test" + }, + "devDependencies": { + "agentry": "file:../../agentry/packages/cli", + "tsx": "^4.19.2" + }, + "pnpm": { + "overrides": { + "@agentry/core": "file:../../agentry/packages/core", + "@agentry/claude": "file:../../agentry/packages/claude", + "@agentry/mcp": "file:../../agentry/packages/mcp" + } + } +} diff --git a/e2e/pnpm-lock.yaml b/e2e/pnpm-lock.yaml new file mode 100644 index 0000000..2e64ee3 --- /dev/null +++ b/e2e/pnpm-lock.yaml @@ -0,0 +1,673 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + '@agentry/core': file:../../agentry/packages/core + '@agentry/claude': file:../../agentry/packages/claude + '@agentry/mcp': file:../../agentry/packages/mcp + +importers: + + .: + devDependencies: + agentry: + specifier: file:../../agentry/packages/cli + version: file:../../agentry/packages/cli + tsx: + specifier: ^4.19.2 + version: 4.23.1 + +packages: + + '@agentry/claude@file:../../agentry/packages/claude': + resolution: {directory: ../../agentry/packages/claude, type: directory} + + '@agentry/core@file:../../agentry/packages/core': + resolution: {directory: ../../agentry/packages/core, type: directory} + + '@agentry/mcp@file:../../agentry/packages/mcp': + resolution: {directory: ../../agentry/packages/mcp, type: directory} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jest/expect-utils@29.7.0': + resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/types@29.6.3': + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@sinclair/typebox@0.27.12': + resolution: {integrity: sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/node@26.1.2': + resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} + + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + + agentry@file:../../agentry/packages/cli: + resolution: {directory: ../../agentry/packages/cli, type: directory} + hasBin: true + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + expect@29.7.0: + resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + jest-diff@29.7.0: + resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-matcher-utils@29.7.0: + resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-message-util@29.7.0: + resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tsx@4.23.1: + resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + engines: {node: '>=18.0.0'} + hasBin: true + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@agentry/claude@file:../../agentry/packages/claude': + dependencies: + '@agentry/core': file:../../agentry/packages/core + + '@agentry/core@file:../../agentry/packages/core': + dependencies: + expect: 29.7.0 + zod: 3.25.76 + + '@agentry/mcp@file:../../agentry/packages/mcp': + dependencies: + '@agentry/core': file:../../agentry/packages/core + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.29.7': {} + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@jest/expect-utils@29.7.0': + dependencies: + jest-get-type: 29.6.3 + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.12 + + '@jest/types@29.6.3': + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 26.1.2 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + + '@sinclair/typebox@0.27.12': {} + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/node@26.1.2': + dependencies: + undici-types: 8.3.0 + + '@types/stack-utils@2.0.3': {} + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.35': + dependencies: + '@types/yargs-parser': 21.0.3 + + agentry@file:../../agentry/packages/cli: + dependencies: + '@agentry/claude': file:../../agentry/packages/claude + '@agentry/core': file:../../agentry/packages/core + '@agentry/mcp': file:../../agentry/packages/mcp + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + ci-info@3.9.0: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + diff-sequences@29.6.3: {} + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + escape-string-regexp@2.0.0: {} + + expect@29.7.0: + dependencies: + '@jest/expect-utils': 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + fsevents@2.3.3: + optional: true + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + is-number@7.0.0: {} + + jest-diff@29.7.0: + dependencies: + chalk: 4.1.2 + diff-sequences: 29.6.3 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-get-type@29.6.3: {} + + jest-matcher-utils@29.7.0: + dependencies: + chalk: 4.1.2 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-message-util@29.7.0: + dependencies: + '@babel/code-frame': 7.29.7 + '@jest/types': 29.6.3 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-util@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 26.1.2 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.2 + + js-tokens@4.0.0: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + react-is@18.3.1: {} + + slash@3.0.0: {} + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tsx@4.23.1: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + undici-types@8.3.0: {} + + zod@3.25.76: {} diff --git a/package.json b/package.json index c1a39ba..3b0ab80 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", - "test:e2e": "vitest run --config vitest.config.e2e.ts", + "test:e2e": "pnpm -C e2e install && pnpm -C e2e test", "test:coverage": "vitest run --coverage", "lint": "eslint src/", "prepublishOnly": "npm run build" diff --git a/src/__tests__/e2e/commands.test.ts b/src/__tests__/e2e/commands.test.ts deleted file mode 100644 index d7c2e30..0000000 --- a/src/__tests__/e2e/commands.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * E2E tests: invoke scheduler plugin commands via Claude CLI subprocess. - * - * These tests require the `claude` CLI to be installed. - * They are skipped automatically if it is not available. - */ - -import { describe, it, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; - -import { - runSchedulerCommand, - assertContainsAny, - assertNotContains, - checkClaudeAvailable, -} from './helpers.js'; -import { - createEmptyProject, - createPopulatedProject, - cleanupProject, -} from './fixtures.js'; - -describe('E2E Scheduler Commands', { timeout: 120_000 }, () => { - let claudeAvailable = false; - - beforeAll(async () => { - claudeAvailable = await checkClaudeAvailable(); - if (!claudeAvailable) { - console.warn('Claude CLI not found — skipping E2E tests'); - } - }); - - describe('Empty State', () => { - let projectDir: string; - - beforeAll(async () => { - if (!claudeAvailable) return; - projectDir = await createEmptyProject(); - }); - - afterAll(async () => { - if (projectDir) await cleanupProject(projectDir); - }); - - it('/scheduler:status — reports no tasks', async ({ skip }) => { - if (!claudeAvailable) skip(); - const result = await runSchedulerCommand('status', projectDir); - const output = result.stdout + result.stderr; - assertContainsAny(output, ['none configured', 'no tasks', '0 tasks', 'no scheduled']); - assertContainsAny(output, ['macos', 'darwin', 'launchd', 'mac']); - }); - - it('/scheduler:list — reports no tasks', async ({ skip }) => { - if (!claudeAvailable) skip(); - const result = await runSchedulerCommand('list', projectDir); - const output = result.stdout + result.stderr; - assertContainsAny(output, ['no scheduled tasks', 'no tasks', '0 tasks', 'none']); - assertContainsAny(output, ['/scheduler:add', 'add', 'create', 'schedule']); - }); - - it('/scheduler:history — reports no history', async ({ skip }) => { - if (!claudeAvailable) skip(); - const result = await runSchedulerCommand('history', projectDir); - const output = result.stdout + result.stderr; - assertContainsAny(output, [ - 'no execution history', - 'no history', - "haven't been executed", - 'no executions', - 'no records', - 'none', - ]); - }); - }); - - describe('Populated State', () => { - let projectDir: string; - - beforeAll(async () => { - if (!claudeAvailable) return; - projectDir = await createPopulatedProject(); - }); - - afterAll(async () => { - if (projectDir) await cleanupProject(projectDir); - }); - - it('/scheduler:status — shows task info', async ({ skip }) => { - if (!claudeAvailable) skip(); - const result = await runSchedulerCommand('status', projectDir); - const output = result.stdout + result.stderr; - assertContainsAny(output, ['e2e-daily-review', 'E2E Daily Review']); - assertNotContains(output, ['none configured']); - }); - - it('/scheduler:list — shows task with schedule', async ({ skip }) => { - if (!claudeAvailable) skip(); - const result = await runSchedulerCommand('list', projectDir); - const output = result.stdout + result.stderr; - assertContainsAny(output, ['E2E Daily Review', 'e2e-daily-review']); - assertContainsAny(output, ['daily', '9:00', '9 am', '09:00', '0 9 * * *']); - }); - - it('/scheduler:history — runs without error', async ({ skip }) => { - if (!claudeAvailable) skip(); - // Note: history reads from ~/.claude/execution-history.jsonl (global only), - // so project-level fixture data is not visible to this command. - // We verify the command completes and produces recognizable output. - const result = await runSchedulerCommand('history', projectDir); - const output = result.stdout + result.stderr; - assertContainsAny(output, [ - // If global history exists, it shows records - 'execution', 'history', 'run', - // If no global history, it reports empty state - 'no execution history', "haven't been executed", 'no history', - ]); - }); - - it('/scheduler:logs e2e-daily-review — shows log content', async ({ skip }) => { - if (!claudeAvailable) skip(); - const result = await runSchedulerCommand('logs', projectDir, 'e2e-daily-review'); - const output = result.stdout + result.stderr; - assertContainsAny(output, [ - 'Reviewing recent commits', - 'Found 3 commits', - 'Review complete', - 'recent commits for project', - ]); - assertNotContains(output, ['not found', 'no logs']); - }); - - it('/scheduler:logs nonexistent-task — reports not found', async ({ skip }) => { - if (!claudeAvailable) skip(); - const result = await runSchedulerCommand('logs', projectDir, 'nonexistent-task'); - const output = result.stdout + result.stderr; - assertContainsAny(output, ['not found', 'no task', 'no logs', 'does not exist', 'unknown']); - }); - }); - - describe('Mutating Commands', () => { - let projectDir: string; - - beforeEach(async () => { - if (!claudeAvailable) return; - projectDir = await createEmptyProject(); - }); - - afterEach(async () => { - if (projectDir) await cleanupProject(projectDir); - }); - - it('/scheduler:add — creates a task', async ({ skip }) => { - if (!claudeAvailable) skip(); - const result = await runSchedulerCommand( - 'add', - projectDir, - 'Schedule a task called "E2E Nightly" to run "Check updates" every day at 11pm', - ); - const output = result.stdout + result.stderr; - assertContainsAny(output, ['created', 'added', 'scheduled', 'success', 'registered']); - // Verify the output references the task name or schedule - assertContainsAny(output, ['nightly', 'E2E Nightly', 'check updates', '11', '23:00']); - }); - }); -}); diff --git a/src/__tests__/e2e/fixtures.ts b/src/__tests__/e2e/fixtures.ts deleted file mode 100644 index cdc1523..0000000 --- a/src/__tests__/e2e/fixtures.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * E2E test fixtures: temp directory setup with sample data. - */ - -import fs from 'node:fs/promises'; -import path from 'node:path'; -import os from 'node:os'; - -/** - * Create a temp project directory with no `.claude/` dir (empty state). - */ -export async function createEmptyProject(): Promise { - return fs.mkdtemp(path.join(os.tmpdir(), 'scheduler-e2e-empty-')); -} - -/** - * Create a temp project directory populated with sample scheduler data. - */ -export async function createPopulatedProject(): Promise { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'scheduler-e2e-pop-')); - const claudeDir = path.join(tmpDir, '.claude'); - const logsDir = path.join(claudeDir, 'logs'); - - await fs.mkdir(claudeDir, { recursive: true }); - await fs.mkdir(logsDir, { recursive: true }); - - // schedules.json with one sample task - const schedulesConfig = { - version: 1, - tasks: [ - { - id: 'e2e-daily-review', - name: 'E2E Daily Review', - enabled: true, - trigger: { - type: 'cron', - expression: '0 9 * * *', - timezone: 'local', - }, - execution: { - command: 'Review recent commits', - workingDirectory: tmpDir, - timeout: 300, - skipPermissions: false, - }, - tags: [], - createdAt: '2026-01-15T10:00:00.000Z', - updatedAt: '2026-01-15T10:00:00.000Z', - }, - ], - }; - await fs.writeFile( - path.join(claudeDir, 'schedules.json'), - JSON.stringify(schedulesConfig, null, 2), - 'utf-8', - ); - - // execution-history.jsonl with one success and one failure record - const successRecord = { - id: 'exec-001', - taskId: 'e2e-daily-review', - taskName: 'E2E Daily Review', - project: tmpDir, - startedAt: '2026-01-16T09:00:00.000Z', - completedAt: '2026-01-16T09:01:30.000Z', - status: 'success', - triggeredBy: 'scheduled', - duration: 90000, - exitCode: 0, - }; - const failureRecord = { - id: 'exec-002', - taskId: 'e2e-daily-review', - taskName: 'E2E Daily Review', - project: tmpDir, - startedAt: '2026-01-17T09:00:00.000Z', - completedAt: '2026-01-17T09:00:45.000Z', - status: 'failure', - triggeredBy: 'scheduled', - duration: 45000, - exitCode: 1, - error: 'Command exited with code 1', - }; - const historyContent = - JSON.stringify(successRecord) + '\n' + JSON.stringify(failureRecord) + '\n'; - await fs.writeFile( - path.join(claudeDir, 'execution-history.jsonl'), - historyContent, - 'utf-8', - ); - - // Log files for e2e-daily-review - await fs.writeFile( - path.join(logsDir, 'e2e-daily-review.out.log'), - 'Reviewing recent commits for project\nFound 3 commits in the last 24 hours\nReview complete\n', - 'utf-8', - ); - await fs.writeFile( - path.join(logsDir, 'e2e-daily-review.err.log'), - 'Warning: large diff detected in commit abc123\n', - 'utf-8', - ); - await fs.writeFile( - path.join(logsDir, 'e2e-daily-review.status'), - 'success', - 'utf-8', - ); - - return tmpDir; -} - -/** - * Clean up a temp project directory. - */ -export async function cleanupProject(dir: string): Promise { - await fs.rm(dir, { recursive: true, force: true }); -} diff --git a/src/__tests__/e2e/helpers.ts b/src/__tests__/e2e/helpers.ts deleted file mode 100644 index 76aea77..0000000 --- a/src/__tests__/e2e/helpers.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * E2E test helpers: subprocess runner + assertion utilities. - */ - -import { spawn } from 'node:child_process'; -import { execFile } from 'node:child_process'; -import path from 'node:path'; - -const PROJECT_ROOT = path.resolve(import.meta.dirname, '..', '..', '..'); - -export interface CommandResult { - stdout: string; - stderr: string; - exitCode: number; -} - -/** - * Run a scheduler plugin command via the Claude CLI subprocess. - * - * Resolves on the `exit` event (not `close`) to avoid hangs when child - * processes spawned by hooks keep inherited pipes open. A short drain - * delay after exit ensures buffered stdout/stderr data is captured. - */ -export async function runSchedulerCommand( - command: string, - cwd: string, - extraPrompt?: string, -): Promise { - const prompt = extraPrompt - ? `/scheduler:${command} ${extraPrompt}` - : `/scheduler:${command}`; - - return new Promise((resolve, reject) => { - const child = spawn( - 'claude', - ['--plugin-dir', PROJECT_ROOT, '-p', prompt], - { - cwd, - - stdio: ['ignore', 'pipe', 'pipe'], - }, - ); - - let stdout = ''; - let stderr = ''; - let settled = false; - - const settle = (exitCode: number) => { - if (settled) return; - settled = true; - clearTimeout(timer); - // Short delay to let remaining pipe data flush before resolving - setTimeout(() => resolve({ stdout, stderr, exitCode }), 200); - }; - - child.stdout.on('data', (data: Buffer) => { - stdout += data.toString(); - }); - - child.stderr.on('data', (data: Buffer) => { - stderr += data.toString(); - }); - - const timer = setTimeout(() => { - if (!settled) { - child.kill('SIGKILL'); - settle(-1); - } - }, 115_000); - - // Use `exit` instead of `close` — `close` waits for all pipes to close, - // which can hang if hooks spawn children that inherit the FDs. - child.on('exit', (code: number | null) => { - settle(code ?? 1); - }); - - child.on('error', (err: Error) => { - if (!settled) { - settled = true; - clearTimeout(timer); - reject(err); - } - }); - }); -} - -/** - * Assert that the output contains all of the given patterns (case-insensitive). - */ -export function assertContains(output: string, patterns: string[]): void { - const lower = output.toLowerCase(); - for (const pattern of patterns) { - if (!lower.includes(pattern.toLowerCase())) { - throw new Error( - `Expected output to contain "${pattern}" but it did not.\nOutput:\n${output}`, - ); - } - } -} - -/** - * Assert that the output contains at least one of the given alternatives (case-insensitive). - */ -export function assertContainsAny(output: string, alternatives: string[]): void { - const lower = output.toLowerCase(); - const found = alternatives.some((alt) => lower.includes(alt.toLowerCase())); - if (!found) { - throw new Error( - `Expected output to contain at least one of [${alternatives.map((a) => `"${a}"`).join(', ')}] but none were found.\nOutput:\n${output}`, - ); - } -} - -/** - * Assert that the output does NOT contain any of the given patterns (case-insensitive). - */ -export function assertNotContains(output: string, patterns: string[]): void { - const lower = output.toLowerCase(); - for (const pattern of patterns) { - if (lower.includes(pattern.toLowerCase())) { - throw new Error( - `Expected output NOT to contain "${pattern}" but it was found.\nOutput:\n${output}`, - ); - } - } -} - -/** - * Check if the `claude` CLI is available on the system. - */ -export async function checkClaudeAvailable(): Promise { - return new Promise((resolve) => { - execFile('which', ['claude'], { timeout: 5_000 }, (error) => { - resolve(!error); - }); - }); -} diff --git a/src/config.ts b/src/config.ts index b50b39f..d3c5c30 100644 --- a/src/config.ts +++ b/src/config.ts @@ -17,8 +17,17 @@ import { isSafeIdentifier } from './utils/shell.js'; // --- Path Resolution --- +/** + * The scheduler's state directory (schedules, logs, history). Defaults to + * `~/.claude`; override with `CLAUDE_SCHEDULER_STATE_DIR`. Isolating this lets + * tests exercise the commands without touching real global state. + */ +export function getStateDir(): string { + return process.env.CLAUDE_SCHEDULER_STATE_DIR ?? path.join(os.homedir(), '.claude'); +} + export function getGlobalSchedulesPath(): string { - return path.join(os.homedir(), '.claude', 'schedules.json'); + return path.join(getStateDir(), 'schedules.json'); } export function getProjectSchedulesPath(projectPath: string): string { @@ -26,11 +35,11 @@ export function getProjectSchedulesPath(projectPath: string): string { } export function getLogsDir(): string { - return path.join(os.homedir(), '.claude', 'logs'); + return path.join(getStateDir(), 'logs'); } export function getHistoryPath(): string { - return path.join(os.homedir(), '.claude', 'execution-history.jsonl'); + return path.join(getStateDir(), 'execution-history.jsonl'); } /** diff --git a/vitest.config.e2e.ts b/vitest.config.e2e.ts deleted file mode 100644 index e52dc49..0000000 --- a/vitest.config.e2e.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - include: ['src/__tests__/e2e/**/*.test.ts'], - testTimeout: 120_000, - pool: 'forks', - poolOptions: { - forks: { - singleFork: true, - }, - }, - restoreMocks: true, - passWithNoTests: true, - }, -}); diff --git a/vitest.config.ts b/vitest.config.ts index d96737c..072c5ac 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,7 +3,6 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { include: ['src/__tests__/**/*.test.ts'], - exclude: ['src/__tests__/e2e/**'], testTimeout: 10000, restoreMocks: true, passWithNoTests: true,