Files
mesh-catalog/modules/mesh-build-node/cmd/mesh-build-node/build_test.go
T
jschoubben 98c66fb1e4 Build modules: a failed command's reason says its error, not its last progress line (issue 1703)
buildah writes STEP lines on stdout and its error on stderr, so the last line of both hid every image build's cause.
mesh-build-image's reason is now stderr's last lines (600 bytes) with the exit status, and the journal gets both tails;
mesh-build-node keeps stderr's end and stdout's start apart; mesh-build-go's cause skips go build's '# package' line.
2026-10-26 15:21:01 +01:00

305 lines
12 KiB
Go

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/<a>/<path>.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/<artifact> 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)
}
}
// issue 1703: a failed command's reason keeps stderr's end and stdout's start, each bounded, so a long stdout (npm's
// progress) never pushes npm's error out, and tsc's first error on stdout stays in.
func TestRanFailedKeepsTheError(t *testing.T) {
noise := strings.Repeat("npm progress line\n", 200)
cases := map[string]struct {
ran forgekey.Ran
want []string
wantNot []string
}{
"passed": {forgekey.Ran{Stdout: "ok"}, nil, nil},
"npm, long stdout": {forgekey.Ran{Status: 1, Stdout: noise, Stderr: "npm ERR! early\n" + noise + "npm ERR! code E404\n"}, []string{"status 1", "npm ERR! code E404"}, []string{"npm ERR! early"}},
"tsc on stdout": {forgekey.Ran{Status: 2, Stdout: "src/a.ts(1,7): error TS2322: wrong\n" + noise}, []string{"error TS2322"}, nil},
"never ran": {forgekey.Ran{Err: "ENOENT"}, []string{"ENOENT"}, nil},
}
for name, c := range cases {
got := ranFailed(c.ran)
if c.want == nil && got != "" {
t.Errorf("%s: %q, want none", name, got)
}
for _, w := range c.want {
if !strings.Contains(got, w) {
t.Errorf("%s: %q lacks %q", name, got, w)
}
}
for _, w := range c.wantNot {
if strings.Contains(got, w) {
t.Errorf("%s: %q carries %q", name, got, w)
}
}
if len(got) > failedBytes+100 {
t.Errorf("%s: %d bytes, past its bound", name, len(got))
}
}
}