Skip to content

Commit 3e6d2cf

Browse files
authored
Merge pull request #85 from LAA-Software-Engineering/feat/cli-test-command
feat(cli): agentctl test — YAML workflow fixtures
2 parents bb8b912 + 95c3ce0 commit 3e6d2cf

16 files changed

Lines changed: 743 additions & 3 deletions

File tree

docs/EXAMPLES.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Examples
22

3-
Short, runnable patterns for **`apiVersion: agentic.dev/v0`**. For the full YAML spec, CLI behaviour, and field semantics, see [**`DESIGN_DOC.md`**](DESIGN_DOC.md).
3+
Short, runnable patterns for **`apiVersion: agentic.dev/v0`**. For the full YAML spec, CLI behaviour, and field semantics, see [**`DESIGN_DOC.md`**](DESIGN_DOC.md). For **`agentctl test`** fixture format, see [**`TESTING.md`**](TESTING.md).
44

55
A checked-in copy of the **OpenAI `support_snippet`** project from **section 4** lives under [**`examples/example1/`**](../examples/example1/). Its **`metadata.name`** is **`example1`**, matching that folder. From the repository root, pass **`--project examples/example1`** to **`agentctl`** (or **`cd` there** and use **`--project .`**).
66

docs/TESTING.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Fixture workflow tests (`agentctl test`)
2+
3+
Design doc **§10.2** and **§17.4** describe YAML-driven regression tests for workflows. **`agentctl test`** (issue #73) runs those fixtures locally.
4+
5+
## Discovery
6+
7+
- All **`*.yaml`** / **`*.yml`** files under **`<project-root>/tests/`**, recursively.
8+
- If **`tests/`** is missing or no cases run, the command exits **0** and prints that no tests were found.
9+
10+
## Suite file format
11+
12+
Each file targets one workflow **`metadata.name`**:
13+
14+
```yaml
15+
# Optional, for forward compatibility:
16+
# apiVersion: agentic.dev/test/v0
17+
18+
workflow: demo
19+
20+
cases:
21+
- name: happy-path
22+
input:
23+
repo: acme/api
24+
number: 42
25+
expect:
26+
outputContains:
27+
- summary
28+
29+
- name: invalid-number
30+
input:
31+
repo: acme/api
32+
number: -1
33+
expectError: true
34+
```
35+
36+
- **`workflow`**: required; must match a **Workflow** resource in the project.
37+
- **`cases`**: at least one; each **`name`** is required.
38+
- **`input`**: object passed as workflow input JSON (same as **`agentctl run`**).
39+
- **`expect.output`**: for successful runs, each string in **`outputContains`** must appear as a substring in the **JSON-serialized workflow output** (`run.output` in SQLite).
40+
- **`expectError: true`**: the run must **not** succeed (any failure: validation, policy, step error, etc.).
41+
42+
## Execution model
43+
44+
- Same pipeline as **`agentctl run`**: load project, **defaults**, **`-e` / `--env`** overlays, validate graph, then execute each case.
45+
- Each case uses a **fresh temporary SQLite file** (no trace pollution between cases).
46+
- Prefer **`mock`** model providers and **native** / **mock** tools so runs stay **deterministic** without network.
47+
48+
## CLI
49+
50+
```bash
51+
agentctl test
52+
agentctl test workflow/demo
53+
agentctl test demo -o json
54+
```
55+
56+
Global flags: **`--project`**, **`-e` / `--env`**, **`-o table|json|yaml`**.
57+
58+
Non-zero exit if any case fails.
59+
60+
## See also
61+
62+
- **[`EXAMPLES.md`](EXAMPLES.md)** — project and workflow layout.
63+
- **`internal/testkit/`** — parser and runner.
64+
- **`internal/cli/testdata/wf_tests/`** — minimal example project with **`tests/demo.yaml`**.

internal/cli/doc.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
// The state command (section 10.2, §14.1) lists or shows rows from the SQLite deployment store
1313
// (applied_resources, applied_projects) read-only via [state.DeploymentStore].
1414
//
15+
// The test command (section 10.2, §17.4) runs YAML workflow fixtures from <project>/tests/ via [testkit].
16+
//
1517
// The plan command compares that prepared graph to the SQLite deployment store (default
1618
// .agentic/state.db, or project.spec.state.dsn / --state) and prints a diff plus risk delta
1719
// via [plan.ComputePlan] and [plan.FormatPlan].

internal/cli/root.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ func NewRootCmd() *cobra.Command {
2222
root := &cobra.Command{
2323
Use: "agentctl",
2424
Short: "Declarative control plane for agent systems",
25-
Long: "agentctl validates, inspects, plans, diffs, applies, runs, and reads deployment state for declarative agent systems defined as YAML.",
25+
Long: "agentctl validates, inspects, plans, diffs, applies, runs, tests, and reads deployment state for declarative agent systems defined as YAML.",
2626
SilenceErrors: true,
2727
Run: func(cmd *cobra.Command, args []string) {
2828
_ = cmd.Help()
@@ -41,6 +41,7 @@ func NewRootCmd() *cobra.Command {
4141
root.AddCommand(newApplyCmd())
4242
root.AddCommand(newStateCmd())
4343
root.AddCommand(newRunCmd())
44+
root.AddCommand(newTestCmd())
4445
root.AddCommand(newLogsCmd())
4546
return root
4647
}

internal/cli/root_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ func TestRootHelp_listsGlobalFlags(t *testing.T) {
1919
t.Fatal(err)
2020
}
2121
out := buf.String()
22-
for _, sub := range []string{"diff", "inspect", "state"} {
22+
for _, sub := range []string{"diff", "inspect", "state", "test"} {
2323
if !strings.Contains(out, sub) {
2424
t.Fatalf("help should mention %q subcommand:\n%s", sub, out)
2525
}

internal/cli/test.go

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
"text/tabwriter"
10+
11+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/render"
12+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/testkit"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
func newTestCmd() *cobra.Command {
17+
return &cobra.Command{
18+
Use: "test [workflow/<name>]",
19+
Short: "Run YAML fixture tests under tests/",
20+
Long: `Discover YAML files under <project>/tests/ (recursive), parse workflow test cases,
21+
and execute each case with the same project load, normalization, and environment overlay as
22+
agentctl run (issue #73, design doc §10.2, §17.4).
23+
24+
Use mock/native providers in project YAML for deterministic runs. Assertions: expect.outputContains
25+
(substrings on the workflow output JSON) and expectError.
26+
27+
Optional argument filters to one workflow by metadata name: workflow/demo or demo (both accepted).
28+
29+
Exit codes (§11.2): 0 all passed, 1 failures or I/O errors, 2 validation (bad project, bad suite, unknown workflow filter).`,
30+
Example: ` agentctl test
31+
agentctl test workflow/demo
32+
agentctl test demo -o json`,
33+
SilenceUsage: true,
34+
RunE: func(cmd *cobra.Command, args []string) error {
35+
if len(args) > 1 {
36+
return NewExitError(ExitValidationError, fmt.Errorf("test: at most one workflow filter argument"))
37+
}
38+
return runTest(cmd, args)
39+
},
40+
}
41+
}
42+
43+
func parseTestWorkflowFilter(arg string) (string, error) {
44+
arg = strings.TrimSpace(arg)
45+
if arg == "" {
46+
return "", nil
47+
}
48+
low := strings.ToLower(arg)
49+
if strings.HasPrefix(low, "workflow/") {
50+
return parseWorkflowTarget(arg)
51+
}
52+
return arg, nil
53+
}
54+
55+
func runTest(cmd *cobra.Command, args []string) error {
56+
ctx := context.Background()
57+
g := Globals()
58+
59+
graph, root, err := prepareProjectGraph(g.ProjectRoot, g)
60+
if err != nil {
61+
return NewExitError(ExitValidationError, err)
62+
}
63+
64+
var wfFilter string
65+
if len(args) == 1 {
66+
var perr error
67+
wfFilter, perr = parseTestWorkflowFilter(args[0])
68+
if perr != nil {
69+
return NewExitError(ExitValidationError, fmt.Errorf("test: %w", perr))
70+
}
71+
}
72+
73+
rootAbs := root
74+
testsDir := filepath.Join(rootAbs, "tests")
75+
if _, err := os.Stat(testsDir); os.IsNotExist(err) {
76+
return writeTestNoTests(cmd, g, rootAbs)
77+
}
78+
79+
envName := strings.TrimSpace(g.Env)
80+
envLabel := planEnvironment(g)
81+
opts := testkit.RunOptions{
82+
EnvironmentName: envName,
83+
EnvLabel: envLabel,
84+
}
85+
86+
outcomes, err := testkit.LoadAndRunAll(ctx, rootAbs, opts, wfFilter)
87+
if err != nil {
88+
return NewExitError(ExitValidationError, err)
89+
}
90+
if len(outcomes) == 0 {
91+
return writeTestNoTests(cmd, g, rootAbs)
92+
}
93+
94+
if werr := writeTestResults(cmd, rootAbs, graph.Meta.Name, envLabel, outcomes, g); werr != nil {
95+
return werr
96+
}
97+
98+
failed := 0
99+
for _, o := range outcomes {
100+
if !o.Passed {
101+
failed++
102+
}
103+
}
104+
if failed > 0 {
105+
return NewExitError(ExitGenericFailure, fmt.Errorf("test: %d case(s) failed", failed))
106+
}
107+
return nil
108+
}
109+
110+
func writeTestNoTests(cmd *cobra.Command, g *Global, root string) error {
111+
out := cmd.OutOrStdout()
112+
switch g.Output {
113+
case render.FormatJSON:
114+
return render.WriteJSON(out, map[string]any{
115+
"projectRoot": root,
116+
"message": "no tests found under tests/",
117+
"cases": []any{},
118+
})
119+
case render.FormatYAML:
120+
return render.WriteYAML(out, map[string]any{
121+
"projectRoot": root,
122+
"message": "no tests found under tests/",
123+
"cases": []any{},
124+
})
125+
default:
126+
_, err := fmt.Fprintf(out, "No tests found under %s/tests\n", root)
127+
return err
128+
}
129+
}
130+
131+
func writeTestResults(cmd *cobra.Command, projectRoot, projectName, env string, outcomes []testkit.CaseOutcome, g *Global) error {
132+
out := cmd.OutOrStdout()
133+
switch g.Output {
134+
case render.FormatJSON:
135+
rows := make([]map[string]any, len(outcomes))
136+
passed := 0
137+
for i, o := range outcomes {
138+
rows[i] = map[string]any{
139+
"file": relPath(projectRoot, o.File), "workflow": o.Workflow, "case": o.Case,
140+
"passed": o.Passed, "detail": o.Detail,
141+
}
142+
if o.Passed {
143+
passed++
144+
}
145+
}
146+
return render.WriteJSON(out, map[string]any{
147+
"projectRoot": projectRoot,
148+
"project": projectName,
149+
"environment": env,
150+
"passed": passed,
151+
"failed": len(outcomes) - passed,
152+
"cases": rows,
153+
})
154+
case render.FormatYAML:
155+
rows := make([]map[string]any, len(outcomes))
156+
passed := 0
157+
for i, o := range outcomes {
158+
rows[i] = map[string]any{
159+
"file": relPath(projectRoot, o.File), "workflow": o.Workflow, "case": o.Case,
160+
"passed": o.Passed, "detail": o.Detail,
161+
}
162+
if o.Passed {
163+
passed++
164+
}
165+
}
166+
return render.WriteYAML(out, map[string]any{
167+
"projectRoot": projectRoot,
168+
"project": projectName,
169+
"environment": env,
170+
"passed": passed,
171+
"failed": len(outcomes) - passed,
172+
"cases": rows,
173+
})
174+
default:
175+
passed := 0
176+
for _, o := range outcomes {
177+
if o.Passed {
178+
passed++
179+
}
180+
}
181+
if _, err := fmt.Fprintf(out, "Project: %s (%s)\nEnvironment: %s\n\n", projectName, projectRoot, env); err != nil {
182+
return err
183+
}
184+
tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
185+
_, _ = fmt.Fprintln(tw, "FILE\tWORKFLOW\tCASE\tRESULT\tDETAIL")
186+
for _, o := range outcomes {
187+
res := "pass"
188+
if !o.Passed {
189+
res = "fail"
190+
}
191+
d := o.Detail
192+
if d == "" {
193+
d = "-"
194+
}
195+
_, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", relPath(projectRoot, o.File), o.Workflow, o.Case, res, d)
196+
}
197+
if err := tw.Flush(); err != nil {
198+
return err
199+
}
200+
_, err := fmt.Fprintf(out, "\n%d passed, %d failed\n", passed, len(outcomes)-passed)
201+
return err
202+
}
203+
}
204+
205+
func relPath(root, p string) string {
206+
r, err := filepath.Rel(root, p)
207+
if err != nil {
208+
return p
209+
}
210+
return r
211+
}

0 commit comments

Comments
 (0)