Skip to content

Commit 0ea5308

Browse files
committed
feat(coverage): add parity runtime and publish tasks, update README and Taskfile
1 parent d1f12c8 commit 0ea5308

8 files changed

Lines changed: 232 additions & 3 deletions

File tree

‎.github/workflows/go-cli.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -250,8 +250,8 @@ jobs:
250250
repo-token: ${{ secrets.GITHUB_TOKEN }}
251251

252252
- run: task reference
253-
- run: task parity:runtime
254-
- run: task parity:publish
253+
- run: task coverage:parity-runtime
254+
- run: task coverage:parity-publish
255255
- if: always()
256256
run: |
257257
mkdir -p artifacts

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,8 @@ single percentage:
146146
task coverage # hermetic unit profile + ratchet gate
147147
task coverage:e2e # real Docker E2E profile (requires Docker)
148148
task coverage:parity-contract # instrumented CLI subprocess vs the TS oracle
149+
task coverage:parity-runtime # full Docker parity, collected nightly in CI
150+
task coverage:parity-publish # real publication to an ephemeral OCI registry
149151
```
150152

151153
The subprocess profile uses Go's application coverage (`go build -cover` plus

‎Taskfile.yml‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ vars:
1111
# Minimum hermetic-unit statement coverage the `coverage` gate enforces. Set as a
1212
# ratchet just below the current level; raise it as coverage grows (never lower).
1313
COVERAGE_MIN: "48"
14+
# Package ratchets prevent well-covered parsing packages from masking a
15+
# regression in the orchestration layers. Keep these just below the measured
16+
# hermetic baseline and only move them upward.
17+
COVERAGE_CLI_MIN: "28"
18+
COVERAGE_DOCKER_MIN: "77"
19+
COVERAGE_LIFECYCLE_MIN: "73"
20+
COVERAGE_OCI_MIN: "85"
1421

1522
tasks:
1623
default:
@@ -81,6 +88,27 @@ tasks:
8188
if (t+0 < m+0) { printf "FAIL: coverage %.1f%% is below the %s%% floor\n", t, m; exit 1 }
8289
printf "OK: coverage %.1f%% >= %s%% floor\n", t, m
8390
}'
91+
# Weighted statement coverage per package. This catches the failure mode
92+
# where gains in parsers hide lost coverage in CLI/OCI orchestration.
93+
- cmd: |
94+
check_pkg() {
95+
pkg="$1"; min="$2"
96+
result=$(awk -v p="github.com/devcontainers/cli/internal/${pkg}/" '
97+
NR > 1 && index($1,p)==1 { total += $2; if ($3 > 0) covered += $2 }
98+
END { if (total == 0) print "missing"; else printf "%.1f", covered*100/total }
99+
' coverage.out)
100+
if [ "$result" = missing ]; then
101+
echo "FAIL: no coverage data for internal/$pkg"; return 1
102+
fi
103+
awk -v p="$pkg" -v t="$result" -v m="$min" 'BEGIN {
104+
if (t+0 < m+0) { printf "FAIL: internal/%s coverage %.1f%% is below %s%%\n", p,t,m; exit 1 }
105+
printf "OK: internal/%s coverage %.1f%% >= %s%%\n", p,t,m
106+
}'
107+
}
108+
check_pkg cli {{.COVERAGE_CLI_MIN}}
109+
check_pkg docker {{.COVERAGE_DOCKER_MIN}}
110+
check_pkg lifecycle {{.COVERAGE_LIFECYCLE_MIN}}
111+
check_pkg oci {{.COVERAGE_OCI_MIN}}
84112
85113
coverage:e2e:
86114
desc: Measure the real in-process E2E paths (requires Docker)
@@ -108,6 +136,37 @@ tasks:
108136
- go tool covdata textfmt -i=artifacts/coverage/data/contract -o=artifacts/coverage/parity-contract.out
109137
- go tool cover -func=artifacts/coverage/parity-contract.out
110138

139+
coverage:parity-runtime:
140+
desc: Measure the instrumented Go binary through the full Docker parity lane
141+
deps: [build:coverage, reference:compile]
142+
preconditions:
143+
- sh: docker info >/dev/null 2>&1
144+
msg: Docker is required for runtime parity coverage
145+
- sh: |
146+
d=$(mktemp -d) && printf 'FROM busybox\n' > "$d/Dockerfile" && \
147+
docker buildx build --cache-to=type=local,dest="$d/c" -t parity-coverage-builder-probe "$d" >/dev/null 2>&1; \
148+
rc=$?; rm -rf "$d" >/dev/null 2>&1; exit $rc
149+
msg: The Docker builder must support cache export for runtime parity coverage
150+
cmds:
151+
- rm -rf artifacts/coverage/data/runtime
152+
- mkdir -p artifacts/coverage/data/runtime
153+
- GOCOVERDIR={{.ROOT_DIR}}/artifacts/coverage/data/runtime CLI_GO={{.ROOT_DIR}}/artifacts/coverage/bin/{{.BINARY}} PARITY_REPORT_FILE={{.ROOT_DIR}}/artifacts/parity-runtime.json PARITY_STRICT=true PARITY_LANE=all go test ./internal/cli -run '^TestParityMatrix$' -count=1 -parallel {{.PARITY_PARALLEL | default "2"}} -timeout 60m -v {{.CLI_ARGS}}
154+
- go tool covdata textfmt -i=artifacts/coverage/data/runtime -o=artifacts/coverage/parity-runtime.out
155+
- go tool cover -func=artifacts/coverage/parity-runtime.out
156+
157+
coverage:parity-publish:
158+
desc: Measure real feature/template publication against an ephemeral OCI registry
159+
deps: [build:coverage, reference:compile]
160+
preconditions:
161+
- sh: docker info >/dev/null 2>&1
162+
msg: Docker is required for publish parity coverage
163+
cmds:
164+
- rm -rf artifacts/coverage/data/publish
165+
- mkdir -p artifacts/coverage/data/publish
166+
- GOCOVERDIR={{.ROOT_DIR}}/artifacts/coverage/data/publish CLI_GO={{.ROOT_DIR}}/artifacts/coverage/bin/{{.BINARY}} go test ./internal/cli -run '^TestPublishParity$' -count=1 -timeout 10m -v {{.CLI_ARGS}}
167+
- go tool covdata textfmt -i=artifacts/coverage/data/publish -o=artifacts/coverage/parity-publish.out
168+
- go tool cover -func=artifacts/coverage/parity-publish.out
169+
111170
test:e2e:
112171
desc: Run Go E2E tests with Docker
113172
deps: [build]

‎internal/cli/publish_parity_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ func TestPublishParity(t *testing.T) {
2626
if _, err := os.Stat(filepath.Join(repoRoot, "reference", "dist", "spec-node", "devContainersSpecCLI.js")); err != nil {
2727
t.Skip("TS reference not compiled")
2828
}
29-
goCLI := filepath.Join(repoRoot, "devcontainer")
29+
goCLI := envOr("CLI_GO", filepath.Join(repoRoot, "devcontainer"))
3030
if _, err := os.Stat(goCLI); err != nil {
3131
t.Skip("Go CLI not built")
3232
}

‎internal/docker/runargs_test.go‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,27 @@ func TestCreateContainerArgs_MountConsistency(t *testing.T) {
152152
t.Errorf("args = %q", joined)
153153
}
154154
}
155+
156+
// FuzzParseMountSpec targets user-controlled --mount strings. Beyond not
157+
// panicking, every accepted result must satisfy the invariants relied on by
158+
// container creation: a destination and an explicit/defaulted mount type.
159+
func FuzzParseMountSpec(f *testing.F) {
160+
f.Add("type=bind,source=/workspace,target=/workspaces/project,readonly")
161+
f.Add("type=volume,source=data,destination=/data")
162+
f.Add("src=/tmp,dst=/tmp/container,ro=true")
163+
f.Add("target=/default-bind")
164+
f.Add("type=volume,source=missing-target")
165+
166+
f.Fuzz(func(t *testing.T, spec string) {
167+
m, err := ParseMountSpec(spec)
168+
if err != nil {
169+
return
170+
}
171+
if m.Target == "" {
172+
t.Fatal("accepted mount has an empty target")
173+
}
174+
if m.Type == "" {
175+
t.Fatal("accepted mount has an empty type")
176+
}
177+
})
178+
}

‎internal/exec/exec.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ func (r OSRunner) Run(ctx context.Context, name string, args ...string) ([]byte,
3939

4040
err := cmd.Run()
4141
if err != nil {
42+
// CommandContext normally reports the signal used to kill the child as an
43+
// *exec.ExitError. Preserve the more useful cancellation/deadline cause at
44+
// this process boundary so callers can reliably use errors.Is.
45+
if ctxErr := ctx.Err(); ctxErr != nil {
46+
return stdout.Bytes(), stderr.Bytes(), -1, ctxErr
47+
}
4248
if exitErr, ok := err.(*osexec.ExitError); ok {
4349
// Ran and exited non-zero: report the code, not an error.
4450
return stdout.Bytes(), stderr.Bytes(), exitErr.ExitCode(), nil

‎internal/exec/exec_test.go‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package exec
2+
3+
import (
4+
"context"
5+
"errors"
6+
"os"
7+
"strings"
8+
"testing"
9+
"time"
10+
)
11+
12+
// TestHelperProcess is a real child-process endpoint used by OSRunner tests.
13+
// It deliberately exercises the os/exec boundary (argv, environment, both
14+
// streams, exit status and cancellation) instead of mocking CommandContext.
15+
func TestHelperProcess(t *testing.T) {
16+
if os.Getenv("GO_WANT_EXEC_HELPER") != "1" {
17+
return
18+
}
19+
20+
args := helperArgs(os.Args)
21+
if len(args) == 0 {
22+
os.Exit(2)
23+
}
24+
switch args[0] {
25+
case "echo":
26+
_, _ = os.Stdout.WriteString(strings.Join(args[1:], "|"))
27+
_, _ = os.Stderr.WriteString(os.Getenv("EXEC_HELPER_STDERR"))
28+
case "exit":
29+
os.Exit(17)
30+
case "wait":
31+
time.Sleep(time.Hour)
32+
default:
33+
os.Exit(2)
34+
}
35+
os.Exit(0)
36+
}
37+
38+
func helperArgs(args []string) []string {
39+
for i, arg := range args {
40+
if arg == "--" {
41+
return args[i+1:]
42+
}
43+
}
44+
return nil
45+
}
46+
47+
func helperRunner(env ...string) OSRunner {
48+
return OSRunner{Env: append([]string{
49+
"GO_WANT_EXEC_HELPER=1",
50+
}, env...)}
51+
}
52+
53+
func TestOSRunnerProcessContract(t *testing.T) {
54+
stdout, stderr, code, err := helperRunner("EXEC_HELPER_STDERR=warning").Run(
55+
t.Context(), os.Args[0], "-test.run=^TestHelperProcess$", "--", "echo", "one", "two",
56+
)
57+
if err != nil {
58+
t.Fatalf("Run: %v", err)
59+
}
60+
if got := string(stdout); got != "one|two" {
61+
t.Errorf("stdout = %q, want one|two", got)
62+
}
63+
if got := string(stderr); got != "warning" {
64+
t.Errorf("stderr = %q, want warning", got)
65+
}
66+
if code != 0 {
67+
t.Errorf("code = %d, want 0", code)
68+
}
69+
}
70+
71+
func TestOSRunnerNonZeroIsExitCodeNotTransportError(t *testing.T) {
72+
_, _, code, err := helperRunner().Run(
73+
t.Context(), os.Args[0], "-test.run=^TestHelperProcess$", "--", "exit",
74+
)
75+
if err != nil {
76+
t.Fatalf("non-zero child returned transport error: %v", err)
77+
}
78+
if code != 17 {
79+
t.Fatalf("code = %d, want 17", code)
80+
}
81+
}
82+
83+
func TestOSRunnerHonorsCancellation(t *testing.T) {
84+
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
85+
defer cancel()
86+
_, _, code, err := helperRunner().Run(
87+
ctx, os.Args[0], "-test.run=^TestHelperProcess$", "--", "wait",
88+
)
89+
if !errors.Is(err, context.DeadlineExceeded) {
90+
t.Fatalf("error = %v, want context deadline exceeded", err)
91+
}
92+
if code != -1 {
93+
t.Fatalf("code = %d, want -1 for a process killed by its context", code)
94+
}
95+
}
96+
97+
func TestOSRunnerMissingBinaryIsTransportError(t *testing.T) {
98+
_, _, code, err := (OSRunner{}).Run(t.Context(), "/definitely/not/a/devcontainer-test-binary")
99+
if err == nil {
100+
t.Fatal("missing binary returned nil error")
101+
}
102+
if code != -1 {
103+
t.Fatalf("code = %d, want -1", code)
104+
}
105+
}

‎internal/jsonc/jsonc_test.go‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package jsonc
22

33
import (
4+
"encoding/json"
45
"os"
56
"path/filepath"
7+
"reflect"
68
"testing"
79
)
810

@@ -132,3 +134,34 @@ func TestUnmarshal_AllFixtures(t *testing.T) {
132134
}
133135
t.Logf("parsed %d fixtures successfully", count)
134136
}
137+
138+
// FuzzStandardizePreservesMeaning checks the contract that accepted JSONC is
139+
// converted into valid JSON without changing the decoded value. The seed corpus
140+
// includes the syntax extensions and BOM behavior accepted by devcontainer.json.
141+
func FuzzStandardizePreservesMeaning(f *testing.F) {
142+
f.Add([]byte(`{"image":"ubuntu"}`))
143+
f.Add([]byte("\xef\xbb\xbf{\"image\":\"ubuntu\",}"))
144+
f.Add([]byte("{/* comment */\"features\":{},}"))
145+
f.Add([]byte(`{"nested":[1,true,null,{"x":"// not a comment"}]}`))
146+
147+
f.Fuzz(func(t *testing.T, input []byte) {
148+
standard, err := StripComments(input)
149+
if err != nil {
150+
return
151+
}
152+
if !json.Valid(standard) {
153+
t.Fatalf("StripComments succeeded with invalid JSON: %q", standard)
154+
}
155+
156+
var fromJSONC, fromStandard any
157+
if err := Unmarshal(input, &fromJSONC); err != nil {
158+
t.Fatalf("StripComments accepted input that Unmarshal rejected: %v", err)
159+
}
160+
if err := json.Unmarshal(standard, &fromStandard); err != nil {
161+
t.Fatalf("standard output cannot be decoded: %v", err)
162+
}
163+
if !reflect.DeepEqual(fromJSONC, fromStandard) {
164+
t.Fatalf("standardization changed value: JSONC=%#v JSON=%#v", fromJSONC, fromStandard)
165+
}
166+
})
167+
}

0 commit comments

Comments
 (0)