diff --git a/modules/mesh-build-node/cmd/mesh-build-node/build_integration_test.go b/modules/mesh-build-node/cmd/mesh-build-node/build_integration_test.go new file mode 100644 index 00000000..758de1bf --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/build_integration_test.go @@ -0,0 +1,91 @@ +//go:build integration + +package main + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "slices" + "testing" + + "git.novox.be/novox/mesh-sdk/go/forgekey" +) + +// realMachine installs the real toolchain from the module's own lock (npm ci --ignore-scripts, from the mesh's +// registry for @novox, which reads without a credential) and makes two workspaces holding one fixture bundle. +func realMachine(t *testing.T) (*Module, string) { + t.Helper() + for _, p := range []string{"node", "npm"} { + if _, err := exec.LookPath(p); err != nil { + t.Skipf("%s is not installed", p) + } + } + home, cache, work := t.TempDir(), t.TempDir(), t.TempDir() + for _, n := range []string{"package.json", "package-lock.json"} { + raw, err := os.ReadFile(filepath.Join(moduleDir, "files", "toolchain", n)) + if err != nil { + t.Fatal(err) + } + write(t, filepath.Join(home, "toolchain", n), string(raw), 0o644) + } + lock, err := ReadRecipe(filepath.Join(home, "toolchain", "package-lock.json")) + if err != nil { + t.Fatal(err) + } + run := forgekey.ExecRunnerFor(0) + m := &Module{Node: "ace", Recipe: lock, Work: work, Run: run, Ctx: context.Background(), + Toolchain: &Toolchain{Paths: Paths{Home: home, Cache: cache}, Run: run, Registry: func() (Registry, error) { + return Registry{URL: "https://git.novox.be/api/packages/novox/npm/"}, nil + }}} + if err := m.Toolchain.Ensure(context.Background(), m.Recipe.Version()); err != nil { + t.Fatal(err) + } + for _, id := range []string{"w1", "w2"} { + for p, body := range map[string]string{ + "modules/m/module.json": `{"module":"m","build":{"artifacts":[{"name":"tools","kind":"bundle","language":"typescript","entrypoints":["tools/index.js"]}]}}`, + "modules/m/package.json": `{"name":"m","type":"module","scripts":{"test":"node -e \"process.exit(0)\""}}`, + "modules/m/tools/index.ts": "import { serveRegisteredOverStdio } from \"@novox/mesh-sdk/stdio\";\nexport const f: () => Promise = serveRegisteredOverStdio as () => Promise;\n", + } { + write(t, filepath.Join(work, id, "tree", filepath.FromSlash(p)), body, 0o644) + } + } + return m, work +} + +// 1490/AC2: a fixture TypeScript bundle importing @novox/mesh-sdk, built twice by the real tsc and esbuild, has equal +// digests, and its archive holds the bundled .js, the .serve.mjs launcher and package.json. +func TestAFixtureBundleBuiltTwiceHasOneDigest(t *testing.T) { + m, work := realMachine(t) + var digests []string + for _, id := range []string{"w1", "w2"} { + out, err := m.build(context.Background(), map[string]any{"workspace": id, "module": "modules/m", "artifact": "tools"}) + if err != nil { + t.Fatal(err) + } + r := out.(BuildResult) + digests = append(digests, r.Digest) + if want := []string{"package.json", "tools/index.js", "tools/index.serve.mjs"}; !slices.Equal(r.Files, want) { + t.Errorf("files = %v, want %v", r.Files, want) + } + } + if digests[0] != digests[1] { + t.Errorf("digests differ: %v", digests) + } + if links := symlinksUnder(t, work); len(links) != 0 { + t.Errorf("symlinks in the workspaces: %v", links) + } +} + +// 1491: the real type-check and test script of the same fixture pass the unit level. +func TestTheFixturePassesTheUnitLevel(t *testing.T) { + m, _ := realMachine(t) + out, err := m.test(context.Background(), map[string]any{"workspace": "w1", "module": "modules/m", "level": "unit"}) + if err != nil { + t.Fatal(err) + } + if r := out.(*TestResult); !r.Passed || r.NoTests || len(r.Ran) != 2 { + t.Errorf("result = %+v", r) + } +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/build_test.go b/modules/mesh-build-node/cmd/mesh-build-node/build_test.go new file mode 100644 index 00000000..edba7ff2 --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/build_test.go @@ -0,0 +1,219 @@ +package main + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "io" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/forgekey" +) + +const bundleManifest = `{"module":"m","build":{"artifacts":[{"name":"tools","kind":"bundle","language":"typescript",` + + `"entrypoints":["tools/index.js"],"loads":["tools/index.js"]%s}]}}` + +// esbuildEffect stands in for esbuild's output: for each entry `.mesh-build//.js` it writes the bundled file +// under --outdir, named as esbuild names it (the input without its extension, then .js or .mjs when --out-extension:.js=.mjs is given). +func esbuildEffect(t *testing.T) func(c call) { + return func(c call) { + if filepath.Base(c.name) != "esbuild" { + return + } + var outdir, outbase, ext string + var entries []string + for _, a := range c.args { + switch { + case strings.HasPrefix(a, "--outdir="): + outdir = strings.TrimPrefix(a, "--outdir=") + case strings.HasPrefix(a, "--outbase="): + outbase = strings.TrimPrefix(a, "--outbase=") + case a == "--out-extension:.js=.mjs": + ext = ".mjs" + case !strings.HasPrefix(a, "--"): + entries = append(entries, a) + } + } + for _, e := range entries { + rel := strings.TrimPrefix(strings.TrimPrefix(e, outbase), "/") + // esbuild names an output after its input without the extension, and adds .js or the one asked for. + rel = strings.TrimSuffix(rel, filepath.Ext(rel)) + if ext != "" { + rel += ext + } else { + rel += ".js" + } + write(t, filepath.Join(c.dir, outdir, rel), "bundled "+rel, 0o644) + } + } +} + +func buildArgs() map[string]any { + return map[string]any{"workspace": "w1", "module": "modules/m", "artifact": "tools"} +} + +func builtMachine(t *testing.T, extra string) (*Module, *fakeMachine, string) { + m, f, work := newMachine(t) + workspaceWith(t, work, map[string]string{"modules/m/module.json": strings.Replace(bundleManifest, "%s", extra, 1), + "modules/m/tools/index.ts": "export {}"}) + f.effect = esbuildEffect(t) + return m, f, work +} + +// 1490/AC1: tsc and esbuild run with exactly the build agent's flags. The fixture is copied from mesh-controller +// origin/main: internal/builder/toolchain.go:143-147 (tsc: --module NodeNext --moduleResolution NodeNext --target +// ES2022 --rootDir .), builder.go:1637-1705 (--outDir .mesh-build/ and the sources) and :1971-2039 (esbuild: +// --bundle --platform=node --format=esm --target=node22 --outbase --outdir --log-level=warning, the createRequire +// banner, --out-extension:.js=.mjs for the launchers, --external:x). Where the build agent ran them in the toolchain +// image they run here with the toolchain's real paths, from the module's directory (env -C). +func TestTscAndEsbuildRunWithTheBuildAgentsFlags(t *testing.T) { + m, f, work := builtMachine(t, `,"external":["pg"]`) + if _, err := m.build(context.Background(), buildArgs()); err != nil { + t.Fatal(err) + } + dir := filepath.Join(work, "w1", "tree", "modules", "m") + nm := filepath.Join(m.toolchainDir(), "node_modules") + banner := "--banner:js=import { createRequire as __meshRequire } from 'node:module'; const require = __meshRequire(import.meta.url);" + common := []string{"--bundle", "--platform=node", "--format=esm", "--target=node22", + "--outbase=.mesh-build/tools", "--outdir=.mesh-build/tools.bundled", "--log-level=warning", banner, "--external:pg"} + want := []string{ + "node " + nm + "/typescript/bin/tsc --module NodeNext --moduleResolution NodeNext --target ES2022 --rootDir . --outDir .mesh-build/tools tools/index.ts", + nm + "/esbuild/bin/esbuild .mesh-build/tools/tools/index.js " + strings.Join(common, " "), + nm + "/esbuild/bin/esbuild .mesh-build/tools/tools/index.serve.mjs " + strings.Join(common, " ") + " --out-extension:.js=.mjs", + } + if len(f.calls) != len(want) { + t.Fatalf("%d commands ran, want %d: %v", len(f.calls), len(want), f.calls) + } + for i, c := range f.calls { + if c.dir != dir || joined(c) != want[i] { + t.Errorf("command %d: in %s ran\n%s\nwant in %s\n%s", i, c.dir, joined(c), dir, want[i]) + } + } +} + +func readArchive(t *testing.T, path string) map[string]os.FileMode { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + zr, err := gzip.NewReader(bytes.NewReader(raw)) + if err != nil { + t.Fatal(err) + } + tr := tar.NewReader(zr) + out := map[string]os.FileMode{} + for { + h, err := tr.Next() + if err == io.EOF { + return out + } + if err != nil { + t.Fatal(err) + } + out[h.Name] = os.FileMode(h.Mode) + } +} + +// 1490: the step packs the bundle: the archive holds the bundled .js, the .serve.mjs launcher (executable) and the +// package.json that makes it an ES module, and the result is the archive's digest, the same for the same build. +func TestTheBundleIsPackedWithItsLauncherAndPackageJson(t *testing.T) { + m, _, _ := builtMachine(t, ``) + out, err := m.build(context.Background(), buildArgs()) + if err != nil { + t.Fatal(err) + } + r := out.(BuildResult) + if !strings.HasPrefix(r.Digest, "sha256:") || r.Bytes == 0 { + t.Errorf("result = %+v", r) + } + if want := []string{"package.json", "tools/index.js", "tools/index.serve.mjs"}; !slices.Equal(r.Files, want) { + t.Errorf("files = %v, want %v", r.Files, want) + } + modes := readArchive(t, r.Archive) + if len(modes) != 3 || modes["tools/index.serve.mjs"]&0o111 == 0 { + t.Errorf("archive = %v, want the three files with the launcher executable", modes) + } + again, err := m.build(context.Background(), buildArgs()) + if err != nil || again.(BuildResult).Digest != r.Digest { + t.Errorf("a second build: %v, digest %v, want %s", err, again, r.Digest) + } + launcher, _ := os.ReadFile(filepath.Join(filepath.Dir(r.Archive), "tree", "modules", "m", ".mesh-build", "tools", "tools", "index.serve.mjs")) + if !strings.Contains(string(launcher), `import { serveRegisteredOverStdio } from "@novox/mesh-sdk/stdio";`) || !strings.Contains(string(launcher), `await import("./index.js");`) { + t.Errorf("launcher = %q", launcher) + } +} + +// 1490/AC3: no symlink is made anywhere in the workspace, with externals (whose packages are copied) or without. +func TestNoSymlinkIsMadeInTheWorkspace(t *testing.T) { + for _, extra := range []string{``, `,"external":["nats"]`} { + m, _, work := builtMachine(t, extra) + if _, err := m.build(context.Background(), buildArgs()); err != nil { + t.Fatal(err) + } + if links := symlinksUnder(t, filepath.Join(work, "w1")); len(links) != 0 { + t.Errorf("symlinks in the workspace: %v", links) + } + } +} + +// 1490: the bundle's SDK comes from the workspace's node_modules, copied beside the tree, and the packed bundle holds +// the toolchain's packages only when the artifact keeps one external, never typescript or esbuild. +func TestThePackagesAreCopiedBesideTheTreeAndIntoTheBundleOnlyForExternals(t *testing.T) { + m, _, work := builtMachine(t, ``) + out, err := m.build(context.Background(), buildArgs()) + if err != nil { + t.Fatal(err) + } + nm := filepath.Join(work, "w1", "node_modules") + for _, p := range []string{"@novox/mesh-sdk/dist/stdio.js", "@types/node/index.d.ts", "nats/lib/nats.js"} { + if _, err := os.Stat(filepath.Join(nm, p)); err != nil { + t.Errorf("the workspace's node_modules lacks %s", p) + } + } + for _, p := range []string{"typescript", "esbuild", "@esbuild"} { + if _, err := os.Stat(filepath.Join(nm, p)); err == nil { + t.Errorf("the toolchain's own %s was copied", p) + } + } + for f := range readArchive(t, out.(BuildResult).Archive) { + if strings.HasPrefix(f, "node_modules") { + t.Errorf("a bundle without externals carries %s", f) + } + } + m, _, _ = builtMachine(t, `,"external":["nats"]`) + out, _ = m.build(context.Background(), buildArgs()) + files := out.(BuildResult).Files + if !slices.Contains(files, "node_modules/nats/lib/nats.js") || !slices.Contains(files, "node_modules/@novox/mesh-sdk/dist/stdio.js") { + t.Errorf("files = %v, want the runtime packages beside the bundle", files) + } + for _, f := range files { + if strings.Contains(f, "typescript") || strings.Contains(f, "esbuild") { + t.Errorf("the bundle carries %s", f) + } + } +} + +func TestAFailedCompilerFailsTheStepNamingIt(t *testing.T) { + m, f, _ := builtMachine(t, ``) + f.answer["tsc"] = forgekey.Ran{Status: 2, Stdout: tscTypeError} + if _, err := m.build(context.Background(), buildArgs()); err == nil || !strings.Contains(err.Error(), "tsc for tools") || !strings.Contains(err.Error(), "tools/bad.ts") { + t.Errorf("err = %v", err) + } +} + +func TestOnlyATypeScriptBundleOfTheModuleIsBuilt(t *testing.T) { + m, _, _ := builtMachine(t, ``) + for _, a := range []string{"nope", ""} { + args := buildArgs() + args["artifact"] = a + if _, err := m.build(context.Background(), args); err == nil { + t.Errorf("artifact %q was built", a) + } + } +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/check_test.go b/modules/mesh-build-node/cmd/mesh-build-node/check_test.go new file mode 100644 index 00000000..f7402838 --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/check_test.go @@ -0,0 +1,161 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/forgekey" +) + +// tscTypeError is what tsc 5.9.3 printed for `export const n: number = "text";` in tools/bad.ts, run on this machine +// on 2026-10-20 with the flags of the type-check below (status 2). +const tscTypeError = "tools/bad.ts(1,14): error TS2322: Type 'string' is not assignable to type 'number'.\n" + +func testArgs(level string) map[string]any { + return map[string]any{"workspace": "w1", "module": ".", "level": level} +} + +// 1491/AC1: a package with a type error fails the unit level, naming the file. +func TestATypeErrorFailsTheUnitLevelNamingTheFile(t *testing.T) { + m, f, work := newMachine(t) + workspaceWith(t, work, map[string]string{"tools/bad.ts": `export const n: number = "text";`, "package.json": `{"name":"x"}`}) + f.answer["tsc"] = forgekey.Ran{Status: 2, Stdout: tscTypeError} + out, err := m.test(context.Background(), testArgs("unit")) + if err != nil { + t.Fatalf("a type error ended the step %v, want it finished with the verdict in its result", err) + } + r := out.(*TestResult) + if r.Passed || len(r.Failed) != 1 || !strings.Contains(r.Why, "tools/bad.ts") || !strings.Contains(r.Failed[0].Output, "TS2322") { + t.Fatalf("result = %+v, want passed false, naming tools/bad.ts", r) + } + if c := f.calls[0]; !strings.Contains(joined(c), "--noEmit") || !strings.Contains(joined(c), "typescript/bin/tsc") || + !strings.Contains(joined(c), "--moduleResolution NodeNext --target ES2022") || !strings.HasSuffix(joined(c), "tools/bad.ts") { + t.Errorf("the type-check ran %s", joined(c)) + } +} + +// 1491/AC2: a package with a passing test script passes the unit level, which reports its time; over its bound it +// says so and still passes. +func TestAPassingTestScriptPassesTheUnitLevelAndReportsItsTime(t *testing.T) { + m, f, work := newMachine(t) + workspaceWith(t, work, map[string]string{"index.ts": `export const a = 1;`, "package.json": `{"scripts":{"test":"node --test"}}`}) + f.cost["tsc"], f.cost["npm"] = 400*time.Millisecond, 600*time.Millisecond + out, err := m.test(context.Background(), testArgs("unit")) + if err != nil { + t.Fatal(err) + } + r := out.(*TestResult) + if !r.Passed || r.NoTests || r.Elapsed != 1 || r.OverBound || r.Bound != 5 || strings.Join(r.Ran, ",") != "type-check,test" { + t.Errorf("result = %+v, want the type-check and the test script in 1 s within the bound", r) + } + last := f.calls[len(f.calls)-1] + if last.name != "npm" || strings.Join(last.args, " ") != "test" { + t.Errorf("ran %s, want npm test", joined(last)) + } + f.cost["npm"] = 6 * time.Second + out, err = m.test(context.Background(), testArgs("unit")) + if r := out.(*TestResult); err != nil || !r.OverBound { + t.Errorf("a slow unit level = %+v, %v, want passed and over_bound", r, err) + } +} + +// 1491/AC3: a package with neither script nor .ts files has no tests, and nothing runs. +func TestAPackageWithNeitherScriptNorTypeScriptHasNoTests(t *testing.T) { + m, f, work := newMachine(t) + workspaceWith(t, work, map[string]string{"package.json": `{"name":"x"}`, "node_modules/p/x.ts": "ignored", "types.d.ts": "declare const a: 1;"}) + for _, level := range []string{"unit", "integration"} { + out, err := m.test(context.Background(), testArgs(level)) + r := out.(*TestResult) + if err != nil || !r.Passed || !r.NoTests || !strings.Contains(r.Why, "no tests") { + t.Errorf("%s = %+v, %v, want the result to say no tests", level, r, err) + } + } + if len(f.calls) != 0 { + t.Errorf("%d commands ran for a package with no tests", len(f.calls)) + } +} + +// 1491/AC4: with test:integration present the integration level runs it, and reports the setup (the type-check) and +// the running time apart. +func TestTheIntegrationLevelRunsItsScriptAndReportsSetupAndRunningApart(t *testing.T) { + m, f, work := newMachine(t) + workspaceWith(t, work, map[string]string{"index.ts": `export const a = 1;`, "tsconfig.json": `{}`, + "package.json": `{"scripts":{"test":"x","test:integration":"node --test int"}}`}) + f.cost["tsc"], f.cost["npm"] = 2*time.Second, 40*time.Second + out, err := m.test(context.Background(), testArgs("integration")) + if err != nil { + t.Fatal(err) + } + r := out.(*TestResult) + if r.Setup != 2 || r.Elapsed != 40 || r.SetupBound != 30 || r.Bound != 60 || !r.Passed || r.OverBound || r.Level != "integration" { + t.Errorf("result = %+v, want setup 2 s and running 40 s apart", r) + } + last := f.calls[len(f.calls)-1] + if strings.Join(last.args, " ") != "run test:integration" { + t.Errorf("ran %s, want npm run test:integration", joined(last)) + } + if tsc := f.calls[0]; strings.Join(tsc.args[1:], " ") != "--noEmit -p ." { + t.Errorf("a package with a tsconfig was checked with %s", joined(tsc)) + } + f.cost["npm"] = 61 * time.Second + out, _ = m.test(context.Background(), testArgs("integration")) + if !out.(*TestResult).OverBound { + t.Errorf("61 s of running is within the bound") + } +} + +// The commands of a step get an environment built from scratch: no word of this process's. +func TestTheStepsCommandsGetAnEnvironmentOfTheirOwn(t *testing.T) { + t.Setenv("MESH_SECRET_TOKEN", "hunter2") + m, f, work := newMachine(t) + workspaceWith(t, work, map[string]string{"index.ts": `export const a = 1;`, "package.json": `{"scripts":{"test":"x"}}`}) + if _, err := m.test(context.Background(), testArgs("unit")); err != nil { + t.Fatal(err) + } + for _, c := range f.calls { + for _, e := range c.env { + if strings.Contains(e, "hunter2") || strings.HasPrefix(e, "MESH_") { + t.Errorf("%s was given %s", joined(c), e) + } + } + if len(c.env) < 3 || !strings.HasPrefix(c.env[0], "PATH=") || !strings.HasPrefix(c.env[1], "HOME=") { + t.Errorf("%s env = %v, want PATH, HOME and the locale", c.name, c.env) + } + } +} + +// 1491: the step finishes with the verdict when the tests fail, and fails only when they could not be run: a failing +// test script is a verdict, a program that cannot start is not. +func TestAFailingScriptIsAVerdictAndACommandThatCannotStartIsAFailedStep(t *testing.T) { + m, f, work := newMachine(t) + workspaceWith(t, work, map[string]string{"package.json": `{"scripts":{"test":"x"}}`}) + f.answer["npm"] = forgekey.Ran{Status: 1, Stdout: "not ok 1 - adds\n"} + out, err := m.test(context.Background(), testArgs("unit")) + r, _ := out.(*TestResult) + if err != nil || r == nil || r.Passed || len(r.Failed) != 1 || r.Failed[0].Test != "test" || !strings.Contains(r.Failed[0].Output, "not ok 1") { + t.Errorf("a failing script = %+v, %v, want passed false and its output", r, err) + } + f.answer["npm"] = forgekey.Ran{Status: 127, Err: "ENOENT"} + if out, err := m.test(context.Background(), testArgs("unit")); err == nil { + t.Errorf("npm not installed = %v, want the step to fail", out) + } + if _, err := m.test(context.Background(), map[string]any{"workspace": "nope", "level": "unit"}); err == nil { + t.Errorf("a workspace never fetched did not fail the step") + } +} + +func TestAWorkspaceOutsideTheWorkDirectoryIsRefused(t *testing.T) { + m, _, _ := newMachine(t) + for _, a := range []map[string]any{ + {"workspace": "../etc", "level": "unit"}, + {"workspace": "w1", "module": "../x", "level": "unit"}, + {"workspace": "w1", "module": "/etc", "level": "unit"}, + {"workspace": "w1", "level": "smoke"}, + } { + if _, err := m.test(context.Background(), a); err == nil { + t.Errorf("%v was accepted", a) + } + } +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/harness_test.go b/modules/mesh-build-node/cmd/mesh-build-node/harness_test.go new file mode 100644 index 00000000..bb62a7a8 --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/harness_test.go @@ -0,0 +1,116 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/forgekey" +) + +// A fake machine for the step tests: a toolchain directory laid out as `npm ci --no-bin-links` leaves it, a work +// directory, a clock that only the fake commands advance, and a runner that records every command and answers by +// what the test set. + +type call struct { + dir string // env -C + name string // the program after env -C + args []string // its arguments + env []string +} + +type fakeMachine struct { + mu sync.Mutex + calls []call + now time.Time + // cost is how long a program takes, by the base name of its path; answer is what it prints and its status; + // effect runs the program's effects on the disk. + cost map[string]time.Duration + answer map[string]forgekey.Ran + effect func(c call) +} + +func (f *fakeMachine) run(_ context.Context, env []string, name string, args ...string) forgekey.Ran { + f.mu.Lock() + defer f.mu.Unlock() + c := call{name: name, args: args, env: env} + key := filepath.Base(name) + if name == "env" && len(args) >= 3 && args[0] == "-C" { + c = call{dir: args[1], name: args[2], args: args[3:], env: env} + key = filepath.Base(c.name) + if key == "node" && len(c.args) > 0 { + key = filepath.Base(c.args[0]) // node + } + } else if name == "git" { + for _, a := range args { + if slices.Contains([]string{"init", "remote", "fetch", "checkout", "rev-parse", "ls-files"}, a) { + key = "git " + a + break + } + } + } + f.calls = append(f.calls, c) + f.now = f.now.Add(f.cost[key]) + if f.effect != nil { + f.effect(c) + } + return f.answer[key] +} + +func (f *fakeMachine) clock() time.Time { f.mu.Lock(); defer f.mu.Unlock(); return f.now } + +// keys of the programs: tsc, esbuild, npm. + +func newMachine(t *testing.T) (*Module, *fakeMachine, string) { + t.Helper() + home, cache, work := t.TempDir(), t.TempDir(), t.TempDir() + f := &fakeMachine{now: time.Unix(1_000_000, 0), cost: map[string]time.Duration{}, answer: map[string]forgekey.Ran{}} + m := &Module{Node: "ace", Recipe: Recipe{Steps: "1", Node: "v26.10.0", Lock: "lock"}, + Toolchain: &Toolchain{Paths: Paths{Home: home, Cache: cache}}, + Work: work, Run: f.run, Clock: f.clock, Ctx: context.Background()} + // The toolchain directory as npm ci leaves it, with no bin links. + nm := filepath.Join(m.toolchainDir(), "node_modules") + for _, p := range []string{"typescript/bin/tsc", "esbuild/bin/esbuild", "@esbuild/linux-x64/bin/esbuild", + "@novox/mesh-sdk/dist/stdio.js", "@types/node/index.d.ts", "nats/lib/nats.js"} { + write(t, filepath.Join(nm, p), "x", 0o644) + } + return m, f, work +} + +func write(t *testing.T, path, body string, mode os.FileMode) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), mode); err != nil { + t.Fatal(err) + } +} + +// workspaceWith makes workspace "w1" of the machine with the files given (path under tree -> body). +func workspaceWith(t *testing.T, work string, files map[string]string) string { + t.Helper() + for p, body := range files { + write(t, filepath.Join(work, "w1", "tree", filepath.FromSlash(p)), body, 0o644) + } + return "w1" +} + +func symlinksUnder(t *testing.T, root string) []string { + t.Helper() + var out []string + filepath.Walk(root, func(p string, info os.FileInfo, err error) error { + if err == nil && info.Mode()&os.ModeSymlink != 0 { + out = append(out, p) + } + return nil + }) + return out +} + +func joined(c call) string { return c.name + " " + strings.Join(c.args, " ") } diff --git a/modules/mesh-build-node/cmd/mesh-build-node/workspace.go b/modules/mesh-build-node/cmd/mesh-build-node/workspace.go new file mode 100644 index 00000000..69b9f195 --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/workspace.go @@ -0,0 +1,138 @@ +package main + +// A workspace and the packages a step runs against (novox/hq ADR 0469 §2 and §5; issues 1490, 1491). A workspace is +// //tree, made by the fetch step. The toolchain's packages are copied to // +// node_modules, which lies above the tree: node, tsc and esbuild resolve an import by walking up from the importing +// file, so @novox/mesh-sdk and the types are found there while the tree itself, which a bundle is packed from, stays +// what the commit holds. A copy and not a link, because the mesh makes no symlink. + +import ( + "context" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + + "git.novox.be/novox/mesh-sdk/go/forgekey" +) + +var workspaceOK = regexp.MustCompile(`^[a-z0-9]+$`) + +// notCopied are the toolchain's own tools: a bundle imports none of them, and they are tens of megabytes. +var notCopied = map[string]bool{"typescript": true, "esbuild": true, "@esbuild": true} + +// moduleDirOf answers the directory of a module in a workspace and the workspace's root (the step's directory), +// refusing an id or a directory that is not what a fetch made. +func (m *Module) moduleDirOf(workspace, module string) (root, dir string, err error) { + if !workspaceOK.MatchString(workspace) { + return "", "", fmt.Errorf("%q is not a workspace id", workspace) + } + if module == "" { + module = "." + } + clean := filepath.Clean(filepath.FromSlash(module)) + if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return "", "", fmt.Errorf("the module directory %q is not inside the workspace", module) + } + root = filepath.Join(m.Work, workspace) + dir = filepath.Join(root, "tree", clean) + if info, err := os.Stat(dir); err != nil || !info.IsDir() { + return "", "", fmt.Errorf("workspace %s holds no directory %q", workspace, module) + } + return root, dir, nil +} + +// toolchainDir is the installed toolchain directory of the recipe in force. +func (m *Module) toolchainDir() string { + return m.Toolchain.Paths.Dir(m.recipe().Version()) +} + +// tsc and esbuild are the toolchain's own programs, run by their real paths. +func (m *Module) tsc() string { + return filepath.Join(m.toolchainDir(), "node_modules", "typescript", "bin", "tsc") +} +func (m *Module) esbuild() string { + return filepath.Join(m.toolchainDir(), "node_modules", "esbuild", "bin", "esbuild") +} + +// provide copies the toolchain's packages to /node_modules unless they are there. +func (m *Module) provide(root string) error { + dest := filepath.Join(root, "node_modules") + if _, err := os.Stat(dest); err == nil { + return nil + } + return copyPackages(filepath.Join(m.toolchainDir(), "node_modules"), dest) +} + +// copyPackages copies the packages of a node_modules directory except the toolchain's own tools. It copies regular +// files and directories only and keeps their permission bits; anything else in the source is an error. +func copyPackages(from, to string) error { + if err := os.MkdirAll(to, 0o755); err != nil { + return err + } + return filepath.WalkDir(from, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(from, path) + if rel == "." { + return nil + } + if top := strings.Split(rel, string(filepath.Separator))[0]; notCopied[top] || top == ".package-lock.json" || top == ".bin" { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + switch { + case d.IsDir(): + return os.MkdirAll(filepath.Join(to, rel), 0o755) + case d.Type().IsRegular(): + return copyFile(path, filepath.Join(to, rel)) + default: + return fmt.Errorf("%s is neither a file nor a directory, and the packages copied carry only those", path) + } + }) +} + +func copyFile(from, to string) error { + in, err := os.Open(from) + if err != nil { + return err + } + defer in.Close() + info, err := in.Stat() + if err != nil { + return err + } + out, err := os.OpenFile(to, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode().Perm()) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + return err + } + return out.Close() +} + +// runIn runs a command with dir as its working directory (`env -C`), so it is the plain line the build agent runs from +// the module's root, with the same relative paths. The command's environment is baseEnv and extra. +func (m *Module) runIn(ctx context.Context, dir string, extra []string, name string, args ...string) forgekey.Ran { + return m.Run(ctx, append(m.baseEnv(), extra...), "env", append([]string{"-C", dir, name}, args...)...) +} + +// ranFailed says why a command did not pass, or "" when it did. +func ranFailed(r forgekey.Ran) string { + if r.Status == 0 && r.Err == "" { + return "" + } + out := strings.TrimSpace(r.Stdout + "\n" + r.Stderr) + if len(out) > 1500 { + out = out[:1500] + " ..." + } + return fmt.Sprintf("status %d %s: %s", r.Status, r.Err, out) +}