package main import ( "archive/tar" "bytes" "compress/gzip" "context" "io" "maps" "os" "path/filepath" "slices" "strings" "testing" "git.novox.be/novox/mesh-sdk/go/buildstep" "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", "stage-tools", "modules", "module") 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]) } } } // readArchive reads the archive a build pushed (ADR 0498: the packed file never stays on the node). func readArchive(t *testing.T, r BuildResult) map[string]os.FileMode { t.Helper() raw := archiveOf(t, r) 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) 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(m.Work, "w1", "stage-tools", "modules", "module", ".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 staging directory's node_modules, two levels above the module, 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", "stage-tools", "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)) { 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) } } } // 1490 (review 7): the build agent runs a module mounted at /app/modules/module with the toolchain's packages at // /app/node_modules (mesh-controller builder.go `within`), so esbuild writes the path comments of what it inlines // relative to the module's directory as `../../node_modules/...`. The step builds at that depth: in a staging // directory whose module directory has the packages two levels above it, whatever the module's place in the // repository, so the bundle's bytes (and its digest) equal the build agent's. func TestTheBuildRunsWithThePackagesTwoLevelsAboveTheModule(t *testing.T) { for _, module := range []string{"modules/m", "."} { m, f, work := newMachine(t) files := map[string]string{"module.json": strings.Replace(bundleManifest, "%s", "", 1), "tools/index.ts": "export {}"} if module != "." { files = map[string]string{"modules/m/module.json": strings.Replace(bundleManifest, "%s", "", 1), "modules/m/tools/index.ts": "export {}"} } workspaceWith(t, work, files) f.effect = esbuildEffect(t) args := buildArgs() args["module"] = module if _, err := m.build(context.Background(), args); err != nil { t.Fatalf("%s: %v", module, err) } for _, c := range f.calls { rel, err := filepath.Rel(c.dir, filepath.Join(c.dir, "..", "..", "node_modules")) if err != nil || rel != "../../node_modules" { t.Fatalf("%s: relative path %q (%v)", module, rel, err) } if _, err := os.Stat(filepath.Join(c.dir, "..", "..", "node_modules", "@novox", "mesh-sdk", "dist", "stdio.js")); err != nil { t.Errorf("%s: %s ran in %s, which has no packages at ../../node_modules: %v", module, filepath.Base(c.name), c.dir, err) } if filepath.Base(c.dir) != "module" || filepath.Base(filepath.Dir(c.dir)) != "modules" { t.Errorf("%s: ran in %s, want .../modules/module", module, c.dir) } } if !strings.HasPrefix(f.calls[0].dir, filepath.Join(work, "w1")) { t.Errorf("%s: staged outside the workspace: %s", module, f.calls[0].dir) } } } // R4e: the launchers a build writes are mesh-sdk's one rule (buildstep.Launchers), the names the controller reads a bundle // by; an entrypoint that is not JavaScript gets none. func TestTheLaunchersAreTheSDKsRule(t *testing.T) { a := artifact{Language: "typescript", Entrypoints: []string{"tools/index.js", "web/server.js", "assets/x.css"}} got, err := writeLaunchers(t.TempDir(), a) if err != nil { t.Fatal(err) } if want := buildstep.Launchers(a.Language, a.Entrypoints); !maps.Equal(got, want) || len(got) != 2 { t.Errorf("launchers %v, mesh-sdk's rule %v", got, want) } }