import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs" import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path" import { bundleWith, composeMap, inlinePlugin, PROJECT_MODULE_SCHEME, RUNTIME_MODULES, runtimeModulesPlugin, writeIsolates } from "../bundle/bundler" import { appArgs, sources, values } from "../lib/args" import { collectAssets } from "../lib/project" import { fail } from "../lib/fail" import { projectEntry } from "../lib/mode" import { requireBinary } from "../lib/util" import { remapPositions } from "../server/remap" import { discover, TEST_FILE, TESTS_DIR, type Tree } from "./discover" // sol test: run the test files of a project on the runtime the code ships // on (okf/done/test-harness.md). Each `tests/*.test.ts` is bundled on its // own and handed to a fresh test host process, one file after another. The // binary is the host: it evaluates the file once to list the tests it // registers through flux:test and once more for each test, in an engine of // its own, and prints one JSON record per line on stdout. Which binary // follows from the file's imports: `flux --test` for a flux program, the // dev client (`solidrt-go --test`, headless and stepped by frames) for a // file that imports the app runtime. This command is the only reporter. // The module name the test host evaluates the file under, which is what // its stack frames cite and so what the bundle's sourcemap is keyed by. const ENTRY_MODULE = "main" // What a stack frame inside the harness cites; those frames are dropped // from a report, since they say nothing about the test. const HARNESS_FRAME = "(flux:test:" // The runtime modules the `flux` binary does not have: lattice's builtins, // flux's gui layer and a custom runtime's own modules (runtime:*, which only // the project's runtime has). A test file whose bundle imports one runs on // the dev client, the project's own when it has one (lib/runtime.ts). const APP_MODULE = /^sol:|^runtime:|^flux:(rendertree|camera|microphone|audio|gpu|spatial|video|test\/gui)$/ // Where an app test's staged bundle and its data root live, test's subdir of // the project's tool state (the .solidrt-data the dev server keeps its own // in: volatile, rewritten by every run), and the folder in it a failed // test's snapshot lands in. const STAGE_DIR = join(".solidrt-data", "test") const FAILURES_DIR = "failures" // The layers `--only` selects: a flux test runs on the flux binary, an app // test on the dev client. const LAYERS = ["flux", "app"] as const type Layer = (typeof LAYERS)[number] // Marks a stdout line as a record of the test host's, not something a // native library printed. It starts with a control character (the ASCII // record separator) so that nothing prints it by accident. const RECORD_PREFIX = "\x1esol-test " // How long one file may run before its process is stopped. A safety cap // against a wedged process, which the host's own cap per test cannot see; // not a wait. const FILE_TIMEOUT_MS = 120_000 /** * One test's result, with what its engine logged while it ran and, for a * failure, the details the host read in its engine (labelled text: what was * in flight, and for an app test the time, the tree and a snapshot's path). */ type TestResult = { name: string ok: boolean durationMs: number error?: { message: string; stack: string } output: string[] details?: [label: string, text: string][] } type RunRecord = | { type: "loaded"; tests: string[]; output: string[] } | { type: "failed"; message: string; output: string[] } | { type: "result"; result: TestResult } | { type: "done" } type FileOutcome = { tests: TestResult[] /** What the file logged while it loaded, and what the process printed beside its records. */ output: string[] /** Why the file as a whole failed, whatever its tests reported. */ error: string | null /** The file is of the other layer than `--only` asked for: nothing ran. */ skipped: boolean } // -- Discovery -- // The filesystem behind discover.ts: the real reads, and a project's // workspace members by its package.json `workspaces` globs (the array, or // the `{ packages }` form; a `!` pattern excludes), matched on the member's // path relative to the project. let workspaceGlobs = new Map() function workspacesOf(root: string): { include: Bun.Glob[]; exclude: Bun.Glob[] } { let known = workspaceGlobs.get(root) if (known) return known let pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) let declared: unknown = Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces?.packages let patterns = Array.isArray(declared) ? declared.filter((p): p is string => typeof p === "string") : [] let globs = { include: patterns.filter((p) => !p.startsWith("!")).map((p) => new Bun.Glob(p)), exclude: patterns.filter((p) => p.startsWith("!")).map((p) => new Bun.Glob(p.slice(1))), } workspaceGlobs.set(root, globs) return globs } const tree: Tree = { dirs: (dir) => readdirSync(dir, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) .sort(), isProject: (dir) => existsSync(join(dir, "package.json")), isWorkspaceMember(root, dir) { let rel = relative(root, dir).split("\\").join("/") let { include, exclude } = workspacesOf(root) return include.some((glob) => glob.match(rel)) && !exclude.some((glob) => glob.match(rel)) }, testFiles: (dir) => readdirSync(dir) .filter((name) => TEST_FILE.test(name)) .sort() .map((name) => join(dir, name)), } // -- One file -- // `projectModules`: the `runtime:*` modules the file imports, which only // the project's own runtime has (BundleOutput.projectModules). type Bundled = { code: string; map: string | null; app: boolean; projectModules: string[] } // A flux program's bundle: plain TypeScript, the runtime modules external, // binary and text imports inlined as the app bundle inlines them, with the // sourcemap composed over that step so a failure cites the source line. // JSX needs the Solid transform, which this bundle does not run (Bun's own // would import a jsx runtime core does not ship), so a .tsx anywhere in the // closure settles the layer by itself: the file is an app test and the app // bundler builds it again, whatever became of this bundle. Core's root // reaches one (the Logo), so a plain .ts test gets there through // @solidrt/test or @solidrt/core. async function bundleFlux(file: string): Promise { let jsx = false let imported = new Set() let babelMaps = new Map() let result try { result = await Bun.build({ entrypoints: [file], target: "browser", format: "esm", external: RUNTIME_MODULES, sourcemap: "external", throw: false, plugins: [ runtimeModulesPlugin(imported), inlinePlugin(new Set(), babelMaps), { name: "jsx settles the layer", setup(build) { build.onLoad({ filter: /\.tsx$/ }, () => { jsx = true return { contents: "", loader: "js" } }) }, }, ], }) } catch (e) { return String(e) } if (jsx) return { code: "", map: null, app: true, projectModules: [] } if (!result.success) return result.logs.map(String).join("\n") let entry = result.outputs.find((o) => o.kind === "entry-point") if (!entry) return "The bundler produced no output" let names = [...imported] return { code: await entry.text(), map: await composeMap(result.outputs, babelMaps), app: names.some((name) => APP_MODULE.test(name)), projectModules: names.filter((name) => name.startsWith(PROJECT_MODULE_SCHEME)).sort(), } } // An app's bundle (JSX through the Solid transform), staged like a render: // the bundle, its isolates, the manifest and the project's assets under one // root, which the client mounts. Returns the staged bundle's path. // The isolates are the project's, found under its source root (the // directory of its entry) and named as `sol run` names them, so // `isolate("mesher")` works from the app and from the test. A package with // no app entry (a library) has no source root: the test file's own folder // is searched, as for any entry. `projectModules` as in Bundled. async function stageApp( file: string, ): Promise<{ path: string; stage: string; map: string | null; projectModules: string[] } | string> { let dir = workingDir(file) let project = existsSync(join(dir, "package.json")) ? dir : null let entry = project === null ? null : projectEntry(project) let isolateRoot = entry !== null && existsSync(entry) ? dirname(entry) : undefined let result = await bundleWith({ entry: file, dev: true, minify: false, project, isolateRoot }) if (!result) return "See the compile error above" let stage = join(dir, STAGE_DIR, basename(file).replace(/\.test\.tsx?$/, "")) rmSync(stage, { recursive: true, force: true }) let path = join(stage, "test.sol.js") await Bun.write(path, result.code) writeIsolates(join(stage, "isolates"), result.isolates) await Bun.write(join(stage, "manifest.json"), result.manifest) for (let asset of collectAssets(project).assets) { let dest = join(stage, asset.path) mkdirSync(dirname(dest), { recursive: true }) cpSync(join(project!, asset.path), dest) } return { path, stage, map: result.map, projectModules: result.projectModules } } // The directory a test file runs in: the package or project that holds its // tests/ folder, so a relative path in a test means the same wherever // `sol test` was started. function workingDir(file: string): string { let dir = dirname(file) return basename(dir) === TESTS_DIR ? dirname(dir) : dir } async function lines(stream: ReadableStream, onLine: (line: string) => void): Promise { let decoder = new TextDecoder() let rest = "" for await (let chunk of stream) { rest += decoder.decode(chunk, { stream: true }) let parts = rest.split("\n") rest = parts.pop()! for (let line of parts) onLine(line) } if (rest !== "") onLine(rest) } async function runFile(file: string, filter: string | undefined, seed: number | undefined, only: Layer | undefined): Promise { let outcome: FileOutcome = { tests: [], output: [], error: null, skipped: false } let unbundled = (log: string) => { outcome.error = "The file failed to bundle" outcome.output = log.split("\n") return outcome } // JSX needs the Solid transform, so a .tsx file is an app test as it // stands; a .ts file is one when its bundle imports the app runtime or // reaches JSX. let bundled: Bundled | string = file.endsWith(".tsx") ? { code: "", map: null, app: true, projectModules: [] } : await bundleFlux(file) if (typeof bundled === "string") return unbundled(bundled) if (only !== undefined && (only === "app") !== bundled.app) { outcome.skipped = true return outcome } // The host's flags come ahead of the script; everything after `--` on // the command line reaches the file as its flux:process argv. let hostArgs = ["--test"] if (filter !== undefined) hostArgs.push("--filter", filter) if (seed !== undefined) hostArgs.push("--seed", String(seed)) let proc let map = bundled.map if (bundled.app) { let staged = await stageApp(file) if (typeof staged === "string") return unbundled(staged) map = staged.map // A data root of the file's own, empty at the start, and a folder for // the snapshots of failed tests; the calendar's zone is fixed so that // local-time methods read the same everywhere. let client = [ ...hostArgs, "--data-root", join(staged.stage, "data"), "--failures", join(staged.stage, FAILURES_DIR), "--assets", staged.stage, staged.path, ...appArgs, ] // The project's own dev client when the file imports one of its modules. proc = Bun.spawn([await testHost("solidrt-go", staged.projectModules[0] ?? null), ...client], { cwd: workingDir(file), env: { ...process.env, TZ: "UTC" }, stdout: "pipe", stderr: "pipe", }) } else { // The sandbox: a folder under the package's test stage that the host // enters and empties before every engine, so a test writes relative // paths and starts from no stored state. One per package: files run // one after another, and the wipe is per engine. The package's own // files are mounted as an app's are: `assets/...` reads its assets // folder, read-only, wherever the cwd is. let sandbox = join(workingDir(file), STAGE_DIR, "data") mkdirSync(sandbox, { recursive: true }) // "-": the bundle on stdin. proc = Bun.spawn([await testHost("flux"), ...hostArgs, "--data-root", sandbox, "--assets", workingDir(file), "-", ...appArgs], { cwd: workingDir(file), stdin: new Blob([bundled.code]), stdout: "pipe", stderr: "pipe", }) } let maps = map ? { [ENTRY_MODULE]: map } : null let timedOut = false let timer = setTimeout(() => { timedOut = true proc.kill() }, FILE_TIMEOUT_MS) // What a test logged arrives inside its record; a line that is no record // is something the process printed beside them. let remap = (text: string[]) => text.map((line) => remapPositions(line, maps)) let failed: string[] = [] let loaded = false let done = false let onLine = (line: string) => { if (!line.startsWith(RECORD_PREFIX)) { outcome.output.push(remapPositions(line, maps)) return } let record = JSON.parse(line.slice(RECORD_PREFIX.length)) as RunRecord if (record.type === "result") { outcome.tests.push({ ...record.result, output: remap(record.result.output) }) } else if (record.type === "loaded") { loaded = true outcome.output.push(...remap(record.output)) } else if (record.type === "failed") { failed.push(record.message) outcome.output.push(...remap(record.output)) } else { done = true } } let stderr: string[] = [] await Promise.all([lines(proc.stdout, onLine), lines(proc.stderr, (line) => stderr.push(line)), proc.exited]) clearTimeout(timer) // Source positions for the bundle's, and no frames of the harness. let cited = (text: string) => remapPositions(text, maps) .split("\n") .filter((line) => line.trim() !== "" && !line.includes(HARNESS_FRAME)) .join("\n") for (let test of outcome.tests) { if (!test.error) continue test.error.message = cited(test.error.message) test.error.stack = cited(test.error.stack) // A snapshot's path as the host wrote it is absolute; here it reads // from where the command runs. test.details = test.details?.map(([label, text]) => [label, label === "Snapshot" && isAbsolute(text) ? relative(process.cwd(), text) : text]) } let last = outcome.tests.at(-1)?.name let after = last === undefined ? "before its first test reported" : `after "${last}"` if (timedOut) outcome.error = `The file did not finish within ${FILE_TIMEOUT_MS / 1000} s and was stopped ${after}` else if (failed.length > 0) { // An uncaught error is in the output already; the host's own reason (a // file that timed out or never finished loading) is only here. let reason = cited(failed[0]!) let logged = outcome.output.join("\n").includes(reason) outcome.error = logged ? "The file failed before its tests ran" : `The file failed before its tests ran: ${reason}` } else if (!loaded) outcome.error = "The test host ended before the file had loaded" else if (!done) outcome.error = `The run ended ${after}, with tests left unreported` // stderr is the host's own log (the client's runtime lines): worth // reading when the file as a whole went wrong, noise when it ran. if (outcome.error) outcome.output.push(...stderr) while (outcome.output.at(-1)?.trim() === "") outcome.output.pop() return outcome } // -- Reporting -- function indent(text: string[], by: string): string { return text.map((line) => by + line).join("\n") } function count(n: number, what: string): string { return `${n} ${what}` } // A duration as a reader scans it: seconds past a second, whole // milliseconds past ten, a decimal below that. function duration(ms: number): string { if (ms >= 1000) return `${(ms / 1000).toFixed(1)} s` if (ms >= 10) return `${ms.toFixed(0)} ms` return `${ms.toFixed(1)} ms` } // The file's line carries the time its tests took in their engines (not // the bundling or the process); with --durations every test follows with // its own, the times aligned on a column. function report(name: string, outcome: FileOutcome, durations: boolean) { let failed = outcome.tests.filter((t) => !t.ok).length let passed = outcome.tests.length - failed let counts = [failed > 0 ? count(failed, "failed") : "", count(passed, "passed")].filter(Boolean).join(", ") let took = outcome.tests.reduce((sum, t) => sum + t.durationMs, 0) console.log(`${name}: ${outcome.error ? "FAILED" : `${counts}, ${duration(took)}`}`) if (durations && outcome.tests.length > 0) { let times = outcome.tests.map((t) => duration(t.durationMs)) let width = Math.max(...times.map((t) => t.length)) for (let [i, test] of outcome.tests.entries()) console.log(` ${times[i]!.padStart(width)} ${test.name}`) } for (let test of outcome.tests) { if (test.ok && test.output.length === 0) continue console.log(`\n ${test.ok ? "Passed" : "FAILED"}: ${test.name}`) if (test.error) { console.log(indent(test.error.message.split("\n"), " ")) if (test.error.stack !== "") console.log(indent(test.error.stack.split("\n"), " ")) } // The details under the error: a one-line text on the label's line, a // longer one (the tree) indented below it. for (let [label, text] of test.details ?? []) { let lines = text.split("\n") if (lines.length === 1) console.log(` ${label}: ${text}`) else console.log(` ${label}:\n${indent(lines, " ")}`) } if (test.output.length > 0) console.log(` Output:\n${indent(test.output, " ")}`) } if (outcome.error) { console.log(`\n ${outcome.error}${outcome.tests.length > 0 ? ` (${counts} until then)` : ""}`) if (outcome.output.length > 0) console.log(indent(outcome.output, " ")) } else if (outcome.output.length > 0) { console.log(`\n Output outside the tests:\n${indent(outcome.output, " ")}`) } if (outcome.error || outcome.tests.some((t) => !t.ok || t.output.length > 0) || outcome.output.length > 0) console.log("") } // The host binaries. `flux` is checked on first use: one built without the // `test` feature has no flux:test and takes `--test` for a script path (a // client built without it refuses the flag). The client is resolved per // file, since the file decides whether the project's own is required // (`needed`, lib/runtime.ts): a file that imports a module of the project's // runtime fails here when the project did not build the client for this // host, instead of at its first import on the stock one. let checkedFlux: string | null = null async function testHost(name: "flux" | "solidrt-go", needed: string | null = null): Promise { if (name === "solidrt-go") return requireBinary(name, needed) if (checkedFlux) return checkedFlux let path = requireBinary(name) let proc = Bun.spawn([path, "-"], { stdin: new Blob([`console.log(Flux.capabilities.includes("test"))`]), stdout: "pipe", stderr: "inherit", }) let [out] = await Promise.all([new Response(proc.stdout).text(), proc.exited]) if (out.trim() !== "true") { fail(`The flux binary at ${path} was built without flux:test. Build it with the test feature: make -C flux flux, in a SolidRT checkout.`) } checkedFlux = path return path } // --only flux|app: the layer to run; the files of the other layer are left // out without a word, as a filter leaves out a file none of whose tests // match. function onlyOption(): Layer | undefined { let raw = values.only if (raw === undefined) return undefined if (!(LAYERS as readonly string[]).includes(raw)) fail(`Invalid --only value "${raw}": expected ${LAYERS.join(" or ")}`) return raw as Layer } // --seed: the number Math.random starts from in every test. Without it the // tests run on flux:test's own fixed seed, so a run is the same either way. function seedOption(): number | undefined { let raw = values.seed if (raw === undefined) return undefined let seed = Number(raw) if (!/^\d+$/.test(raw) || !Number.isSafeInteger(seed)) fail(`Invalid --seed value "${raw}": expected a non-negative integer`) return seed } export async function main() { // Every path given (a file, or a folder whose tests/ folders are // searched), the current folder without one; a file named twice runs once. let given = sources.length ? sources : ["."] let files: string[] = [] let skipped: string[] = [] for (let path of given) { let target = resolve(path) if (!existsSync(target)) fail(`No such file or folder: ${path} (resolved from ${process.cwd()})`) let found = statSync(target).isDirectory() ? discover(target, tree) : { files: [target], skipped: [] } for (let file of found.files) if (!files.includes(file)) files.push(file) skipped.push(...found.skipped) } let target = given.map((path) => resolve(path)).join(", ") if (files.length === 0) { // Other projects are left to their own `sol test`; a run that found // nothing names them, since that is the likely answer. let passedOver = skipped.length === 0 ? "" : ` Projects of their own were not entered, run sol test in one: ${skipped.map((dir) => relative(process.cwd(), dir)).join(", ")}.` fail(`No test files found under ${target} (looked for ${TESTS_DIR}/*.test.ts and ${TESTS_DIR}/*.test.tsx).${passedOver}`) } let filter = values.filter let seed = seedOption() let only = onlyOption() let started = performance.now() let failed = 0 let passed = 0 let brokenFiles = 0 let ran = 0 for (let file of files) { let outcome = await runFile(file, filter, seed, only) if (outcome.skipped) continue ran++ // With a filter, a file none of whose tests match has nothing to say. if (filter !== undefined && outcome.tests.length === 0 && !outcome.error) continue report(relative(process.cwd(), file), outcome, values.durations) failed += outcome.tests.filter((t) => !t.ok).length passed += outcome.tests.filter((t) => t.ok).length if (outcome.error) brokenFiles++ } if (ran === 0) fail(`No ${only} test file under ${target}`) if (failed + passed === 0 && brokenFiles === 0) { fail(filter === undefined ? "The test files register no tests" : `No test name contains "${filter}"`) } let parts = [failed > 0 ? count(failed, "failed") : "", count(passed, "passed")].filter(Boolean) if (brokenFiles > 0) parts.push(`${count(brokenFiles, brokenFiles === 1 ? "file" : "files")} did not complete`) // A run on another seed says which, so a failure in it can be run again; // a run of one layer says so too. let notes = [count(ran, ran === 1 ? "file" : "files"), duration(performance.now() - started)] if (only !== undefined) notes.push(`${only} tests only`) if (seed !== undefined) notes.push(`seed ${seed}`) console.log(`Tests: ${parts.join(", ")} (${notes.join(", ")})`) process.exit(failed > 0 || brokenFiles > 0 ? 1 : 0) }