Skip to content

Commit bc99616

Browse files
aledbfclaude
andcommitted
test(parity): run the ts-oracle in the pinned node image
The goldens recorded what the host's Node.js answered, so the same reference functions gave other answers on another machine (URL parsing, IDNA and Node.js error messages change between releases). The oracle now bundles and runs in node:24-bookworm-slim, pinned by digest in versions.yaml and handed in by the Taskfile, with no network: Node.js 24 is what CI installs and runs the reference on, and the newest release the reference's lockfile builds on (its nan 2.25.0 does not compile node-pty against Node.js 26). A bump that changes an answer fails the oracle gate until the goldens are refreshed. The readLockfile oracle writes its temporary folder as <dir>, so a message naming the file stays the same from run to run. TestTSOracleGoldensMatchTheReference skips without Docker or without TS_ORACLE_IMAGE, which task parity:contract sets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 21b31a3 commit bc99616

7 files changed

Lines changed: 84 additions & 16 deletions

File tree

‎Taskfile.yml‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -249,16 +249,16 @@ tasks:
249249
- 'echo "Schema refreshed. Run ''task spec:compliance'' — any new spec property fails until modeled in internal/config/types.go."'
250250

251251
testdata:ts-oracle:
252-
desc: Rewrite every internal/**/testdata/ts-oracle/*.json with the results of the reference TypeScript functions (tools/tsoracle)
252+
desc: Rewrite every internal/**/testdata/ts-oracle/*.json with the results of the reference TypeScript functions (tools/tsoracle), run in the node image versions.yaml pins (needs Docker)
253253
deps: [reference:compile]
254254
cmds:
255-
- go run ./cmd/devtool ts-oracle
255+
- go run ./cmd/devtool ts-oracle "$(go tool versions ref node)"
256256

257257
testdata:ts-oracle:check:
258258
desc: Fail when a testdata/ts-oracle golden differs from what the reference TypeScript functions return
259259
deps: [reference:compile]
260260
cmds:
261-
- go test ./internal/devtools -run '^TestTSOracleGoldensMatchTheReference$' -count=1 -v
261+
- TS_ORACLE_IMAGE="$(go tool versions ref node)" go test ./internal/devtools -run '^TestTSOracleGoldensMatchTheReference$' -count=1 -v
262262

263263
reference:
264264
desc: Install and compile the TypeScript parity oracle
@@ -280,7 +280,7 @@ tasks:
280280
# must be in the inventory (needs reference/ checked out, so it lives here).
281281
- go test ./internal/cli -run TestOracleFlagCoverage -count=1
282282
# The ts-oracle goldens must be what the reference's functions return.
283-
- go test ./internal/devtools -run '^TestTSOracleGoldensMatchTheReference$' -count=1
283+
- TS_ORACLE_IMAGE="$(go tool versions ref node)" go test ./internal/devtools -run '^TestTSOracleGoldensMatchTheReference$' -count=1
284284
- PARITY_REPORT_FILE={{.ROOT_DIR}}/artifacts/parity-contract.json PARITY_STRICT=true PARITY_LANE=contract PARITY_SKIP_DOCKER=true PARITY_SKIP_NETWORK=true go test ./internal/cli -run TestParityMatrix -count=1 -timeout 10m -v {{.CLI_ARGS}}
285285

286286
parity:semantic:

‎internal/devtools/command.go‎

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,11 @@ func Main(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
4949
case "metrics":
5050
err = t.metrics(rest)
5151
case "ts-oracle":
52-
err = t.tsOracle(".", false)
52+
if len(rest) != 1 {
53+
fmt.Fprintln(stderr, "usage: devtool ts-oracle <node image>")
54+
return 2
55+
}
56+
err = t.tsOracle(".", rest[0], false)
5357
default:
5458
fmt.Fprintf(stderr, "devtool: unknown command %q\n", cmd)
5559
return 2
@@ -553,26 +557,49 @@ func (t tool) metrics(args []string) error {
553557

554558
// tsOracle rewrites every internal/**/testdata/ts-oracle/*.json under root
555559
// with the answers the reference's own functions give to the golden's
556-
// requests; with check it writes nothing and fails on a stale golden.
560+
// requests; with check it writes nothing and fails on a stale golden. Node.js
561+
// runs in image, the node image versions.yaml pins (the Taskfile hands it in),
562+
// so the answers do not depend on the host's Node.js: URL parsing, IDNA and
563+
// JSON.parse messages change between releases.
557564
//
558565
// mutate-lane: TestTSOracleGoldensMatchTheReference
559-
func (t tool) tsOracle(root string, check bool) error {
566+
func (t tool) tsOracle(root, image string, check bool) error {
567+
root, err := filepath.Abs(root)
568+
if err != nil {
569+
return err
570+
}
571+
// The reference may be a symlink out of the repository; esbuild reads it
572+
// through its real path, so that path is mounted too.
573+
reference, err := filepath.EvalSymlinks(filepath.Join(root, "reference"))
574+
if err != nil {
575+
return err
576+
}
560577
dir, err := os.MkdirTemp("", "ts-oracle-")
561578
if err != nil {
562579
return err
563580
}
564581
defer os.RemoveAll(dir)
582+
node := func(mounts []string, args ...string) *exec.Cmd {
583+
run := []string{"run", "--rm", "-i", "--network=none", "-u", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid())}
584+
for _, m := range mounts {
585+
run = append(run, "-v", m)
586+
}
587+
return exec.Command("docker", append(append(run, image, "node"), args...)...)
588+
}
565589
bundle := filepath.Join(dir, "oracle.js")
566-
if err := t.run("node", filepath.Join(root, "tools/tsoracle/build.mjs"), filepath.Join(root, "reference"), bundle); err != nil {
567-
return fmt.Errorf("bundle tools/tsoracle/oracle.ts: %w", err)
590+
build := node([]string{root + ":" + root + ":ro", reference + ":" + reference + ":ro", dir + ":" + dir},
591+
filepath.Join(root, "tools/tsoracle/build.mjs"), reference, bundle)
592+
build.Stdout, build.Stderr = t.out, t.errw
593+
if err := build.Run(); err != nil {
594+
return fmt.Errorf("bundle tools/tsoracle/oracle.ts in %s: %w", image, err)
568595
}
569596
return refreshTSOracleFiles(root, func(request []byte) ([]byte, error) {
570-
cmd := exec.Command("node", bundle)
597+
cmd := node([]string{dir + ":" + dir + ":ro"}, bundle)
571598
cmd.Stdin = bytes.NewReader(request)
572599
cmd.Stderr = t.errw
573600
out, err := cmd.Output()
574601
if err != nil {
575-
return nil, fmt.Errorf("run the oracle: %w", err)
602+
return nil, fmt.Errorf("run the oracle in %s: %w", image, err)
576603
}
577604
return out, nil
578605
}, check, t.errw)

‎internal/devtools/command_test.go‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,16 @@ func TestMainUsageErrors(t *testing.T) {
121121
if code != 1 || out != "" || errOut != "devtool coverage-report: usage: coverage-report <profile|covdata-dir> [title]\n" {
122122
t.Errorf("a command's error: code=%d stdout=%q stderr=%q", code, out, errOut)
123123
}
124+
// ts-oracle takes the node image, the one argument the Taskfile hands in.
125+
for _, args := range [][]string{{"ts-oracle"}, {"ts-oracle", "a", "b"}} {
126+
if code, out, errOut := runMain(t, "", args...); code != 2 || out != "" || errOut != "usage: devtool ts-oracle <node image>\n" {
127+
t.Errorf("%q: code=%d stdout=%q stderr=%q", args, code, out, errOut)
128+
}
129+
}
130+
// With its image it runs, and here, with no reference/ to bundle, fails.
131+
if code, _, errOut := runMain(t, "", "ts-oracle", "node:test"); code != 1 || !strings.Contains(errOut, "reference") {
132+
t.Errorf("ts-oracle with an image: code=%d stderr=%q, want the missing reference/", code, errOut)
133+
}
124134
}
125135

126136
func TestParityAffectedReadsFilesFromStdin(t *testing.T) {

‎internal/devtools/tsoracle_test.go‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55
"errors"
66
"os"
7+
"os/exec"
78
"path/filepath"
89
"slices"
910
"strings"
@@ -217,16 +218,24 @@ func TestRefreshTSOracleFiles(t *testing.T) {
217218
}
218219
}
219220

220-
// The recorded goldens are what the reference's functions return today. It
221-
// runs where `task reference` has installed the reference (the parity CI job,
222-
// through task parity:contract).
221+
// The recorded goldens are what the reference's functions return today, run in
222+
// the node image versions.yaml pins, which task parity:contract hands in as
223+
// TS_ORACLE_IMAGE. It runs where `task reference` has installed the reference
224+
// and Docker is reachable (the parity CI job).
223225
func TestTSOracleGoldensMatchTheReference(t *testing.T) {
224226
const root = "../.."
227+
image := os.Getenv("TS_ORACLE_IMAGE")
228+
if image == "" {
229+
t.Skip("TS_ORACLE_IMAGE not set; run it through task parity:contract or task testdata:ts-oracle:check")
230+
}
225231
if _, err := os.Stat(filepath.Join(root, "reference/node_modules/esbuild")); err != nil {
226232
t.Skipf("reference not installed (%v); run `task reference`", err)
227233
}
234+
if err := exec.Command("docker", "info").Run(); err != nil {
235+
t.Skipf("docker not reachable (%v); the oracle runs in the pinned node image", err)
236+
}
228237
var out strings.Builder
229-
if err := (tool{out: &out, errw: &out}).tsOracle(root, true); err != nil {
238+
if err := (tool{out: &out, errw: &out}).tsOracle(root, image, true); err != nil {
230239
t.Fatalf("%v\n%s", err, out.String())
231240
}
232241
}

‎internal/features/lockfile_oracle_test.go‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ package features
33
import (
44
"encoding/base64"
55
"encoding/json"
6+
"errors"
67
"os"
78
"path/filepath"
9+
"strings"
810
"testing"
911

1012
"github.com/devcontainers/cli/internal/tsoracle/oracletest"
@@ -44,7 +46,8 @@ func TestTSOracleReadLockfile(t *testing.T) {
4446
lockfile, initLockfile, err := ReadLockfile(filepath.Join(dir, "devcontainer.json"))
4547
switch {
4648
case err != nil:
47-
return nil, err
49+
// The oracle writes its temporary folder as <dir>.
50+
return nil, errors.New(strings.ReplaceAll(err.Error(), dir, "<dir>"))
4851
case initLockfile:
4952
return "init", nil
5053
case lockfile != nil:

‎tools/tsoracle/oracle.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,13 @@ const fns: Record<string, (...args: any[]) => any> = {
117117
}
118118
const { lockfile, initLockfile } = await readLockfile({ configFilePath: URI.file(path.join(dir, 'devcontainer.json')) } as any);
119119
return initLockfile ? 'init' : lockfile ? 'lockfile' : 'none';
120+
} catch (err: any) {
121+
// The temporary folder is <dir> in the golden, so a message that names
122+
// the file stays the same from run to run.
123+
if (typeof err?.message === 'string') {
124+
err.message = err.message.split(dir).join('<dir>');
125+
}
126+
throw err;
120127
} finally {
121128
fs.rmSync(dir, { recursive: true, force: true });
122129
}

‎versions.yaml‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,18 @@
1313
task lint, with this repository's .golangci.yml. go-tools' version, so the spin-stack
1414
repositories are measured by the same linter; the image's Go must be at least go.mod's.
1515
16+
- name: node
17+
kind: image
18+
source: node
19+
version: 24-bookworm-slim
20+
pin: sha256:55fb5f5a527ea2c5c4a95f963e48ef90aa38c7b346054fdc8ee9957a2b55d205
21+
track: digest
22+
note: >-
23+
The Node.js the ts-oracle runs the reference's functions in (task testdata:ts-oracle,
24+
TestTSOracleGoldensMatchTheReference), so a golden does not depend on the host's Node: URL
25+
parsing, IDNA and JSON.parse messages change between Node releases. A bump that changes an
26+
answer fails the oracle gate until the goldens are refreshed and the Go ports follow.
27+
1628
- name: distroless
1729
kind: image
1830
source: gcr.io/distroless/static

0 commit comments

Comments
 (0)