257 lines
10 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|