Files
mesh-catalog/modules/mesh-build-node/cmd/mesh-build-node/build_test.go
T

257 lines
10 KiB
Go

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/<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])
}
}
}
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), "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).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)
}
}
}
// 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)
}
}
}