Add the Node build module's workspace and the tests of its build and test steps (issues 1490, 1491), part 18 of 19

This commit is contained in:
2026-10-20 21:04:12 +00:00
parent 59e6f53a12
commit 1d6c905407
5 changed files with 725 additions and 0 deletions
@@ -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<void> = serveRegisteredOverStdio as () => Promise<void>;\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)
}
}
@@ -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/<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", "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)
}
}
}
@@ -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)
}
}
}
@@ -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 <dir>
name string // the program after env -C <dir>
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 <tsc>
}
} 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, " ") }
@@ -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
// <work dir>/<step id>/tree, made by the fetch step. The toolchain's packages are copied to <work dir>/<step id>/
// 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 <root>/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)
}