- Update roadmap milestones and phase docs across multiple phases - Update plan, code-review, create-roadmap, update-roadmap, finalize-task-routing skills - Update dev-corp-runtime-deploy, dev-runtime-deploy, orchestrate-agent-task-loop skills - Refactor agent-task-loop dispatch script - Add streamgate Go package (commit_boundary, evidence_tail, filter_registry, stream_release) - Add test inventory files (dev, dev-corp, unified) - Update test smoke tests and rules for dev/dev-corp - Update docs/edge-local-dev-guide and e2e scripts - Update inventory-query Go package - Remove deprecated templates and inventory.yaml files - Add orchestrate-agent-task-loop tests
786 lines
21 KiB
Go
786 lines
21 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// fixtureYAML is a minimal inventory shape covering both dev and dev-corp patterns.
|
|
const fixtureYAML = `
|
|
inventory_id: inventory-dev
|
|
common_inventory: agent-test/inventory.yaml
|
|
test_env: dev
|
|
profile: dev-runtime-provider-pool
|
|
last_updated_at: "2026-07-13"
|
|
source:
|
|
remote_runner:
|
|
ssh: toki@toki-labs.com
|
|
edge:
|
|
id: edge-toki-labs-dev
|
|
build:
|
|
binaries:
|
|
edge: build/dev-runtime/bin/edge
|
|
model:
|
|
alias: ornith:35b
|
|
aliases:
|
|
gemma4:26b:
|
|
capacity_total: 5
|
|
providers:
|
|
- id: mac-mlx-vllm
|
|
served_model: mlx-community/gemma-4-26b-a4b-it-nvfp4
|
|
ornith:35b:
|
|
capacity_total: 8
|
|
providers:
|
|
- id: corp-dgx-spark-01-ornith
|
|
- id: corp-dgx-spark-02-ornith
|
|
active_edge_model_group:
|
|
providers:
|
|
gx10-vllm:
|
|
capacity_total: 4
|
|
qwen3_6_reference:
|
|
alias: qwen3.6:35b
|
|
id: qwen3-6-ref
|
|
provider: mac-mlx-vllm
|
|
nodes:
|
|
- id: mac-codex-node
|
|
alias: mac-codex
|
|
providers:
|
|
- id: mac-mlx-vllm
|
|
type: vllm-mlx
|
|
- id: gx10-vllm-node
|
|
alias: gx10-vllm
|
|
provider:
|
|
id: gx10-vllm
|
|
type: vllm
|
|
- id: onexplayer-lemonade-node
|
|
alias: onexplayer-lemonade
|
|
direct_providers:
|
|
- id: ornith-direct
|
|
family: spark_ornith
|
|
- id: node-3
|
|
alias: node-3
|
|
- id: node-4
|
|
alias: node-4
|
|
- id: node-5
|
|
alias: node-5
|
|
- id: node-6
|
|
alias: node-6
|
|
- id: node-7
|
|
alias: node-7
|
|
- id: node-8
|
|
alias: node-8
|
|
- id: node-9
|
|
alias: node-9
|
|
- id: node-10
|
|
alias: node-10
|
|
`
|
|
|
|
func parseFixture(t *testing.T) map[string]interface{} {
|
|
t.Helper()
|
|
var doc map[string]interface{}
|
|
if err := yaml.Unmarshal([]byte(fixtureYAML), &doc); err != nil {
|
|
t.Fatalf("failed to parse fixture YAML: %v", err)
|
|
}
|
|
return doc
|
|
}
|
|
|
|
func TestQueryEnvironmentProjectionIsBounded(t *testing.T) {
|
|
data := parseFixture(t)
|
|
proj := buildEnvProjection(data)
|
|
|
|
if proj.InventoryID != "inventory-dev" {
|
|
t.Errorf("expected inventory_id=inventory-dev, got %q", proj.InventoryID)
|
|
}
|
|
if proj.CommonInventory != "agent-test/inventory.yaml" {
|
|
t.Errorf("expected common_inventory=agent-test/inventory.yaml, got %q", proj.CommonInventory)
|
|
}
|
|
if proj.Env != "dev" {
|
|
t.Errorf("expected env=dev, got %q", proj.Env)
|
|
}
|
|
if proj.Profile != "dev-runtime-provider-pool" {
|
|
t.Errorf("expected profile=dev-runtime-provider-pool, got %q", proj.Profile)
|
|
}
|
|
if proj.LastUpdatedAt != "2026-07-13" {
|
|
t.Errorf("expected last_updated_at=2026-07-13, got %q", proj.LastUpdatedAt)
|
|
}
|
|
if proj.Source == nil {
|
|
t.Error("expected source to be present in projection")
|
|
}
|
|
if proj.Edge == nil {
|
|
t.Error("expected edge to be present in projection")
|
|
}
|
|
if proj.Build == nil {
|
|
t.Error("expected build to be present in projection")
|
|
}
|
|
|
|
// Ensure model and nodes are NOT in the projection.
|
|
jsonBytes, err := json.Marshal(proj)
|
|
if err != nil {
|
|
t.Fatalf("failed to marshal projection: %v", err)
|
|
}
|
|
jsonStr := string(jsonBytes)
|
|
if strings.Contains(jsonStr, `"model"`) {
|
|
t.Error("projection should not contain model key")
|
|
}
|
|
if strings.Contains(jsonStr, `"nodes"`) {
|
|
t.Error("projection should not contain nodes key")
|
|
}
|
|
}
|
|
|
|
func TestQueryNodeByIDAndAlias(t *testing.T) {
|
|
data := parseFixture(t)
|
|
|
|
// Match by id.
|
|
matches := queryNodes(data, "gx10-vllm-node")
|
|
if len(matches) != 1 {
|
|
t.Fatalf("expected 1 match for id=gx10-vllm-node, got %d: %+v", len(matches), matches)
|
|
}
|
|
if matches[0].Path != "nodes[1]" {
|
|
t.Errorf("expected path=nodes[1], got %q", matches[0].Path)
|
|
}
|
|
nodeObj, ok := matches[0].Value.(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("expected map[string]interface{}, got %T", matches[0].Value)
|
|
}
|
|
if nodeObj["id"] != "gx10-vllm-node" {
|
|
t.Errorf("expected id=gx10-vllm-node, got %v", nodeObj["id"])
|
|
}
|
|
|
|
// Match by alias.
|
|
matches = queryNodes(data, "mac-codex")
|
|
if len(matches) != 1 {
|
|
t.Fatalf("expected 1 match for alias=mac-codex, got %d: %+v", len(matches), matches)
|
|
}
|
|
if matches[0].Path != "nodes[0]" {
|
|
t.Errorf("expected path=nodes[0], got %q", matches[0].Path)
|
|
}
|
|
}
|
|
|
|
func TestQueryModelAcrossAliasShapes(t *testing.T) {
|
|
data := parseFixture(t)
|
|
|
|
// Match by alias key in model.aliases.
|
|
matches := queryModels(data, "gemma4:26b")
|
|
if len(matches) == 0 {
|
|
t.Fatal("expected at least 1 match for alias key gemma4:26b")
|
|
}
|
|
found := false
|
|
for _, m := range matches {
|
|
if m.Path == "model.aliases.gemma4:26b" {
|
|
found = true
|
|
modelMap, ok := m.Value.(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("expected map[string]interface{}, got %T", m.Value)
|
|
}
|
|
if modelMap["capacity_total"] != 5 {
|
|
t.Errorf("expected capacity_total=5, got %v", modelMap["capacity_total"])
|
|
}
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected match at model.aliases.gemma4:26b, got %+v", matches)
|
|
}
|
|
|
|
// Match by entity alias in model subtree.
|
|
matches = queryModels(data, "qwen3.6:35b")
|
|
if len(matches) == 0 {
|
|
t.Fatal("expected at least 1 match for entity alias qwen3.6:35b")
|
|
}
|
|
found = false
|
|
for _, m := range matches {
|
|
if m.Path == "model.qwen3_6_reference.alias" && m.Value == "qwen3.6:35b" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected match at model.qwen3_6_reference.alias, got %+v", matches)
|
|
}
|
|
|
|
// Match by entity id in model subtree.
|
|
matches = queryModels(data, "qwen3-6-ref")
|
|
if len(matches) == 0 {
|
|
t.Fatal("expected at least 1 match for entity id qwen3-6-ref")
|
|
}
|
|
found = false
|
|
for _, m := range matches {
|
|
if m.Path == "model.qwen3_6_reference.id" && m.Value == "qwen3-6-ref" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected match at model.qwen3_6_reference.id, got %+v", matches)
|
|
}
|
|
}
|
|
|
|
func TestQueryProviderSortsAndDeduplicatesMatches(t *testing.T) {
|
|
data := parseFixture(t)
|
|
|
|
matches := queryProviders(data, "mac-mlx-vllm")
|
|
if len(matches) == 0 {
|
|
t.Fatal("expected matches for provider mac-mlx-vllm")
|
|
}
|
|
|
|
// Check no duplicates.
|
|
paths := make(map[string]bool)
|
|
for _, m := range matches {
|
|
if paths[m.Path] {
|
|
t.Errorf("duplicate path found: %s", m.Path)
|
|
}
|
|
paths[m.Path] = true
|
|
}
|
|
|
|
// Check sorted by path.
|
|
for i := 1; i < len(matches); i++ {
|
|
if matches[i].Path < matches[i-1].Path {
|
|
t.Errorf("matches not sorted: %s < %s", matches[i].Path, matches[i-1].Path)
|
|
}
|
|
}
|
|
|
|
// Verify expected paths exist.
|
|
expectedPaths := map[string]bool{
|
|
"nodes[0].providers[0]": false,
|
|
"model.aliases.gemma4:26b.providers[0]": false,
|
|
"model.qwen3_6_reference.provider": false,
|
|
}
|
|
for _, m := range matches {
|
|
if _, ok := expectedPaths[m.Path]; ok {
|
|
expectedPaths[m.Path] = true
|
|
}
|
|
}
|
|
for p, found := range expectedPaths {
|
|
if !found {
|
|
t.Errorf("expected path %s not found in matches %+v", p, matches)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestQueryZeroMatch(t *testing.T) {
|
|
data := parseFixture(t)
|
|
|
|
matches := queryNodes(data, "nonexistent")
|
|
if len(matches) != 0 {
|
|
t.Errorf("expected 0 matches, got %d: %+v", len(matches), matches)
|
|
}
|
|
|
|
matches = queryProviders(data, "nonexistent-provider")
|
|
if len(matches) != 0 {
|
|
t.Errorf("expected 0 matches, got %d: %+v", len(matches), matches)
|
|
}
|
|
|
|
matches = queryModels(data, "nonexistent-model")
|
|
if len(matches) != 0 {
|
|
t.Errorf("expected 0 matches, got %d: %+v", len(matches), matches)
|
|
}
|
|
}
|
|
|
|
func TestParseFlagsRejectsInvalidCombinations(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
wantErr bool
|
|
}{
|
|
{"missing env", []string{"--model", "x"}, true},
|
|
{"multiple selectors", []string{"--env", "dev", "--model", "x", "--node", "y"}, true},
|
|
{"unknown flag", []string{"--env", "dev", "--unknown"}, true},
|
|
{"env without value", []string{"--env"}, true},
|
|
{"model without value", []string{"--env", "dev", "--model"}, true},
|
|
{"invalid env value", []string{"--env", "invalid-env"}, true},
|
|
{"valid no selector", []string{"--env", "dev"}, false},
|
|
{"valid single selector", []string{"--env", "dev", "--node", "x"}, false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
_, err := parseFlags(tt.args)
|
|
if tt.wantErr && err == nil {
|
|
t.Error("expected error but got nil")
|
|
}
|
|
if !tt.wantErr && err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestQueryIsDeterministic(t *testing.T) {
|
|
data := parseFixture(t)
|
|
|
|
var outputs []string
|
|
for i := 0; i < 5; i++ {
|
|
matches := queryProviders(data, "gx10-vllm")
|
|
out, err := json.Marshal(matches)
|
|
if err != nil {
|
|
t.Fatalf("json marshal error: %v", err)
|
|
}
|
|
outputs = append(outputs, string(out))
|
|
}
|
|
|
|
for i := 1; i < len(outputs); i++ {
|
|
if outputs[i] != outputs[0] {
|
|
t.Errorf("query not deterministic: output %d differs from output 0", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestQueryProviderInDirectProviders(t *testing.T) {
|
|
data := parseFixture(t)
|
|
|
|
matches := queryProviders(data, "ornith-direct")
|
|
if len(matches) != 1 {
|
|
t.Fatalf("expected 1 match for ornith-direct, got %d: %+v", len(matches), matches)
|
|
}
|
|
if matches[0].Path != "nodes[2].direct_providers[0]" {
|
|
t.Errorf("expected path=nodes[2].direct_providers[0], got %q", matches[0].Path)
|
|
}
|
|
}
|
|
|
|
func TestQueryModelAliasKey(t *testing.T) {
|
|
data := parseFixture(t)
|
|
|
|
matches := queryModels(data, "ornith:35b")
|
|
if len(matches) == 0 {
|
|
t.Fatal("expected matches for ornith:35b")
|
|
}
|
|
|
|
foundAliasKey := false
|
|
foundEntity := false
|
|
for _, m := range matches {
|
|
if m.Path == "model.aliases.ornith:35b" {
|
|
foundAliasKey = true
|
|
}
|
|
if m.Path == "model.alias" && m.Value == "ornith:35b" {
|
|
foundEntity = true
|
|
}
|
|
}
|
|
if !foundAliasKey {
|
|
t.Errorf("expected match at model.aliases.ornith:35b, got %+v", matches)
|
|
}
|
|
if !foundEntity {
|
|
t.Errorf("expected match at model.alias, got %+v", matches)
|
|
}
|
|
}
|
|
|
|
func TestQueryProviderInModelAliasesProviders(t *testing.T) {
|
|
data := parseFixture(t)
|
|
|
|
matches := queryProviders(data, "corp-dgx-spark-01-ornith")
|
|
if len(matches) != 1 {
|
|
t.Fatalf("expected 1 match for corp-dgx-spark-01-ornith, got %d: %+v", len(matches), matches)
|
|
}
|
|
if matches[0].Path != "model.aliases.ornith:35b.providers[0]" {
|
|
t.Errorf("expected path=model.aliases.ornith:35b.providers[0], got %q", matches[0].Path)
|
|
}
|
|
}
|
|
|
|
func TestEncodeJSONOutput(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
proj := buildEnvProjection(parseFixture(t))
|
|
encodeJSON(&buf, proj)
|
|
|
|
if buf.Len() == 0 {
|
|
t.Error("expected non-empty JSON output")
|
|
}
|
|
|
|
var result envProjection
|
|
if err := json.Unmarshal(buf.Bytes(), &result); err != nil {
|
|
t.Fatalf("failed to unmarshal JSON output: %v\noutput: %s", err, buf.String())
|
|
}
|
|
if result.Env != "dev" {
|
|
t.Errorf("expected env=dev in JSON output, got %q", result.Env)
|
|
}
|
|
}
|
|
|
|
func TestQueryNodeReturnsEntityValue(t *testing.T) {
|
|
data := parseFixture(t)
|
|
matches := queryNodes(data, "gx10-vllm-node")
|
|
if len(matches) != 1 {
|
|
t.Fatalf("expected 1 match, got %d", len(matches))
|
|
}
|
|
nodeMap, ok := matches[0].Value.(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("expected map[string]interface{}, got %T", matches[0].Value)
|
|
}
|
|
if nodeMap["id"] != "gx10-vllm-node" {
|
|
t.Errorf("expected id=gx10-vllm-node, got %v", nodeMap["id"])
|
|
}
|
|
}
|
|
|
|
func TestQueryModelAliasReturnsValueAtPath(t *testing.T) {
|
|
data := parseFixture(t)
|
|
matches := queryModels(data, "gemma4:26b")
|
|
if len(matches) != 1 {
|
|
t.Fatalf("expected 1 match, got %d", len(matches))
|
|
}
|
|
modelMap, ok := matches[0].Value.(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("expected map[string]interface{}, got %T", matches[0].Value)
|
|
}
|
|
if modelMap["capacity_total"] != 5 {
|
|
t.Errorf("expected capacity_total=5, got %v", modelMap["capacity_total"])
|
|
}
|
|
}
|
|
|
|
func TestQueryProviderAcrossCanonicalShapes(t *testing.T) {
|
|
data := parseFixture(t)
|
|
matches := queryProviders(data, "gx10-vllm")
|
|
if len(matches) != 2 {
|
|
t.Fatalf("expected 2 matches, got %d: %+v", len(matches), matches)
|
|
}
|
|
|
|
paths := map[string]interface{}{
|
|
matches[0].Path: matches[0].Value,
|
|
matches[1].Path: matches[1].Value,
|
|
}
|
|
|
|
val1, ok := paths["model.active_edge_model_group.providers.gx10-vllm"]
|
|
if !ok {
|
|
t.Errorf("expected path model.active_edge_model_group.providers.gx10-vllm, got keys: %v", paths)
|
|
} else {
|
|
m, ok := val1.(map[string]interface{})
|
|
if !ok || m["capacity_total"] != 4 {
|
|
t.Errorf("expected map with capacity_total=4, got %T: %v", val1, val1)
|
|
}
|
|
}
|
|
|
|
val2, ok := paths["nodes[1].provider"]
|
|
if !ok {
|
|
t.Errorf("expected path nodes[1].provider, got keys: %v", paths)
|
|
} else {
|
|
m, ok := val2.(map[string]interface{})
|
|
if !ok || m["id"] != "gx10-vllm" {
|
|
t.Errorf("expected map with id=gx10-vllm, got %T: %v", val2, val2)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestQueryNodeSortsByPath(t *testing.T) {
|
|
data := parseFixture(t)
|
|
nodes, ok := data["nodes"].([]interface{})
|
|
if !ok {
|
|
t.Fatalf("nodes slice not found")
|
|
}
|
|
for i, n := range nodes {
|
|
obj, ok := n.(map[string]interface{})
|
|
if ok {
|
|
obj["alias"] = "target-node"
|
|
obj["id"] = fmt.Sprintf("node-id-%d", i)
|
|
}
|
|
}
|
|
matches := queryNodes(data, "target-node")
|
|
if len(matches) < 10 {
|
|
t.Fatalf("expected at least 10 matches, got %d", len(matches))
|
|
}
|
|
for i := 1; i < len(matches); i++ {
|
|
if matches[i].Path < matches[i-1].Path {
|
|
t.Errorf("matches not sorted by path: %s < %s", matches[i].Path, matches[i-1].Path)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseFlagsRejectsUnsupportedEnv(t *testing.T) {
|
|
_, err := parseFlags([]string{"--env", "unsupported"})
|
|
if err == nil {
|
|
t.Error("expected error for unsupported env, got nil")
|
|
}
|
|
}
|
|
|
|
func TestValidateInventoryRejectsIdentityMismatch(t *testing.T) {
|
|
data := parseFixture(t)
|
|
err := validateInventory(data, "dev-corp", "")
|
|
if err == nil {
|
|
t.Error("expected identity mismatch error, got nil")
|
|
}
|
|
}
|
|
|
|
func TestResolveInventoryPathFromSharedManifest(t *testing.T) {
|
|
manifest := []byte(`
|
|
inventory_id: inventory
|
|
environments:
|
|
dev:
|
|
inventory_id: inventory-dev
|
|
path: agent-test/inventory-dev.yaml
|
|
dev-corp:
|
|
inventory_id: inventory-dev-corp
|
|
path: agent-test/inventory-dev-corp.yaml
|
|
`)
|
|
|
|
path, err := resolveInventoryPath(manifest, "dev-corp")
|
|
if err != nil {
|
|
t.Fatalf("unexpected resolve error: %v", err)
|
|
}
|
|
if path != "agent-test/inventory-dev-corp.yaml" {
|
|
t.Fatalf("expected dev-corp inventory path, got %q", path)
|
|
}
|
|
}
|
|
|
|
func TestResolveInventoryPathRejectsIdentityMismatch(t *testing.T) {
|
|
manifest := []byte(`
|
|
inventory_id: inventory
|
|
environments:
|
|
dev:
|
|
inventory_id: inventory-dev-corp
|
|
path: agent-test/inventory-dev.yaml
|
|
`)
|
|
|
|
if _, err := resolveInventoryPath(manifest, "dev"); err == nil {
|
|
t.Fatal("expected inventory identity mismatch")
|
|
}
|
|
}
|
|
|
|
func TestValidateInventoryRejectsInvalidProjectionTypes(t *testing.T) {
|
|
data := parseFixture(t)
|
|
delete(data, "profile")
|
|
err := validateInventory(data, "dev", "")
|
|
if err == nil {
|
|
t.Error("expected error for missing profile, got nil")
|
|
}
|
|
}
|
|
|
|
func TestValidateInventoryRequiresCommonInventoryIdentity(t *testing.T) {
|
|
data := parseFixture(t)
|
|
delete(data, "common_inventory")
|
|
if err := validateInventory(data, "dev", ""); err == nil {
|
|
t.Fatal("expected common_inventory validation error")
|
|
}
|
|
}
|
|
|
|
func TestRunClassifiesSchemaError(t *testing.T) {
|
|
data := parseFixture(t)
|
|
delete(data, "profile")
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
loader := func(env string) (map[string]interface{}, error) {
|
|
return data, nil
|
|
}
|
|
|
|
code := run([]string{"--env", "dev"}, &stdout, &stderr, loader)
|
|
if code != exitError {
|
|
t.Errorf("expected exitError(2), got %d. stderr: %s", code, stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunExitCodesAndStreams(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
modifyData func(map[string]interface{})
|
|
wantCode int
|
|
wantStdout bool
|
|
wantStderr bool
|
|
}{
|
|
{
|
|
name: "success env projection",
|
|
args: []string{"--env", "dev"},
|
|
wantCode: exitOK,
|
|
wantStdout: true,
|
|
wantStderr: false,
|
|
},
|
|
{
|
|
name: "success query match",
|
|
args: []string{"--env", "dev", "--node", "gx10-vllm-node"},
|
|
wantCode: exitOK,
|
|
wantStdout: true,
|
|
wantStderr: false,
|
|
},
|
|
{
|
|
name: "zero match",
|
|
args: []string{"--env", "dev", "--node", "definitely-not-a-node"},
|
|
wantCode: exitZeroMatch,
|
|
wantStdout: false,
|
|
wantStderr: true,
|
|
},
|
|
{
|
|
name: "flag error",
|
|
args: []string{"--env", "dev", "--model", "x", "--node", "y"},
|
|
wantCode: exitError,
|
|
wantStdout: false,
|
|
wantStderr: true,
|
|
},
|
|
{
|
|
name: "schema error (missing edge)",
|
|
args: []string{"--env", "dev"},
|
|
modifyData: func(d map[string]interface{}) { delete(d, "edge") },
|
|
wantCode: exitError,
|
|
wantStdout: false,
|
|
wantStderr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
data := parseFixture(t)
|
|
if tt.modifyData != nil {
|
|
tt.modifyData(data)
|
|
}
|
|
loader := func(env string) (map[string]interface{}, error) {
|
|
return data, nil
|
|
}
|
|
var stdout, stderr bytes.Buffer
|
|
code := run(tt.args, &stdout, &stderr, loader)
|
|
if code != tt.wantCode {
|
|
t.Errorf("expected code %d, got %d. stderr: %s", tt.wantCode, code, stderr.String())
|
|
}
|
|
if tt.wantStdout && stdout.Len() == 0 {
|
|
t.Error("expected output in stdout, got empty")
|
|
}
|
|
if !tt.wantStdout && stdout.Len() > 0 {
|
|
t.Errorf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
if tt.wantStderr && stderr.Len() == 0 {
|
|
t.Error("expected output in stderr, got empty")
|
|
}
|
|
if !tt.wantStderr && stderr.Len() > 0 {
|
|
t.Errorf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateInventoryRejectsInvalidProjectionObjectTypes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
modify func(map[string]interface{})
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "source is scalar",
|
|
modify: func(d map[string]interface{}) { d["source"] = "invalid" },
|
|
wantErr: "missing or invalid type for source",
|
|
},
|
|
{
|
|
name: "source is list",
|
|
modify: func(d map[string]interface{}) { d["source"] = []interface{}{"a"} },
|
|
wantErr: "missing or invalid type for source",
|
|
},
|
|
{
|
|
name: "edge is null",
|
|
modify: func(d map[string]interface{}) { d["edge"] = nil },
|
|
wantErr: "missing or invalid type for edge",
|
|
},
|
|
{
|
|
name: "build is scalar",
|
|
modify: func(d map[string]interface{}) { d["build"] = 42 },
|
|
wantErr: "missing or invalid type for build",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
data := parseFixture(t)
|
|
tt.modify(data)
|
|
err := validateInventory(data, "dev", "")
|
|
if err == nil {
|
|
t.Error("expected error, got nil")
|
|
} else if err.Error() != tt.wantErr {
|
|
t.Errorf("expected error %q, got %q", tt.wantErr, err.Error())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateInventoryRequiresProviderModelAndNodes(t *testing.T) {
|
|
data := parseFixture(t)
|
|
|
|
// Remove model for provider query.
|
|
delete(data, "model")
|
|
err := validateInventory(data, "dev", "provider")
|
|
if err == nil {
|
|
t.Error("expected error for missing model in provider query, got nil")
|
|
} else if err.Error() != "missing or invalid type for model" {
|
|
t.Errorf("expected model error, got %q", err.Error())
|
|
}
|
|
|
|
// Restore model but remove nodes for provider query.
|
|
data["model"] = map[string]interface{}{
|
|
"aliases": map[string]interface{}{},
|
|
}
|
|
delete(data, "nodes")
|
|
err = validateInventory(data, "dev", "provider")
|
|
if err == nil {
|
|
t.Error("expected error for missing nodes in provider query, got nil")
|
|
} else if err.Error() != "missing or invalid type for nodes" {
|
|
t.Errorf("expected nodes error, got %q", err.Error())
|
|
}
|
|
|
|
// nodes is wrong type for provider query.
|
|
data["nodes"] = "invalid"
|
|
err = validateInventory(data, "dev", "provider")
|
|
if err == nil {
|
|
t.Error("expected error for invalid nodes type in provider query, got nil")
|
|
}
|
|
|
|
// nodes is wrong type for node query.
|
|
delete(data, "model")
|
|
err = validateInventory(data, "dev", "node")
|
|
if err == nil {
|
|
t.Error("expected error for invalid nodes type in node query, got nil")
|
|
}
|
|
}
|
|
|
|
func TestRunClassifiesProviderSchemaError(t *testing.T) {
|
|
data := parseFixture(t)
|
|
delete(data, "model")
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
loader := func(env string) (map[string]interface{}, error) {
|
|
return data, nil
|
|
}
|
|
|
|
code := run([]string{"--env", "dev", "--provider", "test"}, &stdout, &stderr, loader)
|
|
if code != exitError {
|
|
t.Errorf("expected exitError(2), got %d. stderr: %s", code, stderr.String())
|
|
}
|
|
if stdout.Len() > 0 {
|
|
t.Errorf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
if stderr.Len() == 0 {
|
|
t.Error("expected error message in stderr")
|
|
}
|
|
}
|
|
|
|
func TestRunHelpUsesInjectedStreams(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
}{
|
|
{"help flag", []string{"--env", "dev", "--help"}},
|
|
{"help short", []string{"--env", "dev", "-h"}},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
data := parseFixture(t)
|
|
var stdout, stderr bytes.Buffer
|
|
loaderCalled := false
|
|
loader := func(env string) (map[string]interface{}, error) {
|
|
loaderCalled = true
|
|
return data, nil
|
|
}
|
|
|
|
code := run(tt.args, &stdout, &stderr, loader)
|
|
if code != exitOK {
|
|
t.Errorf("expected exitOK(0), got %d", code)
|
|
}
|
|
if loaderCalled {
|
|
t.Error("loader should not be called for help")
|
|
}
|
|
if stdout.Len() == 0 {
|
|
t.Error("expected usage output in stdout")
|
|
}
|
|
if !strings.Contains(stdout.String(), "usage:") {
|
|
t.Errorf("expected usage text in stdout, got %q", stdout.String())
|
|
}
|
|
if stderr.Len() > 0 {
|
|
t.Errorf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
})
|
|
}
|
|
}
|