Skip to content

Commit 4d8c0eb

Browse files
aledbfclaude
andcommitted
test(parity): fail CI on a stale ts-oracle golden
testdata:ts-oracle:check ran in no CI job, so a reference upgrade or a hand-edited golden went unseen. TestTSOracleGoldensMatchTheReference bundles the oracle and compares every golden with what the reference's functions return; task parity:contract, which CI runs after task reference, now runs it, and testdata:ts-oracle:check is that test. tsOracle keeps only the bundle and the node process, the lane test holding it; refreshTSOracleFiles reads, compares and writes, tested with a fake oracle. build.mjs resolves a symlinked reference/, since esbuild reports real paths. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 5329e57 commit 4d8c0eb

4 files changed

Lines changed: 110 additions & 25 deletions

File tree

‎Taskfile.yml‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -258,8 +258,7 @@ tasks:
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 run ./cmd/devtool ts-oracle
262-
- git diff --exit-code -- '*/testdata/ts-oracle/*.json'
261+
- go test ./internal/devtools -run '^TestTSOracleGoldensMatchTheReference$' -count=1 -v
263262

264263
reference:
265264
desc: Install and compile the TypeScript parity oracle
@@ -280,6 +279,8 @@ tasks:
280279
# Oracle flag-surface gate: every flag the TS oracle declares per command
281280
# must be in the inventory (needs reference/ checked out, so it lives here).
282281
- go test ./internal/cli -run TestOracleFlagCoverage -count=1
282+
# The ts-oracle goldens must be what the reference's functions return.
283+
- go test ./internal/devtools -run '^TestTSOracleGoldensMatchTheReference$' -count=1
283284
- 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}}
284285

285286
parity:semantic:

‎internal/devtools/command.go‎

Lines changed: 40 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package devtools
22

33
import (
44
"bufio"
5+
"bytes"
56
"encoding/json"
67
"fmt"
78
"io"
@@ -48,7 +49,7 @@ func Main(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
4849
case "metrics":
4950
err = t.metrics(rest)
5051
case "ts-oracle":
51-
err = t.tsOracle()
52+
err = t.tsOracle(".", false)
5253
default:
5354
fmt.Fprintf(stderr, "devtool: unknown command %q\n", cmd)
5455
return 2
@@ -550,50 +551,68 @@ func (t tool) metrics(args []string) error {
550551
return nil
551552
}
552553

553-
// tsOracle rewrites every internal/**/testdata/ts-oracle/*.json with the
554-
// answers the reference's own functions give to the golden's requests.
555-
func (t tool) tsOracle() error {
554+
// tsOracle rewrites every internal/**/testdata/ts-oracle/*.json under root
555+
// 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.
557+
//
558+
// mutate-lane: TestTSOracleGoldensMatchTheReference
559+
func (t tool) tsOracle(root string, check bool) error {
556560
dir, err := os.MkdirTemp("", "ts-oracle-")
557561
if err != nil {
558562
return err
559563
}
560564
defer os.RemoveAll(dir)
561565
bundle := filepath.Join(dir, "oracle.js")
562-
if err := t.run("node", "tools/tsoracle/build.mjs", "reference", bundle); err != nil {
566+
if err := t.run("node", filepath.Join(root, "tools/tsoracle/build.mjs"), filepath.Join(root, "reference"), bundle); err != nil {
563567
return fmt.Errorf("bundle tools/tsoracle/oracle.ts: %w", err)
564568
}
565-
paths, err := TSOracleGoldens(os.DirFS("."))
569+
return refreshTSOracleFiles(root, func(request []byte) ([]byte, error) {
570+
cmd := exec.Command("node", bundle)
571+
cmd.Stdin = bytes.NewReader(request)
572+
cmd.Stderr = t.errw
573+
out, err := cmd.Output()
574+
if err != nil {
575+
return nil, fmt.Errorf("run the oracle: %w", err)
576+
}
577+
return out, nil
578+
}, check, t.errw)
579+
}
580+
581+
// refreshTSOracleFiles asks oracle for every golden under root and rewrites
582+
// the ones whose answers changed, naming each on errw; with check it writes
583+
// nothing and fails naming the stale ones.
584+
func refreshTSOracleFiles(root string, oracle func(request []byte) ([]byte, error), check bool, errw io.Writer) error {
585+
paths, err := TSOracleGoldens(os.DirFS(root))
566586
if err != nil {
567587
return err
568588
}
569589
goldens := map[string][]byte{}
570590
for _, p := range paths {
571-
if goldens[p], err = os.ReadFile(p); err != nil {
591+
if goldens[p], err = os.ReadFile(filepath.Join(root, p)); err != nil {
572592
return err
573593
}
574594
}
575-
refreshed, err := RefreshTSOracleGoldens(goldens, func(request []byte) ([]byte, error) {
576-
cmd := exec.Command("node", bundle)
577-
cmd.Stdin = strings.NewReader(string(request))
578-
cmd.Stderr = t.errw
579-
out, err := cmd.Output()
580-
if err != nil {
581-
return nil, fmt.Errorf("run the oracle: %w", err)
582-
}
583-
return out, nil
584-
})
595+
refreshed, err := RefreshTSOracleGoldens(goldens, oracle)
585596
if err != nil {
586597
return err
587598
}
599+
var stale []string
588600
for _, p := range paths {
589-
if string(refreshed[p]) == string(goldens[p]) {
601+
if bytes.Equal(refreshed[p], goldens[p]) {
590602
continue
591603
}
592-
if err := os.WriteFile(p, refreshed[p], 0o644); err != nil {
604+
stale = append(stale, p)
605+
if check {
606+
continue
607+
}
608+
if err := os.WriteFile(filepath.Join(root, p), refreshed[p], 0o644); err != nil {
593609
return err
594610
}
595-
fmt.Fprintln(t.errw, "updated", p)
611+
fmt.Fprintln(errw, "updated", p)
612+
}
613+
if check && len(stale) > 0 {
614+
return fmt.Errorf("stale ts-oracle goldens, run `go tool task testdata:ts-oracle`: %s", strings.Join(stale, ", "))
596615
}
597-
fmt.Fprintf(t.errw, "%d ts-oracle goldens checked\n", len(paths))
616+
fmt.Fprintf(errw, "%d ts-oracle goldens checked\n", len(paths))
598617
return nil
599618
}

‎internal/devtools/tsoracle_test.go‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package devtools
33
import (
44
"encoding/json"
55
"errors"
6+
"os"
7+
"path/filepath"
68
"slices"
79
"strings"
810
"testing"
@@ -168,3 +170,63 @@ func TestRefreshTSOracleGoldensAsksWithAnEmptyCasesArray(t *testing.T) {
168170
t.Errorf("request = %s, want an empty cases array", asked)
169171
}
170172
}
173+
174+
// refreshTSOracleFiles rewrites only the goldens whose answers changed and
175+
// names them; with check it writes nothing and fails naming the stale ones.
176+
func TestRefreshTSOracleFiles(t *testing.T) {
177+
root := t.TempDir()
178+
write := func(p, content string) {
179+
t.Helper()
180+
if err := os.MkdirAll(filepath.Dir(filepath.Join(root, p)), 0o755); err != nil {
181+
t.Fatal(err)
182+
}
183+
if err := os.WriteFile(filepath.Join(root, p), []byte(content), 0o644); err != nil {
184+
t.Fatal(err)
185+
}
186+
}
187+
const fresh = "{\n \"cases\": [\n {\n \"fn\": \"len\",\n \"args\": [\n \"ab\"\n ],\n \"result\": 2\n }\n ]\n}\n"
188+
const stale = `{"cases":[{"fn":"len","args":["abc"]}]}`
189+
write("internal/a/testdata/ts-oracle/fresh.json", fresh)
190+
write("internal/b/testdata/ts-oracle/stale.json", stale)
191+
var sent []byte
192+
193+
var errw strings.Builder
194+
err := refreshTSOracleFiles(root, fakeOracle(&sent), true, &errw)
195+
if err == nil || !strings.Contains(err.Error(), "internal/b/testdata/ts-oracle/stale.json") || strings.Contains(err.Error(), "fresh.json") {
196+
t.Fatalf("check = %v, want the stale golden named alone", err)
197+
}
198+
if got, _ := os.ReadFile(filepath.Join(root, "internal/b/testdata/ts-oracle/stale.json")); string(got) != stale {
199+
t.Errorf("check rewrote the stale golden: %s", got)
200+
}
201+
202+
errw.Reset()
203+
if err := refreshTSOracleFiles(root, fakeOracle(&sent), false, &errw); err != nil {
204+
t.Fatal(err)
205+
}
206+
if want := "updated internal/b/testdata/ts-oracle/stale.json\n2 ts-oracle goldens checked\n"; errw.String() != want {
207+
t.Errorf("stderr = %q, want %q", errw.String(), want)
208+
}
209+
if got, _ := os.ReadFile(filepath.Join(root, "internal/b/testdata/ts-oracle/stale.json")); !strings.Contains(string(got), `"result": 3`) {
210+
t.Errorf("stale golden not refreshed: %s", got)
211+
}
212+
if err := refreshTSOracleFiles(root, fakeOracle(&sent), true, &errw); err != nil {
213+
t.Errorf("check after a refresh = %v, want nil", err)
214+
}
215+
if err := refreshTSOracleFiles(root, func([]byte) ([]byte, error) { return nil, errors.New("node failed") }, true, &errw); err == nil || !strings.Contains(err.Error(), "node failed") {
216+
t.Errorf("oracle failure = %v", err)
217+
}
218+
}
219+
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).
223+
func TestTSOracleGoldensMatchTheReference(t *testing.T) {
224+
const root = "../.."
225+
if _, err := os.Stat(filepath.Join(root, "reference/node_modules/esbuild")); err != nil {
226+
t.Skipf("reference not installed (%v); run `task reference`", err)
227+
}
228+
var out strings.Builder
229+
if err := (tool{out: &out, errw: &out}).tsOracle(root, true); err != nil {
230+
t.Fatalf("%v\n%s", err, out.String())
231+
}
232+
}

‎tools/tsoracle/build.mjs‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,18 @@
33
// The plugin exports the module-private reference functions the oracle calls,
44
// so they are tested as they are, not copied.
55
import { createRequire } from 'node:module';
6+
import { realpathSync } from 'node:fs';
67
import { readFile } from 'node:fs/promises';
78
import path from 'node:path';
89
import { fileURLToPath } from 'node:url';
910

10-
const [refDir, outfile] = process.argv.slice(2).map(p => path.resolve(p));
11-
if (!refDir || !outfile) {
11+
const [refArg, outfile] = process.argv.slice(2).map(p => path.resolve(p));
12+
if (!refArg || !outfile) {
1213
console.error('usage: node build.mjs <reference dir> <outfile>');
1314
process.exit(2);
1415
}
16+
// esbuild reports real paths, so a symlinked reference/ must be resolved too.
17+
const refDir = realpathSync(refArg);
1518
const esbuild = createRequire(path.join(refDir, 'package.json'))('esbuild');
1619

1720
const privateExports = {

0 commit comments

Comments
 (0)