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/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 (buildstep.By): the checker names it when a failure hides // its cause. By string `json:"by,omitempty"` } // Failure is the SDK's failure contract (buildstep.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 = buildstep.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) { 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, By: buildstep.By("mesh-build-node")} 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, 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(where, name, 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(dir, sources) if emit := m.emitBuild(dir, scripts); emit != nil { name, line = "build", emit } start := m.now() r := m.runIn(ctx, root, false, dir, nil, "node", line...) check = m.now().Sub(start) if err := ran(name, r, ""); err != nil { return nil, err } res.Ran = append(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, 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 = append(res.Ran, script) } 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{buildstep.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 }