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" "path/filepath" "strings" "time" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "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"` // By is the build module and the SDK it was built with (sdkstep.By): the checker names it when a failure hides // its cause. By string `json:"by,omitempty"` // OutOfStep are the generated TypeScript files of a contracts module out of step with the seat generator (CAT-BN2, // buildstep.OutOfStep): the unit level fails on any, Why naming each. Absent when none is. OutOfStep []string `json:"out-of-step,omitempty"` } // Failure is the SDK's failure contract (sdkstep.Failure, issue 1901): a failing test of the test script, read from // node's TAP reporter, or a type-check or build that failed, each with its cause (never empty, never a passing line) and // its own output up to 4096 bytes, cause first. type Failure = sdkstep.Failure 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) { res, err := m.testLevel(ctx, args) if err != nil || res.Level != "unit" { return res, err } // CAT-BN2: a contracts module's generated TypeScript is regenerated and compared; a file out of step fails the level. _, dir, err := m.moduleDirOf(res.Workspace, res.Module) if err != nil { return nil, err } if res.OutOfStep, err = buildstep.OutOfStep(dir, buildstep.GeneratedRootTS); err != nil { return nil, err } if len(res.OutOfStep) > 0 { why := "generated files out of step with the seat generator (regenerate them): " + strings.Join(res.OutOfStep, ", ") if res.Why != "" && !res.NoTests { why = res.Why + "; " + why } res.Passed, res.NoTests, res.Why = false, false, why } return res, nil } func (m *Module) testLevel(ctx context.Context, args map[string]any) (*TestResult, 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 } res := &TestResult{Workspace: workspace, Module: module, Level: level, By: sdkstep.By("mesh-build-node")} script := "test" if level == "integration" { script = "test:integration" } where := module if where == "" { where = "." } targets, err := testTargets(dir, where) if err != nil { return nil, err } var check, run time.Duration var failed []Failure tested := false for _, tg := range targets { scripts, err := packageScripts(tg.dir) if err != nil { return nil, err } sources, err := typeScriptFiles(tg.dir) if err != nil { return nil, err } hasScript := scripts[script] != "" if (level == "integration" && !hasScript) || (!hasScript && len(sources) == 0) { continue } tested = true if err := m.provide(root); err != nil { return nil, fmt.Errorf("providing the toolchain's packages to the workspace: %w", err) } // 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, report string) 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, testFailures(tg.where, name, tg.dir, r, report)...) } return nil } if len(sources) > 0 { // A package that builds (a build or prepare script, which the fetch's --ignore-scripts never runs) is emitted // by tsc into the workspace, shut off like every run step, so its tests find what they import of its own output // (issue 1869: mesh-sdk's tests import ../dist); any other is type-checked without emitting. name, line := "type-check", m.typeCheck(tg.dir, sources) if emit := m.emitBuild(tg.dir, scripts); emit != nil { name, line = "build", emit } start := m.now() r := m.runIn(ctx, root, false, tg.dir, nil, "node", line...) check += m.now().Sub(start) if err := ran(name, r, ""); err != nil { return nil, err } res.Ran = appendOnce(res.Ran, name) } if hasScript && len(failed) == 0 { start := m.now() // node --test writes its TAP report to a file of the workspace (issue 1901), whatever the script prints itself report := filepath.Join(root, ".mesh-node-test-"+level+".tap") _ = os.Remove(report) env := []string{"PATH=" + binDir(root) + ":" + pathOrDefault(), "npm_config_cache=" + filepath.Join(m.Toolchain.Paths.Cache, "npm"), "NODE_OPTIONS=" + buildstep.NodeTestOptions(report)} r := m.runIn(ctx, root, false, tg.dir, env, "npm", scriptArgs(script)...) run += m.now().Sub(start) tap, _ := os.ReadFile(report) _ = os.Remove(report) if err := ran(script, r, string(tap)); err != nil { return nil, err } res.Ran = appendOnce(res.Ran, script) } } if !tested { res.Passed, res.NoTests = true, true res.Why = "no tests: the package has no test script and no .ts files" if level == "integration" { res.Why = "no tests: the package has no test:integration script" } return res, nil } res.Failed = failed var why []string for _, f := range failed { why = append(why, failureWhy(f)) } 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 } // testFailures is the module's parse path of a command that failed (the conformance kit runs it, issue 1901): the test // script's failing tests from node's TAP report, or, for the type-check or the build, or a script whose report names no // failing test, the command's own output with the line that names its error as the cause. func testFailures(where, name, dir string, r forgekey.Ran, report string) []Failure { if name == "test" || name == "test:integration" { return buildstep.ReadTAP(where, name, dir, report, r.Stdout+"\n"+r.Stderr) } return []Failure{sdkstep.ConsoleFailure(where, name, r.Stdout+"\n"+r.Stderr)} } // failureWhy is the words of one failure: the test or check that failed, where, and its cause. func failureWhy(f Failure) string { file := "" if f.File != "" { file = " (" + f.File + ")" } return fmt.Sprintf("%s failed in %s%s: %s", f.Test, f.Package, file, firstLine(f.Cause)) } 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 ` 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...) } // emitBuild is tsc's line that builds a package as its tsconfig.json says (`tsc -p .`, emitting where it says), when the // package builds (a build or prepare script) and has a tsconfig.json; nil otherwise. func (m *Module) emitBuild(dir string, scripts map[string]string) []string { if scripts["build"] == "" && scripts["prepare"] == "" { return nil } if _, err := os.Stat(filepath.Join(dir, "tsconfig.json")); err != nil { return nil } return []string{m.tsc(), "-p", "."} } // 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 } // testTarget is a directory whose tests a level runs, and the name it is reported under. type testTarget struct{ dir, where string } // testTargets are the directories a module's tests run in: each TypeScript package artifact's own path when every // package the module declares names one (ADR 0634 §8: its package.json, its lock, its tests), else the module's // directory. func testTargets(dir, where string) ([]testTarget, error) { whole := []testTarget{{dir, where}} raw, err := os.ReadFile(filepath.Join(dir, "module.json")) if err != nil { return whole, nil } var mj struct { Build struct { Artifacts []artifact `json:"artifacts"` } `json:"build"` } if json.Unmarshal(raw, &mj) != nil { return whole, nil } var out []testTarget seen := map[string]bool{} for _, a := range mj.Build.Artifacts { if a.Language != "typescript" { continue } if a.Kind != "package" { return whole, nil // a bundle is tested in its module's directory } at := a.packageDir(dir) if at == "." { return whole, nil } if !seen[at] { seen[at] = true out = append(out, testTarget{filepath.Join(dir, filepath.FromSlash(at)), path.Join(where, at)}) } } if len(out) == 0 { return whole, nil } return out, nil } func appendOnce(list []string, s string) []string { for _, x := range list { if x == s { return list } } return append(list, s) }