Skip to content

Commit e48a0f0

Browse files
aledbfclaude
andcommitted
test: add spec behavior gates (merge rules, lifecycle order, discovery)
Complement the schema attribute-coverage gate with normative-behavior tests anchored to the spec (containers.dev/implementors/spec), independent of the TS oracle — so parity ≠ the only behavioral signal: - imagemeta TestSpecMetadataMergeRules: the per-property merge contract — booleans OR across sources (init/privileged), scalars last-wins (devcontainer.json last), arrays union without duplicates, objects merged per-key last-wins, lifecycle commands accumulated in order. - lifecycle TestSpecLifecycleOrder / TestSpecWaitForGatesExecution: the canonical onCreate→updateContent→postCreate→postStart→postAttach order, and waitFor + skipNonBlocking stopping execution after the wait phase. - config TestSpecConfigDiscoveryPrecedence / TestSpecDevContainerIDDeterminism: .devcontainer/devcontainer.json preferred over .devcontainer.json, and a stable order-independent ${devcontainerId}. Broaden `task spec:compliance` to run every TestSpec* across packages as one gate (already wired into the PR and release lanes). Proven non-vacuous: breaking the init OR-merge rule fails TestSpecMetadataMergeRules. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8c34ef5 commit e48a0f0

4 files changed

Lines changed: 217 additions & 2 deletions

File tree

Taskfile.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,12 @@ tasks:
8989
- go vet ./cmd/... ./internal/...
9090

9191
spec:compliance:
92-
desc: Verify config structs model every devcontainer.json schema property (spec base, excludes Go-only additions)
92+
desc: Verify spec compliance — schema attribute coverage + normative behavior (merge/lifecycle/discovery), excludes Go-only additions
9393
cmds:
94-
- go test ./internal/config -run TestSpecCompliance -count=1
94+
# All spec contracts are TestSpec* across packages: config attribute
95+
# coverage + variable/discovery/devcontainerId behavior, imagemeta merge
96+
# rules, lifecycle execution order.
97+
- go test ./internal/... -run '^TestSpec' -count=1
9598

9699
spec:schema-update:
97100
desc: Refresh the vendored devcontainer.json schema the spec-compliance gate checks against
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package config
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
// TestSpecConfigDiscoveryPrecedence locks the specification's config discovery
10+
// order (containers.dev/implementors/spec, "config location"): a tool searches
11+
// for devcontainer.json as .devcontainer/devcontainer.json first, then the
12+
// top-level .devcontainer.json.
13+
func TestSpecConfigDiscoveryPrecedence(t *testing.T) {
14+
dir := t.TempDir()
15+
if err := os.MkdirAll(filepath.Join(dir, ".devcontainer"), 0o755); err != nil {
16+
t.Fatal(err)
17+
}
18+
nested := filepath.Join(dir, ".devcontainer", "devcontainer.json")
19+
root := filepath.Join(dir, ".devcontainer.json")
20+
if err := os.WriteFile(nested, []byte(`{"image":"x"}`), 0o644); err != nil {
21+
t.Fatal(err)
22+
}
23+
if err := os.WriteFile(root, []byte(`{"image":"y"}`), 0o644); err != nil {
24+
t.Fatal(err)
25+
}
26+
27+
// Both present → .devcontainer/devcontainer.json wins.
28+
if got := FindConfigFile(dir); got != nested {
29+
t.Errorf("both present: FindConfigFile = %q, want %q (.devcontainer/ precedence)", got, nested)
30+
}
31+
// Only the top-level .devcontainer.json.
32+
if err := os.Remove(nested); err != nil {
33+
t.Fatal(err)
34+
}
35+
if got := FindConfigFile(dir); got != root {
36+
t.Errorf("root only: FindConfigFile = %q, want %q", got, root)
37+
}
38+
// Neither → not found.
39+
if err := os.Remove(root); err != nil {
40+
t.Fatal(err)
41+
}
42+
if got := FindConfigFile(dir); got != "" {
43+
t.Errorf("none present: FindConfigFile = %q, want empty", got)
44+
}
45+
}
46+
47+
// TestSpecDevContainerIDDeterminism locks the spec's ${devcontainerId} contract:
48+
// a stable identifier derived from the container's id-labels — the same label
49+
// set always yields the same id (independent of map order), and different label
50+
// sets yield different ids.
51+
func TestSpecDevContainerIDDeterminism(t *testing.T) {
52+
a := map[string]string{
53+
"devcontainer.local_folder": "/x",
54+
"devcontainer.config_file": "/x/.devcontainer/devcontainer.json",
55+
}
56+
// Same labels, different insertion order (Go maps iterate randomly).
57+
b := map[string]string{
58+
"devcontainer.config_file": "/x/.devcontainer/devcontainer.json",
59+
"devcontainer.local_folder": "/x",
60+
}
61+
if ComputeDevContainerID(a) != ComputeDevContainerID(b) {
62+
t.Error("devcontainerId is not stable across equal label sets (must be order-independent)")
63+
}
64+
if ComputeDevContainerID(a) == ComputeDevContainerID(map[string]string{"devcontainer.local_folder": "/y"}) {
65+
t.Error("devcontainerId collided for different label sets")
66+
}
67+
if ComputeDevContainerID(a) == "" {
68+
t.Error("devcontainerId is empty")
69+
}
70+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package imagemeta
2+
3+
import "testing"
4+
5+
func specBool(b bool) *bool { return &b }
6+
func specStr(s string) *string { return &s }
7+
8+
// TestSpecMetadataMergeRules locks the dev container specification's normative
9+
// metadata merge rules (containers.dev/implementors/spec, "Merge logic"):
10+
// entries are merged in order with devcontainer.json considered LAST, and each
11+
// property category merges differently. This asserts the whole contract in one
12+
// place, independent of the TS oracle.
13+
func TestSpecMetadataMergeRules(t *testing.T) {
14+
// A feature entry (earlier) and the devcontainer.json entry (last).
15+
feature := Entry{
16+
Init: specBool(true),
17+
Privileged: specBool(false),
18+
RemoteUser: "node",
19+
WaitFor: "onCreateCommand",
20+
CapAdd: []string{"SYS_PTRACE"},
21+
RemoteEnv: map[string]*string{"FOO": specStr("from-feature")},
22+
OnCreateCommand: "feature-oncreate",
23+
}
24+
devcontainer := Entry{
25+
Init: specBool(false), // spec: booleans are OR — must NOT override the feature's true
26+
Privileged: specBool(true),
27+
RemoteUser: "vscode", // spec: scalars — last (devcontainer.json) wins
28+
WaitFor: "postCreateCommand",
29+
CapAdd: []string{"SYS_ADMIN", "SYS_PTRACE"}, // spec: arrays — union, no duplicates
30+
RemoteEnv: map[string]*string{"FOO": specStr("from-config"), "BAR": specStr("c")},
31+
OnCreateCommand: "config-oncreate",
32+
}
33+
34+
m := MergeConfiguration([]Entry{feature, devcontainer})
35+
36+
// Rule: boolean properties (init, privileged) are true if ANY source is true.
37+
if m.Init == nil || !*m.Init {
38+
t.Errorf("init = %v, want true (OR across sources: a feature requested it)", m.Init)
39+
}
40+
if m.Privileged == nil || !*m.Privileged {
41+
t.Errorf("privileged = %v, want true (OR across sources)", m.Privileged)
42+
}
43+
44+
// Rule: scalar properties — the last source (devcontainer.json) wins.
45+
if m.RemoteUser != "vscode" {
46+
t.Errorf("remoteUser = %q, want vscode (last wins)", m.RemoteUser)
47+
}
48+
if m.WaitFor != "postCreateCommand" {
49+
t.Errorf("waitFor = %q, want postCreateCommand (last wins)", m.WaitFor)
50+
}
51+
52+
// Rule: array properties — union without duplicates.
53+
if len(m.CapAdd) != 2 || !contains(m.CapAdd, "SYS_PTRACE") || !contains(m.CapAdd, "SYS_ADMIN") {
54+
t.Errorf("capAdd = %v, want a 2-element union {SYS_PTRACE, SYS_ADMIN}", m.CapAdd)
55+
}
56+
57+
// Rule: object properties (remoteEnv) — merged per key, last value wins.
58+
if v := m.RemoteEnv["FOO"]; v == nil || *v != "from-config" {
59+
t.Errorf("remoteEnv[FOO] = %v, want from-config (last wins per key)", v)
60+
}
61+
if v := m.RemoteEnv["BAR"]; v == nil || *v != "c" {
62+
t.Errorf("remoteEnv[BAR] = %v, want c (union of keys)", v)
63+
}
64+
65+
// Rule: lifecycle commands accumulate across sources, in entry order, so every
66+
// source's hook runs (devcontainer.json last).
67+
if len(m.OnCreateCommands) != 2 || m.OnCreateCommands[0] != "feature-oncreate" || m.OnCreateCommands[1] != "config-oncreate" {
68+
t.Errorf("onCreateCommands = %v, want [feature-oncreate config-oncreate]", m.OnCreateCommands)
69+
}
70+
}
71+
72+
func contains(xs []string, want string) bool {
73+
for _, x := range xs {
74+
if x == want {
75+
return true
76+
}
77+
}
78+
return false
79+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package lifecycle
2+
3+
import (
4+
"reflect"
5+
"testing"
6+
7+
"github.com/devcontainers/cli/internal/imagemeta"
8+
"github.com/devcontainers/cli/internal/log"
9+
)
10+
11+
// TestSpecLifecycleOrder locks the specification's lifecycle command order
12+
// (containers.dev/implementors/spec, "Lifecycle scripts"): the in-container
13+
// finalization phases run onCreateCommand → updateContentCommand →
14+
// postCreateCommand, then on start/attach postStartCommand → postAttachCommand.
15+
func TestSpecLifecycleOrder(t *testing.T) {
16+
// AllPhases advertises the canonical order, initializeCommand (host) first.
17+
wantPhases := []Phase{
18+
PhaseInitialize, PhaseOnCreate, PhaseUpdateContent,
19+
PhasePostCreate, PhasePostStart, PhasePostAttach,
20+
}
21+
if got := AllPhases(); !reflect.DeepEqual(got, wantPhases) {
22+
t.Fatalf("AllPhases() = %v, want %v", got, wantPhases)
23+
}
24+
25+
// RunHooks runs the in-container phases in exactly that order. Each phase has
26+
// a single command; the recording executor captures the execution sequence.
27+
merged := &imagemeta.MergedConfig{
28+
OnCreateCommands: []interface{}{"cmd-onCreate"},
29+
UpdateContentCommands: []interface{}{"cmd-updateContent"},
30+
PostCreateCommands: []interface{}{"cmd-postCreate"},
31+
PostStartCommands: []interface{}{"cmd-postStart"},
32+
PostAttachCommands: []interface{}{"cmd-postAttach"},
33+
}
34+
exec := &mockExecutor{}
35+
if err := RunHooks(log.Null, exec, merged, RunOptions{}); err != nil {
36+
t.Fatalf("RunHooks: %v", err)
37+
}
38+
39+
want := []string{"cmd-onCreate", "cmd-updateContent", "cmd-postCreate", "cmd-postStart", "cmd-postAttach"}
40+
if !reflect.DeepEqual(exec.commands, want) {
41+
t.Fatalf("execution order = %v, want %v", exec.commands, want)
42+
}
43+
}
44+
45+
// TestSpecWaitForGatesExecution locks the spec's waitFor semantics: with
46+
// skipNonBlocking, execution stops after the waitFor phase (default
47+
// updateContentCommand), so later phases do not run before the tool connects.
48+
func TestSpecWaitForGatesExecution(t *testing.T) {
49+
merged := &imagemeta.MergedConfig{
50+
WaitFor: "updateContentCommand",
51+
OnCreateCommands: []interface{}{"cmd-onCreate"},
52+
UpdateContentCommands: []interface{}{"cmd-updateContent"},
53+
PostCreateCommands: []interface{}{"cmd-postCreate"},
54+
}
55+
exec := &mockExecutor{}
56+
if err := RunHooks(log.Null, exec, merged, RunOptions{SkipNonBlocking: true}); err != nil {
57+
t.Fatalf("RunHooks: %v", err)
58+
}
59+
want := []string{"cmd-onCreate", "cmd-updateContent"}
60+
if !reflect.DeepEqual(exec.commands, want) {
61+
t.Fatalf("with waitFor=updateContentCommand + skipNonBlocking, ran %v, want %v (postCreate must not run)", exec.commands, want)
62+
}
63+
}

0 commit comments

Comments
 (0)