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

264 lines
9.6 KiB
Go

package main
// The test step (novox/hq ADR 0445 §4; issues 1491 and 1292): the two levels of a TypeScript package's tests, run
// against a fetched workspace with the toolchain's tsc. The unit level is the package's type-check and its
// package.json `test` script; the integration level is its `test:integration` script, with the type-check as its
// setup. Each is timed and judged against its bound; a level over its bound says so in the result and does not fail
// (the measure of the catalogue's TypeScript is a later release's, R4f). A package with neither a script nor .ts files
// has no tests, and the result says so.
//
// **The bounds are the Go module's** (5 s for the unit level; 30 s of setup and 60 s of running for the integration
// level), because ADR 0445 names no figures for TypeScript. They are constants here, said in every result.
import (
"context"
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// The bounds of a level (ADR 0445 §4).
const (
UnitBound = 5 * time.Second
IntegrationSetupBound = 30 * time.Second
IntegrationRunBound = 60 * time.Second
)
// TestResult is what a test step passes with, with the field names of mesh-build-go's (issue 1290). The step ends
// passed whenever the level ran, whatever its verdict, and Passed is the verdict; it ends failed only when the tests
// could not be run (no toolchain, a command that cannot start, a workspace that was never fetched).
type TestResult struct {
Workspace string `json:"workspace"`
Module string `json:"module"`
Level string `json:"level"`
Passed bool `json:"passed"`
// Why says, in words, why the level failed or went over its bound, or that it has no tests.
Why string `json:"why,omitempty"`
NoTests bool `json:"no_tests,omitempty"`
// Ran names what ran: "type-check", "test" or "test:integration".
Ran []string `json:"ran,omitempty"`
// Elapsed is the level's running time in seconds (at the unit level the type-check and the test script together);
// Setup is the integration level's setup, the type-check, in seconds. Bound and SetupBound are the bounds the
// level is judged by.
Elapsed float64 `json:"elapsed"`
Setup float64 `json:"setup,omitempty"`
Bound float64 `json:"bound"`
SetupBound float64 `json:"setup_bound,omitempty"`
// OverBound says a bound was exceeded. Unlike mesh-build-go's, an over-bound level still passes: the measure of
// the catalogue's TypeScript against the bounds is a later release's (R4f), not this one's gate.
OverBound bool `json:"over_bound"`
Failed []Failure `json:"failed,omitempty"`
}
// Failure is a check or script that failed; Output is the first lines it wrote.
type Failure struct {
Package string `json:"package"`
Test string `json:"test,omitempty"`
Output string `json:"output,omitempty"`
}
func (m *Module) testTool() StepTool {
return StepTool{
Name: "build_node_test",
Description: "Run a TypeScript package's tests in a fetched workspace: the unit level is the type-check (the toolchain's " +
"tsc) and the package's test script, the integration level its test:integration script. Each level is timed " +
"and judged against its bound (unit 5 s; integration 30 s setup and 60 s running); a level over its bound " +
"says so and does not fail. A package with neither script nor .ts files has no tests. Answers a step id at " +
"once; the step ends on its key in the steps state.",
Input: map[string]any{
"type": "object",
"properties": map[string]any{
"workspace": map[string]any{"type": "string", "description": "the workspace id a fetch step answered"},
"module": map[string]any{"type": "string", "description": "the package's directory in the workspace, '.' when it is the root"},
"level": map[string]any{"type": "string", "enum": []string{"unit", "integration"}},
},
"required": []string{"workspace", "level"},
},
Long: true,
Run: m.test,
}
}
func (m *Module) test(ctx context.Context, args map[string]any) (any, error) {
workspace, _ := args["workspace"].(string)
module, _ := args["module"].(string)
level, _ := args["level"].(string)
if level != "unit" && level != "integration" {
return nil, fmt.Errorf("level %q is neither unit nor integration", level)
}
root, dir, err := m.moduleDirOf(workspace, module)
if err != nil {
return nil, err
}
scripts, err := packageScripts(dir)
if err != nil {
return nil, err
}
sources, err := typeScriptFiles(dir)
if err != nil {
return nil, err
}
res := &TestResult{Workspace: workspace, Module: module, Level: level}
script := "test"
if level == "integration" {
script = "test:integration"
}
hasScript := scripts[script] != ""
if level == "integration" && !hasScript {
res.Passed, res.NoTests, res.Why = true, true, "no tests: the package has no test:integration script"
return res, nil
}
if !hasScript && len(sources) == 0 {
res.Passed, res.NoTests, res.Why = true, true, "no tests: the package has no test script and no .ts files"
return res, nil
}
if err := m.provide(root); err != nil {
return nil, fmt.Errorf("providing the toolchain's packages to the workspace: %w", err)
}
where := module
if where == "" {
where = "."
}
var check, run time.Duration
var failed []Failure
// a command that could not start is not a verdict: the step fails. One that ran and said no is.
ran := func(name string, r forgekey.Ran) error {
if r.Err != "" {
return fmt.Errorf("%s could not be run: status %d %s: %s", name, r.Status, r.Err, firstLine(r.Stderr))
}
if r.Status != 0 {
failed = append(failed, Failure{Package: where, Test: name, Output: lines(r.Stdout + "\n" + r.Stderr)})
}
return nil
}
if len(sources) > 0 {
start := m.now()
r := m.runIn(ctx, dir, nil, "node", m.typeCheck(dir, sources)...)
check = m.now().Sub(start)
if err := ran("type-check", r); err != nil {
return nil, err
}
res.Ran = append(res.Ran, "type-check")
}
if hasScript && len(failed) == 0 {
start := m.now()
env := []string{"PATH=" + binDir(root) + ":" + pathOrDefault(), "npm_config_cache=" + filepath.Join(m.Toolchain.Paths.Cache, "npm")}
r := m.runIn(ctx, dir, env, "npm", scriptArgs(script)...)
run = m.now().Sub(start)
if err := ran(script, r); err != nil {
return nil, err
}
res.Ran = append(res.Ran, script)
}
res.Failed = failed
var why []string
for _, f := range failed {
why = append(why, fmt.Sprintf("%s failed in %s: %s", f.Test, f.Package, firstLine(f.Output)))
}
if level == "unit" {
res.Elapsed, res.Bound = (check + run).Seconds(), UnitBound.Seconds()
res.OverBound = check+run > UnitBound
if res.OverBound {
why = append(why, fmt.Sprintf("the unit level took %.2f s, over its bound of %s", res.Elapsed, UnitBound))
}
} else {
res.Setup, res.Elapsed = check.Seconds(), run.Seconds()
res.SetupBound, res.Bound = IntegrationSetupBound.Seconds(), IntegrationRunBound.Seconds()
res.OverBound = check > IntegrationSetupBound || run > IntegrationRunBound
if res.OverBound {
why = append(why, fmt.Sprintf("the integration level took %.2f s of setup and %.2f s of running, over its bounds of %s and %s",
res.Setup, res.Elapsed, IntegrationSetupBound, IntegrationRunBound))
}
}
res.Passed = len(failed) == 0
res.Why = strings.Join(why, "; ")
return res, nil
}
// lines is the first lines of a command's output.
func lines(out string) string {
var keep []string
for _, l := range strings.Split(out, "\n") {
if l = strings.TrimSpace(l); l != "" {
keep = append(keep, l)
if len(keep) == 15 {
break
}
}
}
return strings.Join(keep, "\n")
}
func firstLine(s string) string {
if i := strings.IndexByte(strings.TrimSpace(s), '\n'); i >= 0 {
return strings.TrimSpace(s)[:i]
}
return strings.TrimSpace(s)
}
// scriptArgs is npm's line for a script: `npm test` for test, `npm run <name>` for another.
func scriptArgs(script string) []string {
if script == "test" {
return []string{"test"}
}
return []string{"run", script}
}
// typeCheck is tsc's line for the type-check: the package's tsconfig.json when it has one, else the build agent's
// flags (toolchain.go:143-147) with --noEmit over every .ts file of the package.
func (m *Module) typeCheck(dir string, sources []string) []string {
if _, err := os.Stat(filepath.Join(dir, "tsconfig.json")); err == nil {
return []string{m.tsc(), "--noEmit", "-p", "."}
}
return append([]string{m.tsc(), "--noEmit", "--module", "NodeNext", "--moduleResolution", "NodeNext",
"--target", "ES2022", "--rootDir", "."}, sources...)
}
// packageScripts are the scripts of the package.json in dir (none without one).
func packageScripts(dir string) (map[string]string, error) {
raw, err := os.ReadFile(filepath.Join(dir, "package.json"))
if os.IsNotExist(err) {
return map[string]string{}, nil
}
if err != nil {
return nil, err
}
var p struct {
Scripts map[string]string `json:"scripts"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("the package.json is not JSON: %w", err)
}
return p.Scripts, nil
}
// typeScriptFiles are the .ts files of the package (declaration files excepted), relative and sorted, without
// node_modules, hidden directories and a build's output.
func typeScriptFiles(dir string) ([]string, error) {
var out []string
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
name := d.Name()
if d.IsDir() {
if path != dir && (name == "node_modules" || strings.HasPrefix(name, ".")) {
return filepath.SkipDir
}
return nil
}
if strings.HasSuffix(name, ".ts") && !strings.HasSuffix(name, ".d.ts") {
rel, _ := filepath.Rel(dir, path)
out = append(out, filepath.ToSlash(rel))
}
return nil
})
return out, err
}