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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user