diff --git a/.claude/skills/zephyr/SKILL.md b/.claude/skills/zephyr/SKILL.md new file mode 100644 index 0000000..596849a --- /dev/null +++ b/.claude/skills/zephyr/SKILL.md @@ -0,0 +1,315 @@ +--- +name: zephyr +description: Zephyr Scale CLI - manage test resources, run test cycles interactively, and automate test operations +targets: ["claudecode"] +claudecode: + allowed-tools: + - "Bash" + - "Read" + - "Write" +--- + +# Zephyr Scale CLI Operations + +The `zephyr` CLI wraps the entire Zephyr Scale REST API. Both users and AI agents can perform the same operations. + +## Global Options + +Every command accepts: + +``` +-p, --profile Profile name (default: "default") +-c, --config Custom config file path +--text Human-readable text output (default: JSON) +--verbose Detailed logging +``` + +--- + +## 1. Snapshot Workflow (Recommended for Test Execution) + +The snapshot workflow is the primary way to manage test execution. It syncs a test cycle into a local JSON file, which can be sorted, edited, and played back interactively. + +### play init - Interactive Setup + +Select a test cycle interactively and create a snapshot: + +```bash +zephyr play init +# 1) PRJ-R1: Sprint 1 Regression +# 2) PRJ-R2: Sprint 2 Smoke Test +# Select cycle number: 1 +# => Snapshot saved to PRJ-R1.json (42 test cases) +# => Run: zephyr play PRJ-R1.json +``` + +### snapshot sync - Sync from Zephyr + +**Initial sync** (test cycle key to new file): +```bash +zephyr snapshot sync PRJ-R1 -o PRJ-R1.json +``` + +**Re-sync** (update existing file from Zephyr): +```bash +zephyr snapshot sync PRJ-R1.json +``` + +**Reload a single test case** (partial update): +```bash +zephyr snapshot sync PRJ-R1.json -t PRJ-T123 +``` + +### snapshot sort - Sort Test Cases + +```bash +zephyr snapshot sort PRJ-R1.json --by folder # by folder path +zephyr snapshot sort PRJ-R1.json --by key # by test case key +zephyr snapshot sort PRJ-R1.json --by name # by test case name +zephyr snapshot sort PRJ-R1.json --by status # by execution status +zephyr snapshot sort PRJ-R1.json --by created # by creation date +zephyr snapshot sort PRJ-R1.json --by original # by Zephyr execution order +``` + +### play - Interactive TUI + +Launch the interactive test execution interface: + +```bash +zephyr play PRJ-R1.json +zephyr play PRJ-R1.json --filter unexecuted +zephyr play PRJ-R1.json --filter "folder=/Authentication" +zephyr play PRJ-R1.json --filter "status=Fail" +``` + +The TUI allows step-by-step test execution with keyboard navigation. Both human users and AI agents can drive test cycles through this workflow. + +### snapshot record - Record Results (CLI) + +Record test results without the TUI: + +```bash +# Set overall execution status +zephyr snapshot record PRJ-R1.json PRJ-T123 --status Pass + +# Set status with comment +zephyr snapshot record PRJ-R1.json PRJ-T123 --status Fail --comment "Button not visible" + +# Update a specific step result +zephyr snapshot record PRJ-R1.json PRJ-T123 --step 0 --status Pass --actual-result "Page loaded" +``` + +--- + +## 2. Test Case Management + +### Create + +```bash +zephyr testcase create \ + --name "Login succeeds with valid credentials" \ + --objective "Verify login flow" \ + --precondition "Login page is displayed" \ + --folder-id 123456 \ + --step "Enter username|Username is entered" \ + --step "Enter password|Password is entered" \ + --step "Click login|Dashboard is displayed" +``` + +`--step` format: `"description|expected result"` or `"description|test data|expected result"` + +### Get / List + +```bash +zephyr testcase get PRJ-T123 +zephyr testcase list --folder-id 123456 +``` + +### Update (metadata only, does NOT update steps) + +```bash +zephyr testcase update PRJ-T123 \ + --name "Updated title" \ + --objective "Updated objective" \ + --folder-id 654321 +``` + +--- + +## 3. Test Step Management + +Use when you need to modify steps after test case creation. + +### Create / Replace Steps + +```bash +# Overwrite all steps +zephyr teststep create PRJ-T123 \ + --mode OVERWRITE \ + --inline "Step description" \ + --expected-result "Expected result" \ + --test-data "Test data" + +# Append new steps +zephyr teststep create PRJ-T123 \ + --mode APPEND \ + --inline "New step" \ + --expected-result "Expected result" +``` + +### List Steps + +```bash +zephyr teststep list PRJ-T123 +``` + +--- + +## 4. Test Cycle Management + +### Create / Get / List / Update + +```bash +zephyr testcycle create --name "Sprint 1 Regression" --description "Full regression" +zephyr testcycle get PRJ-R1 +zephyr testcycle list +zephyr testcycle update PRJ-R1 --name "Updated name" +``` + +### Folder Tree (shows test cases grouped by folder) + +```bash +zephyr testcycle tree PRJ-R1 +``` + +--- + +## 5. Test Execution Management + +### Create (add a test case to a cycle) + +```bash +zephyr testexecution create \ + --test-case-key PRJ-T123 \ + --test-cycle-key PRJ-R1 \ + --status-name "Not Executed" +``` + +### Update + +```bash +# Update single execution +zephyr testexecution update PRJ-E1 --status-name "Pass" + +# Bulk update: all executions in a cycle +zephyr testexecution update --test-cycle PRJ-R1 --status-name "Pass" +``` + +### Get / List + +```bash +zephyr testexecution get PRJ-E1 +zephyr testexecution list --test-cycle PRJ-R1 +``` + +--- + +## 6. Test Plan Management + +```bash +zephyr testplan create --name "Release v2.0" --objective "Full coverage" +zephyr testplan get PRJ-P1 +zephyr testplan list +``` + +--- + +## 7. Folder Management + +### Create + +```bash +zephyr folder create \ + --name "Authentication" \ + --folder-type TEST_CASE \ + --parent-id 12345 # omit for root folder +``` + +### Get / List / Tree + +```bash +zephyr folder get 12345 +zephyr folder list --folder-type TEST_CASE +zephyr folder tree +``` + +--- + +## 8. Other Resources + +### Environments + +```bash +zephyr environment list +zephyr environment get 1 +zephyr environment create --name "Production" +zephyr environment update 1 --name "Staging" +``` + +### Statuses + +```bash +zephyr status list +zephyr status list --status-type TEST_EXECUTION +zephyr status get 1 +zephyr status create --name "In Review" --type TEST_CASE +``` + +### Priorities + +```bash +zephyr priority list +zephyr priority get 1 +zephyr priority create --name "Critical" +``` + +### Projects + +```bash +zephyr project list +zephyr project get PRJ +``` + +### Issue Links (Jira integration) + +```bash +zephyr issuelink testcases JIRA-123 +zephyr issuelink testcycles JIRA-123 +zephyr issuelink testplans JIRA-123 +zephyr issuelink executions JIRA-123 +``` + +### Open in Browser + +```bash +zephyr open PRJ-T123 # test case +zephyr open PRJ-R1 # test cycle +zephyr open PRJ-P1 # test plan +``` + +--- + +## Typical AI Agent Workflow + +1. **Setup**: `zephyr play init` or `zephyr snapshot sync PRJ-R1 -o PRJ-R1.json` +2. **Sort**: `zephyr snapshot sort PRJ-R1.json --by folder` +3. **Review**: Read `PRJ-R1.json` to understand test cases and steps +4. **Execute**: `zephyr snapshot record PRJ-R1.json PRJ-T123 --status Pass` +5. **Re-sync**: `zephyr snapshot sync PRJ-R1.json` to pull latest from Zephyr + +## Notes + +- All commands output JSON by default. Add `--text` for human-readable output. +- Use `jq` for filtering: `zephyr testcase list | jq '.[].key'` +- Project key is read from the profile config; no need to pass `--project-key` for most commands. +- The snapshot JSON file is the source of truth during a test execution session. Always re-sync before starting. diff --git a/.gitignore b/.gitignore index 1ed7e3b..487e732 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,12 @@ # Dependencies node_modules/ -bun.lockb +bun.lock # Build outputs dist/ *.exe -zephyr -zephyr-* +/zephyr +/zephyr-* # Generated files src/generated/ diff --git a/README.md b/README.md index 8a114a7..027ac3d 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,31 @@ curl -L https://github.com/bun913/zephyr-cli/releases/latest/download/zephyr-lin chmod +x /usr/local/bin/zephyr ``` +## Recommended Usage + +This CLI is designed to be used by both **humans** and **AI agents**. + +- **Manual testing**: Use `zephyr play` to interactively record test results step by step in a terminal UI. +- **AI-assisted testing**: Every operation available in the `play` TUI is also exposed as a standalone CLI command (`snapshot record`, `testexecution create/update`, etc.), so AI agents can drive the exact same workflow programmatically. +- **AI agent integration**: A [Claude Code skill definition](.claude/skills/zephyr/SKILL.md) is included. AI agents can read it to understand all available commands and automate Zephyr Scale operations end-to-end. + +### Quick Start + +```bash +# 1. Interactive setup — select a test cycle and create a snapshot +zephyr play init + +# 2. Sort by folder for a logical ordering +zephyr snapshot sort PRJ-R1.json --by folder + +# 3a. Manual: launch the interactive TUI +zephyr play PRJ-R1.json + +# 3b. AI / CLI: record results programmatically +zephyr snapshot record PRJ-R1.json PRJ-T123 --status Pass +zephyr snapshot record PRJ-R1.json PRJ-T456 --status Fail --comment "Button not visible" +``` + ## Highlights ### Folder Tree View diff --git a/src/commands/play.ts b/src/commands/play.ts index 3b8df89..df7e920 100644 --- a/src/commands/play.ts +++ b/src/commands/play.ts @@ -1,11 +1,57 @@ +import * as readline from "node:readline"; import type { Command } from "commander"; +import type { ZephyrV2Client } from "zephyr-api-client"; import { getProfile, loadConfig } from "../config/manager"; import type { GlobalOptions } from "../config/types"; import { parseFilter } from "../play/filter"; import { renderPlayTUI } from "../play/tui/App"; +import { fetchCycleData } from "../snapshot/fetch"; +import { writeSnapshot } from "../snapshot/file"; +import { buildSnapshot } from "../snapshot/merge"; import { createClient } from "../utils/client"; import { formatError } from "../utils/error"; import { logger, setLoggerVerbose } from "../utils/logger"; +import { createSpinner } from "../utils/spinner"; + +async function fetchAllTestCycles( + client: ZephyrV2Client, + projectKey: string, +): Promise<{ key: string; name: string }[]> { + const cycles: { key: string; name: string }[] = []; + let startAt = 0; + const maxResults = 100; + + while (true) { + const response = await client.testcycles.listTestCycles({ + projectKey, + maxResults, + startAt, + }); + + const items = response.data.values || []; + for (const cycle of items) { + cycles.push({ key: cycle.key, name: cycle.name }); + } + + if (!response.data.next) break; + startAt += maxResults; + } + + return cycles; +} + +function prompt(question: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stderr, + }); + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} export function registerPlayCommand(program: Command): void { program @@ -21,6 +67,46 @@ export function registerPlayCommand(program: Command): void { const profile = getProfile(config, globalOptions.profile); const client = createClient(profile); + if (file === "init") { + // Interactive cycle selection and snapshot creation + const spinner = createSpinner("Fetching test cycles..."); + const cycles = await fetchAllTestCycles(client, profile.projectKey); + spinner.stop(); + + if (cycles.length === 0) { + logger.error("No test cycles found"); + process.exit(1); + } + + for (let i = 0; i < cycles.length; i++) { + const c = cycles[i]; + if (!c) continue; + process.stderr.write(`${i + 1}) ${c.key}: ${c.name}\n`); + } + + const answer = await prompt("\nSelect cycle number: "); + const index = Number(answer) - 1; + const selected = cycles[index]; + if (!selected || Number.isNaN(index) || index < 0 || index >= cycles.length) { + logger.error("Invalid selection"); + process.exit(1); + } + + const syncSpinner = createSpinner(`Syncing ${selected.key}...`); + const progress = { onProgress: (msg: string) => syncSpinner.update(msg) }; + const data = await fetchCycleData(client, profile.projectKey, selected.key, progress); + syncSpinner.update("Building snapshot..."); + const snapshot = buildSnapshot(profile.projectKey, selected.key, data); + const outputPath = `${selected.key}.json`; + writeSnapshot(outputPath, snapshot); + + syncSpinner.stop( + `Snapshot saved to ${outputPath} (${snapshot.testCases.length} test cases)`, + ); + process.stderr.write(`\nRun: zephyr play ${outputPath}\n`); + return; + } + const filter = options.filter ? parseFilter(options.filter) : undefined; renderPlayTUI({ diff --git a/src/snapshot/fractional-index.ts b/src/snapshot/fractional-index.ts deleted file mode 100644 index 82fccb4..0000000 --- a/src/snapshot/fractional-index.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { SnapshotTestCase } from "./types"; - -export function calcOriginalIndex( - zephyrOrder: string[], - newKey: string, - localCases: SnapshotTestCase[], -): number { - const position = zephyrOrder.indexOf(newKey); - if (position === -1) { - // Key not found in zephyr order; append after max - const maxIndex = localCases.reduce((max, c) => Math.max(max, c.originalIndex), -1); - return maxIndex + 1; - } - - const localMap = new Map(); - for (const c of localCases) { - localMap.set(c.key, c); - } - - // Search backward for nearest existing key - let before: number | null = null; - for (let i = position - 1; i >= 0; i--) { - const key = zephyrOrder[i]; - if (key === undefined) continue; - const existing = localMap.get(key); - if (existing) { - before = existing.originalIndex; - break; - } - } - - // Search forward for nearest existing key - let after: number | null = null; - for (let i = position + 1; i < zephyrOrder.length; i++) { - const key = zephyrOrder[i]; - if (key === undefined) continue; - const existing = localMap.get(key); - if (existing) { - after = existing.originalIndex; - break; - } - } - - if (before !== null && after !== null) { - return (before + after) / 2; - } - if (before !== null) { - return before + 1; - } - if (after !== null) { - return after - 1; - } - - // No neighbors found - return position; -} diff --git a/src/snapshot/merge.ts b/src/snapshot/merge.ts index 4dc02ce..06e0a02 100644 --- a/src/snapshot/merge.ts +++ b/src/snapshot/merge.ts @@ -4,7 +4,6 @@ import type { FetchedSingleTestCaseData, FetchedTestStep, } from "./fetch"; -import { calcOriginalIndex } from "./fractional-index"; import type { Snapshot, SnapshotExecution, SnapshotStep, SnapshotTestCase } from "./types"; export interface MergeResult { @@ -120,7 +119,8 @@ export function mergeSnapshot(local: Snapshot, data: FetchedCycleData): MergeRes remoteExecMap.set(exec.testCaseKey, { executionId: exec.id }); } - const zephyrOrder = data.executions.map((e) => e.testCaseKey); + // Build Zephyr-order index map (testCaseKey → sequential index) + const zephyrIndexMap = new Map(data.executions.map((e, i) => [e.testCaseKey, i])); const added: string[] = []; const removed: string[] = []; @@ -132,13 +132,9 @@ export function mergeSnapshot(local: Snapshot, data: FetchedCycleData): MergeRes for (const localCase of local.testCases) { const remote = remoteExecMap.get(localCase.key); if (remote) { - // Update from remote, keeping originalIndex and excluded - const updatedCase = buildTestCase( - remote.executionId, - localCase.key, - localCase.originalIndex, - data, - ); + // Update from remote, reassign originalIndex to Zephyr order + const zephyrIndex = zephyrIndexMap.get(localCase.key) ?? localCase.originalIndex; + const updatedCase = buildTestCase(remote.executionId, localCase.key, zephyrIndex, data); updatedCase.excluded = localCase.excluded; newTestCases.push(updatedCase); updated.push(localCase.key); @@ -152,7 +148,7 @@ export function mergeSnapshot(local: Snapshot, data: FetchedCycleData): MergeRes // Remaining in remote → new items for (const exec of data.executions) { if (!processedKeys.has(exec.testCaseKey)) { - const originalIndex = calcOriginalIndex(zephyrOrder, exec.testCaseKey, newTestCases); + const originalIndex = zephyrIndexMap.get(exec.testCaseKey) ?? newTestCases.length; const newCase = buildTestCase(exec.id, exec.testCaseKey, originalIndex, data); newTestCases.push(newCase); added.push(exec.testCaseKey);