264 lines
9.6 KiB
Go
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
|
|
}
|