From 446d141127ce7ea7599aa595878c1229e3bc99be Mon Sep 17 00:00:00 2001 From: Nev Date: Thu, 22 Oct 2026 23:55:00 -0700 Subject: [PATCH 1/2] ci: run the benchmark, measure runner coverage and fail on circular imports - CI runs the benchmark once, on Node 22, after the unit tests, and uploads internal/runner coverage under a new runner Codecov flag with a 30% floor. - The core, chai shim and runner rollup configs now fail the build on a circular import between this repository's own modules instead of printing a warning, since module evaluation order then depends on which file loads first. Cycles wholly inside packages under node_modules are still warnings, and circularImports.test.ts checks both cases with real rollup builds of each config. - core/scripts/copyRunnerBin.js now writes core/node_modules/@nevware21/tripwire as a folder holding only a package.json whose entry points lead back to core/dist, instead of a link to core itself. core contains that folder, so the link was a loop that git clean -xdf (the clean build) and git status --ignored walked until the paths grew too long. The script removes an old link when it finds one, and only the link. - A tsconfig with a syntax error crashed with TypeScript's internal "Debug Failure" on Windows instead of being reported, because readConfigFile was given a path with backslashes. tsCompile.ts now passes forward slashes, so the existing fallbacks apply. - The browser and worker module graph resolved a folder import that node could not resolve to the folder itself rather than to its index file. - browserRunner.ts imported path/win32, which broke --server-root on Linux and macOS. It now imports path. - A puppeteer run whose page reported a failure before it finished loading crashed the CLI with an unhandled rejection. The run's completion is now marked handled up front, as the browser warm-up already was, and the failure is still reported by the await that follows. - nyc report now runs with --cwd set to the project's own folder, so an nyc run wrapping the CLI, whose NYC_CWD every child process inherits, no longer makes it report another project's files. - Runner coverage rises from 47.5% to 97.6% of statements, with 1269 tests across the server, codecov, compile, execute, client, CLI, reporter and browser UI modules. - test/src/support/textImports.ts gives node the html, css and ico loaders that rollup's inlining provides, so anything that reaches the browser runner can be imported from source. - test/src/support/uxDom.ts is a shared stand-in DOM for the browser UI modules. It replaces five identical copies in the existing UI tests and adds the element, event and document members the rendering, menu, frame mode and page client tests need. - cliFromSource.test.ts runs the CLI from the runner's sources in one child process against small temporary projects. It covers the node, browser and worker targets through a stand-in puppeteer, both --watch loops, open and manual launches, and the coverage-report command. Its launcher loads its bootstrap compile past nyc's .ts hook, so every measured file is compiled the same way as in the parent run and nyc never merges two statement maps for one file. - The worker runner and the test frame run under node with their global, script loading and messages stood in for, and the real tripwire as the runtime. The page client is loaded fresh for each test against its own stand-in URL, elements and timers. - core's browser and worker /api/status tests, commented out since they were added, are re-enabled. They skip when the page or worker was not loaded by tripwire's runner, since the benchmark's karma arms load the same folders and nothing there serves /api/status. --- .github/workflows/ci.yml | 23 + codecov.yml | 21 + core/package.json | 4 +- core/rollup.config.js | 16 +- core/scripts/copyRunnerBin.js | 65 +- core/test/src/browser/statusEndpoint.test.ts | 20 +- core/test/src/support/statusEndpoint.ts | 13 + core/test/src/worker/statusEndpoint.test.ts | 20 +- internal/runner/rollup.config.js | 16 +- internal/runner/src/codecov/nycReport.ts | 31 +- internal/runner/src/compile/tsCompile.ts | 15 +- internal/runner/src/execute/browser.ts | 14 +- internal/runner/src/execute/browserRunner.ts | 2 +- internal/runner/src/execute/puppeteer.ts | 9 + .../runner/src/execute/targetSessionCache.ts | 29 +- .../runner/test/src/cli/cliFromSource.test.ts | 549 ++++++++++++++++ .../test/src/cli/graphWorkerEntry.test.ts | 108 +++ .../runner/test/src/cli/parseArgs.test.ts | 36 + .../runner/test/src/client/runnerHost.test.ts | 165 +++++ .../test/src/client/workerRunner.test.ts | 201 ++++++ .../test/src/codecov/instrumenter.test.ts | 224 +++++++ .../runner/test/src/codecov/provider.test.ts | 330 ++++++++++ .../runner/test/src/codecov/reports.test.ts | 248 +++++++ .../test/src/compile/cacheStore.test.ts | 27 + .../test/src/compile/tsCompileProject.test.ts | 497 ++++++++++++++ .../runner/test/src/config/loader.test.ts | 70 ++ .../runner/test/src/execute/assets.test.ts | 174 +++++ .../test/src/execute/browserRunner.test.ts | 475 ++++++++++++++ .../runner/test/src/execute/compile.test.ts | 183 ++++++ .../test/src/execute/graphWorker.test.ts | 115 ++++ .../test/src/execute/moduleAssets.test.ts | 152 +++++ .../test/src/execute/moduleGraph.test.ts | 269 ++++++++ .../runner/test/src/execute/puppeteer.test.ts | 361 ++++++++++ .../src/execute/targetSessionCache.test.ts | 319 +++++++++ .../test/src/reporters/addReporters.test.ts | 61 ++ .../test/src/rollup/circularImports.test.ts | 135 ++++ .../runner/test/src/server/server.test.ts | 618 +++++++++++++++++ .../runner/test/src/support/cliFromSource.js | 245 +++++++ .../runner/test/src/support/textImports.ts | 31 + internal/runner/test/src/support/uxDom.ts | 378 +++++++++++ .../runner/test/src/ux/assertions.test.ts | 110 +--- internal/runner/test/src/ux/client.test.ts | 619 ++++++++++++++++++ internal/runner/test/src/ux/filtering.test.ts | 90 +-- internal/runner/test/src/ux/frameMode.test.ts | 245 +++++++ internal/runner/test/src/ux/logging.test.ts | 94 +-- internal/runner/test/src/ux/menu.test.ts | 172 ++++- internal/runner/test/src/ux/rendering.test.ts | 400 +++++++++++ internal/runner/test/src/ux/selection.test.ts | 90 +-- .../runner/test/src/ux/ui-control.test.ts | 94 +-- shim/chai/rollup.config.js | 16 +- 50 files changed, 7699 insertions(+), 500 deletions(-) create mode 100644 internal/runner/test/src/cli/cliFromSource.test.ts create mode 100644 internal/runner/test/src/cli/graphWorkerEntry.test.ts create mode 100644 internal/runner/test/src/client/runnerHost.test.ts create mode 100644 internal/runner/test/src/client/workerRunner.test.ts create mode 100644 internal/runner/test/src/codecov/instrumenter.test.ts create mode 100644 internal/runner/test/src/codecov/provider.test.ts create mode 100644 internal/runner/test/src/codecov/reports.test.ts create mode 100644 internal/runner/test/src/compile/tsCompileProject.test.ts create mode 100644 internal/runner/test/src/execute/assets.test.ts create mode 100644 internal/runner/test/src/execute/browserRunner.test.ts create mode 100644 internal/runner/test/src/execute/compile.test.ts create mode 100644 internal/runner/test/src/execute/graphWorker.test.ts create mode 100644 internal/runner/test/src/execute/moduleAssets.test.ts create mode 100644 internal/runner/test/src/execute/moduleGraph.test.ts create mode 100644 internal/runner/test/src/execute/puppeteer.test.ts create mode 100644 internal/runner/test/src/execute/targetSessionCache.test.ts create mode 100644 internal/runner/test/src/reporters/addReporters.test.ts create mode 100644 internal/runner/test/src/rollup/circularImports.test.ts create mode 100644 internal/runner/test/src/server/server.test.ts create mode 100644 internal/runner/test/src/support/cliFromSource.js create mode 100644 internal/runner/test/src/support/textImports.ts create mode 100644 internal/runner/test/src/support/uxDom.ts create mode 100644 internal/runner/test/src/ux/client.test.ts create mode 100644 internal/runner/test/src/ux/frameMode.test.ts create mode 100644 internal/runner/test/src/ux/rendering.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3a5e51f..5143f262 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,10 +40,24 @@ jobs: run: npm run build --verbose timeout-minutes: 15 + # Runs every project's `test` script. The benchmark harness's `test` is deliberately its + # validate.js check and its unit tests, a few seconds, and NOT the benchmark itself, so this + # step stays fast on all four Node versions -- the karma arms run once, in the step below. + # `npm run test:full` at the repo root is the two combined, for anyone who wants everything in + # one command. - name: Unit tests run: node common/scripts/install-run-rush.js test --verbose timeout-minutes: 20 + # The benchmark harness is the only project that configures karma and ts-mocha, and karma 6's + # dependency tree does not tolerate every Node version in the matrix above, so it is measured on + # one version only. Its toolchain is pinned to exact versions -- see internal/benchmark's README, + # "Pinned toolchain". Changing the version here means changing that README too. + - name: Benchmark + if: matrix.node == 22 + run: npm run test:bench + timeout-minutes: 30 + - name: Docs link check run: npm run readme-link-check timeout-minutes: 10 @@ -65,3 +79,12 @@ jobs: files: ./shim/chai/coverage/coverage-final.json token: ${{ secrets.CODECOV_TOKEN }} flags: chai_shim + + - name: Upload Runner Coverage + if: matrix.node == 22 + uses: codecov/codecov-action@v5 + with: + slug: ${{ github.repository }} + files: ./internal/runner/coverage/coverage-final.json + token: ${{ secrets.CODECOV_TOKEN }} + flags: runner diff --git a/codecov.yml b/codecov.yml index 731fbe22..3988754c 100644 --- a/codecov.yml +++ b/codecov.yml @@ -18,6 +18,7 @@ coverage: paths: - core/src - shim/chai/src + - internal/runner/src project: core: paths: @@ -31,6 +32,22 @@ coverage: target: 70% flags: - chai_shim + # runner/ was outside coverage measurement entirely until the runner flag was + # added. The target is a floor set just under the first measured value (32.4% + # lines) rather than an aspiration -- raise it as execute/, ux/, codecov/, + # compile/ and client/ gain tests, and do not lower it. + # + # The figure is low partly because it is honest: ux/browser and assets are + # browser-side code that the node test process cannot execute, and they are + # deliberately left in the denominator rather than excluded, so the number + # reflects what is actually untested rather than what is conveniently + # measurable. + runner: + paths: + - internal/runner/src + target: 30% + flags: + - runner flags: core: @@ -40,3 +57,7 @@ flags: chai_shim: paths: - shim/chai/src + + runner: + paths: + - internal/runner/src diff --git a/core/package.json b/core/package.json index 0d9adfd3..c950915f 100644 --- a/core/package.json +++ b/core/package.json @@ -63,8 +63,8 @@ "test:nodesrc": "cross-env TRIPWIRE_PREFER_SOURCE=1 nyc --cache node ./bin/tripwire.js --type-check -p ./test/tsconfig.test.json \"./test/src/!(browser|worker)/**/*.test.ts\" -- --trace-uncaught && node ./bin/tripwire.js coverage-report", "test:browser": "node ./bin/tripwire.js --target browser --coverage --type-check -p ./test/tsconfig.test.json \"./test/src/!(node|worker)/**/*.test.ts\"", "test:worker": "node ./bin/tripwire.js --target worker --coverage --type-check -p ./test/tsconfig.test.json \"./test/src/!(node|browser)/**/*.test.ts\"", - "debug:browser": "node ./bin/tripwire.js --target browser --browser-launch open --source-maps --show-files --verbose --type-check -p ./test/tsconfig.test.json \"./test/src/!(node|worker)/**/*.test.ts\"", - "debug:worker": "node ./bin/tripwire.js --target worker --browser-launch open --source-maps --show-files --verbose --type-check -p ./test/tsconfig.test.json \"./test/src/!(node|browser)/**/*.test.ts\"", + "debug:browser": "node ./bin/tripwire.js --target browser --browser-launch open --source-maps --show-files --type-check -p ./test/tsconfig.test.json \"./test/src/!(node|worker)/**/*.test.ts\"", + "debug:worker": "node ./bin/tripwire.js --target worker --browser-launch open --source-maps --show-files --type-check -p ./test/tsconfig.test.json \"./test/src/!(node|browser)/**/*.test.ts\"", "lint": "grunt tripwire-lint", "coverage:report": "npm run coverage:nyc && npm run coverage:merge", "coverage:nyc": "nyc report --reporter=json", diff --git a/core/rollup.config.js b/core/rollup.config.js index e41bb89a..a862f930 100644 --- a/core/rollup.config.js +++ b/core/rollup.config.js @@ -179,6 +179,20 @@ const rollupConfigMainEntry = (srcPath, destPath, path, format = "umd") => { return taskRollupConfig; }; +// A circular import between this repository's own modules fails the build: which module's exports +// exist when another reads them then depends on which file happens to load first. A cycle entirely +// inside a third-party package is left as a warning, since it is not ours to fix. +function failOnCircularImport(warning, warn) { + if (warning.code === "CIRCULAR_DEPENDENCY") { + const ids = warning.ids || []; + if (!ids.some((id) => /[\\/]node_modules[\\/]/.test(id))) { + throw new Error("Circular import: " + (ids.length ? ids.join(" -> ") : warning.message)); + } + } + + warn(warning); +} + export default [ rollupConfigMainEntry("build/es5/mod", "dist/es5", "main", "umd"), rollupConfigMainEntry("build/es6/mod", "dist/es6", "main", "umd"), @@ -199,4 +213,4 @@ export default [ rollupConfigFactory("build/es6/mod", "bundle/es6", true, "iife", "iife"), rollupConfigFactory("build/es6/mod", "bundle/es6", false, "umd", "umd"), rollupConfigFactory("build/es6/mod", "bundle/es6", true, "umd", "umd") -]; +].map((config) => Object.assign(config, { onwarn: failOnCircularImport })); diff --git a/core/scripts/copyRunnerBin.js b/core/scripts/copyRunnerBin.js index b12c628f..2210d2af 100644 --- a/core/scripts/copyRunnerBin.js +++ b/core/scripts/copyRunnerBin.js @@ -70,29 +70,56 @@ function copyRecursive(src, dest) { } } -function ensureLocalLinks() { - const coreDir = path.resolve(__dirname, ".."); - const nevware21Dir = path.join(coreDir, "node_modules", "@nevware21"); - const linkType = process.platform === "win32" ? "junction" : "dir"; +// Removes the link an older build put at `linkPath` -- only the link, never the folder it points to. +function _removeLink(linkPath) { + let stat = null; + try { + stat = fs.lstatSync(linkPath); + } catch (e) { + return; + } - if (!fs.existsSync(nevware21Dir)) { - fs.mkdirSync(nevware21Dir, { recursive: true }); + if (stat.isSymbolicLink()) { + // A Windows junction is reported as a link too, and unlink removes just the junction + fs.unlinkSync(linkPath); + console.log("\x1b[36mRemoved the old self-link: core/node_modules/@nevware21/tripwire\x1b[0m"); } +} - // Self-link so the copied runner binary can dynamically resolve "@nevware21/tripwire" - // (the package under test) when run against core's own dogfood test suite from within - // core/ -- the default UX/compile providers are bundled directly into the binary now - // (internal/runner/src/ux, internal/runner/src/compile), so no other local links are needed here. - const localLinks = { - "tripwire": coreDir +function ensureSelfPackage() { + const coreDir = path.resolve(__dirname, ".."); + const packageDir = path.join(coreDir, "node_modules", "@nevware21", "tripwire"); + + // Lets the copied runner binary, and core's own test files, resolve "@nevware21/tripwire" (the + // package under test) when core's dogfood test suite runs from within core/ -- the default + // UX/compile providers are bundled directly into the binary now (internal/runner/src/ux, + // internal/runner/src/compile), so nothing else needs resolving here. + // + // It is a folder holding just a package.json whose entry points lead back to core's build, and + // not a link to core itself: core contains this very folder, so such a link was a loop. Anything + // walking ignored folders through it -- `git clean -xdf` (the clean build), `git status --ignored` + // -- went round core/node_modules/@nevware21/tripwire/node_modules/... until the paths grew too + // long for the file system. Node resolves `main` to the real file under core/dist, so the module + // loaded is the same one either way. + _removeLink(packageDir); + fs.mkdirSync(packageDir, { recursive: true }); + + const corePackage = JSON.parse(fs.readFileSync(path.join(coreDir, "package.json"), "utf8")); + const redirect = { + name: corePackage.name, + version: corePackage.version, + private: true, + description: "Points back to this checkout's core build. Written by core/scripts/copyRunnerBin.js.", + main: "../../../" + corePackage.main, + module: "../../../" + corePackage.module, + types: "../../../" + corePackage.types }; + const text = JSON.stringify(redirect, null, 4) + "\n"; + const target = path.join(packageDir, "package.json"); - for (const [name, target] of Object.entries(localLinks)) { - const linkPath = path.join(nevware21Dir, name); - if (!fs.existsSync(linkPath)) { - fs.symlinkSync(target, linkPath, linkType); - console.log("\x1b[36mCreated local link: core/node_modules/@nevware21/" + name + "\x1b[0m"); - } + if (!fs.existsSync(target) || fs.readFileSync(target, "utf8") !== text) { + fs.writeFileSync(target, text, "utf8"); + console.log("\x1b[36mWrote local package: core/node_modules/@nevware21/tripwire\x1b[0m"); } } @@ -105,7 +132,7 @@ function main() { console.log("\x1b[36mCopying runner bin artifacts...\x1b[0m"); copyRecursive(srcDir, dstDir); - ensureLocalLinks(); + ensureSelfPackage(); console.log("\x1b[32mDone copying runner bin artifacts to core/bin\x1b[0m"); } diff --git a/core/test/src/browser/statusEndpoint.test.ts b/core/test/src/browser/statusEndpoint.test.ts index 3f71e54a..66dd8029 100644 --- a/core/test/src/browser/statusEndpoint.test.ts +++ b/core/test/src/browser/statusEndpoint.test.ts @@ -7,14 +7,20 @@ */ import { assert } from "../../../src/assert/assertClass"; -import { assertStatusResponse } from "../support/statusEndpoint"; +import { ITestContext } from "../../../src/interfaces/suite/ITestContext"; +import { assertStatusResponse, isServedByTripwire } from "../support/statusEndpoint"; describe("/api/status server endpoint", () => { - // it("returns running status from the browser server", async () => { - // const response = await fetch("/api/status"); - // const body = await response.json(); + it("returns running status from the browser server", async function (this: ITestContext) { + if (!isServedByTripwire()) { + // Another runner's page, with nothing serving /api/status (see isServedByTripwire) + this.skip(); + } - // assert.equal(typeof response.status, "number", "Validate that fetch returned a numeric status code"); - // assertStatusResponse(response.status, response.headers.get("content-type"), body); - // }); + const response = await fetch("/api/status"); + const body = await response.json(); + + assert.equal(typeof response.status, "number", "Validate that fetch returned a numeric status code"); + assertStatusResponse(response.status, response.headers.get("content-type"), body); + }); }); \ No newline at end of file diff --git a/core/test/src/support/statusEndpoint.ts b/core/test/src/support/statusEndpoint.ts index 842acc10..01d60818 100644 --- a/core/test/src/support/statusEndpoint.ts +++ b/core/test/src/support/statusEndpoint.ts @@ -8,6 +8,19 @@ import { assert } from "../../../src/assert/assertClass"; +/** + * Whether this page or worker was loaded by tripwire's own browser runner, whose server answers + * `/api/status` on the same origin. The browser and worker suites also run under other runners -- the + * benchmark's karma arms load these same folders -- where nothing answers that path, so the tests that + * fetch it skip there rather than fail. + * + * The marker is the module registry tripwire's runner installs before any test module loads: + * `__tripwireDefine__`, on the test frame's window (frame.html) or on the worker's global scope. + */ +export function isServedByTripwire(): boolean { + return typeof (globalThis as any).__tripwireDefine__ === "function"; +} + export function assertStatusResponse(statusCode: number, contentType: string | null, body: any): void { assert.equal(statusCode, 200, "Validate that /api/status returns HTTP 200"); assert.equal(contentType, "application/json; charset=utf-8", "Validate that /api/status returns JSON content"); diff --git a/core/test/src/worker/statusEndpoint.test.ts b/core/test/src/worker/statusEndpoint.test.ts index 678d9d66..0db409ec 100644 --- a/core/test/src/worker/statusEndpoint.test.ts +++ b/core/test/src/worker/statusEndpoint.test.ts @@ -7,14 +7,20 @@ */ import { assert } from "../../../src/assert/assertClass"; -import { assertStatusResponse } from "../support/statusEndpoint"; +import { ITestContext } from "../../../src/interfaces/suite/ITestContext"; +import { assertStatusResponse, isServedByTripwire } from "../support/statusEndpoint"; describe("/api/status server endpoint", () => { - // it("returns running status from the worker server", async () => { - // const response = await fetch("/api/status"); - // const body = await response.json(); + it("returns running status from the worker server", async function (this: ITestContext) { + if (!isServedByTripwire()) { + // Another runner's worker, with nothing serving /api/status (see isServedByTripwire) + this.skip(); + } - // assert.equal(typeof response.status, "number", "Validate that fetch returned a numeric status code"); - // assertStatusResponse(response.status, response.headers.get("content-type"), body); - // }); + const response = await fetch("/api/status"); + const body = await response.json(); + + assert.equal(typeof response.status, "number", "Validate that fetch returned a numeric status code"); + assertStatusResponse(response.status, response.headers.get("content-type"), body); + }); }); \ No newline at end of file diff --git a/internal/runner/rollup.config.js b/internal/runner/rollup.config.js index 85fab912..b079068b 100644 --- a/internal/runner/rollup.config.js +++ b/internal/runner/rollup.config.js @@ -401,4 +401,18 @@ const mainBundle = { ] }; -module.exports = [clientBundle, workerBundle, mainBundle]; +// A circular import between this repository's own modules fails the build: which module's exports +// exist when another reads them then depends on which file happens to load first. A cycle entirely +// inside a third-party package is left as a warning, since it is not ours to fix. +function failOnCircularImport(warning, warn) { + if (warning.code === "CIRCULAR_DEPENDENCY") { + const ids = warning.ids || []; + if (!ids.some((id) => /[\\/]node_modules[\\/]/.test(id))) { + throw new Error("Circular import: " + (ids.length ? ids.join(" -> ") : warning.message)); + } + } + + warn(warning); +} + +module.exports = [clientBundle, workerBundle, mainBundle].map((config) => Object.assign(config, { onwarn: failOnCircularImport })); diff --git a/internal/runner/src/codecov/nycReport.ts b/internal/runner/src/codecov/nycReport.ts index d0baa295..c4fa5e3d 100644 --- a/internal/runner/src/codecov/nycReport.ts +++ b/internal/runner/src/codecov/nycReport.ts @@ -7,6 +7,8 @@ */ import { spawnSync } from "child_process"; +import * as fs from "fs"; +import * as path from "path"; /** * Builds one `--reporter ` pair per configured reporter (nyc's own convention for @@ -23,6 +25,33 @@ export function _buildReporterArgs(reporters?: string[]): string[] { return args; } +/** + * The project folder `nyc report` should work in: the nearest folder at or above `cwd` holding a + * package.json, or `cwd` itself when there is none -- the same folder nyc picks on its own. + * + * It is passed to nyc explicitly as `--cwd` because nyc otherwise starts that search from `NYC_CWD` + * when it is set, and an nyc run *above* this process sets it for every child (re-adding it to each + * spawn even if it is removed from the environment). The report would then resolve its paths and load + * its `.nycrc` include and exclude patterns from that outer project, and report none of this one's files. + */ +export function _nycCwd(cwd: string): string { + let dir = path.resolve(cwd); + + for (;;) { + // eslint-disable-next-line security/detect-non-literal-fs-filename + if (fs.existsSync(path.join(dir, "package.json"))) { + return dir; + } + + const parent = path.dirname(dir); + if (parent === dir) { + return path.resolve(cwd); + } + + dir = parent; + } +} + /** * Shared `nyc report` invocation used by both the browser/worker coverage writer * ({@link NycCoverageReporter}) and the node-target {@link runCoverageReport} command -- @@ -42,7 +71,7 @@ export function _spawnNycReport(cwd: string, reportArgs: string[], label: string const result = spawnSync( process.execPath, - [nycPath, "report", ...reportArgs], + [nycPath, "report", "--cwd", _nycCwd(cwd), ...reportArgs], { cwd, stdio: "inherit" } ); diff --git a/internal/runner/src/compile/tsCompile.ts b/internal/runner/src/compile/tsCompile.ts index 433f5242..7f139ccb 100644 --- a/internal/runner/src/compile/tsCompile.ts +++ b/internal/runner/src/compile/tsCompile.ts @@ -157,7 +157,7 @@ export function getProjectFileNames(projectPath: string, cwd: string): string[] } const ts = loadTypescript(cwd); - const loadedConfig = ts.readConfigFile(fullProjectPath, ts.sys.readFile); + const loadedConfig = _readTsConfig(ts, fullProjectPath); if (loadedConfig.error) { return null; } @@ -200,6 +200,15 @@ function _normCasePath(filePath: string, caseSensitive: boolean): string { return caseSensitive ? normalized : normalized.toLowerCase(); } +/** + * Reads a tsconfig, passing its path with forward slashes the way TypeScript keeps its own file + * names. Given a Windows path with backslashes, a syntax error in the config fails TypeScript's + * internal path assertion ("Debug Failure") rather than coming back as the config's `error`. + */ +function _readTsConfig(ts: any, configPath: string): any { + return ts.readConfigFile(_normCasePath(configPath, true), ts.sys.readFile); +} + /** * Per-process cache of TypeScript programs keyed by ":". * Creating a TypeScript program is expensive (it parses all project files). By caching the @@ -320,7 +329,7 @@ function _getOrCreateTsProgramEntry(ts: any, configPath: string, includeSourceMa return _tsProgCache[cacheKey]; } - const loadedConfig = ts.readConfigFile(configPath, ts.sys.readFile); + const loadedConfig = _readTsConfig(ts, configPath); if (loadedConfig.error) { return null; } @@ -773,7 +782,7 @@ export function registerTypeScriptHook(projectPath: string, cwd: string, checkDi // eslint-disable-next-line security/detect-non-literal-fs-filename if (fs.existsSync(fullProjectPath)) { - const loadedConfig = ts.readConfigFile(fullProjectPath, ts.sys.readFile); + const loadedConfig = _readTsConfig(ts, fullProjectPath); if (!loadedConfig.error) { const parsed = ts.parseJsonConfigFileContent(loadedConfig.config, ts.sys, path.dirname(fullProjectPath)); if (!parsed.errors || !parsed.errors.length) { diff --git a/internal/runner/src/execute/browser.ts b/internal/runner/src/execute/browser.ts index fc711ded..bcc76a36 100644 --- a/internal/runner/src/execute/browser.ts +++ b/internal/runner/src/execute/browser.ts @@ -99,6 +99,15 @@ export async function _runInteractiveBrowserTarget(target: string, graphBuilders } } +function _isFile(filePath: string): boolean { + try { + // eslint-disable-next-line security/detect-non-literal-fs-filename + return fs.statSync(filePath).isFile(); + } catch (e) { + return false; + } +} + export function _resolveBrowserDependency(spec: string, moduleId: string, cwd: string): string { try { return require.resolve(spec, { @@ -120,8 +129,9 @@ export function _resolveBrowserDependency(spec: string, moduleId: string, cwd: s ]; for (let lp = 0; lp < candidates.length; lp++) { - // eslint-disable-next-line security/detect-non-literal-fs-filename - if (fs.existsSync(candidates[lp])) { + // A file, not just a path that exists: the bare path of a folder import is the + // folder itself, which has to fall through to its index file. + if (_isFile(candidates[lp])) { return candidates[lp]; } } diff --git a/internal/runner/src/execute/browserRunner.ts b/internal/runner/src/execute/browserRunner.ts index a2dd5167..a917520d 100644 --- a/internal/runner/src/execute/browserRunner.ts +++ b/internal/runner/src/execute/browserRunner.ts @@ -6,7 +6,7 @@ * Licensed under the MIT license. */ -import path from "path/win32"; +import * as path from "path"; import { _loadUxProvider } from "../client/loadUxProvider"; import { IServerContext } from "../interfaces/IServerContext"; import { logApiHandler } from "../server/handlers/api/logMsg"; diff --git a/internal/runner/src/execute/puppeteer.ts b/internal/runner/src/execute/puppeteer.ts index f5b9f665..81f004fd 100644 --- a/internal/runner/src/execute/puppeteer.ts +++ b/internal/runner/src/execute/puppeteer.ts @@ -175,6 +175,15 @@ export async function _runPuppeteerOnce(session: IBrowserRunnerSession, target: // Armed *before* navigating -- the page's own "ready" -> run -> report round trip can only // ever report back to whichever completion is currently live. const completion = session.nextCompletion(); + + // Nothing awaits this until the browser is up and `page.goto` has returned. A page that fails + // fast can report its error inside that window, and the rejection was then an + // unhandledRejection, killing the process with a raw stack. Marking it handled changes nothing + // about the error: the await below still sees the same rejection and reports it the normal way. + completion.catch(() => { + // Reported by the await below. + }); + const browser = await (browserPromise || _launchPuppeteerBrowser(process.cwd())); try { diff --git a/internal/runner/src/execute/targetSessionCache.ts b/internal/runner/src/execute/targetSessionCache.ts index 86bcebbc..f692f281 100644 --- a/internal/runner/src/execute/targetSessionCache.ts +++ b/internal/runner/src/execute/targetSessionCache.ts @@ -52,6 +52,30 @@ export interface ITargetSessionCacheOptions { watch: boolean; } +/** + * The spelling of `filePath` to watch it by: absolute, with the platform's separators, every 8.3 + * short name expanded and any link resolved -- the file system's own name for it. On Windows the + * libuv in Node 24 aborts the whole process (an assertion in src/win/fs-event.c) when a change is + * reported under a watched directory spelled any other way, as with forward slashes (the browser + * and worker module graph's ids) or a short name (a temp folder such as C:\Users\BUILDA~1\...). + * A file that no longer exists keeps its own name, in its resolved directory if that still exists. + */ +export function _watchPath(filePath: string): string { + const resolved = path.resolve(filePath); + + try { + // eslint-disable-next-line security/detect-non-literal-fs-filename + return fs.realpathSync.native(resolved); + } catch (e) { + try { + // eslint-disable-next-line security/detect-non-literal-fs-filename + return path.join(fs.realpathSync.native(path.dirname(resolved)), path.basename(resolved)); + } catch (e2) { + return resolved; + } + } +} + /** * Watches `filePaths` for changes and calls `onChange` (debounced ~200ms, so a single save * that fires multiple fs events only triggers one call) until `close()` is called. Watches each @@ -97,8 +121,9 @@ export function watchFiles(filePaths: string[], onChange: () => void): { close() const filesByDir: Record = {}; filePaths.forEach((filePath) => { - const dir = path.dirname(filePath); - (filesByDir[dir] = filesByDir[dir] || []).push(path.basename(filePath)); + const watched = _watchPath(filePath); + const dir = path.dirname(watched); + (filesByDir[dir] = filesByDir[dir] || []).push(path.basename(watched)); }); try { diff --git a/internal/runner/test/src/cli/cliFromSource.test.ts b/internal/runner/test/src/cli/cliFromSource.test.ts new file mode 100644 index 00000000..109abcfe --- /dev/null +++ b/internal/runner/test/src/cli/cliFromSource.test.ts @@ -0,0 +1,549 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { execFile } from "child_process"; +import { replaceAnsi } from "@nevware21/chromacon"; +import { assert } from "@nevware21/tripwire"; + +const RUNNER_DIR = path.resolve(__dirname, "../../.."); +const LAUNCHER = path.resolve(__dirname, "../support/cliFromSource.js"); + +const TS_CONFIG = { + compilerOptions: { target: "es2018", module: "commonjs", esModuleInterop: true, skipLibCheck: true, types: [] as string[], lib: [ "es2018" ] }, + include: [ "test/**/*.ts" ] +}; + +// A stand-in for puppeteer, installed in the browser fixture's node_modules: its one page reports a run +// to the server it is pointed at, passing for the browser target and failing for the worker target. +// Any other page (the CLI's warm-up) fails to load. +const FAKE_PUPPETEER = [ + "const http = require(\"http\");", + "function report(url) {", + " const target = new URL(url);", + " const failed = target.pathname.indexOf(\"/worker\") !== -1 ? 1 : 0;", + " const body = JSON.stringify({ summary: { total: 1, passed: 1 - failed, failed: failed, skipped: 0, pending: 0, duration: 1 } });", + " return new Promise((resolve, reject) => {", + " const req = http.request({ host: target.hostname, port: target.port, method: \"POST\", path: \"/api/result\", agent: false,", + " headers: { \"content-type\": \"application/json\", \"content-length\": Buffer.byteLength(body) } }, (res) => {", + " res.resume();", + " res.on(\"end\", resolve);", + " });", + " req.on(\"error\", reject);", + " req.end(body);", + " });", + "}", + "exports.launch = async function () {", + " const page = {", + " on: function () {},", + " goto: async function (url) {", + " if (url.indexOf(\"/__tripwire__/\") === -1) {", + " throw new Error(\"nothing is listening\");", + " }", + " await report(url);", + " }", + " };", + " return { pages: async () => [ page ], newPage: async () => page, close: async () => {} };", + "};", + "" +].join("\n"); + +const BROWSER_TEST = "import { describe, it } from \"@nevware21/tripwire\";\ndescribe(\"browser\", () => {\n it(\"runs\", () => {\n });\n});\n"; + +const BROWSER_FILES: { [file: string]: string } = { + "package.json": JSON.stringify({ name: "@nevware21/tripwire", version: "0.0.0" }), + "src/index.ts": "export function describe(name: string, fn: () => void): void {\n fn();\n}\nexport function it(name: string, fn: () => void): void {\n fn();\n}\n", + "test/a.test.ts": BROWSER_TEST, + "test/setup.ts": "export const setup = true;\n", + "node_modules/puppeteer/package.json": JSON.stringify({ name: "puppeteer", version: "0.0.0", main: "index.js" }), + "node_modules/puppeteer/index.js": FAKE_PUPPETEER +}; + +const BROWSER_TS_CONFIG = { compilerOptions: TS_CONFIG.compilerOptions, include: [ "src/**/*.ts", "test/**/*.ts" ] }; + +const BROKEN = "export const broken: number = \"not a number\";\n"; + +interface IInvocation { + args: string[]; + exitCode: number; + out: string[]; + err: string[]; + spawned: string[][]; +} + +/** + * The tripwire CLI run end to end against small throwaway projects, loaded from the runner's sources so + * a coverage run measures them (the rest of this suite runs under the bundled CLI). Every invocation + * runs in one child process, one after another; each test below then checks what one of them printed + * and the exit code it left. + */ +describe("tripwire cli, run from source", function () { + // One child process compiles the runner and runs every invocation + this.timeout(300000); + + let scratch: string; + let projects: { [name: string]: string }; + let results: { [name: string]: IInvocation }; + + function write(dir: string, name: string, text: string): string { + const file = path.join(dir, name); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, text, "utf8"); + + return file; + } + + // A project whose node_modules links back to this repo's tripwire, as an installed one would -- + // unless `noTripwire` leaves it out + function makeProject(name: string, files: { [file: string]: string }, tsconfig?: any, noTripwire?: boolean): string { + const dir = path.join(scratch, name); + fs.mkdirSync(path.join(dir, "node_modules", "@nevware21"), { recursive: true }); + if (!noTripwire) { + fs.symlinkSync(path.dirname(require.resolve("@nevware21/tripwire/package.json")), path.join(dir, "node_modules", "@nevware21", "tripwire"), "junction"); + } + + write(dir, "tsconfig.json", JSON.stringify(tsconfig || TS_CONFIG)); + Object.keys(files).forEach((file) => write(dir, file, files[file])); + + return dir; + } + + function run(name: string): IInvocation { + const result = results[name]; + assert.isObject(result, "the '" + name + "' invocation ran"); + + return result; + } + + function out(name: string): string { + return replaceAnsi(run(name).out.join("\n"), ""); + } + + function err(name: string): string { + return replaceAnsi(run(name).err.join("\n"), ""); + } + + before(async () => { + // The real path, as the CLI prints paths from its working directory (on macOS the temp folder is a link) + scratch = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-cli-source-"))); + + const passing = [ + "import { describe, it, assert } from \"@nevware21/tripwire\";", + "describe(\"passing\", () => {", + " it(\"adds\", () => {", + " assert.equal(1 + 1, 2);", + " });", + " it(\"joins\", () => {", + " assert.equal([ \"a\", \"b\" ].join(\"\"), \"ab\");", + " });", + "});", + "" + ].join("\n"); + + projects = { + pass: makeProject("pass", { + "test/pass.test.ts": passing, + "setup.js": "console.log(\"setup loaded\");\n" + }), + fail: makeProject("fail", { + "test/fail.test.ts": [ + "import { describe, it, assert } from \"@nevware21/tripwire\";", + "describe(\"failing\", () => {", + " it(\"fails\", () => {", + " assert.equal(1, 2);", + " });", + " it(\"is never reached with --bail\", () => {", + " assert.ok(true);", + " });", + "});", + "" + ].join("\n") + }), + hooks: makeProject("hooks", { + // A failing `after` hook belongs to no test, so it reaches no pass/fail count + "test/hook.test.ts": [ + "import { describe, it, after } from \"@nevware21/tripwire\";", + "describe(\"hooked\", () => {", + " after(() => {", + " throw new Error(\"teardown broke\");", + " });", + " it(\"passes\", () => {", + " });", + "});", + "" + ].join("\n") + }), + typeError: makeProject("typeError", { + "test/bad.test.ts": "import { it } from \"@nevware21/tripwire\";\nconst count: number = \"not a number\";\nit(\"never runs\", () => count);\n" + }), + scope: makeProject("scope", { + "test/a.test.ts": passing, + "test/b.test.ts": passing.replace("\"passing\"", "\"also passing\"") + }, { compilerOptions: TS_CONFIG.compilerOptions, files: [ "test/a.test.ts" ] }), + loadError: makeProject("loadError", { + "test/load.test.ts": "throw new Error(\"broken at load\");\n" + }), + coverage: makeProject("coverage", { + "package.json": "{}", + ".tripwirerc.json": JSON.stringify({ erasedCoverageMode: "zero" }) + }), + // Named @nevware21/tripwire, as core's own suite is, so the browser module graph uses this + // project's small src/index.ts as the tripwire runtime instead of compiling all of core + browser: makeProject("browser", BROWSER_FILES, BROWSER_TS_CONFIG), + empty: makeProject("empty", { "test/empty.test.ts": "export const nothing = true;\n" }), + // Nothing installed resolves @nevware21/tripwire, so the CLI falls back to tripwire's sources + browserNoTripwire: makeProject("browserNoTripwire", BROWSER_FILES, BROWSER_TS_CONFIG, true), + // The watch runs are left waiting on their files, so each has a project of its own. The + // browser one starts with a type error, so its first round has built nothing to watch. + watchNode: makeProject("watchNode", { "test/watch.test.ts": passing }), + watchBrowser: makeProject("watchBrowser", Object.assign({}, BROWSER_FILES, { "test/a.test.ts": BROWSER_TEST + BROKEN }), BROWSER_TS_CONFIG) + }; + + // nyc output for the coverage-report command: one file, half covered + const covered = path.join(projects.coverage, "src", "a.js"); + const data: any = {}; + data[covered] = { + path: covered, + statementMap: { + 0: { start: { line: 1, column: 0 }, end: { line: 1, column: 10 } }, + 1: { start: { line: 2, column: 0 }, end: { line: 2, column: 10 } } + }, + fnMap: {}, + branchMap: {}, + s: { 0: 1, 1: 0 }, + f: {}, + b: {} + }; + write(projects.coverage, "raw/out.json", JSON.stringify(data)); + + const manualReport = { summary: { total: 2, passed: 2, failed: 0, skipped: 0, pending: 0, duration: 1 } }; + const plan: Array<{ name: string, cwd: string, args: string[], report?: any, watch?: any, tty?: boolean, stubSpawn?: boolean, env?: any }> = [ + { name: "help", cwd: projects.pass, args: [ "--help" ] }, + { name: "badOption", cwd: projects.pass, args: [ "--no-such-option" ] }, + { name: "pass", cwd: projects.pass, args: [ "test/**/*.test.ts", "--type-check", "--show-files", "--require", "./setup.js" ] }, + { name: "passAgain", cwd: projects.pass, args: [ "test/**/*.test.ts", "--type-check" ] }, + { name: "isolated", cwd: projects.pass, args: [ "test/**/*.test.ts", "--isolated-transpile", "--reporter", "dot" ] }, + { name: "noGrepMatch", cwd: projects.pass, args: [ "test/**/*.test.ts", "--grep", "nothing-matches-this" ] }, + { name: "noFiles", cwd: projects.pass, args: [ "nothing/**/*.test.ts", "--ignore", "**/skip/**" ] }, + { name: "server", cwd: projects.pass, args: [ "test/**/*.test.ts", "--server", "--bind-address", "127.0.0.1" ] }, + { name: "browserReporter", cwd: projects.pass, args: [ "test/**/*.test.ts", "--target", "browser", "--reporter", "./my-reporter.js" ] }, + { name: "fail", cwd: projects.fail, args: [ "test/**/*.test.ts", "--bail" ] }, + { name: "hookWarn", cwd: projects.hooks, args: [ "test/**/*.test.ts" ] }, + { name: "hookFail", cwd: projects.hooks, args: [ "test/**/*.test.ts", "--fail-on-hook" ] }, + { name: "typeError", cwd: projects.typeError, args: [ "test/**/*.test.ts", "--type-check" ] }, + { name: "scopeWarn", cwd: projects.scope, args: [ "test/**/*.test.ts", "--type-check" ] }, + { name: "scopeStrict", cwd: projects.scope, args: [ "test/**/*.test.ts", "--strict-type-check" ] }, + { name: "loadError", cwd: projects.loadError, args: [ "test/**/*.test.ts" ] }, + { name: "coverageReport", cwd: projects.coverage, args: [ "coverage-report", "--nyc-output-dir", "raw", "--coverage-reporter", "json-summary", "--erased-coverage-mode=full" ] }, + { name: "coverageReportBadMode", cwd: projects.coverage, args: [ "coverage-report", "--erased-coverage-mode", "bogus" ] }, + { name: "browserPuppeteer", cwd: projects.browser, args: [ "test/**/*.test.ts", "--target", "browser", "--type-check", "--require", "./test/setup.ts" ] }, + { name: "workerPuppeteer", cwd: projects.browser, args: [ "test/**/*.test.ts", "--target", "worker" ] }, + { name: "browserGraphThread", cwd: projects.browser, args: [ "test/**/*.test.ts", "--target", "browser", "--graph-thread" ] }, + { name: "browserManual", cwd: projects.browser, args: [ "test/**/*.test.ts", "--target", "browser", "--browser-launch", "manual" ], report: manualReport }, + { name: "browserNoServer", cwd: projects.browser, args: [ "test/**/*.test.ts", "--target", "browser", "--no-server" ] }, + { name: "browserPreloadPackage", cwd: projects.browser, args: [ "test/**/*.test.ts", "--target", "browser", "--require", "some-package" ] }, + { name: "workerPreloadMissing", cwd: projects.browser, args: [ "test/**/*.test.ts", "--target", "worker", "--require", "./missing-setup.ts" ] }, + { name: "browserNoFiles", cwd: projects.browser, args: [ "nothing/**/*.test.ts", "--target", "browser" ] }, + { name: "browserTty", cwd: projects.browser, args: [ "test/**/*.test.ts", "--target", "browser" ], tty: true }, + { + name: "browserOpen", + cwd: projects.browser, + args: [ "test/**/*.test.ts", "--target", "worker", "--browser-launch", "open", "--no-server" ], + report: manualReport, + stubSpawn: true + }, + { name: "watchBrowserNoServer", cwd: projects.browser, args: [ "test/**/*.test.ts", "--target", "browser", "--watch", "--no-server" ] }, + { name: "watchBrowserNoFiles", cwd: projects.browser, args: [ "nothing/**/*.test.ts", "--target", "browser", "--watch" ] }, + { name: "emptyRun", cwd: projects.empty, args: [ "test/**/*.test.ts" ] }, + { name: "noProject", cwd: projects.pass, args: [ "test/**/*.test.ts", "--type-check", "-p", "missing.json", "--slow", "50" ] }, + { name: "nodeTty", cwd: projects.pass, args: [ "test/**/*.test.ts" ], tty: true }, + // The browser target does not run tests with the runtime the CLI loads, so these can load any + { name: "preferSource", cwd: projects.browser, args: [ "test/**/*.test.ts", "--target", "browser" ], env: { TRIPWIRE_PREFER_SOURCE: "1" } }, + { name: "noTripwireInstalled", cwd: projects.browserNoTripwire, args: [ "test/**/*.test.ts", "--target", "browser" ] }, + + // Last, as the launcher leaves each of these waiting on its files once it has gone round + { + name: "watchNode", + cwd: projects.watchNode, + args: [ "test/**/*.test.ts", "--watch" ], + watch: { edits: [ { file: "test/watch.test.ts", text: passing.replace("describe(\"passing\", () => {", "describe(\"passing\", () => {\n it(\"was added\", () => {\n });") } ] } + }, + { + name: "watchBrowser", + cwd: projects.watchBrowser, + args: [ "test/**/*.test.ts", "--target", "browser", "--watch", "--type-check", "--require", "./test/setup.ts" ], + watch: { + edits: [ + { file: "test/a.test.ts", text: BROWSER_TEST + "export const fixed: number = 1;\n" }, + { file: "test/a.test.ts", text: BROWSER_TEST + BROKEN } + ] + } + } + ]; + + const planFile = write(scratch, "plan.json", JSON.stringify(plan)); + const resultsFile = path.join(scratch, "results.json"); + + await new Promise((resolve, reject) => { + execFile(process.execPath, [ LAUNCHER, RUNNER_DIR, planFile, resultsFile ], { cwd: scratch, maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => { + if (error || !fs.existsSync(resultsFile)) { + reject(new Error("The CLI launcher failed: " + (error ? error.message : "no results") + "\n" + stdout + "\n" + stderr)); + return; + } + + resolve(); + }); + }); + + const ran: IInvocation[] = JSON.parse(fs.readFileSync(resultsFile, "utf8")); + results = {}; + plan.forEach((step, idx) => { + results[step.name] = ran[idx]; + }); + }); + + after(() => { + if (scratch) { + fs.rmSync(scratch, { recursive: true, force: true }); + } + }); + + describe("usage", () => { + it("prints the usage for --help", () => { + assert.equal(run("help").exitCode, 0); + assert.includes(out("help"), "tripwire [options] "); + assert.includes(out("help"), "coverage-report [--erased-coverage-mode ]"); + }); + + it("rejects an unknown option with the usage", () => { + assert.equal(run("badOption").exitCode, 1); + assert.includes(err("badOption"), "--no-such-option"); + assert.includes(out("badOption"), "tripwire [options] "); + }); + }); + + describe("node target", () => { + it("type-checks, runs and summarises a passing project", () => { + const text = out("pass"); + + assert.equal(run("pass").exitCode, 0, err("pass")); + assert.includes(text, "setup loaded"); + assert.includes(text, "Matched 1 file(s)"); + assert.includes(text, " - test/pass.test.ts"); + assert.includes(text, "Type-checking TypeScript project: " + path.join(projects.pass, "tsconfig.json")); + assert.includes(text, "Type-check completed in "); + assert.includes(text, "Compile cache: rebuilt"); + assert.includes(text, "Run summary: total=2 passed=2 failed=0"); + }); + + it("reuses the compile cache, skipping a clean type-check, when nothing changed", () => { + assert.equal(run("passAgain").exitCode, 0, err("passAgain")); + assert.includes(out("passAgain"), "Compile cache: reused -- nothing changed, type-check skipped"); + assert.includes(out("passAgain"), "Run summary: total=2 passed=2"); + }); + + it("compiles each file on its own when asked, with the chosen reporter", () => { + assert.equal(run("isolated").exitCode, 0, err("isolated")); + assert.includes(out("isolated"), "Compile cache: isolated"); + assert.includes(out("isolated"), "Run summary: total=2 passed=2"); + }); + + it("fails a run whose --grep matches nothing", () => { + assert.equal(run("noGrepMatch").exitCode, 1); + assert.includes(err("noGrepMatch"), "No tests matched --grep nothing-matches-this. Check the pattern, or drop it to run the full suite."); + }); + + it("reports patterns that match no files as a user error", () => { + assert.equal(run("noFiles").exitCode, 1); + assert.includes(err("noFiles"), "No files matched: nothing/**/*.test.ts"); + assert.includes(err("noFiles"), "Files matching --ignore were excluded: **/skip/**"); + }); + + it("runs with the embedded server when asked", () => { + assert.equal(run("server").exitCode, 0, err("server")); + assert.includes(out("server"), "Run summary: total=2 passed=2"); + }); + + it("fails a run with a failing test, stopping at the first with --bail", () => { + assert.equal(run("fail").exitCode, 1); + assert.includes(out("fail"), "failed=1"); + assert.isFalse(out("fail").indexOf("passed=1") !== -1, "the test after the failure did not pass"); + }); + + it("warns about a failing hook, and fails the run on one with --fail-on-hook", () => { + assert.equal(run("hookWarn").exitCode, 0, err("hookWarn")); + assert.includes(out("hookWarn"), "1 hook failed during this run -- run with --fail-on-hook to fail the run on them."); + + assert.equal(run("hookFail").exitCode, 1); + assert.includes(out("hookFail"), "Hook failures (1 hook):"); + assert.includes(err("hookFail"), "teardown broke"); + assert.includes(err("hookFail"), "Run failed: 1 hook failed (--fail-on-hook)."); + }); + + it("fails the run on a type error with --type-check", () => { + assert.equal(run("typeError").exitCode, 1); + assert.includes(err("typeError"), "TypeScript compile failed with 1 error(s)"); + assert.includes(err("typeError"), "TS2322"); + }); + + it("warns when matched files are outside the type-checked project, and fails with --strict-type-check", () => { + assert.equal(run("scopeWarn").exitCode, 0, err("scopeWarn")); + assert.includes(err("scopeWarn"), "Warning: "); + assert.includes(err("scopeWarn"), "b.test.ts"); + + assert.equal(run("scopeStrict").exitCode, 1); + assert.includes(err("scopeStrict"), "Type-check scope: "); + }); + + it("re-runs with --watch when a test file changes", () => { + const text = out("watchNode"); + + assert.includes(text, "Run summary: total=2 passed=2"); + assert.includes(text, "Run summary: total=3 passed=3", "the edited file was loaded again"); + assert.equal(text.split("Watching ").length - 1, 2); + assert.includes(text, "for changes... (Ctrl+C to stop)"); + }); + + it("fails a run whose files register no tests", () => { + assert.equal(run("emptyRun").exitCode, 1); + assert.includes(err("emptyRun"), "No tests were executed. Verify imports resolve to the same tripwire runtime and that your files register tests."); + }); + + it("warns when the project to type-check against cannot be read", () => { + assert.equal(run("noProject").exitCode, 0, err("noProject")); + assert.includes(err("noProject"), "the type-check covered no files: missing.json could not be read."); + assert.includes(err("noProject"), "Use --strict-type-check to fail the run instead of warning."); + assert.includes(out("noProject"), "Run summary: total=2 passed=2"); + }); + + it("runs with the reporters writing to a terminal", () => { + assert.equal(run("nodeTty").exitCode, 0, err("nodeTty")); + assert.includes(out("nodeTty"), "Run summary: total=2 passed=2"); + }); + + it("reports a test file that throws while loading", () => { + assert.equal(run("loadError").exitCode, 1); + assert.includes(err("loadError"), "broken at load"); + }); + }); + + describe("browser and worker targets", () => { + it("type-checks, builds and runs the browser target through puppeteer", () => { + const text = out("browserPuppeteer"); + + assert.equal(run("browserPuppeteer").exitCode, 0, err("browserPuppeteer")); + assert.includes(text, "Matched 2 file(s)"); + assert.includes(text, "Type-checking TypeScript project: " + path.join(projects.browser, "tsconfig.json")); + assert.includes(text, "Launching browser target via Puppeteer..."); + assert.includes(text, "Module graph: "); + assert.includes(text, "Ready to run tests after "); + assert.includes(text, "Run summary: total=1 passed=1 failed=0"); + }); + + it("fails a worker run whose page reports a failure", () => { + assert.equal(run("workerPuppeteer").exitCode, 1); + assert.includes(out("workerPuppeteer"), "Launching worker target via Puppeteer..."); + assert.includes(out("workerPuppeteer"), "failed=1"); + }); + + it("builds the graph on the main thread when the graph thread cannot", () => { + assert.equal(run("browserGraphThread").exitCode, 0, err("browserGraphThread")); + assert.includes(out("browserGraphThread"), "Run summary: total=1 passed=1"); + }); + + it("serves a manual launch until the page reports its results", () => { + const text = out("browserManual"); + + assert.equal(run("browserManual").exitCode, 0, err("browserManual")); + assert.includes(text, "Launching browser target via browser UI (mode: manual, style: classic)..."); + assert.includes(text, "Ready after "); + assert.includes(text, "Open this URL in your browser to start the run: http://127.0.0.1:"); + assert.includes(text, "Run summary: total=2 passed=2"); + }); + + it("rejects running without the server, a package preload, a missing preload file and patterns matching nothing", () => { + assert.equal(run("browserNoServer").exitCode, 1); + assert.includes(err("browserNoServer"), "--no-server is not supported for browser target when --browser-launch is 'puppeteer'."); + + assert.equal(run("browserPreloadPackage").exitCode, 1); + assert.includes(err("browserPreloadPackage"), "Browser/worker preload currently supports file paths only. Unsupported preload module: some-package"); + + assert.equal(run("workerPreloadMissing").exitCode, 1); + assert.includes(err("workerPreloadMissing"), "Preload file not found: " + path.join(projects.browser, "missing-setup.ts")); + + assert.equal(run("browserNoFiles").exitCode, 1); + assert.includes(err("browserNoFiles"), "No files matched: nothing/**/*.test.ts"); + }); + + it("re-runs the browser target through puppeteer as its files change, through compile failures", () => { + const text = out("watchBrowser"); + + assert.includes(text, "Matched 2 file(s)", "the preload and the test"); + assert.includes(text, "Launching browser target via Puppeteer (--watch)..."); + assert.equal(text.split("Compile failed: TypeScript compile failed with 1 error(s)").length - 1, 2, "before the fix and after breaking it again"); + assert.equal(text.split("Run summary: total=1 passed=1").length - 1, 1, "a run once it was fixed"); + assert.equal(text.split("Watching ").length - 1, 3); + }); + + it("rejects a watch without the server, or with patterns matching nothing", () => { + assert.equal(run("watchBrowserNoServer").exitCode, 1); + assert.includes(err("watchBrowserNoServer"), "--no-server is not supported for browser target when --browser-launch is 'puppeteer'."); + + assert.equal(run("watchBrowserNoFiles").exitCode, 1); + assert.includes(err("watchBrowserNoFiles"), "No files matched: nothing/**/*.test.ts"); + }); + + it("opens the system browser for an open launch, keeping the server it needs", () => { + const invocation = run("browserOpen"); + + assert.equal(invocation.exitCode, 0, err("browserOpen")); + assert.includes(err("browserOpen"), "Ignoring --no-server because --browser-launch open requires the server to be running."); + assert.includes(out("browserOpen"), "Launching worker target via browser UI (mode: open, style: classic)..."); + assert.includes(out("browserOpen"), "Opening browser via OS default handler..."); + assert.equal(invocation.spawned.length, 1); + assert.includes(invocation.spawned[0].join(" "), "/__tripwire__/worker"); + assert.includes(out("browserOpen"), "Run summary: total=2 passed=2"); + }); + + it("loads tripwire from the project's own sources when asked to prefer them", () => { + assert.equal(run("preferSource").exitCode, 0, err("preferSource")); + assert.includes(out("preferSource"), "Run summary: total=1 passed=1"); + }); + + it("falls back to tripwire's sources when the project has none installed", () => { + assert.equal(run("noTripwireInstalled").exitCode, 0, err("noTripwireInstalled")); + assert.includes(out("noTripwireInstalled"), "Run summary: total=1 passed=1"); + }); + + it("runs with progress output when stdout is a terminal", () => { + assert.equal(run("browserTty").exitCode, 0, err("browserTty")); + assert.includes(out("browserTty"), "Run summary: total=1 passed=1"); + }); + + it("rejects a custom reporter module for the browser target", () => { + assert.equal(run("browserReporter").exitCode, 1); + assert.includes(err("browserReporter"), "Custom reporter module './my-reporter.js' is not supported for the browser target."); + }); + }); + + describe("coverage-report", () => { + it("reprints the nyc report with the erased coverage mode applied", () => { + assert.equal(run("coverageReport").exitCode, 0, err("coverageReport")); + + const summary = JSON.parse(fs.readFileSync(path.join(projects.coverage, "coverage", "coverage-summary.json"), "utf8")); + assert.equal(summary.total.statements.pct, 50); + }); + + it("rejects an unknown erased coverage mode", () => { + assert.equal(run("coverageReportBadMode").exitCode, 1); + assert.includes(err("coverageReportBadMode"), "Invalid --erased-coverage-mode 'bogus'. Expected zero, full, or ignore."); + }); + }); +}); diff --git a/internal/runner/test/src/cli/graphWorkerEntry.test.ts b/internal/runner/test/src/cli/graphWorkerEntry.test.ts new file mode 100644 index 00000000..6ce309a5 --- /dev/null +++ b/internal/runner/test/src/cli/graphWorkerEntry.test.ts @@ -0,0 +1,108 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import "../support/textImports"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { createRequire } from "module"; +import { assert } from "@nevware21/tripwire"; +import { _graphWorkerEntry } from "../../../src/tripwire"; +import { setGraphTypeCheck } from "../../../src/compile/tsCompile"; + +/** + * The entry point a module-graph worker thread runs: it builds the graph its workerData describes and + * posts back either the graph or the failure. Here it runs on this thread, with the thread's data and + * port stood in for. + */ +describe("cli graph worker entry", function () { + // The graph compiles a small TypeScript project + this.timeout(60000); + + const workerThreads = createRequire(__filename)("worker_threads"); + const orgWorkerData = workerThreads.workerData; + const orgParentPort = workerThreads.parentPort; + let project: string; + let posted: any[]; + let orgLog: typeof console.log; + + function write(name: string, text: string): string { + const file = path.join(project, name); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, text, "utf8"); + + return file; + } + + function runEntry(entryFiles: string[], typeCheck?: boolean, withPort: boolean = true): void { + workerThreads.workerData = { entryFiles: entryFiles, cwd: project, parsed: {}, typeCheck: !!typeCheck }; + workerThreads.parentPort = withPort ? { postMessage: (result: any) => posted.push(result) } : null; + + _graphWorkerEntry(); + } + + before(() => { + project = fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-graph-entry-")); + + // Its own tripwire package, as in core's own suite, so the graph's runtime is this small file + write("package.json", JSON.stringify({ name: "@nevware21/tripwire", version: "0.0.0" })); + write("tsconfig.json", JSON.stringify({ compilerOptions: { target: "es2018", module: "commonjs", types: [], lib: [ "es2018" ] }, include: [ "src/**/*.ts", "test/**/*.ts" ] })); + write("src/index.ts", "export function it(name: string, fn: () => void): void {\n fn();\n}\n"); + write("test/a.test.ts", "import { it } from \"@nevware21/tripwire\";\nit(\"runs\", () => {\n});\n"); + write("test/builtin.test.ts", "import * as fs from \"fs\";\nexport const exists = fs.existsSync;\n"); + }); + + after(() => { + fs.rmSync(project, { recursive: true, force: true }); + }); + + beforeEach(() => { + posted = []; + orgLog = console.log; + console.log = () => {}; + }); + + afterEach(() => { + console.log = orgLog; + workerThreads.workerData = orgWorkerData; + workerThreads.parentPort = orgParentPort; + setGraphTypeCheck(false); + }); + + it("posts the graph it built for the entry files", () => { + runEntry([ path.join(project, "test/a.test.ts") ]); + + assert.equal(posted.length, 1); + assert.isTrue(posted[0].ok); + assert.deepEqual(posted[0].graph.entryIds, [ path.join(project, "test/a.test.ts").split(path.sep).join("/") ]); + }); + + it("posts a user error with its hints", () => { + runEntry([ path.join(project, "test/builtin.test.ts") ]); + + assert.isFalse(posted[0].ok); + assert.isTrue(posted[0].user); + assert.includes(posted[0].message, "Node builtin module 'fs' is not supported in browser/worker target"); + assert.isTrue(posted[0].hints.length > 0); + assert.isString(posted[0].stack); + }); + + it("posts any other failure as one that is not the user's", () => { + runEntry([ path.join(project, "test/missing.test.ts") ]); + + assert.isFalse(posted[0].ok); + assert.isFalse(posted[0].user); + assert.includes(posted[0].message, "ENOENT"); + }); + + it("posts nothing without a parent thread to post to", () => { + runEntry([ path.join(project, "test/a.test.ts") ], false, false); + + assert.equal(posted.length, 0); + }); +}); diff --git a/internal/runner/test/src/cli/parseArgs.test.ts b/internal/runner/test/src/cli/parseArgs.test.ts index 092778fc..54ef05e9 100644 --- a/internal/runner/test/src/cli/parseArgs.test.ts +++ b/internal/runner/test/src/cli/parseArgs.test.ts @@ -47,6 +47,25 @@ describe("CLI argument parsing", () => { it("should treat a following flag as a missing value rather than consuming it", () => { assert.includes(parse(["spec.ts", "--timeout", "--bail"]).error, "Missing value for --timeout"); }); + + it("should report the missing value of every option that takes one, by its long name", () => { + const options: { [flag: string]: string } = { + "--slow": "slow", "-s": "slow", "--retries": "retries", "--grep": "grep", "-g": "grep", "--fgrep": "fgrep", + "-f": "fgrep", "--ignore": "ignore", "--coverage-instrumenter": "coverage-instrumenter", + "--erased-coverage-mode": "erased-coverage-mode", "--coverage-reporter": "coverage-reporter", + "--coverage-src": "coverage-src", "--reporter": "reporter", "--server-root": "server-root", "--target": "target", + "--env": "env", "--browser-launch": "browser-launch", "--browser-style": "browser-style", "--ux-provider": "ux-provider", + "--project": "project", "-p": "project", "--tsProject": "project", "--ts-project": "project", + "--type-check-scope": "type-check-scope", "--spec": "spec", "--file": "file", "--require": "require", "-r": "require" + }; + + Object.keys(options).forEach((flag) => { + assert.includes(parse(["spec.ts", flag]).error, "Missing value for --" + options[flag], flag); + if (flag.indexOf("--") === 0) { + assert.includes(parse(["spec.ts", flag + "="]).error, "Missing value for --" + options[flag], flag + "= with nothing after it"); + } + }); + }); }); describe("file patterns", () => { @@ -392,6 +411,11 @@ describe("CLI argument parsing", () => { assert.equal(parsed.browserStyle, "classic"); }); + it("should take the style from --browser-style, case-insensitively", () => { + assert.equal(parse(["spec.ts", "--browser-style", "SLATE"]).browserStyle, "slate"); + assert.equal(parse(["spec.ts", "--browser-style=classic"]).browserStyle, "classic"); + }); + it("should accept each launch mode", () => { assert.equal(parse(["spec.ts", "--browser-launch", "open"]).browserLaunch, "open"); assert.equal(parse(["spec.ts", "--browser-launch=manual"]).browserLaunch, "manual"); @@ -501,6 +525,18 @@ describe("CLI argument parsing", () => { assert.equal(parse(["spec.ts", "--bind-address=127.0.0.1"], { bindAddress: "0.0.0.0" } as ITripwireConfig).bindAddress, "127.0.0.1"); }); + it("should carry the coverage instrumenter, the UX provider and the source map choice through", () => { + const parsed = parse(["spec.ts", "--coverage-instrumenter", "none", "--ux-provider=./ux.js", "--source-maps", "--no-source-maps"]); + + assert.equal(parsed.coverageInstrumenter, "none"); + assert.equal(parsed.uxProvider, "./ux.js"); + assert.isFalse(parsed.sourceMaps, "the last of --source-maps and --no-source-maps wins"); + }); + + it("should reject a --grep pattern that is not a valid regular expression", () => { + assert.includes(parse(["spec.ts", "--grep", "(unclosed"]).error, "Invalid value for --grep: (unclosed"); + }); + it("should report a missing --bind-address value", () => { assert.includes(parse(["spec.ts", "--bind-address"]).error, "Missing value for --bind-address"); }); diff --git a/internal/runner/test/src/client/runnerHost.test.ts b/internal/runner/test/src/client/runnerHost.test.ts new file mode 100644 index 00000000..0abd0830 --- /dev/null +++ b/internal/runner/test/src/client/runnerHost.test.ts @@ -0,0 +1,165 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import "../support/textImports"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { assert } from "@nevware21/tripwire"; +import { applyGlobalRunnerConfig, createContextServer, createRunnerHost } from "../../../src/client/runnerHost"; +import { _loadUxProvider } from "../../../src/client/loadUxProvider"; +import * as defaultUx from "../../../src/ux"; +import { _isUserError } from "../../../src/internal/userError"; + +/** + * What the browser and worker runtimes build around a run in the page: the runner host, the view of + * the CLI's server the payload describes, and the run settings applied to tripwire's configuration. + * Also how the CLI picks the browser UX it serves. + */ +describe("client runner host", () => { + describe("createContextServer", () => { + it("describes the server named in the run settings", () => { + const server = createContextServer({ server: { url: "http://127.0.0.1:1234/", port: 1234, isRunning: true } }); + + assert.equal(server.url, "http://127.0.0.1:1234/"); + assert.equal(server.port, 1234); + assert.isTrue(server.isRunning); + }); + + it("defaults the port and treats a server as running unless told otherwise", () => { + assert.equal(createContextServer({ server: { url: "http://host/", port: "80" } }).port, 0); + assert.isTrue(createContextServer({ server: { url: "http://host/" } }).isRunning); + assert.isFalse(createContextServer({ server: { url: "http://host/", isRunning: false } }).isRunning); + }); + + it("starts and stops only its own running flag", async () => { + const server = createContextServer({ server: { url: "http://host/", isRunning: false } }); + + assert.equal(server.start(), server); + assert.isTrue(server.isRunning); + await server.stop(); + assert.isFalse(server.isRunning); + }); + + it("has no server without settings naming one", () => { + assert.isNull(createContextServer(null)); + assert.isNull(createContextServer({})); + assert.isNull(createContextServer({ server: { port: 80 } })); + }); + }); + + describe("createRunnerHost", () => { + it("works on a clone of the configuration, with its own notifications and the settings' server", () => { + const clone = { cloned: true }; + const notifyMgr = { notify: true }; + const tripwire: any = { + assertConfig: { $ops: { clone: () => clone } }, + createNotifyMgr: () => notifyMgr + }; + + const host = createRunnerHost(tripwire, { server: { url: "http://host/" } }); + + assert.equal(host.opts.v, clone); + assert.equal(host.notifyMgr, notifyMgr); + assert.equal(host.serverMgr.server.url, "http://host/"); + assert.equal(host.serverMgr.start(), host.serverMgr.server); + host.serverMgr.setProvider({}); + }); + + it("uses the configuration as it is when it cannot be cloned, and copes without a server or notifications", () => { + const config = { timeout: 5 }; + const host = createRunnerHost({ assertConfig: config } as any, {}); + + assert.equal(host.opts.v, config); + assert.isNull(host.notifyMgr); + assert.isNull(host.serverMgr.server); + assert.isNull(host.serverMgr.start()); + }); + + it("clones into a fresh host for the same module and settings", () => { + const tripwire: any = { assertConfig: { $ops: { clone: () => ({}) } } }; + const host = createRunnerHost(tripwire, { server: { url: "http://host/" } }); + + const copy = host.clone(); + + assert.notEqual(copy, host); + assert.equal(copy.serverMgr.server.url, "http://host/"); + }); + }); + + describe("applyGlobalRunnerConfig", () => { + it("applies the run's timeout, slow and retries settings, and puts the old values back", () => { + const config: any = { timeout: 2000, slow: 75, retries: 0, other: "kept" }; + const restore = applyGlobalRunnerConfig({ assertConfig: config }, { timeout: 100, retries: 2, unrelated: true }); + + assert.deepEqual(config, { timeout: 100, slow: 75, retries: 2, other: "kept" }); + + restore(); + assert.deepEqual(config, { timeout: 2000, slow: 75, retries: 0, other: "kept" }); + }); + + it("does nothing without a configuration or settings", () => { + applyGlobalRunnerConfig(null, { timeout: 1 })(); + applyGlobalRunnerConfig({}, { timeout: 1 })(); + + const config: any = { timeout: 3 }; + applyGlobalRunnerConfig({ assertConfig: config }, null)(); + assert.equal(config.timeout, 3); + }); + }); + + describe("_loadUxProvider", () => { + let scratch: string; + + function provider(name: string, source: string): string { + const file = path.join(scratch, name + ".js"); + fs.writeFileSync(file, source, "utf8"); + + return file; + } + + function failure(name: string): any { + try { + _loadUxProvider(name); + } catch (e) { + return e; + } + + return null; + } + + before(() => { + scratch = fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-ux-provider-")); + }); + + after(() => { + fs.rmSync(scratch, { recursive: true, force: true }); + }); + + it("uses the built in UX unless a provider is named", () => { + assert.equal(_loadUxProvider(), defaultUx as any); + assert.equal(_loadUxProvider(""), defaultUx as any); + }); + + it("loads a named provider with both page builders", () => { + const name = provider("complete", "exports.createBrowserRunnerHtml = () => 'page';\nexports.createFsDirListingHtml = () => 'listing';\n"); + + assert.equal(_loadUxProvider(name).createBrowserRunnerHtml("browser", "classic"), "page"); + }); + + it("rejects a provider missing either page builder as a user error", () => { + const noPage = failure(provider("noPage", "exports.createFsDirListingHtml = () => 'listing';\n")); + const noListing = failure(provider("noListing", "exports.createBrowserRunnerHtml = () => 'page';\n")); + + assert.isTrue(_isUserError(noPage)); + assert.includes(noPage.message, "missing createBrowserRunnerHtml(target, style)"); + assert.isTrue(_isUserError(noListing)); + assert.includes(noListing.message, "missing createFsDirListingHtml(style, relativePath, entries)"); + }); + }); +}); diff --git a/internal/runner/test/src/client/workerRunner.test.ts b/internal/runner/test/src/client/workerRunner.test.ts new file mode 100644 index 00000000..04424334 --- /dev/null +++ b/internal/runner/test/src/client/workerRunner.test.ts @@ -0,0 +1,201 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import * as tripwire from "@nevware21/tripwire"; +import { assert } from "@nevware21/tripwire"; +import { startWorkerRunner } from "../../../src/client/workerRunner"; + +/** + * The script the worker target runs inside its web worker: it loads the bundled test modules, runs them + * with tripwire, and posts its progress and results back to the page. Here it runs on node with the + * worker's global, script loading and messages stood in for, and the real tripwire as the runtime. + */ +describe("client worker runner", function () { + this.timeout(30000); + + const g = globalThis as any; + let messages: any[]; + let scripts: { [url: string]: () => void }; + + // The modules the page would serve: tripwire itself, and a test module using it + function defineModules(scope: any): void { + scripts = { + "/tripwire.js": () => scope.__tripwireDefine__("/tripwire", (_require: any, module: any) => { + module.exports = tripwire; + }), + "/test/a.js": () => scope.__tripwireDefine__("/test/a", (require: any) => { + const tw = require("/tripwire"); + tw.describe("worker suite", () => { + tw.it("passes", () => { + tw.assert.equal(1, 1); + }); + tw.it("fails", () => { + tw.assert.equal(1, 2); + }); + }); + }), + "/test/failFirst.js": () => scope.__tripwireDefine__("/test/failFirst", (require: any) => { + const tw = require("/tripwire"); + tw.describe("fails first", () => { + tw.it("fails", () => { + tw.assert.equal(1, 2); + }); + tw.it("would pass", () => { + tw.assert.equal(1, 1); + }); + }); + }), + "/src/extra.js": () => scope.__tripwireDefine__("/src/extra", (_require: any, module: any) => { + module.exports = "only loaded for coverage"; + }), + "/broken.js": () => { + throw new Error("unexpected token"); + } + }; + } + + function payload(extra?: any): any { + return Object.assign({ + moduleUrls: { tripwire: "/tripwire.js", a: "/test/a.js", extra: "/src/extra.js" }, + tripwireModuleId: "/tripwire", + entryIds: [ "/test/a" ], + coverageOnlyIds: [ "/src/extra", "/not-in-the-bundle" ], + settings: { reporter: [ "dot" ] } + }, extra || {}); + } + + // Sends one message to a freshly started worker runner and collects what it posts back. The + // runner bridges the console into its messages for good, so the console is put back afterwards. + async function post(data: any): Promise { + const scope: any = { + postMessage: (msg: any) => messages.push(msg) + }; + const orgConsole = { log: console.log, info: console.info, warn: console.warn, error: console.error }; + + g.self = scope; + g.importScripts = (url: string) => { + if (!scripts[url]) { + throw new Error("404 " + url); + } + + scripts[url](); + }; + + try { + defineModules(scope); + startWorkerRunner(); + await scope.onmessage({ data: data }); + } finally { + console.log = orgConsole.log; + console.info = orgConsole.info; + console.warn = orgConsole.warn; + console.error = orgConsole.error; + delete g.self; + delete g.importScripts; + } + + return messages; + } + + function ofKind(kind: string): any[] { + return messages.filter((msg) => msg.kind === kind); + } + + beforeEach(() => { + messages = []; + }); + + it("runs the test modules and posts the summary", async () => { + await post({ payload: payload(), autoFinish: true }); + + const result = messages[messages.length - 1]; + assert.equal(result.summary.total, 2); + assert.equal(result.summary.passed, 1); + assert.equal(result.summary.failed, 1); + assert.isNull(result.coverage); + assert.equal(ofKind("testStart").length, 0, "no progress for a run that finishes on its own"); + }); + + it("posts each suite, test and assertion as it runs for the interactive page", async () => { + await post({ payload: payload() }); + + assert.deepEqual(ofKind("suiteStart").map((msg) => msg.suiteParts), [ [ "worker suite" ] ]); + assert.deepEqual(ofKind("testStart").map((msg) => msg.testName), [ "passes", "fails" ]); + assert.deepEqual(ofKind("testDone").map((msg) => [ msg.testName, msg.endStatus ]), [ [ "passes", "passed" ], [ "fails", "failed" ] ]); + assert.equal(ofKind("suiteDone").length, 1); + + const asserts = ofKind("assert"); + assert.isTrue(asserts.length >= 2); + assert.isTrue(asserts[0].passed); + assert.equal(asserts[0].testName, "passes"); + const failed = asserts.filter((msg) => !msg.passed)[0]; + assert.equal(failed.testName, "fails"); + assert.includes(failed.errorText, "expected 1 to equal 2"); + }); + + it("sends the reporter's console output to the page", async () => { + await post({ payload: payload({ settings: { reporter: [ "spec" ], slow: 10, trace: true } }), autoFinish: true }); + + const logged = ofKind("log").map((msg) => msg.message).join("\n"); + assert.includes(logged, "worker suite"); + assert.includes(logged, "passes"); + }); + + it("stops at the first failure with bail", async () => { + const failFirst = { moduleUrls: { tripwire: "/tripwire.js", failFirst: "/test/failFirst.js" }, entryIds: [ "/test/failFirst" ] }; + + await post({ payload: payload(Object.assign({ settings: { reporter: [ "dot" ] } }, failFirst)), autoFinish: true }); + assert.equal(messages[messages.length - 1].summary.passed, 1, "without bail the second test runs"); + + messages = []; + await post({ payload: payload(Object.assign({ settings: { bail: true, reporter: [ "dot" ] } }, failFirst)), autoFinish: true }); + const result = messages[messages.length - 1]; + assert.equal(result.summary.failed, 1); + assert.equal(result.summary.passed, 0, "nothing runs after the failure"); + }); + + it("only runs the test the page selected, with the run's timeout", async () => { + await post({ payload: payload({ settings: { timeout: 1234, reporter: [ "dot" ] } }), selection: { testTitle: "worker suite > passes" }, autoFinish: true }); + + const result = messages[messages.length - 1]; + assert.equal(result.summary.passed, 1); + assert.equal(result.summary.failed, 0); + }); + + it("posts the error when the run cannot start", async () => { + await post({ payload: payload({ tripwireModuleId: "/not-defined" }), autoFinish: true }); + + assert.includes(messages[messages.length - 1].error, "Module not found in registry: /not-defined"); + }); + + it("posts the module scripts that failed to load", async () => { + await post({ payload: payload({ moduleUrls: { tripwire: "/tripwire.js", broken: "/broken.js", gone: "/gone.js" } }) }); + + const error = messages[messages.length - 1].error; + assert.includes(error, "Failed to load 2 worker module script(s): /broken.js (unexpected token), /gone.js (404 /gone.js)"); + }); + + describe("discover", () => { + it("posts the suites and tests the modules declare", async () => { + await post({ kind: "discover", payload: payload() }); + + const discovery = ofKind("discovery")[0]; + assert.isUndefined(discovery.error); + assert.includes(JSON.stringify(discovery.discovery), "worker suite"); + assert.includes(JSON.stringify(discovery.discovery), "fails"); + }); + + it("posts an empty discovery with the error when the modules cannot be loaded", async () => { + await post({ kind: "discover", payload: payload({ entryIds: [ "/not-defined" ] }) }); + + const discovery = ofKind("discovery")[0]; + assert.includes(discovery.error, "Module not found in registry: /not-defined"); + assert.isObject(discovery.discovery); + }); + }); +}); diff --git a/internal/runner/test/src/codecov/instrumenter.test.ts b/internal/runner/test/src/codecov/instrumenter.test.ts new file mode 100644 index 00000000..610ae427 --- /dev/null +++ b/internal/runner/test/src/codecov/instrumenter.test.ts @@ -0,0 +1,224 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import * as ts from "typescript"; +import { assert } from "@nevware21/tripwire"; +import { createInstrumenter, extractSourceMap, IstanbulCoverageInstrumenter } from "../../../src/codecov/instrumenter"; +import { _isUserError, _userErrorHints } from "../../../src/internal/userError"; + +function captureWarnings(fn: () => void): string[] { + const warnings: string[] = []; + const orgWarn = console.warn; + console.warn = (...args: any[]) => { + warnings.push(args.join(" ")); + }; + + try { + fn(); + } finally { + console.warn = orgWarn; + } + + return warnings; +} + +function inlineMap(map: any): string { + return "//# sourceMappingURL=data:application/json;charset=utf-8;base64," + Buffer.from(JSON.stringify(map)).toString("base64"); +} + +/** + * The coverage instrumenter rewrites the browser and worker payloads so they record coverage. It loads an + * istanbul style module from the project, and accepts several shapes of module API. + */ +describe("codecov instrumenter", () => { + let scratch: string; + + function writeModule(name: string, source: string): string { + const file = path.join(scratch, name + ".js"); + fs.writeFileSync(file, source, "utf8"); + + return file; + } + + before(() => { + scratch = fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-instrumenter-")); + }); + + after(() => { + fs.rmSync(scratch, { recursive: true, force: true }); + }); + + describe("extractSourceMap", () => { + const map = { version: 3, sources: [ "a.ts" ], names: [] as string[], mappings: "AAAA" }; + + it("reads an inline base64 source map", () => { + assert.deepEqual(extractSourceMap("var a = 1;\n" + inlineMap(map), "/proj/a.js"), map); + assert.deepEqual(extractSourceMap("var a = 1;\n//# sourceMappingURL=data:application/json;base64," + Buffer.from(JSON.stringify(map)).toString("base64"), "/proj/a.js"), map, + "the charset is optional"); + }); + + it("returns null for an inline map that is not JSON", () => { + assert.isNull(extractSourceMap("var a;\n//# sourceMappingURL=data:application/json;base64," + Buffer.from("not json").toString("base64"), "/proj/a.js")); + }); + + it("reads a map file next to the module, decoding its name", () => { + const dir = path.join(scratch, "maps"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "my file.js.map"), JSON.stringify(map), "utf8"); + + assert.deepEqual(extractSourceMap("var a;\n//# sourceMappingURL=my%20file.js.map", path.join(dir, "module.js")), map); + }); + + it("returns null when there is no map, the map file is missing or unreadable, or the data url is not base64 JSON", () => { + const dir = path.join(scratch, "maps"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "broken.js.map"), "{", "utf8"); + + assert.isNull(extractSourceMap("var a = 1;", "/proj/a.js")); + assert.isNull(extractSourceMap("", "/proj/a.js")); + assert.isNull(extractSourceMap(undefined, "/proj/a.js")); + assert.isNull(extractSourceMap("var a;\n//# sourceMappingURL=missing.js.map", path.join(dir, "module.js"))); + assert.isNull(extractSourceMap("var a;\n//# sourceMappingURL=broken.js.map", path.join(dir, "module.js"))); + assert.isNull(extractSourceMap("var a;\n//# sourceMappingURL=data:text/plain,hello", path.join(dir, "module.js"))); + }); + }); + + describe("with istanbul-lib-instrument", () => { + it("instruments a module and reports the file's coverage map", () => { + const instrumenter = createInstrumenter(scratch); + assert.ok(instrumenter instanceof IstanbulCoverageInstrumenter); + + const moduleId = path.join(scratch, "src", "a.js"); + const output = instrumenter.instrument("var a = 1;\nfunction f() { return a; }\n", moduleId); + + assert.includes(output, "__coverage__"); + const coverage = instrumenter.getLastFileCoverage(); + assert.equal(coverage.path, moduleId); + assert.isTrue(Object.keys(coverage.statementMap).length >= 2); + assert.equal(Object.keys(coverage.fnMap).length, 1); + }); + + it("maps the coverage back through the input source map", () => { + const compiled = ts.transpileModule("export const answer: number = 42;\nexport function ask(): number { return answer; }\n", { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES5, inlineSourceMap: true, inlineSources: true }, + fileName: path.join(scratch, "src", "b.ts") + }).outputText; + const moduleId = path.join(scratch, "src", "b.js"); + const sourceMap = extractSourceMap(compiled, moduleId); + const instrumenter = createInstrumenter(scratch); + + assert.isObject(sourceMap); + instrumenter.instrument(compiled, moduleId, sourceMap); + assert.isObject(instrumenter.getLastFileCoverage().inputSourceMap, "the input map travels with the coverage"); + }); + + it("reports a missing instrumenter module as a user error with install hints", () => { + const instrumenter = new IstanbulCoverageInstrumenter(scratch, "definitely-not-an-instrumenter"); + + let failure: any = null; + try { + instrumenter.instrument("var a;", "/proj/a.js"); + } catch (e) { + failure = e; + } + + assert.isTrue(_isUserError(failure)); + assert.includes(failure.message, "unable to resolve 'definitely-not-an-instrumenter'"); + assert.includes(_userErrorHints(failure)[0], "npm install --save-dev definitely-not-an-instrumenter"); + }); + + it("reports a module without an instrumenter API as a user error", () => { + const instrumenter = new IstanbulCoverageInstrumenter(scratch, writeModule("noApi", "module.exports = { other: 1 };")); + + assert.throws(() => instrumenter.getLastFileCoverage(), "does not expose createInstrumenter or instrumentSync"); + }); + }); + + describe("createInstrumenter", () => { + it("passes the source through unchanged when instrumentation is turned off", () => { + [ "none", "NONE", "" ].forEach((name) => { + assert.equal(createInstrumenter(scratch, name).instrument("var a = 1;", "/proj/a.js"), "var a = 1;", JSON.stringify(name)); + }); + }); + + it("warns and passes the source through when the module cannot be resolved", () => { + let instrumenter: any; + const warnings = captureWarnings(() => { + instrumenter = createInstrumenter(scratch, "definitely-not-an-instrumenter"); + }); + + assert.includes(warnings[0], "unable to resolve 'definitely-not-an-instrumenter'"); + assert.equal(instrumenter.instrument("src", "/proj/a.js"), "src"); + }); + + it("uses a module exposing instrumentSync directly, and its last file coverage", () => { + const file = writeModule("syncApi", [ + "let last = null;", + "module.exports = {", + " instrumentSync: (src, id, map) => { last = { path: id, map: !!map }; return '/*sync*/' + src; },", + " lastFileCoverage: () => last", + "};" + ].join("\n")); + const instrumenter = createInstrumenter(scratch, file); + + assert.equal(instrumenter.instrument("x", "/proj/a.js"), "/*sync*/x"); + assert.deepEqual(instrumenter.getLastFileCoverage(), { path: "/proj/a.js", map: false }); + instrumenter.instrument("x", "/proj/b.js", { mappings: "AAAA" }); + assert.deepEqual(instrumenter.getLastFileCoverage(), { path: "/proj/b.js", map: true }); + }); + + it("unwraps a default export that creates the instrumenter, and copes without lastFileCoverage", () => { + const file = writeModule("defaultApi", [ + "module.exports = { default: { createInstrumenter: (options) => ({", + " instrumentSync: (src) => '/*' + options.coverageVariable + '*/' + src", + "}) } };" + ].join("\n")); + const instrumenter = createInstrumenter(scratch, file); + + assert.equal(instrumenter.instrument("x", "/proj/a.js"), "/*__coverage__*/x"); + assert.isNull(instrumenter.getLastFileCoverage()); + }); + + it("accepts a factory that returns an instrumentSync object", () => { + const file = writeModule("factoryObject", [ + "module.exports = (options, ctx) => ({", + " instrumentSync: (src, id, map) => '/*' + ctx.cwd.length + (map ? ':map' : '') + '*/' + src", + "});" + ].join("\n")); + const instrumenter = createInstrumenter(scratch, file); + + assert.equal(instrumenter.instrument("x", "/proj/a.js"), "/*" + scratch.length + "*/x"); + assert.equal(instrumenter.instrument("x", "/proj/a.js", { mappings: "AAAA" }), "/*" + scratch.length + ":map*/x"); + }); + + it("accepts a factory that returns an instrument function, keeping the source when it does not answer with a string", () => { + const fnStyle = createInstrumenter(scratch, writeModule("factoryFn", "module.exports = () => (src, id) => '/*' + id + '*/' + src;")); + const notString = createInstrumenter(scratch, writeModule("factoryFnNumber", "module.exports = () => () => 42;")); + + assert.equal(fnStyle.instrument("x", "a.js"), "/*a.js*/x"); + assert.equal(notString.instrument("x", "a.js"), "x"); + }); + + it("warns and passes the source through for a module it cannot use", () => { + let objectApi: any; + let emptyFactory: any; + const warnings = captureWarnings(() => { + objectApi = createInstrumenter(scratch, writeModule("objectNoApi", "module.exports = { nothing: true };")); + emptyFactory = createInstrumenter(scratch, writeModule("factoryNothing", "module.exports = () => undefined;")); + }); + + assert.equal(warnings.length, 2); + warnings.forEach((w) => assert.includes(w, "does not expose a supported API")); + assert.equal(objectApi.instrument("x", "a.js"), "x"); + assert.equal(emptyFactory.instrument("x", "a.js"), "x"); + }); + }); +}); diff --git a/internal/runner/test/src/codecov/provider.test.ts b/internal/runner/test/src/codecov/provider.test.ts new file mode 100644 index 00000000..33ab10e0 --- /dev/null +++ b/internal/runner/test/src/codecov/provider.test.ts @@ -0,0 +1,330 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { assert } from "@nevware21/tripwire"; +import { DefaultCoverageFilter } from "../../../src/codecov/filter"; +import { CoverageData, CoverageEntry, ICoverageInstrumenter, ICoverageReporter } from "../../../src/codecov/interfaces"; +import { _asFullyCovered, _asZeroCovered, _isErasedEntry, createDefaultCoverageProvider, DefaultCoverageProvider } from "../../../src/codecov/provider"; +import { NycCoverageReporter } from "../../../src/codecov/reporter"; + +function entry(file: string, statements: number, functions?: number): CoverageEntry { + const statementMap: any = {}; + const s: any = {}; + for (let lp = 0; lp < statements; lp++) { + statementMap[lp] = { start: { line: lp + 1, column: 0 }, end: { line: lp + 1, column: 5 } }; + s[lp] = 0; + } + + const fnMap: any = {}; + const f: any = {}; + for (let lp = 0; lp < (functions || 0); lp++) { + fnMap[lp] = { name: "f" + lp, decl: { start: { line: 1, column: 0 }, end: { line: 1, column: 1 } }, loc: { start: { line: 1, column: 0 }, end: { line: 1, column: 1 } } }; + f[lp] = 0; + } + + return { path: file, statementMap, fnMap, branchMap: {}, s, f, b: {} }; +} + +/** + * An instrumenter that records what it was asked to instrument, and answers with a made-up coverage + * map per file so the provider's bookkeeping can be checked without istanbul. + */ +function recordingInstrumenter(coverageFor?: (file: string) => CoverageEntry | null, failFor?: RegExp): ICoverageInstrumenter & { calls: any[] } { + let last: CoverageEntry | null = null; + const instrumenter = { + calls: [] as any[], + instrument(source: string, moduleId: string, inputSourceMap?: any): string { + instrumenter.calls.push({ moduleId, hasMap: !!inputSourceMap }); + if (failFor && failFor.test(moduleId)) { + throw new Error("cannot instrument " + moduleId); + } + + last = coverageFor ? coverageFor(moduleId) : null; + return "/*instrumented*/" + source; + }, + getLastFileCoverage(): CoverageEntry | null { + return last; + } + }; + + return instrumenter; +} + +function recordingReporter(): ICoverageReporter & { writes: any[] } { + const reporter = { + writes: [] as any[], + write(coverage: CoverageData, cwd: string, target: string): void { + reporter.writes.push({ coverage, cwd, target }); + } + }; + + return reporter; +} + +function captureWarnings(fn: () => void): string[] { + const warnings: string[] = []; + const orgWarn = console.warn; + console.warn = (...args: any[]) => { + warnings.push(args.join(" ")); + }; + + try { + fn(); + } finally { + console.warn = orgWarn; + } + + return warnings; +} + +describe("codecov provider", () => { + describe("erased entries", () => { + it("treats a file with no functions, no branches and at most one statement as erased", () => { + assert.isTrue(_isErasedEntry({ path: "a" })); + assert.isTrue(_isErasedEntry(entry("a", 0))); + assert.isTrue(_isErasedEntry(entry("a", 1))); + assert.isFalse(_isErasedEntry(entry("a", 2))); + assert.isFalse(_isErasedEntry(entry("a", 0, 1))); + assert.isFalse(_isErasedEntry(Object.assign(entry("a", 0), { branchMap: { 0: {} }, b: { 0: [ 0 ] } }))); + }); + + it("zeroes every counter without touching the original", () => { + const original = Object.assign(entry("a", 2, 1), { b: { 0: [ 3, 4 ] }, branchMap: { 0: {} } }); + original.s[0] = 5; + original.f[0] = 2; + + const zeroed = _asZeroCovered(original); + + assert.deepEqual(zeroed.s, { 0: 0, 1: 0 }); + assert.deepEqual(zeroed.f, { 0: 0 }); + assert.deepEqual(zeroed.b, { 0: [ 0, 0 ] }); + assert.equal(original.s[0], 5, "the original is not changed"); + assert.deepEqual(_asZeroCovered({ path: "empty" }).s, {}); + }); + + it("marks every counter covered, giving a file without statements one covered statement", () => { + const covered = _asFullyCovered(Object.assign(entry("a", 2, 1), { b: { 0: [ 0, 0 ] }, branchMap: { 0: {} } })); + + assert.deepEqual(covered.s, { 0: 1, 1: 1 }); + assert.deepEqual(covered.f, { 0: 1 }); + assert.deepEqual(covered.b, { 0: [ 1, 1 ] }); + + const synthetic = _asFullyCovered(entry("types", 0)); + assert.deepEqual(synthetic.s, { 0: 1 }); + assert.deepEqual(Object.keys(synthetic.statementMap), [ "0" ]); + assert.deepEqual(synthetic.fnMap, {}); + assert.deepEqual(synthetic.b, {}); + assert.deepEqual(_asFullyCovered({ path: "bare", statementMap: { 0: {} } }).f, {}, "missing counters are treated as empty"); + }); + }); + + describe("instrumentPayload", () => { + const cwd = "/proj"; + const sourceMapComment = "//# sourceMappingURL=data:application/json;base64," + Buffer.from(JSON.stringify({ version: 3, sources: [ "b.ts" ], mappings: "AAAA" })).toString("base64"); + + function payload(): any { + return { + settings: { timeout: 10 }, + entryIds: [ "/proj/test/a.test.js" ], + tripwireModuleId: "/proj/node_modules/@nevware21/tripwire/tripwire.js", + coverageOnlyIds: [ "/proj/src/only.js" ], + modules: { + "/proj/test/a.test.js": "require('/proj/src/b.js'); require(\"/proj/src/throws.js\");", + "/proj/src/b.js": "require( '/proj/src/c.js' ); require('/proj/src/missing.js');\n" + sourceMapComment, + "/proj/src/c.js": "module.exports = 1; require('/proj/src/b.js');", + "/proj/src/only.js": "module.exports = 'only';", + "/proj/src/unused.js": "module.exports = 'never required';", + "/proj/src/throws.js": "module.exports = 'breaks the instrumenter';", + "/proj/test/helper.test.js": "module.exports = 'a test file';", + "/proj/node_modules/@nevware21/tripwire/tripwire.js": "require('/proj/node_modules/dep/index.js');", + "/proj/node_modules/dep/index.js": "module.exports = 'dependency';" + } + }; + } + + it("instruments only the reachable, in-scope, non-test modules", () => { + const instrumenter = recordingInstrumenter(undefined, /throws\.js$/); + const provider = new DefaultCoverageProvider(instrumenter, new DefaultCoverageFilter(), recordingReporter()); + + const result = provider.instrumentPayload(payload(), cwd, {}); + const instrumented = Object.keys(result.modules).filter((id) => result.modules[id].indexOf("/*instrumented*/") === 0).sort(); + + assert.deepEqual(instrumented, [ "/proj/src/b.js", "/proj/src/c.js", "/proj/src/only.js" ]); + assert.equal(result.modules["/proj/src/unused.js"], "module.exports = 'never required';", "an unreachable module is left alone"); + assert.equal(result.modules["/proj/src/throws.js"], "module.exports = 'breaks the instrumenter';", "a module the instrumenter rejects is served as it was"); + assert.equal(result.modules["/proj/test/a.test.js"], payload().modules["/proj/test/a.test.js"], "the entry (a test) is never instrumented"); + assert.isFalse(result.modules["/proj/node_modules/dep/index.js"].indexOf("/*instrumented*/") === 0, "dependencies are out of scope"); + }); + + it("passes a module's own source map to the instrumenter", () => { + const instrumenter = recordingInstrumenter(); + new DefaultCoverageProvider(instrumenter, new DefaultCoverageFilter(), recordingReporter()).instrumentPayload(payload(), cwd, {}); + + const calls: { [id: string]: boolean } = {}; + instrumenter.calls.forEach((call) => { + calls[call.moduleId] = call.hasMap; + }); + + assert.isTrue(calls["/proj/src/b.js"]); + assert.isFalse(calls["/proj/src/c.js"]); + }); + + it("keeps the settings, entries and tripwire module but not the coverage-only list", () => { + const result = new DefaultCoverageProvider(recordingInstrumenter(), new DefaultCoverageFilter(), recordingReporter()).instrumentPayload(payload(), cwd, {}); + + assert.deepEqual(result.settings, { timeout: 10 }); + assert.deepEqual(result.entryIds, [ "/proj/test/a.test.js" ]); + assert.equal(result.tripwireModuleId, "/proj/node_modules/@nevware21/tripwire/tripwire.js"); + assert.isUndefined(result.coverageOnlyIds); + }); + + it("returns a payload without modules as it is", () => { + const provider = new DefaultCoverageProvider(recordingInstrumenter(), new DefaultCoverageFilter(), recordingReporter()); + const empty = { entryIds: [ "a" ] }; + + assert.equal(provider.instrumentPayload(empty, cwd, {}), empty); + assert.isNull(provider.instrumentPayload(null, cwd, {})); + }); + + it("copes with a payload that names no entries or tripwire module", () => { + const result = new DefaultCoverageProvider(recordingInstrumenter(), new DefaultCoverageFilter(), recordingReporter()) + .instrumentPayload({ modules: { "/proj/src/a.js": "x" } }, cwd, {}); + + assert.equal(result.modules["/proj/src/a.js"], "x", "nothing is reachable, so nothing is instrumented"); + }); + }); + + describe("writeCoverage and fillUninstrumentedFiles", function () { + // Filling in the uninstrumented files transpiles them with TypeScript + this.timeout(60000); + + let project: string; + const file = (rel: string) => path.join(project, rel).split(path.sep).join("/"); + + before(() => { + project = fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-provider-")); + fs.mkdirSync(path.join(project, "src")); + fs.mkdirSync(path.join(project, "lib")); + fs.writeFileSync(path.join(project, "src", "covered.ts"), "export const covered = 1;\n", "utf8"); + fs.writeFileSync(path.join(project, "src", "never.ts"), "export function never(): number { return 2; }\n", "utf8"); + fs.writeFileSync(path.join(project, "src", "types.ts"), "export interface IShape { size: number; }\n", "utf8"); + fs.writeFileSync(path.join(project, "src", "broken.ts"), "export const broken = 3;\n", "utf8"); + fs.writeFileSync(path.join(project, "src", "index.ts"), "export * from \"./covered\";\n", "utf8"); + fs.writeFileSync(path.join(project, "src", "spec.test.ts"), "export const t = 1;\n", "utf8"); + fs.writeFileSync(path.join(project, "lib", "other.ts"), "export const other = 4;\n", "utf8"); + }); + + after(() => { + fs.rmSync(project, { recursive: true, force: true }); + }); + + function coverageFor(moduleId: string): CoverageEntry | null { + const normalized = moduleId.split("\\").join("/"); + if (/types\.ts$/.test(normalized)) { + return Object.assign(entry(normalized, 0), { all: true }); + } + + return entry(normalized, 2, 1); + } + + it("adds a zero-coverage entry for every source file the run never loaded", () => { + const instrumenter = recordingInstrumenter(coverageFor, /broken\.ts$/); + const provider = new DefaultCoverageProvider(instrumenter, new DefaultCoverageFilter(), recordingReporter(), {}); + const loaded: CoverageData = {}; + loaded[file("src/covered.ts")] = entry(file("src/covered.ts"), 3); + + const filled = provider.fillUninstrumentedFiles(loaded, project); + const keys = Object.keys(filled).map((k) => path.basename(k)).sort(); + + assert.deepEqual(keys, [ "covered.ts", "never.ts", "types.ts" ], "index files, tests and files that fail to instrument are left out"); + assert.equal(Object.keys(filled[file("src/covered.ts")].statementMap).length, 3, "a loaded file keeps its own coverage"); + assert.isFalse(instrumenter.calls.some((c) => /covered\.ts$/.test(c.moduleId)), "a loaded file is not instrumented again"); + }); + + it("reads the source patterns from the run options", () => { + const provider = new DefaultCoverageProvider(recordingInstrumenter(coverageFor), new DefaultCoverageFilter(), recordingReporter(), { coverageSrc: [ "lib/**/*.ts" ] }); + + assert.deepEqual(Object.keys(provider.fillUninstrumentedFiles({}, project)).map((k) => path.basename(k)), [ "other.ts" ]); + }); + + it("applies the erased coverage mode to files that compile away entirely", () => { + function typesEntryFor(mode?: string): CoverageEntry | undefined { + const provider = new DefaultCoverageProvider(recordingInstrumenter(coverageFor), new DefaultCoverageFilter(), recordingReporter(), mode ? { erasedCoverageMode: mode } : {}); + return provider.fillUninstrumentedFiles({}, project)[file("src/types.ts")]; + } + + assert.deepEqual(typesEntryFor().s, {}, "zero is the default: the empty file stays uncovered"); + assert.deepEqual(typesEntryFor("zero").s, {}); + assert.deepEqual(typesEntryFor("full").s, { 0: 1 }, "full counts it as covered"); + assert.isUndefined(typesEntryFor("ignore"), "ignore leaves it out of the report"); + }); + + it("does not add files when the instrumenter cannot report a file's coverage", () => { + const noLast: ICoverageInstrumenter = { instrument: (source) => source }; + const provider = new DefaultCoverageProvider(noLast, new DefaultCoverageFilter(), recordingReporter(), {}); + + assert.deepEqual(provider.fillUninstrumentedFiles({}, project), {}); + }); + + it("writes the filled and filtered coverage through the reporter", () => { + const reporter = recordingReporter(); + const provider = new DefaultCoverageProvider(recordingInstrumenter(coverageFor), new DefaultCoverageFilter(), reporter, {}); + const loaded: CoverageData = {}; + loaded[file("src/covered.ts")] = entry(file("src/covered.ts"), 1, 1); + loaded[file("src/spec.test.ts")] = entry(file("src/spec.test.ts"), 1, 1); + + provider.writeCoverage(loaded, project, "worker"); + + assert.equal(reporter.writes.length, 1); + assert.equal(reporter.writes[0].target, "worker"); + assert.equal(reporter.writes[0].cwd, project); + const written = Object.keys(reporter.writes[0].coverage).map((k) => path.basename(k)).sort(); + assert.deepEqual(written, [ "broken.ts", "covered.ts", "never.ts", "types.ts" ], "the test file is filtered out"); + }); + + it("writes nothing for missing coverage, and warns when nothing but tests is left", () => { + const reporter = recordingReporter(); + const provider = new DefaultCoverageProvider(recordingInstrumenter(), new DefaultCoverageFilter(), reporter, { coverageSrc: [ "nothing/**/*.ts" ] }); + const onlyTests: CoverageData = {}; + onlyTests[file("src/spec.test.ts")] = entry(file("src/spec.test.ts"), 2); + + provider.writeCoverage(null, project, "browser"); + provider.writeCoverage("not coverage" as any, project, "browser"); + const warnings = captureWarnings(() => { + provider.writeCoverage(onlyTests, project, "browser"); + provider.writeCoverage(onlyTests, project, "worker"); + }); + + assert.equal(reporter.writes.length, 0); + assert.deepEqual(warnings, [ + "No non-test coverage entries available for browser report.", + "No non-test coverage entries available for worker report." + ]); + }); + }); + + describe("createDefaultCoverageProvider", () => { + it("assembles the provider from the run options", () => { + const provider = createDefaultCoverageProvider("/proj", { coverageInstrumenter: " none ", coverageReporters: [ "lcov" ] }); + + assert.ok(provider instanceof DefaultCoverageProvider); + assert.ok(provider.filter instanceof DefaultCoverageFilter); + assert.ok(provider.reporter instanceof NycCoverageReporter); + assert.equal(provider.instrumenter.instrument("x", "/proj/a.js"), "x", "the instrumenter name is trimmed and 'none' disables it"); + }); + + it("defaults to istanbul-lib-instrument", () => { + const provider = createDefaultCoverageProvider(process.cwd(), null); + + assert.includes(provider.instrumenter.instrument("var a = 1;", path.join(process.cwd(), "src", "a.js")), "__coverage__"); + }); + }); +}); diff --git a/internal/runner/test/src/codecov/reports.test.ts b/internal/runner/test/src/codecov/reports.test.ts new file mode 100644 index 00000000..ff002efd --- /dev/null +++ b/internal/runner/test/src/codecov/reports.test.ts @@ -0,0 +1,248 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { assert } from "@nevware21/tripwire"; +import { runCoverageReport } from "../../../src/codecov/nodeCoverageReport"; +import { _buildReporterArgs, _nycCwd, _spawnNycReport } from "../../../src/codecov/nycReport"; +import { NycCoverageReporter } from "../../../src/codecov/reporter"; + +function covered(file: string, hits: number): any { + return { + path: file, + statementMap: { + 0: { start: { line: 1, column: 0 }, end: { line: 1, column: 10 } }, + 1: { start: { line: 2, column: 0 }, end: { line: 2, column: 10 } } + }, + fnMap: {}, + branchMap: {}, + s: { 0: hits, 1: 0 }, + f: {}, + b: {} + }; +} + +function erased(file: string): any { + return { path: file, statementMap: {}, fnMap: {}, branchMap: {}, s: {}, f: {}, b: {}, all: true }; +} + +function captureConsole(fn: () => any): { result: any; warnings: string[]; logs: string[] } { + const warnings: string[] = []; + const logs: string[] = []; + const orgWarn = console.warn; + const orgLog = console.log; + console.warn = (...args: any[]) => { + warnings.push(args.join(" ")); + }; + console.log = (...args: any[]) => { + logs.push(args.join(" ")); + }; + + try { + return { result: fn(), warnings, logs }; + } finally { + console.warn = orgWarn; + console.log = orgLog; + } +} + +function summaryOf(dir: string): any { + return JSON.parse(fs.readFileSync(path.join(dir, "coverage-summary.json"), "utf8")); +} + +/** + * Writing and reporting coverage: the browser and worker reporter, the node report that merges nyc's raw + * output, and the nyc invocation both share. nyc is run for real with the json-summary reporter, which + * writes a file rather than printing, so the results can be read back. + */ +describe("codecov reports", function () { + // Each report starts nyc in a child process + this.timeout(120000); + + let scratch: string; + + beforeEach(() => { + scratch = fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-reports-")); + }); + + afterEach(() => { + fs.rmSync(scratch, { recursive: true, force: true }); + }); + + describe("nycReport", () => { + it("works in the nearest folder with a package.json, as nyc itself would", () => { + const project = path.join(scratch, "project"); + fs.mkdirSync(path.join(project, "src", "deep"), { recursive: true }); + fs.writeFileSync(path.join(project, "package.json"), "{}", "utf8"); + + assert.equal(_nycCwd(path.join(project, "src", "deep")), project); + assert.equal(_nycCwd(project), project); + }); + + it("falls back to the folder it was given when no package.json is above it", () => { + const loose = path.join(scratch, "loose"); + fs.mkdirSync(loose); + + assert.equal(_nycCwd(loose), path.resolve(loose)); + }); + + it("defaults to the text reporter and repeats --reporter for each one", () => { + assert.deepEqual(_buildReporterArgs(), [ "--reporter", "text" ]); + assert.deepEqual(_buildReporterArgs([]), [ "--reporter", "text" ]); + assert.deepEqual(_buildReporterArgs([ "lcov", "json" ]), [ "--reporter", "lcov", "--reporter", "json" ]); + }); + + it("runs nyc report and returns its exit code", () => { + const tempDir = path.join(scratch, "raw"); + fs.mkdirSync(tempDir); + const data: any = {}; + data[path.join(scratch, "src", "a.js")] = covered(path.join(scratch, "src", "a.js"), 1); + fs.writeFileSync(path.join(tempDir, "out.json"), JSON.stringify(data), "utf8"); + + const status = _spawnNycReport(scratch, [ "--temp-dir", tempDir, "--report-dir", path.join(scratch, "report"), "--reporter", "json-summary" ], "test"); + + assert.equal(status, 0); + assert.equal(summaryOf(path.join(scratch, "report")).total.statements.pct, 50); + }); + + it("reports this project's files even when an outer nyc points NYC_CWD at another project", () => { + const tempDir = path.join(scratch, "raw"); + fs.mkdirSync(tempDir); + const data: any = {}; + data[path.join(scratch, "src", "a.js")] = covered(path.join(scratch, "src", "a.js"), 1); + fs.writeFileSync(path.join(tempDir, "out.json"), JSON.stringify(data), "utf8"); + + // Another project whose .nycrc would include none of this one's files + const outer = path.join(scratch, "outer"); + fs.mkdirSync(outer); + fs.writeFileSync(path.join(outer, "package.json"), "{}", "utf8"); + fs.writeFileSync(path.join(outer, ".nycrc"), JSON.stringify({ include: [ "nothing/**" ] }), "utf8"); + + const saved = process.env.NYC_CWD; + process.env.NYC_CWD = outer; + try { + assert.equal(_spawnNycReport(scratch, [ "--temp-dir", tempDir, "--report-dir", path.join(scratch, "report"), "--reporter", "json-summary" ], "test"), 0); + } finally { + if (saved === undefined) { + delete process.env.NYC_CWD; + } else { + process.env.NYC_CWD = saved; + } + } + + assert.equal(summaryOf(path.join(scratch, "report")).total.statements.pct, 50, "this project's file was reported"); + }); + + it("warns with the label when nyc report fails", () => { + const run = captureConsole(() => _spawnNycReport(scratch, [ "--temp-dir", scratch, "--reporter", "no-such-reporter-anywhere" ], "my label")); + + assert.notEqual(run.result, 0); + assert.includes(run.warnings.join("\n"), "nyc report failed for my label with exit code"); + }); + }); + + describe("NycCoverageReporter", () => { + it("writes the browser coverage without the embedded sources and reports it", () => { + const file = path.join(scratch, "src", "a.js"); + const data: any = {}; + data[file] = Object.assign(covered(file, 3), { inputSourceMap: { version: 3, sources: [ "a.ts" ], sourcesContent: [ "huge source" ], mappings: "AAAA" } }); + data[path.join(scratch, "src", "b.js")] = covered(path.join(scratch, "src", "b.js"), 0); + + const run = captureConsole(() => new NycCoverageReporter([ "json-summary" ]).write(data, scratch, "browser")); + + const written = JSON.parse(fs.readFileSync(path.join(scratch, "coverage", "browser", "coverage-final.json"), "utf8")); + assert.isUndefined(written[file].inputSourceMap.sourcesContent, "the sources are stripped"); + assert.deepEqual(written[file].inputSourceMap.sources, [ "a.ts" ], "the rest of the map is kept"); + assert.deepEqual(written[file].s, { 0: 3, 1: 0 }); + assert.includes(run.logs.join("\n"), "coverage-final.json"); + assert.isTrue(fs.existsSync(path.join(scratch, "coverage", "browser", "report", "coverage-summary.json")), "the report was generated"); + }); + + it("writes worker coverage to its own folder, with the text reporter by default", () => { + const reporter = new NycCoverageReporter(); + const data: any = {}; + data[path.join(scratch, "src", "w.js")] = covered(path.join(scratch, "src", "w.js"), 1); + + captureConsole(() => reporter.write(data, scratch, "worker")); + + assert.isTrue(fs.existsSync(path.join(scratch, "coverage", "worker", "coverage-final.json"))); + assert.isFalse(fs.existsSync(path.join(scratch, "coverage", "browser"))); + }); + + it("warns rather than throws when the coverage folder cannot be written", () => { + fs.writeFileSync(path.join(scratch, "coverage"), "a file where the folder should be", "utf8"); + + const run = captureConsole(() => new NycCoverageReporter([ "json-summary" ]).write({}, scratch, "browser")); + + assert.includes(run.warnings.join("\n"), "Failed to write browser coverage file"); + }); + }); + + describe("runCoverageReport", () => { + function writeNycOutput(dir: string): { real: string; empty: string } { + const real = path.join(scratch, "src", "real.js"); + const empty = path.join(scratch, "src", "types.js"); + const data: any = {}; + data[real] = covered(real, 2); + data[empty] = erased(empty); + + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "process-1.json"), JSON.stringify(data), "utf8"); + fs.writeFileSync(path.join(dir, "broken.json"), "{ not coverage", "utf8"); + fs.writeFileSync(path.join(dir, "notes.txt"), "ignored", "utf8"); + + return { real, empty }; + } + + it("warns and fails when there is no nyc output to report", () => { + const run = captureConsole(() => runCoverageReport(scratch, {})); + + assert.equal(run.result, 1); + assert.includes(run.warnings.join("\n"), "No nyc coverage data found at"); + }); + + it("reports erased files as uncovered by default", () => { + const files = writeNycOutput(path.join(scratch, ".nyc_output")); + + const run = captureConsole(() => runCoverageReport(scratch, { coverageReporters: [ "json-summary" ] })); + const summary = summaryOf(path.join(scratch, "coverage")); + + assert.equal(run.result, 0); + assert.includes(run.logs.join("\n"), "zero"); + assert.isObject(summary[files.real]); + assert.isObject(summary[files.empty], "the erased file is still listed"); + assert.equal(summary[files.real].statements.pct, 50); + }); + + it("counts erased files as covered in full mode and drops them in ignore mode", () => { + const files = writeNycOutput(path.join(scratch, "raw")); + + captureConsole(() => runCoverageReport(scratch, { nycOutputDir: "raw", erasedCoverageMode: "full", coverageReporters: [ "json-summary" ] })); + const full = summaryOf(path.join(scratch, "coverage")); + assert.equal(full[files.empty].statements.pct, 100); + + fs.rmSync(path.join(scratch, "coverage"), { recursive: true, force: true }); + captureConsole(() => runCoverageReport(scratch, { nycOutputDir: "raw", erasedCoverageMode: "ignore", coverageReporters: [ "json-summary" ] })); + const ignored = summaryOf(path.join(scratch, "coverage")); + assert.isUndefined(ignored[files.empty]); + assert.isObject(ignored[files.real]); + }); + + it("removes its temporary merge folder", () => { + writeNycOutput(path.join(scratch, ".nyc_output")); + const before = fs.readdirSync(os.tmpdir()).filter((f) => f.indexOf("tripwire-node-coverage-") === 0).length; + + captureConsole(() => runCoverageReport(scratch, { coverageReporters: [ "json-summary" ] })); + + const after = fs.readdirSync(os.tmpdir()).filter((f) => f.indexOf("tripwire-node-coverage-") === 0).length; + assert.equal(after, before); + }); + }); +}); diff --git a/internal/runner/test/src/compile/cacheStore.test.ts b/internal/runner/test/src/compile/cacheStore.test.ts index d4244713..b2b284b2 100644 --- a/internal/runner/test/src/compile/cacheStore.test.ts +++ b/internal/runner/test/src/compile/cacheStore.test.ts @@ -186,6 +186,24 @@ describe("compile cacheStore", () => { assert.isNull(readEmitCache(null, "key")); }); + it("should give up quietly when the cache directory cannot be created", () => { + const dir = _tempDir("blocked"); + try { + // A file where the cache directory would go + const blocked = path.join(dir, "cache"); + fs.writeFileSync(blocked, "not a directory", "utf8"); + const key = emitCacheKey("/p/a.ts", "let a = 1;", "fp"); + + writeEmitCache(blocked, key, "var a = 1;"); + writeProgramManifest(blocked, "/p/tsconfig.json", createProgramManifest("fp", [], [], [], false)); + + assert.isNull(readEmitCache(blocked, key)); + assert.equal(fs.readFileSync(blocked, "utf8"), "not a directory"); + } finally { + _rm(dir); + } + }); + it("should not leave temporary files behind", () => { const dir = _tempDir("notemp"); try { @@ -436,6 +454,15 @@ describe("compile cacheStore", () => { assert.isFalse(checkProgramManifest(manifest, "fp", roots, locks).current); }); + it("should name the lock file whose install changed", () => { + const recorded = createProgramManifest("fp", roots, roots, [ [ "package-lock.json", "1:1" ] ], true); + + const check = checkProgramManifest(recorded, "fp", roots, [ [ "package-lock.json", "2:2" ] ]); + + assert.isFalse(check.current); + assert.equal(check.reason, "an install changed how imports resolve (package-lock.json)"); + }); + it("should reject a null or unrecognised manifest", () => { assert.isFalse(checkProgramManifest(null, "fp", roots, []).current); assert.isFalse(checkProgramManifest({ v: 99 } as any, "fp", roots, []).current); diff --git a/internal/runner/test/src/compile/tsCompileProject.test.ts b/internal/runner/test/src/compile/tsCompileProject.test.ts new file mode 100644 index 00000000..cba22b2c --- /dev/null +++ b/internal/runner/test/src/compile/tsCompileProject.test.ts @@ -0,0 +1,497 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { createRequire } from "module"; +import { assert } from "@nevware21/tripwire"; +import { + compileTypeScript, getCompileCacheStatus, getProjectFileNames, invalidateTypeScriptProgramCache, loadTypescript, + registerTypeScriptHook, setGraphTypeCheck, transpileTypeScriptSource, transpileTypeScriptSourceWithMap +} from "../../../src/compile/tsCompile"; +import { getCompileProvider } from "../../../src/compile/provider"; +import { _isUserError } from "../../../src/internal/userError"; + +const _require = createRequire(__filename); +const _PROJECT_PREFIX = path.join(os.tmpdir(), "tripwire-tsproject-"); + +const TS_CONFIG = { compilerOptions: { target: "es2018", module: "commonjs", lib: [ "es2018" ], types: [] as string[] } }; + +// A const enum is only inlined when the file is emitted from a program that also holds the enum's +// declaration, so it tells a program emit apart from a file transpiled on its own. +const ENUMS = "export const enum Color {\n Red = 1,\n Green = 2\n}\n"; +const MAIN = "import { Color } from \"./enums\";\nexport const red: number = Color.Red;\n"; +const TYPE_ERROR = "export const count: number = \"not a number\";\n"; + +function _configWith(extra: any): any { + return { compilerOptions: TS_CONFIG.compilerOptions, ...extra }; +} + +function _caught(fn: () => any): any { + try { + fn(); + } catch (e) { + return e; + } + + return null; +} + +function _hasText(text: string, value: string): boolean { + return text.indexOf(value) !== -1; +} + +function _filesUnder(dir: string, matcher: (name: string) => boolean): string[] { + const found: string[] = []; + if (fs.existsSync(dir)) { + fs.readdirSync(dir, { withFileTypes: true }).forEach((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + found.push(..._filesUnder(full, matcher)); + } else if (matcher(entry.name)) { + found.push(full); + } + }); + } + + return found; +} + +/** + * Compiling real, throwaway TypeScript projects: the transpile entry points the browser and worker module + * graph uses, the project file list, the tsc check, and the node require hook with its compile cache. + */ +describe("tsCompile projects", function () { + // Every case builds at least one TypeScript program, and one runs tsc in a child process + this.timeout(120000); + + let projects: string[] = []; + + function makeProject(files: { [name: string]: string }, tsconfig?: any): string { + const dir = fs.mkdtempSync(_PROJECT_PREFIX); + + // A node_modules folder keeps the compile cache inside the project, so it is removed with it + fs.mkdirSync(path.join(dir, "node_modules")); + if (tsconfig !== null) { + fs.writeFileSync(path.join(dir, "tsconfig.json"), typeof tsconfig === "string" ? tsconfig : JSON.stringify(tsconfig || TS_CONFIG), "utf8"); + } + + Object.keys(files).forEach((name) => { + const target = path.join(dir, name); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, files[name], "utf8"); + }); + + projects.push(dir); + + return dir; + } + + afterEach(() => { + setGraphTypeCheck(false); + invalidateTypeScriptProgramCache(); + projects.forEach((dir) => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + projects = []; + }); + + describe("loadTypescript", () => { + it("loads the compiler, falling back to the runner's own install", () => { + const ts = loadTypescript(makeProject({})); + + assert.isFunction(ts.transpileModule); + assert.isString(ts.version); + }); + }); + + describe("getProjectFileNames", () => { + it("lists the project's files and keeps them until the program cache is invalidated", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }); + const names = () => (getProjectFileNames("tsconfig.json", dir) || []).map((f) => path.basename(f)).sort(); + + assert.deepEqual(names(), [ "enums.ts", "main.ts" ]); + + fs.writeFileSync(path.join(dir, "later.ts"), "export {};\n", "utf8"); + assert.deepEqual(names(), [ "enums.ts", "main.ts" ], "the cached list is kept"); + + invalidateTypeScriptProgramCache(); + assert.deepEqual(names(), [ "enums.ts", "later.ts", "main.ts" ]); + }); + + it("returns null for a missing, unreadable or empty project", () => { + assert.isNull(getProjectFileNames("missing.json", makeProject({}))); + assert.isNull(getProjectFileNames("tsconfig.json", makeProject({}, "{"))); + assert.isNull(getProjectFileNames("tsconfig.json", makeProject({}, _configWith({ include: [ "nothing/**" ] })))); + }); + }); + + describe("compileTypeScript", () => { + it("type-checks a clean project with tsc", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }); + + compileTypeScript("tsconfig.json", dir); + }); + + it("reports a missing project file as a user error", () => { + const dir = makeProject({}); + const failure = _caught(() => compileTypeScript("missing.json", dir)); + + assert.isTrue(_isUserError(failure)); + assert.includes(failure.message, "TypeScript project file not found: " + path.join(dir, "missing.json")); + }); + }); + + describe("transpileTypeScriptSource", () => { + it("emits from the nearest tsconfig's program, inlining const enums from other project files", () => { + const dir = makeProject({ "enums.ts": ENUMS, "src/main.ts": MAIN.replace("./enums", "../enums") }); + + const output = transpileTypeScriptSource(path.join(dir, "src", "main.ts"), "", dir); + + assert.includes(output, "1 /* Color.Red */"); + assert.isFalse(_hasText(output, "require("), "the const enum import is erased"); + assert.isFalse(_hasText(output, "sourceMappingURL"), "no source map is asked for"); + }); + + it("uses an explicit project path relative to cwd", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }, null); + fs.mkdirSync(path.join(dir, "config")); + fs.writeFileSync(path.join(dir, "config", "tsconfig.app.json"), JSON.stringify(_configWith({ files: [ "../enums.ts", "../main.ts" ] })), "utf8"); + + assert.includes(transpileTypeScriptSource(path.join(dir, "main.ts"), "", dir, "config/tsconfig.app.json"), "1 /* Color.Red */"); + }); + + it("keeps each file's emit until the program cache is invalidated", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }); + const file = path.join(dir, "main.ts"); + + const first = transpileTypeScriptSource(file, "", dir); + fs.writeFileSync(file, "export const red: number = 7;\n", "utf8"); + + assert.equal(transpileTypeScriptSource(file, "", dir), first, "the cached emit is returned"); + + invalidateTypeScriptProgramCache(); + assert.includes(transpileTypeScriptSource(file, "", dir), "exports.red = 7"); + }); + + it("adds a file the project does not include to its program, still inlining the project's const enums", () => { + const dir = makeProject({ "enums.ts": ENUMS, "extra.ts": MAIN }, _configWith({ files: [ "enums.ts" ] })); + + assert.includes(transpileTypeScriptSource(path.join(dir, "extra.ts"), "", dir), "1 /* Color.Red */"); + }); + + it("transpiles the given source on its own when the program has no such file", () => { + const dir = makeProject({ "enums.ts": ENUMS }); + const file = path.join(dir, "notOnDisk.ts"); + + const output = transpileTypeScriptSource(file, MAIN, dir); + assert.includes(output, "require(\"./enums\")"); + assert.includes(output, "Color.Red"); + + assert.equal(transpileTypeScriptSource(file, MAIN, dir), output, "the missing file is remembered, and the source transpiled again"); + }); + + it("transpiles the given source on its own when the project is missing, broken or empty", () => { + const check = (dir: string, projectPath?: string) => { + const output = transpileTypeScriptSource(path.join(dir, "main.ts"), MAIN, dir, projectPath); + assert.includes(output, "require(\"./enums\")", dir); + assert.isFalse(_hasText(output, "sourceMappingURL")); + }; + + check(makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }), "missing.json"); + check(makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }, "{")); + check(makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }, _configWith({ include: [ "nothing/**" ] }))); + }); + + it("transpiles the given source on its own when no tsconfig is found above the file", function () { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }, null); + for (let current = dir; ; current = path.dirname(current)) { + if (fs.existsSync(path.join(current, "tsconfig.json"))) { + // A tsconfig above the temp folder would be found instead + this.skip(); + } + + if (path.dirname(current) === current) { + break; + } + } + + assert.includes(transpileTypeScriptSource(path.join(dir, "main.ts"), MAIN, dir), "require(\"./enums\")"); + }); + }); + + describe("transpileTypeScriptSourceWithMap", () => { + function inlineMap(output: string): any { + const parts = output.split("sourceMappingURL=data:application/json;base64,"); + assert.equal(parts.length, 2, "one inline source map"); + + return JSON.parse(Buffer.from(parts[1].trim(), "base64").toString("utf8")); + } + + it("embeds an inline source map holding the original source", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }); + + const output = transpileTypeScriptSourceWithMap(path.join(dir, "main.ts"), "", dir); + + assert.includes(output, "1 /* Color.Red */"); + assert.includes(inlineMap(output).sourcesContent[0], "Color.Red"); + }); + + it("keeps a program of its own, apart from the one emitting without maps", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }); + const file = path.join(dir, "main.ts"); + + assert.isFalse(_hasText(transpileTypeScriptSource(file, "", dir), "sourceMappingURL")); + assert.includes(transpileTypeScriptSourceWithMap(file, "", dir), "sourceMappingURL"); + assert.isFalse(_hasText(transpileTypeScriptSource(file, "", dir), "sourceMappingURL")); + }); + + it("embeds the map in the fallback transpile as well", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }); + + const output = transpileTypeScriptSourceWithMap(path.join(dir, "main.ts"), MAIN, dir, "missing.json"); + + assert.includes(output, "require(\"./enums\")"); + assert.includes(inlineMap(output).sourcesContent[0], "Color.Red"); + }); + }); + + describe("getCompileProvider", () => { + it("shares one provider backed by the TypeScript compiler", () => { + const provider = getCompileProvider(); + + assert.equal(getCompileProvider(), provider); + assert.equal(provider.compile, compileTypeScript); + assert.equal(provider.registerHook, registerTypeScriptHook); + }); + + it("transpiles with a source map only when one is asked for", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }); + const file = path.join(dir, "main.ts"); + + assert.isFalse(_hasText(getCompileProvider().transpile(file, "", dir), "sourceMappingURL")); + assert.includes(getCompileProvider().transpile(file, "", dir, true), "sourceMappingURL"); + assert.includes(getCompileProvider().transpile(file, MAIN, dir, true, "missing.json"), "require(\"./enums\")"); + }); + }); + + describe("graph type-check", () => { + it("checks the program it emits from, reporting a type error as a user error", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN, "bad.ts": TYPE_ERROR }); + setGraphTypeCheck(true); + + const failure = _caught(() => transpileTypeScriptSource(path.join(dir, "main.ts"), "", dir)); + + assert.isTrue(_isUserError(failure)); + assert.includes(failure.message, "TypeScript compile failed with 1 error(s)"); + assert.includes(failure.message, "TS2322"); + assert.includes(failure.message, "bad.ts"); + }); + + it("does not check when it is turned off", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN, "bad.ts": TYPE_ERROR }); + + assert.includes(transpileTypeScriptSource(path.join(dir, "main.ts"), "", dir), "1 /* Color.Red */"); + }); + + it("checks a rebuilt program again once the program cache is invalidated", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }); + const file = path.join(dir, "main.ts"); + setGraphTypeCheck(true); + + assert.includes(transpileTypeScriptSource(file, "", dir), "1 /* Color.Red */"); + + fs.writeFileSync(path.join(dir, "bad.ts"), TYPE_ERROR, "utf8"); + assert.includes(transpileTypeScriptSource(file, "", dir), "1 /* Color.Red */", "the checked program is reused"); + + invalidateTypeScriptProgramCache(); + assert.includes(_caught(() => transpileTypeScriptSource(file, "", dir)).message, "TypeScript compile failed with 1 error(s)"); + }); + }); + + describe("registerTypeScriptHook", () => { + const extensions: any = (_require as any).extensions; + + // Installs the hook for one session, then restores the hook the runner itself loads .ts files + // with. Re-assigning the earlier hook is also how nyc's require wrapper expects to be unwound. + function session(register: () => void, fn?: () => T): T { + const savedTs = extensions[".ts"]; + const savedTsx = extensions[".tsx"]; + + try { + register(); + + return fn ? fn() : undefined; + } finally { + extensions[".ts"] = savedTs; + if (savedTsx === undefined) { + delete extensions[".tsx"]; + } else { + extensions[".tsx"] = savedTsx; + } + + Object.keys(_require.cache).forEach((key) => { + if (key.indexOf(_PROJECT_PREFIX) === 0) { + delete _require.cache[key]; + } + }); + } + } + + function hookSession(dir: string, fn: () => T, checkDiagnostics?: boolean, overrides?: any, options?: any, projectPath?: string): T { + return session(() => registerTypeScriptHook(projectPath || "tsconfig.json", dir, checkDiagnostics, overrides, options), fn); + } + + function load(dir: string, name: string): any { + return _require(path.join(dir, name)); + } + + it("compiles required .ts and .tsx files through the project's program", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN, "view.tsx": "export const kind: string = \"tsx\";\n" }); + + hookSession(dir, () => { + assert.equal(load(dir, "main.ts").red, 1); + assert.equal(load(dir, "view.tsx").kind, "tsx"); + assert.isUndefined(_require.cache[path.join(dir, "enums.ts")], "the const enum import was erased"); + }); + + assert.deepEqual(getCompileCacheStatus(), { state: "rebuilt", checkSkipped: false, reason: "nothing cached for this project yet" }); + }); + + it("reuses the cached compile while nothing changes, and rebuilds when a file does", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }); + + hookSession(dir, () => load(dir, "main.ts")); + + hookSession(dir, () => { + assert.deepEqual(getCompileCacheStatus(), { state: "reused", checkSkipped: false, reason: "" }); + assert.equal(load(dir, "main.ts").red, 1, "loaded from the emit cache"); + }); + + fs.writeFileSync(path.join(dir, "enums.ts"), ENUMS.replace("Red = 1", "Red = 5"), "utf8"); + hookSession(dir, () => { + const status = getCompileCacheStatus(); + assert.equal(status.state, "rebuilt"); + assert.includes(status.reason, "enums.ts changed"); + assert.equal(load(dir, "main.ts").red, 5); + }); + }); + + it("type-checks the project first when asked, reporting a type error as a user error", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN, "bad.ts": TYPE_ERROR }); + + const failure = _caught(() => hookSession(dir, () => null, true)); + + assert.isTrue(_isUserError(failure)); + assert.includes(failure.message, "TypeScript compile failed with 1 error(s)"); + hookSession(dir, () => assert.equal(load(dir, "main.ts").red, 1), false); + }); + + it("checks only the files the run reaches under the run scope", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN, "bad.ts": TYPE_ERROR }); + const scoped = { typeCheckScope: "run", scopeFiles: [ path.join(dir, "main.ts") ] }; + + hookSession(dir, () => assert.equal(load(dir, "main.ts").red, 1), true, undefined, scoped); + assert.isNotNull(_caught(() => hookSession(dir, () => null, true)), "the whole project still fails"); + }); + + it("skips the type-check when the cached compile was already checked", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }); + + hookSession(dir, () => load(dir, "main.ts"), false); + + hookSession(dir, () => load(dir, "main.ts"), true); + assert.deepEqual(getCompileCacheStatus(), { state: "reused", checkSkipped: false, reason: "the cached compile came from a run that did not type-check" }); + + hookSession(dir, () => load(dir, "main.ts"), true); + assert.deepEqual(getCompileCacheStatus(), { state: "reused", checkSkipped: true, reason: "" }); + }); + + it("applies the tsconfig overrides", () => { + const dir = makeProject({ "loose.ts": "export function echo(value) {\n return value;\n}\n" }); + + hookSession(dir, () => assert.equal(load(dir, "loose.ts").echo(3), 3), true); + + const failure = _caught(() => hookSession(dir, () => null, true, { noImplicitAny: true })); + assert.includes(failure.message, "TS7006"); + }); + + it("keeps incremental build info for a checked run, even when the check fails", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }); + const buildInfo = () => _filesUnder(path.join(dir, "node_modules"), (name) => /\.tsbuildinfo$/.test(name)); + + hookSession(dir, () => assert.equal(load(dir, "main.ts").red, 1), true, undefined, { incremental: true }); + assert.equal(buildInfo().length, 1); + const emitted = _filesUnder(dir, (name) => /\.js$/.test(name)).filter((file) => file.indexOf(path.join(dir, "node_modules")) !== 0); + assert.equal(emitted.length, 0, "no .js is written next to the sources"); + + fs.writeFileSync(path.join(dir, "bad.ts"), TYPE_ERROR, "utf8"); + const failure = _caught(() => hookSession(dir, () => null, true, undefined, { incremental: true })); + assert.includes(failure.message, "TypeScript compile failed with 1 error(s)"); + assert.equal(buildInfo().length, 2, "the new root set keeps its own state"); + }); + + it("transpiles each file on its own when isolated, reusing the emit cache", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }); + + hookSession(dir, () => { + assert.equal(load(dir, "main.ts").red, 1); + assert.isObject(_require.cache[path.join(dir, "enums.ts")], "the preserved enum is required at runtime"); + }, false, undefined, { isolatedTranspile: true }); + + const status = getCompileCacheStatus(); + assert.equal(status.state, "isolated"); + assert.includes(status.reason, "each file is compiled on its own"); + + const cached = _filesUnder(path.join(dir, "node_modules"), () => true).length; + assert.isAbove(cached, 0, "the emitted files are cached"); + + hookSession(dir, () => assert.equal(load(dir, "main.ts").red, 1), false, undefined, { isolatedTranspile: true }); + assert.equal(_filesUnder(path.join(dir, "node_modules"), () => true).length, cached, "the second run only reads the cache"); + }); + + it("runs without the compile cache when it is turned off", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }); + + hookSession(dir, () => assert.equal(load(dir, "main.ts").red, 1), false, undefined, { emitCache: false }); + + assert.deepEqual(getCompileCacheStatus(), { state: "off", checkSkipped: false, reason: "the compile cache is disabled" }); + assert.equal(_filesUnder(path.join(dir, "node_modules"), () => true).length, 0); + }); + + it("adds a required file the project does not include to its program", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN, "extra.ts": MAIN.replace("red", "extra") }, _configWith({ files: [ "enums.ts", "main.ts" ] })); + + hookSession(dir, () => { + assert.equal(load(dir, "main.ts").red, 1); + assert.equal(load(dir, "extra.ts").extra, 1); + }); + + hookSession(dir, () => assert.equal(load(dir, "extra.ts").extra, 1)); + assert.equal(getCompileCacheStatus().state, "reused", "the added file was recorded with the program"); + }); + + it("transpiles a file on its own when the program will not emit it", () => { + const dir = makeProject({ + "enums.ts": ENUMS, + "loose.ts": "const count: number = \"text\";\nexport const value = count;\n" + }, _configWith({ compilerOptions: { ...TS_CONFIG.compilerOptions, noEmitOnError: true } })); + + hookSession(dir, () => assert.equal(load(dir, "loose.ts").value, "text")); + }); + + it("compiles with its default options when the project file is missing or broken", () => { + const missing = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }, null); + const broken = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }, "{"); + + hookSession(missing, () => assert.equal(load(missing, "main.ts").red, 1)); + hookSession(broken, () => assert.equal(load(broken, "main.ts").red, 1)); + }); + }); +}); diff --git a/internal/runner/test/src/config/loader.test.ts b/internal/runner/test/src/config/loader.test.ts index dbb223cf..37eb9f54 100644 --- a/internal/runner/test/src/config/loader.test.ts +++ b/internal/runner/test/src/config/loader.test.ts @@ -9,6 +9,7 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; +import { createRequire } from "module"; import { assert, afterAll as selfAfterAll, beforeAll as selfBeforeAll } from "@nevware21/tripwire"; import { DefaultConfigLoader, loadConfig, mergeConfigs } from "../../../src/config/loader"; import { ILoadedConfig } from "../../../src/interfaces/ILoadedConfig"; @@ -172,6 +173,75 @@ describe("config loader", () => { } }); + it("should load a config written as JavaScript, in a .js file or in an rc file", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-cfg-js-")); + try { + fs.mkdirSync(path.join(dir, "inner")); + fs.writeFileSync(path.join(dir, "tripwire.config.js"), "module.exports = { timeout: 7 };\n", "utf8"); + fs.writeFileSync(path.join(dir, "inner", ".tripwirerc"), "module.exports = { default: { slow: 9 } };\n", "utf8"); + + const found = new DefaultConfigLoader().load(path.join(dir, "inner")).filter((entry) => entry.filePath.indexOf(dir) === 0); + + assert.deepEqual(found.map((entry) => entry.config), [ { timeout: 7 }, { slow: 9 } ]); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("should throw a message naming the file when a JavaScript config fails to load", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-cfg-jsbad-")); + try { + const filePath = path.join(dir, "tripwire.config.js"); + fs.writeFileSync(filePath, "throw new Error(\"bad config\");\n", "utf8"); + + assert.throws(() => { + new DefaultConfigLoader().load(dir); + }, "Failed to load tripwire config file '" + filePath + "': bad config"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + describe("home directory", () => { + const realOs = createRequire(__filename)("os"); + const orgHomedir = realOs.homedir; + let home = ""; + + selfBeforeAll(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-cfg-home-")); + }); + + selfAfterAll(() => { + realOs.homedir = orgHomedir; + fs.rmSync(home, { recursive: true, force: true }); + }); + + it("should read the first config in the user's home, ahead of the project's", () => { + fs.mkdirSync(path.join(home, ".config", "tripwire"), { recursive: true }); + fs.writeFileSync(path.join(home, ".config", "tripwire", "tripwire.config.json"), JSON.stringify({ timeout: 11 }), "utf8"); + realOs.homedir = () => home; + try { + const found = new DefaultConfigLoader().load(root); + + assert.equal(found[0].filePath, path.join(home, ".config", "tripwire", "tripwire.config.json")); + assert.deepEqual(found[0].config, { timeout: 11 }); + } finally { + realOs.homedir = orgHomedir; + } + }); + + it("should look in no home when there is none", () => { + realOs.homedir = () => ""; + try { + const found = new DefaultConfigLoader().load(root); + + assert.isTrue(found.every((entry) => entry.filePath.indexOf(home) !== 0)); + } finally { + realOs.homedir = orgHomedir; + } + }); + }); + it("should prefer the higher-priority file name when several sit in one directory", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-cfg-names-")); try { diff --git a/internal/runner/test/src/execute/assets.test.ts b/internal/runner/test/src/execute/assets.test.ts new file mode 100644 index 00000000..4eacce7c --- /dev/null +++ b/internal/runner/test/src/execute/assets.test.ts @@ -0,0 +1,174 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { assert } from "@nevware21/tripwire"; +import { + _getAssetFile, _getInlineMapAsset, _getRunnerBinAsset, _getSourceAsset, _getVendorAsset, _normalizeAssetUrl +} from "../../../src/execute/assets"; +import { _loadRunnerWorkerScript } from "../../../src/execute/worker"; + +// The asset helpers look their files up next to their own module, which is this folder when the +// tests run from source +const EXECUTE_DIR = path.resolve(__dirname, "../../../src/execute"); + +/** + * The files the browser runner's server hands out besides the test modules: the runner's own bundles + * and maps, the UX sources DevTools asks for, and vendor files from node_modules. + */ +describe("execute assets", () => { + let scratch: string; + + function write(name: string, text: string): string { + const file = path.join(scratch, name); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, text, "utf8"); + + return file; + } + + beforeEach(() => { + scratch = fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-assets-")); + }); + + afterEach(() => { + fs.rmSync(scratch, { recursive: true, force: true }); + }); + + describe("_normalizeAssetUrl", () => { + it("uses forward slashes and drops the query string", () => { + assert.equal(_normalizeAssetUrl("\\src\\a.ts?v=1&x=2"), "/src/a.ts"); + assert.equal(_normalizeAssetUrl("/runner-client.js"), "/runner-client.js"); + }); + + it("treats a missing url as empty", () => { + assert.equal(_normalizeAssetUrl(""), ""); + assert.equal(_normalizeAssetUrl(undefined as any), ""); + }); + }); + + describe("_getAssetFile", () => { + it("reads a runner bundle from the assets folder as JavaScript", () => { + const file = write("runner-client.js", "var client = 1;"); + + const asset = _getAssetFile("/" + path.relative(path.join(EXECUTE_DIR, "assets"), file), false); + + assert.equal(asset.contentType, "application/javascript; charset=utf-8"); + assert.equal(asset.content.toString("utf8"), "var client = 1;"); + }); + + it("reads a map from the assets map folder as JSON", () => { + const file = write("runner-client.js.map", "{\"version\":3}"); + + const asset = _getInlineMapAsset(path.relative(path.join(EXECUTE_DIR, "assets", "map"), file)); + + assert.equal(asset.contentType, "application/json; charset=utf-8"); + assert.equal(asset.content.toString("utf8"), "{\"version\":3}"); + }); + + it("returns undefined when the runner was not built with its assets", () => { + assert.isUndefined(_getAssetFile("no-such-runner-asset.js", false)); + assert.isUndefined(_getInlineMapAsset("no-such-runner-asset.js.map")); + }); + }); + + describe("_getRunnerBinAsset", () => { + it("reads a file next to the runner bundle as JSON", () => { + const file = write("tripwire.js.map", "{\"version\":3,\"sources\":[]}"); + + const asset = _getRunnerBinAsset(path.relative(EXECUTE_DIR, file)); + + assert.equal(asset.contentType, "application/json; charset=utf-8"); + assert.deepEqual(JSON.parse(asset.content.toString("utf8")), { version: 3, sources: [] }); + }); + + it("returns undefined when there is no such file", () => { + assert.isUndefined(_getRunnerBinAsset("/no-such-runner-asset.js.map")); + }); + }); + + describe("_getSourceAsset", () => { + it("serves the runner's UX sources with a content type from their extension", () => { + const types: { [name: string]: string } = { + "browser/ui/client.ts": "application/typescript; charset=utf-8", + "browser/resources/runner.css": "text/css; charset=utf-8", + "browser/resources/runner.html": "text/html; charset=utf-8" + }; + + Object.keys(types).forEach((name) => { + const asset = _getSourceAsset(name); + assert.equal(asset.contentType, types[name], name); + assert.equal(asset.content.toString("utf8"), fs.readFileSync(path.resolve(EXECUTE_DIR, "../ux", name), "utf8"), name); + }); + + assert.equal(_getSourceAsset("\\browser\\ui\\client.ts").contentType, "application/typescript; charset=utf-8", "backslashes and a leading slash"); + }); + + it("serves other files as plain text", () => { + assert.equal(_getSourceAsset("images/favicon.ico").contentType, "text/plain; charset=utf-8"); + }); + + it("falls back to a runner/src/ux folder under the working directory", () => { + write("runner/src/ux/extra/fixture.js", "var a;"); + write("runner/src/ux/extra/fixture.json", "{}"); + write("runner/src/ux/extra/fixture.tsx", "export {};"); + + const orgCwd = process.cwd(); + process.chdir(scratch); + try { + assert.equal(_getSourceAsset("extra/fixture.js").contentType, "application/javascript; charset=utf-8"); + assert.equal(_getSourceAsset("extra/fixture.json").contentType, "application/json; charset=utf-8"); + assert.equal(_getSourceAsset("extra/fixture.tsx").contentType, "application/typescript; charset=utf-8"); + } finally { + process.chdir(orgCwd); + } + }); + + it("refuses empty, parent and drive relative paths, and returns undefined for a missing file", () => { + assert.isUndefined(_getSourceAsset("")); + assert.isUndefined(_getSourceAsset("/")); + assert.isUndefined(_getSourceAsset("../execute/assets.ts")); + assert.isUndefined(_getSourceAsset("browser/../../execute/assets.ts")); + assert.isUndefined(_getSourceAsset("C:/Windows/win.ini")); + assert.isUndefined(_getSourceAsset("browser/ui/no-such-file.ts")); + }); + }); + + describe("_getVendorAsset", () => { + it("picks the content type from the file's extension", () => { + const types: { [ext: string]: string } = { + ".js": "application/javascript; charset=utf-8", + ".MJS": "application/javascript; charset=utf-8", + ".cjs": "application/javascript; charset=utf-8", + ".map": "application/json; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".ts": "text/x-typescript; charset=utf-8", + ".tsx": "text/x-typescript; charset=utf-8", + ".wasm": "application/octet-stream" + }; + + Object.keys(types).forEach((ext) => { + const asset = _getVendorAsset(write("vendor" + ext, "content" + ext)); + assert.equal(asset.contentType, types[ext], ext); + assert.equal(asset.content.toString("utf8"), "content" + ext); + }); + }); + + it("returns undefined for a missing file", () => { + assert.isUndefined(_getVendorAsset(path.join(scratch, "missing.js"))); + }); + }); + + describe("_loadRunnerWorkerScript", () => { + it("reports a runner built without its worker bundle", () => { + assert.throws(() => _loadRunnerWorkerScript(scratch, false), "Missing runner worker asset: runner-worker.js"); + }); + }); +}); diff --git a/internal/runner/test/src/execute/browserRunner.test.ts b/internal/runner/test/src/execute/browserRunner.test.ts new file mode 100644 index 00000000..5034bfb4 --- /dev/null +++ b/internal/runner/test/src/execute/browserRunner.test.ts @@ -0,0 +1,475 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import "../support/textImports"; +import * as fs from "fs"; +import * as http from "http"; +import * as os from "os"; +import * as path from "path"; +import { createRequire } from "module"; +import { replaceAnsi } from "@nevware21/chromacon"; +import { assert } from "@nevware21/tripwire"; +import { IBrowserWorkerModuleGraph, IGraphBuilders, _runInteractiveBrowserTarget } from "../../../src/execute/browser"; +import { _startBrowserRunnerSession, IBrowserRunnerSession } from "../../../src/execute/browserRunner"; +import { IServer } from "../../../src/interfaces/IServer"; + +interface IResponse { + status: number; + headers: http.IncomingHttpHeaders; + raw: Buffer; + body: string; +} + +function send(server: IServer, method: string, reqPath: string, body?: string): Promise { + const target = new URL(server.url); + + return new Promise((resolve, reject) => { + const req = http.request({ + host: target.hostname, + port: target.port, + method: method, + path: reqPath, + agent: false, + headers: body !== undefined ? { "content-type": "application/json", "content-length": Buffer.byteLength(body) } : undefined + }, (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => { + const raw = Buffer.concat(chunks); + resolve({ status: res.statusCode, headers: res.headers, raw: raw, body: raw.toString("utf8") }); + }); + res.on("error", reject); + }); + + req.on("error", reject); + req.end(body); + }); +} + +function inlineMap(map: any): string { + return "\n//# sourceMappingURL=data:application/json;base64," + Buffer.from(JSON.stringify(map)).toString("base64"); +} + +/** + * The interactive browser and worker runner: a real server on loopback, serving the runner pages, the + * compiled module graph (bundled or one asset per module), the run payload, and the results the page + * reports back. + */ +describe("execute browserRunner", function () { + this.timeout(60000); + + let scratch: string; + let logs: string[]; + let orgLog: typeof console.log; + + function write(name: string, text: string): string { + const file = path.join(scratch, name); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, text, "utf8"); + + return file; + } + + // An entry test requiring a helper with a source map, and a vendor file served from disk. Module + // ids use forward slashes, as the graph builder writes them. + function makeGraph(): IBrowserWorkerModuleGraph { + const entry = write("test/a.test.js", "").split(path.sep).join("/"); + const helper = write("src/helper.js", "module.exports = 1;\n").split(path.sep).join("/"); + const vendor = write("node_modules/vendor/index.js", "module.exports = \"vendor\";\n"); + const modules: Record = {}; + const moduleUrls: Record = {}; + + modules[entry] = "const helper = require(" + JSON.stringify(helper) + ");\nexports.a = helper;\n"; + modules[helper] = "module.exports = 1;\n" + inlineMap({ version: 3, sources: [ "helper.ts" ], sourcesContent: [ "export = 1;" ], names: [], mappings: "AAAA" }); + moduleUrls[entry] = "/test/a.test.js"; + moduleUrls[helper] = "/src/helper.js"; + + return { + modules: modules, + entryIds: [ entry ], + tripwireModuleId: "/__tripwire__/tripwire.js", + coverageOnlyIds: [], + moduleUrls: moduleUrls, + rawAssets: { "/node_modules/vendor/index.js": vendor }, + elapsedMs: 1 + }; + } + + function builders(graph: IBrowserWorkerModuleGraph, watch?: boolean): IGraphBuilders & { builds: string[] } { + const builds: string[] = []; + + return { + builds: builds, + resolveEntryFiles: () => graph.entryIds.slice(0), + buildGraph: (target) => { + builds.push(target); + return graph; + }, + watch: !!watch + }; + } + + // Runs one session with the console captured, always stopping its server + async function withSession(parsed: any, graphBuilders: IGraphBuilders, fn: (session: IBrowserRunnerSession) => Promise): Promise { + const session = await _startBrowserRunnerSession(graphBuilders, Object.assign({ bindAddress: "127.0.0.1" }, parsed)); + try { + await fn(session); + } finally { + await Promise.resolve(session.server.stop()); + session.dispose(); + } + } + + beforeEach(() => { + scratch = fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-browser-runner-")); + logs = []; + orgLog = console.log; + console.log = (...args: any[]) => { + logs.push(replaceAnsi(args.join(" "), "")); + }; + }); + + afterEach(() => { + console.log = orgLog; + fs.rmSync(scratch, { recursive: true, force: true }); + }); + + describe("_startBrowserRunnerSession", () => { + it("serves the runner page for each target, isolated from other origins", async () => { + await withSession({ browserStyle: "slate" }, builders(makeGraph()), async (session) => { + const home = await send(session.server, "GET", "/"); + const browser = await send(session.server, "GET", "/__tripwire__/browser"); + const worker = await send(session.server, "GET", "/__tripwire__/worker"); + + assert.includes(home.body, "Browser", "the home page links to each runner"); + assert.equal(browser.status, 200); + assert.equal(browser.headers["content-type"], "text/html; charset=utf-8"); + assert.equal(browser.headers["cross-origin-opener-policy"], "same-origin"); + assert.includes(browser.body, "Tripwire Browser Runner"); + assert.includes(browser.body, "style-slate"); + assert.includes(worker.body, "Tripwire Worker Runner"); + assert.includes(logs.join("\n"), "Server URL: " + session.server.url); + }); + }); + + it("serves the test frame, loading the bundle only for the browser target", async () => { + await withSession({}, builders(makeGraph()), async (session) => { + const browser = await send(session.server, "GET", "/__tripwire__/test-frame"); + const worker = await send(session.server, "GET", "/__tripwire__/test-frame?target=worker"); + + assert.equal(browser.headers["cross-origin-opener-policy"], "same-origin"); + assert.includes(browser.body, ""); + assert.includes(browser.body, "/__tripwire__/runner-client.js"); + assert.isFalse(worker.body.indexOf("bundle-browser.js") !== -1); + }); + }); + + it("serves the modules as one bundle per target, with the merged map", async () => { + await withSession({}, builders(makeGraph()), async (session) => { + assert.equal((await send(session.server, "GET", "/__tripwire__/bundle-browser.js")).status, 404, "not built until the page is used"); + await send(session.server, "GET", "/__tripwire__/browser"); + const bundle = await send(session.server, "GET", "/__tripwire__/bundle-browser.js"); + const map = await send(session.server, "GET", "/__tripwire__/bundle-browser.js.map"); + + assert.equal(bundle.headers["content-type"], "application/javascript; charset=utf-8"); + assert.includes(bundle.body, "__tripwireDefine__(\"/test/a.test.js\""); + assert.includes(bundle.body, "require(\"/src/helper.js\")"); + assert.equal(map.headers["content-type"], "application/json; charset=utf-8"); + assert.deepEqual(JSON.parse(map.body).sources, [ "/src/helper.ts" ]); + + assert.equal((await send(session.server, "GET", "/__tripwire__/bundle-worker.js")).status, 404, "not built until the worker page is used"); + await send(session.server, "GET", "/__tripwire__/worker"); + assert.includes((await send(session.server, "GET", "/__tripwire__/bundle-worker.js")).body, "__tripwireDefine__"); + }); + }); + + it("serves each module on its own when bundling is off", async () => { + await withSession({ bundle: false }, builders(makeGraph()), async (session) => { + const frame = await send(session.server, "GET", "/__tripwire__/test-frame"); + const entry = await send(session.server, "GET", "/test/a.test.js"); + const map = await send(session.server, "GET", "/src/helper.js.map"); + const payload = JSON.parse((await send(session.server, "GET", "/api/payload-worker")).body); + + assert.includes(frame.body, ""); + assert.includes(frame.body, ""); + assert.includes(entry.body, "__tripwireDefine__(\"/test/a.test.js\""); + assert.equal(map.headers["content-type"], "application/json; charset=utf-8"); + assert.deepEqual(payload.moduleUrls, makeGraph().moduleUrls); + }); + }); + + it("serves the run payload for each target", async () => { + await withSession({ timeout: 1234, reporter: [ "dot" ] }, builders(makeGraph()), async (session) => { + const payload = JSON.parse((await send(session.server, "GET", "/api/payload")).body); + const browser = JSON.parse((await send(session.server, "GET", "/api/payload-browser")).body); + const worker = JSON.parse((await send(session.server, "GET", "/api/payload-worker")).body); + + assert.deepEqual(payload.entryIds, [ "/test/a.test.js" ]); + assert.equal(payload.settings.timeout, 1234); + assert.deepEqual(payload.settings.reporter, [ "dot" ]); + assert.deepEqual(payload.settings.server, { url: session.server.url, port: session.server.port, isRunning: true }); + assert.deepEqual(browser, payload); + assert.deepEqual(worker.moduleUrls, { bundle: "/__tripwire__/bundle-worker.js" }); + assert.equal((await send(session.server, "POST", "/api/payload", "{}")).status, 404); + }); + }); + + it("reports each target's build generation for the page's freshness check", async () => { + await withSession({}, builders(makeGraph()), async (session) => { + assert.deepEqual(JSON.parse((await send(session.server, "GET", "/__tripwire__/check-fresh?target=worker")).body), { generation: 0 }); + + await send(session.server, "GET", "/__tripwire__/browser"); + assert.deepEqual(JSON.parse((await send(session.server, "GET", "/__tripwire__/check-fresh")).body), { generation: 1 }); + }); + }); + + it("redirects the bare runtime folder home and refuses anything but GET", async () => { + await withSession({}, builders(makeGraph()), async (session) => { + const redirect = await send(session.server, "GET", "/__tripwire__/"); + const post = await send(session.server, "POST", "/__tripwire__/browser", "{}"); + + assert.equal(redirect.status, 302); + assert.equal(redirect.headers.location, "/"); + assert.equal(post.status, 405); + }); + }); + + it("serves vendor files from disk and the runner's icon", async () => { + await withSession({}, builders(makeGraph()), async (session) => { + // The registry is only filled once a target is built + await send(session.server, "GET", "/__tripwire__/browser"); + const vendor = await send(session.server, "GET", "/node_modules/vendor/index.js"); + const icon = await send(session.server, "GET", "/favicon.ico"); + + assert.equal(vendor.headers["content-type"], "application/javascript; charset=utf-8"); + assert.equal(vendor.body, "module.exports = \"vendor\";\n"); + assert.equal(icon.headers["content-type"], "image/x-icon"); + assert.isTrue(icon.raw.equals(fs.readFileSync(path.resolve(__dirname, "../../../src/ux/images/favicon.ico")))); + }); + }); + + it("falls through to the server root for runner files this build does not have", async () => { + write("served/hello.txt", "hello"); + + await withSession({ serverRoot: scratch }, builders(makeGraph()), async (session) => { + const runtime = await Promise.all([ "/runner-client.js", "/runner-worker.js", "/runner-client.js.map", "/runner-worker.js.map", "/tripwire.js.map", "/src/missing.ts" ] + .map((url) => send(session.server, "GET", url))); + const listing = await send(session.server, "GET", "/served/"); + const file = await send(session.server, "GET", "/served/hello.txt"); + + runtime.forEach((res) => assert.equal(res.status, 404)); + assert.includes(listing.body, "hello.txt"); + assert.equal(file.body, "hello"); + }); + }); + + it("logs the messages the page sends", async () => { + await withSession({}, builders(makeGraph()), async (session) => { + assert.equal((await send(session.server, "POST", "/api/log", JSON.stringify({ entries: [ { level: "info", message: "first" }, { message: "second" } ] }))).status, 204); + await send(session.server, "POST", "/api/log", "not json"); + + const logged = logs.join("\n"); + assert.includes(logged, "first"); + assert.includes(logged, "second"); + assert.includes(logged, "not json"); + }); + }); + + it("fails the worker script request when the runner was built without it", async () => { + await withSession({}, builders(makeGraph()), async (session) => { + assert.equal((await send(session.server, "GET", "/api/worker-script")).status, 500); + assert.equal((await send(session.server, "POST", "/api/worker-script", "{}")).status, 404); + }); + }); + + it("resolves the next completion with the results the page reports", async () => { + await withSession({}, builders(makeGraph()), async (session) => { + const completion = session.nextCompletion(); + const res = await send(session.server, "POST", "/api/result", JSON.stringify({ summary: { passed: 2 }, coverage: { a: 1 } })); + + assert.equal(res.status, 202); + assert.deepEqual(await completion, { summary: { passed: 2 }, coverage: { a: 1 } }); + }); + }); + + it("rejects the completion for a failed run or a missing summary, and ignores results nobody waits for", async () => { + await withSession({}, builders(makeGraph()), async (session) => { + await send(session.server, "POST", "/api/result", JSON.stringify({ summary: { ignored: true } })); + + const failures: string[] = []; + for (const body of [ JSON.stringify({ error: "the page crashed" }), "{}", "not json" ]) { + // Settled as soon as the result arrives, so handled before it is sent + const outcome = session.nextCompletion().then(() => "resolved", (e) => e.message); + await send(session.server, "POST", "/api/result", body); + failures.push(await outcome); + } + + assert.deepEqual(failures, [ "the page crashed", "No summary returned from browser runner UI.", "not json" ]); + assert.equal((await send(session.server, "GET", "/api/result")).status, 404); + }); + }); + + it("builds a target ahead of time, and lists the files it would watch", async () => { + const graph = makeGraph(); + const graphBuilders = builders(graph, true); + + await withSession({}, graphBuilders, async (session) => { + assert.deepEqual(session.getWatchFiles("worker"), []); + session.prepare("worker"); + + assert.deepEqual(graphBuilders.builds, [ "worker" ]); + assert.sameMembers(session.getWatchFiles("worker"), Object.keys(graph.modules).filter((id) => id.indexOf("/__tripwire__/") !== 0)); + }); + }); + + it("uses a custom UX provider, framing the tests itself when the provider does not", async () => { + const provider = write("ux-provider.js", [ + "exports.createBrowserRunnerHtml = (target, style) => '';", + "exports.createFsDirListingHtml = () => '';" + ].join("\n")); + + await withSession({ uxProvider: provider }, builders(makeGraph()), async (session) => { + assert.equal((await send(session.server, "GET", "/__tripwire__/browser")).body, ""); + + const frame = (await send(session.server, "GET", "/__tripwire__/test-frame")).body; + assert.includes(frame, "data-runner-target=\"browser\""); + assert.includes(frame, ""); + }); + }); + + it("instruments the modules for coverage when asked", async () => { + const orgCwd = process.cwd(); + const graph = makeGraph(); + let session: IBrowserRunnerSession; + + // The session takes the project folder, and so the coverage scope, from where it starts + process.chdir(scratch); + try { + session = await _startBrowserRunnerSession(builders(graph), { bindAddress: "127.0.0.1", coverage: true }); + } finally { + process.chdir(orgCwd); + } + + try { + await send(session.server, "GET", "/__tripwire__/browser"); + const bundle = (await send(session.server, "GET", "/__tripwire__/bundle-browser.js")).body; + assert.includes(bundle, "__coverage__"); + } finally { + await Promise.resolve(session.server.stop()); + session.dispose(); + } + }); + }); + + describe("_runInteractiveBrowserTarget", () => { + // Starts a run, then reports `body` the way the page would. The run settles as soon as the + // report arrives, so its outcome is captured before the report is sent. + async function runAndReport(target: string, body: string, launchMode: string = "print"): Promise<{ outcome: Promise<{ value: any, error: any }>, url: string }> { + const firstLog = logs.length; + const outcome = _runInteractiveBrowserTarget(target, builders(makeGraph()), { bindAddress: "127.0.0.1" }, launchMode) + .then((value) => ({ value: value, error: null as any }), (error) => ({ value: null as any, error: error })); + + let url = ""; + for (let lp = 0; lp < 200 && !url; lp++) { + await new Promise((resolve) => setTimeout(resolve, 10)); + const line = logs.slice(firstLog).filter((msg) => msg.indexOf("Runner URL: ") === 0)[0]; + url = line ? line.substring("Runner URL: ".length) : ""; + } + + assert.isTrue(!!url, "the runner url was printed"); + const origin = new URL(url); + await send({ url: origin.origin + "/" } as any, "POST", "/api/result", body); + + return { outcome, url }; + } + + it("prints the runner url and resolves with the summary the page reports", async () => { + const { outcome, url } = await runAndReport("worker", JSON.stringify({ summary: { passed: 1 } })); + const settled = await outcome; + const result = settled.value; + + assert.isNull(settled.error); + assert.isTrue(/^http:\/\/127\.0\.0\.1:\d+\/__tripwire__\/worker$/.test(url), url); + assert.deepEqual(result.summary, { passed: 1 }); + assert.equal(result.reportMs, 0); + assert.includes(logs.join("\n"), "Open this URL in your browser to start the run: " + url); + }); + + describe("opening the system browser", () => { + const childProcess = createRequire(__filename)("child_process"); + const orgSpawn = childProcess.spawn; + const orgPlatform = Object.getOwnPropertyDescriptor(process, "platform"); + let spawned: any[]; + + function onPlatform(platform: string): void { + Object.defineProperty(process, "platform", { value: platform, configurable: true, enumerable: true, writable: false }); + } + + beforeEach(() => { + spawned = []; + childProcess.spawn = (command: string, args: string[], options: any) => { + const call = { command: command, args: args, options: options, unref: 0 }; + spawned.push(call); + + return { unref: () => call.unref++ }; + }; + }); + + afterEach(() => { + childProcess.spawn = orgSpawn; + Object.defineProperty(process, "platform", orgPlatform); + }); + + async function openOn(platform: string): Promise { + onPlatform(platform); + const { outcome, url } = await runAndReport("browser", JSON.stringify({ summary: { passed: 1 } }), "open"); + assert.isNull((await outcome).error); + + return url; + } + + it("uses start on Windows, without a console window", async () => { + const url = await openOn("win32"); + + assert.equal(spawned.length, 1); + assert.equal(spawned[0].command, "cmd"); + assert.deepEqual(spawned[0].args, [ "/c", "start", "", url ]); + assert.isTrue(spawned[0].options.windowsHide); + assert.isTrue(spawned[0].options.detached); + assert.equal(spawned[0].unref, 1, "the CLI does not wait for the browser"); + assert.includes(logs.join("\n"), "Opening browser via OS default handler..."); + assert.includes(logs.join("\n"), "If the browser did not open, use this URL: " + url); + }); + + it("uses open on macOS and xdg-open elsewhere", async () => { + const macUrl = await openOn("darwin"); + const linuxUrl = await openOn("linux"); + + assert.deepEqual(spawned.map((call) => [ call.command, call.args ]), [ [ "open", [ macUrl ] ], [ "xdg-open", [ linuxUrl ] ] ]); + }); + + it("prints the url to open when the browser cannot be started", async () => { + childProcess.spawn = () => { + throw new Error("spawn ENOENT"); + }; + + const url = await openOn("linux"); + + assert.includes(logs.join("\n"), "Unable to auto-open browser. Open this URL manually: " + url); + }); + }); + + it("stops the server and passes the failure on when the page reports one", async () => { + const { outcome, url } = await runAndReport("browser", JSON.stringify({ error: "the page crashed" })); + const failure = (await outcome).error; + + assert.equal(failure && failure.message, "the page crashed"); + assert.includes(url, "/__tripwire__/browser"); + }); + }); +}); diff --git a/internal/runner/test/src/execute/compile.test.ts b/internal/runner/test/src/execute/compile.test.ts new file mode 100644 index 00000000..7d9ae760 --- /dev/null +++ b/internal/runner/test/src/execute/compile.test.ts @@ -0,0 +1,183 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import "../support/textImports"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { assert } from "@nevware21/tripwire"; +import { getCompileProvider } from "../../../src/compile/provider"; +import { getCompileCacheStatus, transpileTypeScriptSource } from "../../../src/compile/tsCompile"; +import { + _getCompileCacheStatus, _getCompileProvider, _getProjectFileNames, _invalidateTypeScriptTranspileCache, _registerTypeScriptHook, + _setGraphTypeCheck +} from "../../../src/execute/compile"; +import { _resolveBrowserDependency } from "../../../src/execute/browser"; +import { _isUserError } from "../../../src/internal/userError"; + +/** + * The execute layer's view of compiling: picking the compile provider, and the wrappers the run uses + * around the TypeScript compiler. Also how a browser or worker module's imports are found on disk. + */ +describe("execute compile", function () { + // Some cases build a small TypeScript program + this.timeout(60000); + + let scratch: string; + + function write(name: string, text: string): string { + const file = path.join(scratch, name); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, text, "utf8"); + + return file; + } + + beforeEach(() => { + scratch = fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-execute-compile-")); + }); + + afterEach(() => { + _setGraphTypeCheck(false); + _invalidateTypeScriptTranspileCache(); + fs.rmSync(scratch, { recursive: true, force: true }); + }); + + describe("_getCompileProvider", () => { + const PROVIDER = "{ compile: function () {}, registerHook: function () {}, transpile: function () { return 'custom'; } }"; + + it("uses the built in TypeScript provider when none is named", () => { + assert.equal(_getCompileProvider(), getCompileProvider()); + assert.equal(_getCompileProvider(""), getCompileProvider()); + }); + + it("loads a named provider module, a provider factory, or a default export", () => { + const direct = _getCompileProvider(write("direct.js", "module.exports = " + PROVIDER + ";")); + const factory = _getCompileProvider(write("factory.js", "module.exports = { getCompileProvider: function () { return " + PROVIDER + "; } };")); + const wrapped = _getCompileProvider(write("wrapped.js", "module.exports = { default: " + PROVIDER + " };")); + + [ direct, factory, wrapped ].forEach((loaded) => { + assert.equal(loaded.transpile("a.ts", "", scratch), "custom"); + }); + }); + + it("rejects a provider without compile or registerHook as a user error", () => { + const noCompile = write("noCompile.js", "module.exports = { registerHook: function () {} };"); + const noHook = write("noHook.js", "module.exports = { compile: function () {} };"); + + [ [ noCompile, "missing compile(options)" ], [ noHook, "missing registerHook(options)" ] ].forEach((entry) => { + let failure: any = null; + try { + _getCompileProvider(entry[0]); + } catch (e) { + failure = e; + } + + assert.isTrue(_isUserError(failure), entry[0]); + assert.includes(failure.message, "Invalid compile provider '" + entry[0] + "': " + entry[1]); + }); + }); + + it("reports a provider that cannot be loaded as a user error", () => { + let failure: any = null; + try { + _getCompileProvider(path.join(scratch, "missing.js")); + } catch (e) { + failure = e; + } + + assert.isTrue(_isUserError(failure)); + }); + + it("passes through a module that exports nothing", () => { + assert.isUndefined(_getCompileProvider(write("empty.js", "module.exports = null;"))); + }); + }); + + describe("compiler wrappers", () => { + it("registers the hook through the provider", () => { + const provider = getCompileProvider(); + const orgRegister = provider.registerHook; + const calls: any[] = []; + provider.registerHook = function () { + calls.push(Array.prototype.slice.call(arguments)); + }; + + try { + _registerTypeScriptHook("tsconfig.json", scratch, true, { strict: true }, { incremental: true }); + } finally { + provider.registerHook = orgRegister; + } + + assert.deepEqual(calls, [ [ "tsconfig.json", scratch, true, { strict: true }, { incremental: true } ] ]); + }); + + it("reports the compile cache status", () => { + assert.equal(_getCompileCacheStatus(), getCompileCacheStatus()); + }); + + it("lists the project's files, or null when the project cannot be read", () => { + write("tsconfig.json", JSON.stringify({ compilerOptions: { types: [] }, include: [ "*.ts" ] })); + const main = write("main.ts", "export const a = 1;\n"); + + assert.deepEqual((_getProjectFileNames("tsconfig.json", scratch) || []).map((f) => path.resolve(f)), [ main ]); + assert.isNull(_getProjectFileNames("missing.json", scratch)); + assert.isNull(_getProjectFileNames(null as any, scratch), "a throw is not passed on"); + + write("later.ts", "export const b = 2;\n"); + _invalidateTypeScriptTranspileCache(); + assert.equal((_getProjectFileNames("tsconfig.json", scratch) || []).length, 2, "invalidated with the program cache"); + }); + + it("turns the graph type-check on and off", () => { + write("tsconfig.json", JSON.stringify({ compilerOptions: { types: [], lib: [ "es2018" ] }, include: [ "*.ts" ] })); + const main = write("main.ts", "export const a = 1;\n"); + write("bad.ts", "export const b: number = \"b\";\n"); + + _setGraphTypeCheck(true); + assert.throws(() => transpileTypeScriptSource(main, "", scratch), "TypeScript compile failed with 1 error(s)"); + + _setGraphTypeCheck(false); + _invalidateTypeScriptTranspileCache(); + assert.includes(transpileTypeScriptSource(main, "", scratch), "exports.a = 1"); + }); + }); + + describe("_resolveBrowserDependency", () => { + it("resolves packages from the module's folder or the working directory", () => { + const runnerDir = path.resolve(__dirname, "../../.."); + + assert.includes(_resolveBrowserDependency("typescript", path.join(scratch, "a.ts"), runnerDir).split(path.sep).join("/"), "/typescript/"); + }); + + it("resolves relative imports node can find", () => { + const helper = write("lib/helper.js", "module.exports = 1;"); + + assert.equal(_resolveBrowserDependency("./lib/helper", path.join(scratch, "a.ts"), scratch), helper); + }); + + it("finds relative imports with extensions node does not load", () => { + const component = write("view.jsx", "export default 1;"); + const indexed = write("widgets/index.jsx", "export default 2;"); + const moduleId = path.join(scratch, "a.ts"); + + assert.equal(_resolveBrowserDependency("./view", moduleId, scratch), component); + assert.equal(_resolveBrowserDependency("./widgets", moduleId, scratch), indexed, "a folder resolves to its index file, not itself"); + assert.equal(_resolveBrowserDependency(component, path.join(scratch, "deep", "b.ts"), scratch), component, "an absolute path"); + }); + + it("passes on node's error for an import it cannot find", () => { + const moduleId = path.join(scratch, "a.ts"); + fs.mkdirSync(path.join(scratch, "empty")); + + assert.throws(() => _resolveBrowserDependency("./missing", moduleId, scratch), "Cannot find module"); + assert.throws(() => _resolveBrowserDependency("./empty", moduleId, scratch), "Cannot find module"); + assert.throws(() => _resolveBrowserDependency("no-such-package-anywhere", moduleId, scratch), "Cannot find module"); + }); + }); +}); diff --git a/internal/runner/test/src/execute/graphWorker.test.ts b/internal/runner/test/src/execute/graphWorker.test.ts new file mode 100644 index 00000000..4b538aa3 --- /dev/null +++ b/internal/runner/test/src/execute/graphWorker.test.ts @@ -0,0 +1,115 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { assert } from "@nevware21/tripwire"; +import { _startGraphWorker } from "../../../src/execute/graphWorker"; +import { _isUserError, _userErrorHints } from "../../../src/internal/userError"; + +/** + * Building the browser and worker module graph on a worker thread. The thread loads the runner's own + * bundle and calls its graph entry point; here a stand-in bundle answers in each of the ways the real + * one can. + */ +describe("execute graphWorker", function () { + // Each case starts a worker thread + this.timeout(30000); + + let scratch: string; + + // A stand-in for the runner bundle, whose graph entry runs the given body on the worker thread + function bin(body: string): string { + const file = path.join(scratch, "bin-" + Math.random().toString(36).substring(2) + ".js"); + fs.writeFileSync(file, [ + "const { parentPort, workerData } = require(\"worker_threads\");", + "exports._graphWorkerEntry = function () {", + body, + "};" + ].join("\n"), "utf8"); + + return file; + } + + async function rejection(promise: Promise): Promise { + try { + await promise; + } catch (e) { + return e; + } + + throw new Error("expected the graph worker to fail"); + } + + beforeEach(() => { + scratch = fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-graph-worker-")); + }); + + afterEach(() => { + fs.rmSync(scratch, { recursive: true, force: true }); + }); + + it("stays on the main thread unless the graph thread is asked for and a bundle is given", () => { + const entry = bin("parentPort.postMessage({ ok: true, graph: {} });"); + + assert.isNull(_startGraphWorker([], scratch, null, false, entry)); + assert.isNull(_startGraphWorker([], scratch, {}, false, entry)); + assert.isNull(_startGraphWorker([], scratch, { graphThread: false }, false, entry)); + assert.isNull(_startGraphWorker([], scratch, { graphThread: true }, false, "")); + }); + + it("stays on the main thread when the run options cannot be sent to a thread", () => { + const entry = bin("parentPort.postMessage({ ok: true, graph: {} });"); + + assert.isNull(_startGraphWorker([], scratch, { graphThread: true, notCloneable: () => 1 }, false, entry)); + }); + + it("resolves with the graph the thread builds from the files and options it was given", async () => { + const entry = bin("parentPort.postMessage({ ok: true, graph: { modules: {}, entryIds: workerData.entryFiles, echo: [ workerData.cwd, workerData.typeCheck, workerData.parsed.target ] } });"); + + const graph: any = await _startGraphWorker([ "a.test.ts", "b.test.ts" ], scratch, { graphThread: true, target: "worker" }, true, entry); + + assert.deepEqual(graph.entryIds, [ "a.test.ts", "b.test.ts" ]); + assert.deepEqual(graph.echo, [ scratch, true, "worker" ]); + }); + + it("rejects with the thread's user error, keeping its stack and hints", async () => { + const entry = bin("parentPort.postMessage({ ok: false, message: \"TypeScript compile failed\", stack: \"Error: TypeScript compile failed\\n at thread\", user: true, hints: [ \"fix it\" ] });"); + + const failure = await rejection(_startGraphWorker([], scratch, { graphThread: true }, false, entry)); + + assert.equal(failure.message, "TypeScript compile failed"); + assert.equal(failure.stack, "Error: TypeScript compile failed\n at thread"); + assert.isTrue(_isUserError(failure)); + assert.deepEqual(_userErrorHints(failure), [ "fix it" ]); + }); + + it("rejects with a plain error when the thread's failure has no details", async () => { + const noMessage = await rejection(_startGraphWorker([], scratch, { graphThread: true }, false, bin("parentPort.postMessage({ ok: false });"))); + const noGraph = await rejection(_startGraphWorker([], scratch, { graphThread: true }, false, bin("parentPort.postMessage({ ok: true });"))); + const nothing = await rejection(_startGraphWorker([], scratch, { graphThread: true }, false, bin("parentPort.postMessage(null);"))); + + [ noMessage, noGraph, nothing ].forEach((failure) => { + assert.equal(failure.message, "the module graph build failed"); + assert.isFalse(_isUserError(failure)); + }); + }); + + it("rejects with the error thrown on the thread", async () => { + const failure = await rejection(_startGraphWorker([], scratch, { graphThread: true }, false, bin("throw new Error(\"graph exploded\");"))); + + assert.equal(failure.message, "graph exploded"); + }); + + it("rejects when the thread exits without answering", async () => { + const failure = await rejection(_startGraphWorker([], scratch, { graphThread: true }, false, bin("process.exit(3);"))); + + assert.equal(failure.message, "the module graph worker exited with code 3"); + }); +}); diff --git a/internal/runner/test/src/execute/moduleAssets.test.ts b/internal/runner/test/src/execute/moduleAssets.test.ts new file mode 100644 index 00000000..8ee4655a --- /dev/null +++ b/internal/runner/test/src/execute/moduleAssets.test.ts @@ -0,0 +1,152 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import * as ts from "typescript"; +import { TraceMap, originalPositionFor } from "@jridgewell/trace-mapping"; +import { assert } from "@nevware21/tripwire"; +import { _buildBundledModuleAsset, _buildModuleAssetRegistry } from "../../../src/execute/moduleAssets"; + +const DEFINE_ARGS = "function(require, module, exports, global, globalThis, window, self, process, __filename, __dirname) {\n"; + +function compile(fileName: string, source: string, withMap: boolean): string { + return ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2018, inlineSourceMap: withMap, inlineSources: withMap }, + fileName: fileName + }).outputText; +} + +function inlineMap(map: any): string { + return "\n//# sourceMappingURL=data:application/json;base64," + Buffer.from(JSON.stringify(map)).toString("base64"); +} + +function lineOf(text: string, value: string): number { + const lines = text.split("\n"); + for (let lp = 0; lp < lines.length; lp++) { + if (lines[lp].indexOf(value) !== -1) { + return lp + 1; + } + } + + return -1; +} + +/** + * Wrapping the compiled test modules for the browser and worker runner: one served asset per module, or + * one bundle holding them all, with each module's source map carried over so DevTools and stack traces + * still point at the original TypeScript. + */ +describe("execute moduleAssets", () => { + const urls: Record = { + "/proj/src/a.ts": "/src/a.js", + "/proj/src/b.ts": "/src/b.js" + }; + + describe("_buildModuleAssetRegistry", () => { + it("wraps each served module in a define call for its url", () => { + const registry = _buildModuleAssetRegistry({ "/proj/src/a.ts": "exports.a = 1;" }, urls); + + assert.deepEqual(Object.keys(registry), [ "/src/a.js" ]); + assert.equal(registry["/src/a.js"], "__tripwireDefine__(\"/src/a.js\", " + DEFINE_ARGS + "exports.a = 1;\n});\n"); + }); + + it("skips modules that have no url", () => { + assert.deepEqual(_buildModuleAssetRegistry({ "/proj/src/unserved.ts": "exports.x = 1;" }, urls), {}); + }); + + it("points each require of a served module at its url, leaving the rest alone", () => { + const registry = _buildModuleAssetRegistry({ + "/proj/src/a.ts": "const b = require(\"/proj/src/b.ts\");\nconst fs = require(\"fs\");\nconst odd = require(\"bad\\q\");\n" + }, urls); + + const body = registry["/src/a.js"]; + assert.includes(body, "require(\"/src/b.js\")"); + assert.includes(body, "require(\"fs\")"); + assert.includes(body, "require(\"bad\\q\")", "an id that is not valid JSON is kept"); + }); + + it("moves an inline source map to its own asset, shifted for the define line", () => { + const compiled = compile("/proj/src/a.ts", "export const a: number = 1;\n", true); + const registry = _buildModuleAssetRegistry({ "/proj/src/a.ts": compiled }, urls); + + const map = JSON.parse(registry["/src/a.js.map"]); + const original = JSON.parse(Buffer.from(compiled.split("base64,")[1], "base64").toString("utf8")); + assert.equal(map.mappings, ";" + original.mappings); + assert.includes(registry["/src/a.js"], "\n//# sourceMappingURL=a.js.map\n});\n"); + assert.isFalse(registry["/src/a.js"].indexOf("base64,") !== -1, "the inline map is removed"); + }); + + it("keeps a module whose inline map cannot be read as it is", () => { + const source = "exports.a = 1;\n//# sourceMappingURL=data:application/json;base64," + Buffer.from("not json").toString("base64"); + const registry = _buildModuleAssetRegistry({ "/proj/src/a.ts": source }, urls); + + assert.isUndefined(registry["/src/a.js.map"]); + assert.includes(registry["/src/a.js"], source); + }); + }); + + describe("_buildBundledModuleAsset", () => { + it("defines every served module in one bundle, without a map when none had one", () => { + const bundle = _buildBundledModuleAsset({ + "/proj/src/a.ts": "exports.a = require(\"/proj/src/b.ts\");\n//# sourceMappingURL=a.js.map", + "/proj/src/b.ts": "exports.b = 2;\n", + "/proj/src/unserved.ts": "exports.x = 1;" + }, urls, "/__tripwire__/bundle.js"); + + assert.isNull(bundle.map); + assert.equal(bundle.code, + "__tripwireDefine__(\"/src/a.js\", " + DEFINE_ARGS + "exports.a = require(\"/src/b.js\");\n});\n" + + "__tripwireDefine__(\"/src/b.js\", " + DEFINE_ARGS + "exports.b = 2;\n});\n"); + }); + + it("merges the modules' maps into one, mapping each module back to its own source", () => { + const aSource = "export const a: number = 1;\nexport function first(): number {\n return a;\n}\n"; + const bSource = "export const b: string = \"two\";\n\nexport function second(): string {\n const label: string = \"second\";\n return label;\n}\n"; + const bundle = _buildBundledModuleAsset({ + "/proj/src/a.ts": compile("/proj/src/a.ts", aSource, true), + "/proj/src/b.ts": compile("/proj/src/b.ts", bSource, true) + }, urls, "/__tripwire__/bundle.js"); + + assert.isString(bundle.map); + const mapComment = "//# sourceMappingURL=bundle.js.map\n"; + assert.equal(bundle.code.lastIndexOf(mapComment), bundle.code.length - mapComment.length, "the map is linked at the end"); + assert.isFalse(bundle.code.indexOf("base64,") !== -1, "the inline maps are removed"); + + const map = JSON.parse(bundle.map); + assert.equal(map.file, "bundle.js"); + assert.deepEqual(map.sources, [ "/src/a.ts", "/src/b.ts" ]); + assert.deepEqual(map.sourcesContent, [ aSource, bSource ]); + + const trace = new TraceMap(bundle.map); + const secondLine = lineOf(bundle.code, "return label;"); + const position = originalPositionFor(trace, { line: secondLine, column: bundle.code.split("\n")[secondLine - 1].indexOf("return") }); + assert.equal(position.source, "/src/b.ts"); + assert.equal(position.line, 5); + }); + + it("ignores mappings without a source and maps it cannot read", () => { + const bundle = _buildBundledModuleAsset({ + "/proj/src/a.ts": "exports.a = 1;" + inlineMap({ version: 3, sources: [], names: [], mappings: "A" }), + "/proj/src/b.ts": "exports.b = 2;" + inlineMap({ version: 3, sources: [ "b.ts" ], names: [], mappings: 42 }) + }, urls, "/bundle.js"); + + assert.isNull(bundle.map); + assert.includes(bundle.code, "exports.a = 1;\n});\n"); + assert.includes(bundle.code, "exports.b = 2;\n});\n"); + }); + + it("copies no source content when a module's map has none", () => { + const bundle = _buildBundledModuleAsset({ + "/proj/src/a.ts": compile("/proj/src/a.ts", "export const a = 1;\n", false) + inlineMap({ version: 3, sources: [ "a.ts" ], names: [], mappings: "AAAA" }) + }, urls, "/bundle.js"); + + const map = JSON.parse(bundle.map); + assert.deepEqual(map.sources, [ "/src/a.ts" ]); + assert.isFalse(!!(map.sourcesContent && map.sourcesContent[0]), "no content"); + }); + }); +}); diff --git a/internal/runner/test/src/execute/moduleGraph.test.ts b/internal/runner/test/src/execute/moduleGraph.test.ts new file mode 100644 index 00000000..20e83ee1 --- /dev/null +++ b/internal/runner/test/src/execute/moduleGraph.test.ts @@ -0,0 +1,269 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import "../support/textImports"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { replaceAnsi } from "@nevware21/chromacon"; +import { assert } from "@nevware21/tripwire"; +import { _createBrowserWorkerModuleGraph } from "../../../src/execute/execute"; +import { _isUserError } from "../../../src/internal/userError"; + +const TS_CONFIG = { + compilerOptions: { target: "es2018", module: "commonjs", esModuleInterop: true, skipLibCheck: true, types: [] as string[], lib: [ "es2018" ] }, + include: [ "src/**/*.ts", "test/**/*.ts" ] +}; + +function id(file: string): string { + return path.resolve(file).split(path.sep).join("/"); +} + +function decodeInlineMap(source: string): any { + const parts = source.split("sourceMappingURL=data:application/json;base64,"); + + return parts.length === 2 ? JSON.parse(Buffer.from(parts[1].trim(), "base64").toString("utf8")) : null; +} + +/** + * Building the module graph the browser and worker targets run: every module reachable from the test + * entries, compiled, with each require pointed at the module's id, and the url each one is served at. + * + * The fixture project is named @nevware21/tripwire, the way core's own suite is, so its own small + * src/index.ts stands in for the tripwire runtime the graph always includes. + */ +describe("execute module graph", function () { + // Each graph compiles the fixture through a TypeScript program + this.timeout(60000); + + let scratch: string; + let project: string; + let logs: string[]; + let orgLog: typeof console.log; + + function write(dir: string, name: string, text: string): string { + const file = path.join(dir, name); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, text, "utf8"); + + return file; + } + + function graphFor(entries: string[], parsed?: any) { + return _createBrowserWorkerModuleGraph(entries.map((name) => path.join(project, name)), project, parsed || {}); + } + + function failureFor(entries: string[]): any { + try { + graphFor(entries); + } catch (e) { + return e; + } + + return null; + } + + before(() => { + scratch = fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-module-graph-")); + project = path.join(scratch, "project"); + + write(project, "package.json", JSON.stringify({ name: "@nevware21/tripwire", version: "1.0.0" })); + write(project, "tsconfig.json", JSON.stringify(TS_CONFIG)); + write(project, "src/index.ts", "import { EOL } from \"os\";\nexport const eol: string = EOL;\nexport function describe(name: string, fn: () => void): void {\n fn();\n}\n"); + write(project, "src/helper.ts", "import { red } from \"@nevware21/chromacon\";\nexport function warn(text: string): string {\n return red(text);\n}\n"); + write(project, "src/unused.ts", "export const unused = 1;\n"); + write(project, "src/data.json", "{ \"answer\": 42 }"); + write(project, "test/a.test.ts", [ + "import { describe } from \"@nevware21/tripwire\";", + "import { warn } from \"../src/helper\";", + "declare const require: any;", + "const data = require(\"../src/data.json\");", + "const vendor = require(\"vendor-pkg\");", + "const thing = require(\"modpkg\");", + "describe(\"a\", () => warn(String(data.answer) + vendor + thing.thing));", + "" + ].join("\n")); + + // A vendor package whose source map stays a separate file, served from disk + write(project, "node_modules/vendor-pkg/package.json", JSON.stringify({ name: "vendor-pkg", version: "2.0.0", main: "index.js" })); + write(project, "node_modules/vendor-pkg/index.js", "module.exports = require(\"./lib\");\n"); + write(project, "node_modules/vendor-pkg/lib.js", "module.exports = \"vendor\";\n//# sourceMappingURL=lib.js.map"); + write(project, "node_modules/vendor-pkg/lib.js.map", JSON.stringify({ version: 3, sources: [ "lib.ts" ], names: [], mappings: "AAAA" })); + + // A package shipping its build/es5/mod output, whose map is pointed back at its TypeScript sources + write(project, "node_modules/modpkg/package.json", JSON.stringify({ name: "modpkg", version: "1.0.0", main: "build/es5/mod/thing.js" })); + write(project, "node_modules/modpkg/build/es5/mod/thing.js", "exports.thing = 1;\n//# sourceMappingURL=thing.js.map"); + write(project, "node_modules/modpkg/build/es5/mod/thing.js.map", JSON.stringify({ version: 3, sources: [ "thing.js" ], names: [], mappings: "AAAA" })); + write(project, "node_modules/modpkg/src/thing.ts", "export const thing = 1;\n"); + }); + + after(() => { + fs.rmSync(scratch, { recursive: true, force: true }); + }); + + beforeEach(() => { + logs = []; + orgLog = console.log; + console.log = (...args: any[]) => { + logs.push(replaceAnsi(args.join(" "), "")); + }; + }); + + afterEach(() => { + console.log = orgLog; + }); + + describe("a project's modules", () => { + let graph: ReturnType; + + before(() => { + const orgWrite = console.log; + console.log = () => {}; + try { + graph = graphFor([ "test/a.test.ts" ]); + } finally { + console.log = orgWrite; + } + }); + + it("starts from the test entries and the tripwire runtime", () => { + assert.deepEqual(graph.entryIds, [ id(path.join(project, "test/a.test.ts")) ]); + assert.equal(graph.tripwireModuleId, id(path.join(project, "src/index.ts"))); + assert.deepEqual(graph.coverageOnlyIds, []); + }); + + it("compiles each TypeScript module and points its requires at the module ids", () => { + const entry = graph.modules[id(path.join(project, "test/a.test.ts"))]; + + assert.includes(entry, "require(\"" + graph.tripwireModuleId + "\")"); + assert.includes(entry, "require(\"" + id(path.join(project, "src/helper.ts")) + "\")"); + assert.includes(entry, "require(\"" + id(path.join(project, "src/data.json")) + "\")"); + assert.includes(entry, "require(\"" + id(path.join(project, "node_modules/vendor-pkg/index.js")) + "\")"); + assert.isFalse(entry.indexOf("sourceMappingURL=data:") !== -1, "no source map unless asked for"); + }); + + it("gives the os module a browser shim and chromacon its browser build", () => { + const runtime = graph.modules[graph.tripwireModuleId]; + const helper = graph.modules[id(path.join(project, "src/helper.ts"))]; + const chromaconId = Object.keys(graph.modules).filter((moduleId) => /\/chromacon\.js$/.test(moduleId))[0]; + + assert.includes(runtime, "require(\"/__tripwire__/browser/os-shim.js\")"); + assert.includes(graph.modules["/__tripwire__/browser/os-shim.js"], "EOL: '\\n'"); + assert.isString(chromaconId, "the browser build of chromacon is in the graph"); + assert.includes(helper, "require(\"" + chromaconId + "\")"); + assert.equal(graph.moduleUrls[chromaconId].indexOf("/node_modules/@nevware21/chromacon/"), 0); + }); + + it("wraps a JSON module as a CommonJS export", () => { + assert.equal(graph.modules[id(path.join(project, "src/data.json"))], "module.exports = { \"answer\": 42 };"); + }); + + it("serves each module at a url mirroring its path, as JavaScript", () => { + assert.equal(graph.moduleUrls[id(path.join(project, "test/a.test.ts"))], "/test/a.test.js"); + assert.equal(graph.moduleUrls[graph.tripwireModuleId], "/src/index.js"); + assert.equal(graph.moduleUrls[id(path.join(project, "src/data.json"))], "/src/data.json"); + assert.equal(graph.moduleUrls[id(path.join(project, "node_modules/vendor-pkg/lib.js"))], "/node_modules/vendor-pkg/lib.js"); + assert.equal(graph.moduleUrls["/__tripwire__/browser/os-shim.js"], "/__tripwire__/browser/os-shim.js"); + }); + + it("serves a vendor module's separate source map from disk", () => { + assert.equal(graph.rawAssets["/node_modules/vendor-pkg/lib.js.map"], id(path.join(project, "node_modules/vendor-pkg/lib.js.map"))); + assert.includes(graph.modules[id(path.join(project, "node_modules/vendor-pkg/lib.js"))], "//# sourceMappingURL=lib.js.map"); + }); + + it("points a build/es5/mod source map back at the package's TypeScript sources", () => { + const thing = graph.modules[id(path.join(project, "node_modules/modpkg/build/es5/mod/thing.js"))]; + const map = decodeInlineMap(thing); + + assert.isObject(map, "the map is inlined"); + assert.deepEqual(map.sources, [ "/node_modules/modpkg/src/thing.ts" ]); + assert.deepEqual(map.sourcesContent, [ "export const thing = 1;\n" ]); + assert.equal(map.sourceRoot, ""); + }); + + it("reports the size of the graph it built", () => { + assert.isTrue(graph.elapsedMs >= 0); + }); + }); + + describe("options", () => { + it("adds the project's other sources for coverage, with source maps", () => { + const graph = graphFor([ "test/a.test.ts" ], { coverage: true, coverageSrc: [ "src/**/*.ts" ] }); + + assert.isTrue(graph.coverageOnlyIds.indexOf(id(path.join(project, "src/unused.ts"))) !== -1, "a source nothing imports is included"); + assert.isObject(decodeInlineMap(graph.modules[id(path.join(project, "src/unused.ts"))])); + assert.includes(logs.join("\n"), "Module graph: "); + }); + + it("adds source maps for an interactive launch, but not for puppeteer", () => { + const entry = id(path.join(project, "test/a.test.ts")); + + assert.isObject(decodeInlineMap(graphFor([ "test/a.test.ts" ], { sourceMaps: true, browserLaunch: "open" }).modules[entry])); + assert.isNull(decodeInlineMap(graphFor([ "test/a.test.ts" ], { sourceMaps: true, browserLaunch: "puppeteer" }).modules[entry])); + }); + }); + + describe("modules from outside the project", () => { + it("serves a package outside the project under its name and version", () => { + write(scratch, "outside/pkg/package.json", JSON.stringify({ name: "outside-pkg", version: "3.0.0" })); + write(scratch, "outside/pkg/lib/util.js", "module.exports = 3;\n"); + write(project, "test/outside.test.ts", "declare const require: any;\nexport const value = require(\"../../outside/pkg/lib/util\");\n"); + + const graph = graphFor([ "test/outside.test.ts" ]); + + assert.equal(graph.moduleUrls[id(path.join(scratch, "outside/pkg/lib/util.js"))], "/__tripwire__/vendor/outside-pkg@3.0.0/lib/util.js"); + }); + + it("numbers a file that belongs to no package", function () { + for (let dir = scratch; ; dir = path.dirname(dir)) { + if (fs.existsSync(path.join(dir, "package.json"))) { + // A package above the temp folder would claim the file + this.skip(); + } + + if (path.dirname(dir) === dir) { + break; + } + } + + write(scratch, "loose/loose.js", "module.exports = 4;\n"); + write(project, "test/loose.test.ts", "declare const require: any;\nexport const value = require(\"../../loose/loose\");\n"); + + const graph = graphFor([ "test/loose.test.ts" ]); + + assert.equal(graph.moduleUrls[id(path.join(scratch, "loose/loose.js"))], "/__tripwire__/vendor/_unpackaged/0/loose.js"); + }); + }); + + describe("modules a browser cannot load", () => { + it("rejects a node builtin as a user error naming the module that needs it", () => { + write(project, "test/builtin.test.ts", "import * as fs from \"node:fs\";\nexport const exists = fs.existsSync;\n"); + + const failure = failureFor([ "test/builtin.test.ts" ]); + + assert.isTrue(_isUserError(failure)); + assert.includes(failure.message, "Node builtin module 'node:fs' is not supported in browser/worker target (required by " + id(path.join(project, "test/builtin.test.ts")) + ")"); + }); + + it("rejects a native addon as a user error", () => { + write(project, "test/addon.node", "not really native"); + write(project, "test/native.test.ts", "declare const require: any;\nexport const addon = require(\"./addon.node\");\n"); + + const failure = failureFor([ "test/native.test.ts" ]); + + assert.isTrue(_isUserError(failure)); + assert.includes(failure.message, "Native module is not supported in browser/worker target: " + id(path.join(project, "test/addon.node"))); + }); + + it("passes on the error for an import that cannot be found", () => { + write(project, "test/missing.test.ts", "declare const require: any;\nexport const gone = require(\"./not-here\");\n"); + + assert.includes(failureFor([ "test/missing.test.ts" ]).message, "Cannot find module './not-here'"); + }); + }); +}); diff --git a/internal/runner/test/src/execute/puppeteer.test.ts b/internal/runner/test/src/execute/puppeteer.test.ts new file mode 100644 index 00000000..2f8b0697 --- /dev/null +++ b/internal/runner/test/src/execute/puppeteer.test.ts @@ -0,0 +1,361 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import "../support/textImports"; +import * as fs from "fs"; +import * as http from "http"; +import * as os from "os"; +import * as path from "path"; +import { replaceAnsi } from "@nevware21/chromacon"; +import { assert } from "@nevware21/tripwire"; +import { IBrowserWorkerModuleGraph } from "../../../src/execute/browser"; +import { _launchPuppeteerBrowser, _runPuppeteerTarget, _warmUpPuppeteerBrowser } from "../../../src/execute/puppeteer"; +import { _isUserError, _userErrorHints } from "../../../src/internal/userError"; + +function post(url: string, body: string): Promise { + const target = new URL(url); + + return new Promise((resolve, reject) => { + const req = http.request({ + host: target.hostname, + port: target.port, + method: "POST", + path: target.pathname, + agent: false, + headers: { "content-type": "application/json", "content-length": Buffer.byteLength(body) } + }, (res) => { + res.resume(); + res.on("end", () => resolve(res.statusCode)); + }); + + req.on("error", reject); + req.end(body); + }); +} + +interface IFakeBrowser { + visited: string[]; + closed: number; + newPages: number; + pages(): Promise; + newPage(): Promise; + close(): Promise; +} + +/** + * A stand-in for a puppeteer browser. Its page sends the given console messages and then, like the + * runner page would, reports `result` to the server it was pointed at. + */ +function fakeBrowser(options: { result?: any, gotoError?: Error, hasPage?: boolean, closeError?: Error, messages?: any[], loadedAfterMs?: number }): IFakeBrowser { + const listeners: Array<(msg: any) => void> = []; + const page = { + on: (event: string, listener: (msg: any) => void) => { + if (event === "console") { + listeners.push(listener); + } + }, + goto: async (url: string) => { + browser.visited.push(url); + (options.messages || []).forEach((msg) => listeners.forEach((listener) => listener(msg))); + if (options.gotoError) { + throw options.gotoError; + } + + await post(new URL(url).origin + "/api/result", JSON.stringify(options.result)); + if (options.loadedAfterMs) { + // The page reported before puppeteer saw it finish loading + await new Promise((resolve) => setTimeout(resolve, options.loadedAfterMs)); + } + } + }; + + const browser: IFakeBrowser = { + visited: [], + closed: 0, + newPages: 0, + pages: async () => options.hasPage === false ? [] : [ page ], + newPage: async () => { + browser.newPages++; + return page; + }, + close: async () => { + browser.closed++; + if (options.closeError) { + throw options.closeError; + } + } + }; + + return browser; +} + +/** + * Running the browser and worker targets headless: the runner's session is started for real on + * loopback, and a stand-in browser plays the part of puppeteer's. + */ +describe("execute puppeteer", function () { + this.timeout(60000); + + let logs: string[]; + let orgLog: typeof console.log; + + const graph: IBrowserWorkerModuleGraph = { + modules: { "/proj/test/a.test.js": "exports.a = 1;\n" }, + entryIds: [ "/proj/test/a.test.js" ], + tripwireModuleId: "/__tripwire__/tripwire.js", + coverageOnlyIds: [], + moduleUrls: { "/proj/test/a.test.js": "/test/a.test.js" }, + rawAssets: {}, + elapsedMs: 0 + }; + const graphs = { browser: graph, worker: graph }; + + beforeEach(() => { + logs = []; + orgLog = console.log; + console.log = (...args: any[]) => { + logs.push(replaceAnsi(args.join(" "), "")); + }; + }); + + afterEach(() => { + console.log = orgLog; + }); + + describe("_runPuppeteerTarget", () => { + it("opens the target's page to finish on its own and resolves with the reported summary", async () => { + const browser = fakeBrowser({ result: { summary: { passed: 3 } } }); + + const result = await _runPuppeteerTarget("browser", graphs, { bindAddress: "127.0.0.1" }, Promise.resolve(browser)); + + assert.deepEqual(result.summary, { passed: 3 }); + assert.isTrue(result.testMs >= 0); + assert.isTrue(/^http:\/\/127\.0\.0\.1:\d+\/__tripwire__\/browser\?autofinish=1$/.test(browser.visited[0]), browser.visited[0]); + assert.equal(browser.closed, 1); + }); + + it("opens a new page for the worker target when the browser has none", async () => { + const browser = fakeBrowser({ result: { summary: { passed: 1 } }, hasPage: false }); + + await _runPuppeteerTarget("worker", graphs, { bindAddress: "127.0.0.1" }, Promise.resolve(browser)); + + assert.equal(browser.newPages, 1); + assert.includes(browser.visited[0], "/__tripwire__/worker?autofinish=1"); + }); + + it("prints the page's console messages", async () => { + const browser = fakeBrowser({ result: { summary: {} }, messages: [ { text: () => "from the page" }, { text: () => "" }, "a bare message" ] }); + + await _runPuppeteerTarget("browser", graphs, { bindAddress: "127.0.0.1" }, Promise.resolve(browser)); + + assert.includes(logs.join("\n"), "from the page"); + assert.includes(logs.join("\n"), "a bare message"); + }); + + it("writes the coverage the page reports for the target", async () => { + const project = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-puppeteer-coverage-"))); + const covered = path.join(project, "src", "a.js"); + const coverage: any = {}; + coverage[covered] = { + path: covered, + statementMap: { 0: { start: { line: 1, column: 0 }, end: { line: 1, column: 10 } } }, + fnMap: {}, + branchMap: {}, + s: { 0: 1 }, + f: {}, + b: {} + }; + const browser = fakeBrowser({ result: { summary: { passed: 1 }, coverage: coverage } }); + const orgCwd = process.cwd(); + const orgWarn = console.warn; + + // The coverage is written under the working directory + process.chdir(project); + console.warn = () => {}; + try { + await _runPuppeteerTarget("worker", graphs, { bindAddress: "127.0.0.1", coverage: true, coverageReporters: [ "json-summary" ] }, Promise.resolve(browser)); + const written = JSON.parse(fs.readFileSync(path.join(project, "coverage", "worker", "coverage-final.json"), "utf8")); + + assert.deepEqual(Object.keys(written), [ covered ]); + } finally { + process.chdir(orgCwd); + console.warn = orgWarn; + fs.rmSync(project, { recursive: true, force: true }); + } + }); + + it("writes no coverage when the page reports none", async () => { + const browser = fakeBrowser({ result: { summary: { passed: 1 } } }); + + const result = await _runPuppeteerTarget("browser", graphs, { bindAddress: "127.0.0.1", coverage: true }, Promise.resolve(browser)); + + assert.deepEqual(result.summary, { passed: 1 }); + assert.isTrue(result.reportMs >= 0); + }); + + it("closes the browser and passes the failure on when the page cannot be loaded", async () => { + const browser = fakeBrowser({ gotoError: new Error("navigation failed"), closeError: new Error("already closed") }); + + let failure: any = null; + try { + await _runPuppeteerTarget("browser", graphs, { bindAddress: "127.0.0.1" }, Promise.resolve(browser)); + } catch (e) { + failure = e; + } + + assert.equal(failure && failure.message, "navigation failed"); + assert.equal(browser.closed, 1, "a failure to close is not reported over the real one"); + }); + + it("passes on a failed run the page reports", async () => { + const browser = fakeBrowser({ result: { error: "the page crashed" } }); + + let failure: any = null; + try { + await _runPuppeteerTarget("browser", graphs, { bindAddress: "127.0.0.1" }, Promise.resolve(browser)); + } catch (e) { + failure = e; + } + + assert.equal(failure && failure.message, "the page crashed"); + }); + + it("passes on a failure the page reports before it has finished loading", async () => { + const browser = fakeBrowser({ result: { error: "failed while loading" }, loadedAfterMs: 100 }); + + let failure: any = null; + try { + await _runPuppeteerTarget("browser", graphs, { bindAddress: "127.0.0.1" }, Promise.resolve(browser)); + } catch (e) { + failure = e; + } + + assert.equal(failure && failure.message, "failed while loading"); + assert.equal(browser.closed, 1); + }); + }); + + describe("_launchPuppeteerBrowser", () => { + let project: string; + + // A stand-in puppeteer package in the project, which is looked in before the runner's own + function fakePackage(name: string, source: string): void { + const dir = path.join(project, "node_modules", name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name: name, version: "0.0.0", main: "index.js" }), "utf8"); + fs.writeFileSync(path.join(dir, "index.js"), source, "utf8"); + } + + async function launchFailure(): Promise { + try { + await _launchPuppeteerBrowser(project); + } catch (e) { + return e; + } + + return null; + } + + const LAUNCH = "exports.launch = (options) => Promise.resolve({ launchedWith: options });\n"; + + beforeEach(() => { + project = fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-puppeteer-load-")); + }); + + afterEach(() => { + fs.rmSync(project, { recursive: true, force: true }); + }); + + it("launches headless Chrome through the project's puppeteer", async () => { + fakePackage("puppeteer", LAUNCH); + + const browser = await _launchPuppeteerBrowser(project); + + assert.isTrue(browser.launchedWith.headless); + assert.includes(browser.launchedWith.args.join(" "), "--no-sandbox"); + assert.includes(browser.launchedWith.args.join(" "), "--disable-web-security"); + }); + + it("imports a puppeteer that cannot be required", async () => { + // Refuses require() the first time it loads, as an ES module puppeteer does on an older Node + const key = "__tripwireFakePuppeteer" + Math.random().toString(36).substring(2); + fakePackage("puppeteer", [ + "if (!globalThis." + key + ") {", + " globalThis." + key + " = true;", + " const e = new Error(\"require() of ES Module not supported\");", + " e.code = \"ERR_REQUIRE_ESM\";", + " throw e;", + "}", + LAUNCH + ].join("\n")); + + const browser = await _launchPuppeteerBrowser(project); + + assert.isTrue(browser.launchedWith.headless); + }); + + it("reports a puppeteer that can be neither required nor imported as a user error", async () => { + fakePackage("puppeteer", "const e = new Error(\"require() of ES Module not supported\\nmore detail\");\ne.code = \"ERR_REQUIRE_ASYNC_MODULE\";\nthrow e;\n"); + + const failure = await launchFailure(); + + assert.isTrue(_isUserError(failure)); + assert.includes(failure.message, "'puppeteer' is installed but could not be loaded on Node.js " + process.version + ": require() of ES Module not supported"); + assert.isFalse(failure.message.indexOf("more detail") !== -1, "only the first line of the cause"); + assert.includes(_userErrorHints(failure)[0], "Check that the installed puppeteer supports this Node.js version."); + }); + + it("reports the first install that fails to load, after trying puppeteer-core", async () => { + fakePackage("puppeteer", "throw new Error(\"puppeteer is broken\");\n"); + fakePackage("puppeteer-core", "throw new Error(\"puppeteer-core is broken too\");\n"); + + const failure = await launchFailure(); + + assert.isTrue(_isUserError(failure)); + assert.includes(failure.message, "'puppeteer' is installed but could not be loaded"); + assert.includes(failure.message, "puppeteer is broken"); + }); + + it("falls back to puppeteer-core when puppeteer cannot be loaded", async () => { + fakePackage("puppeteer", "throw new Error(\"puppeteer is broken\");\n"); + fakePackage("puppeteer-core", LAUNCH); + + const browser = await _launchPuppeteerBrowser(project); + + assert.isTrue(browser.launchedWith.headless); + }); + }); + + describe("_warmUpPuppeteerBrowser", () => { + it("loads a page in the browser's first tab, or a new one", async () => { + const visited: string[] = []; + const page = { goto: async (url: string) => { + visited.push(url); + } }; + let created = 0; + + await _warmUpPuppeteerBrowser({ pages: async () => [ page ], newPage: async () => page }); + await _warmUpPuppeteerBrowser({ pages: async (): Promise => [], newPage: async () => { + created++; + return page; + } }); + + assert.deepEqual(visited, [ "http://127.0.0.1:1/", "http://127.0.0.1:1/" ]); + assert.equal(created, 1); + }); + + it("ignores a failure, which only means the warm-up did not help", async () => { + await _warmUpPuppeteerBrowser({ pages: async () => [ { goto: async () => { + throw new Error("connection refused"); + } } ] }); + await _warmUpPuppeteerBrowser({ pages: async () => { + throw new Error("browser closed"); + } }); + }); + }); +}); diff --git a/internal/runner/test/src/execute/targetSessionCache.test.ts b/internal/runner/test/src/execute/targetSessionCache.test.ts new file mode 100644 index 00000000..afcd4319 --- /dev/null +++ b/internal/runner/test/src/execute/targetSessionCache.test.ts @@ -0,0 +1,319 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { spawnSync } from "child_process"; +import { assert } from "@nevware21/tripwire"; +import { _createTargetSessionCache, _watchPath, InteractiveTarget, watchFiles } from "../../../src/execute/targetSessionCache"; + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitFor(check: () => boolean, timeoutMs: number = 5000): Promise { + const end = Date.now() + timeoutMs; + while (Date.now() < end) { + if (check()) { + return true; + } + + await delay(25); + } + + return check(); +} + +/** + * The interactive browser and worker runner keeps one built module graph per target, rebuilding it only + * when the entry files or a module file changes, and optionally watching those files. + */ +describe("execute targetSessionCache", function () { + // The watch cases wait on real file system notifications + this.timeout(30000); + + let scratch: string; + let disposers: Array<() => void> = []; + + function write(name: string, text: string): string { + const file = path.join(scratch, name); + fs.writeFileSync(file, text, "utf8"); + + return file; + } + + // Moves a file's modified time, so a rebuild check sees it changed without waiting for the clock + function touch(file: string, secondsAhead: number): void { + const when = new Date(Date.now() + secondsAhead * 1000); + fs.utimesSync(file, when, when); + } + + beforeEach(() => { + scratch = fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-sessions-")); + }); + + afterEach(() => { + disposers.forEach((dispose) => dispose()); + disposers = []; + fs.rmSync(scratch, { recursive: true, force: true }); + }); + + describe("watchFiles", () => { + it("reports changes to the watched files once they settle", async () => { + const watched = write("a.ts", "a"); + let changes = 0; + const watcher = watchFiles([ watched ], () => changes++); + disposers.push(() => watcher.close()); + + fs.writeFileSync(watched, "a1", "utf8"); + fs.writeFileSync(watched, "a2", "utf8"); + + assert.isTrue(await waitFor(() => changes > 0), "the change was reported"); + await delay(400); + assert.equal(changes, 1, "a burst of writes is one change"); + }); + + it("ignores other files in the same folder", async () => { + const watched = write("a.ts", "a"); + const other = write("other.ts", "other"); + let changes = 0; + const watcher = watchFiles([ watched ], () => changes++); + disposers.push(() => watcher.close()); + + fs.writeFileSync(other, "changed", "utf8"); + + await delay(500); + assert.equal(changes, 0); + }); + + it("drops a pending change when it is closed", async () => { + const watched = write("a.ts", "a"); + let changes = 0; + const watcher = watchFiles([ watched ], () => changes++); + + fs.writeFileSync(watched, "a1", "utf8"); + await delay(100); + watcher.close(); + watcher.close(); + + await delay(400); + assert.equal(changes, 0); + }); + + it("gives up quietly when a folder cannot be watched", () => { + const watcher = watchFiles([ path.join(scratch, "missing", "a.ts") ], () => { + throw new Error("unexpected"); + }); + + watcher.close(); + }); + + it("reports changes to a file named with forward slashes, as module graph ids are", async () => { + const watched = write("a.ts", "a"); + let changes = 0; + const watcher = watchFiles([ watched.split(path.sep).join("/") ], () => changes++); + disposers.push(() => watcher.close()); + + fs.writeFileSync(watched, "a1", "utf8"); + + assert.isTrue(await waitFor(() => changes > 0), "the change was reported"); + }); + }); + + describe("_watchPath", () => { + // The 8.3 short name Windows gives a path, or "" where there is none (another OS, or a volume + // with short names turned off) + function shortName(longPath: string): string { + if (process.platform !== "win32") { + return ""; + } + + const result = spawnSync("cmd.exe", [ "/d", "/c", "for %I in (\"" + longPath + "\") do @echo %~sI" ], { encoding: "utf8", windowsVerbatimArguments: true }); + const short = result.status === 0 ? String(result.stdout || "").trim() : ""; + + return short && short.toLowerCase() !== longPath.toLowerCase() ? short : ""; + } + + it("spells a path the way the file system does, with the platform's separators", () => { + const file = write("a.ts", "a"); + + const watched = _watchPath(file.split(path.sep).join("/")); + + assert.equal(watched, fs.realpathSync.native(file)); + assert.isTrue(path.isAbsolute(watched)); + if (process.platform === "win32") { + assert.equal(watched.indexOf("/"), -1, "no forward slashes on Windows"); + } + }); + + it("expands an 8.3 short name", function () { + const longDir = path.join(scratch, "a folder with a long name"); + fs.mkdirSync(longDir); + fs.writeFileSync(path.join(longDir, "file.ts"), "f", "utf8"); + const short = shortName(longDir); + if (!short) { + // Nothing to expand on this platform or volume + this.skip(); + } + + assert.equal(_watchPath(path.join(short, "file.ts")), fs.realpathSync.native(path.join(longDir, "file.ts"))); + }); + + it("keeps the name of a file that no longer exists, in its folder's own spelling", () => { + const gone = scratch.split(path.sep).join("/") + "/gone.ts"; + + assert.equal(_watchPath(gone), path.join(fs.realpathSync.native(scratch), "gone.ts")); + }); + + it("keeps a path whose folder does not exist either, made absolute", () => { + const nowhere = path.join(scratch, "missing", "a.ts"); + + assert.equal(_watchPath(nowhere.split(path.sep).join("/")), path.resolve(nowhere)); + }); + }); + + describe("_createTargetSessionCache", () => { + interface ISession { + target: InteractiveTarget; + build: number; + } + + function createCache(entryFiles: { [target: string]: string[] }, moduleFiles: () => string[], watch?: boolean) { + const counts = { builds: 0, resolves: 0 }; + const cache = _createTargetSessionCache({ + resolveEntryFiles: (target) => { + counts.resolves++; + return entryFiles[target].slice(0); + }, + buildGraph: (target, files) => { + const modules: Record = { "/__tripwire__/virtual.js": "virtual" }; + files.concat(moduleFiles()).forEach((file) => { + modules[file] = "source of " + file; + }); + + return { modules, entryIds: files, tripwireModuleId: "", coverageOnlyIds: [], moduleUrls: {}, rawAssets: {}, elapsedMs: 0 }; + }, + assemble: (target) => { + counts.builds++; + return { target, build: counts.builds }; + } + }, { watch: !!watch }); + disposers.push(() => cache.dispose()); + + return { cache, counts }; + } + + it("builds a target once and reuses it while nothing changed", () => { + const entry = write("a.test.ts", "a"); + const { cache, counts } = createCache({ browser: [ entry ], worker: [] }, () => []); + + const first = cache.get("browser"); + assert.deepEqual(first, { target: "browser", build: 1 }); + assert.equal(cache.get("browser"), first); + assert.equal(counts.builds, 1); + assert.equal(cache.getGeneration("browser"), 1); + }); + + it("rebuilds when the entry files change, in any order", () => { + const a = write("a.test.ts", "a"); + const b = write("b.test.ts", "b"); + const entries = { browser: [ a, b ], worker: [] as string[] }; + const { cache, counts } = createCache(entries, () => []); + + cache.get("browser"); + entries.browser = [ b, a ]; + cache.get("browser"); + assert.equal(counts.builds, 1, "the same files in another order"); + + entries.browser = [ a ]; + assert.equal(cache.get("browser").build, 2); + }); + + it("rebuilds when a module file is modified or removed", () => { + const entry = write("a.test.ts", "a"); + const helper = write("helper.ts", "h"); + const { cache } = createCache({ browser: [ entry ], worker: [] }, () => [ helper ]); + + cache.get("browser"); + touch(helper, 10); + assert.equal(cache.get("browser").build, 2, "modified"); + + fs.unlinkSync(helper); + assert.equal(cache.get("browser").build, 3, "removed"); + }); + + it("lists the real module files to watch, without virtual or missing modules", () => { + const entry = write("a.test.ts", "a"); + const missing = path.join(scratch, "missing.ts"); + const { cache } = createCache({ browser: [ entry ], worker: [] }, () => [ missing ]); + + assert.deepEqual(cache.getWatchFiles("browser"), []); + cache.get("browser"); + assert.deepEqual(cache.getWatchFiles("browser"), [ entry ]); + }); + + it("peeks at the cached session without checking it, building only when there is none", () => { + const entry = write("a.test.ts", "a"); + const { cache, counts } = createCache({ browser: [ entry ], worker: [ entry ] }, () => []); + + assert.isUndefined(cache.tryPeek("worker")); + const built = cache.peek("worker"); + assert.equal(built.target, "worker"); + assert.equal(cache.tryPeek("worker"), built); + + touch(entry, 10); + const resolves = counts.resolves; + assert.equal(cache.peek("worker"), built, "a stale session is still returned"); + assert.equal(counts.resolves, resolves, "and nothing was checked"); + }); + + it("numbers builds across targets", () => { + const entry = write("a.test.ts", "a"); + const { cache } = createCache({ browser: [ entry ], worker: [ entry ] }, () => []); + + assert.equal(cache.getGeneration("worker"), 0); + cache.get("browser"); + cache.get("worker"); + assert.equal(cache.getGeneration("browser"), 1); + assert.equal(cache.getGeneration("worker"), 2); + }); + + it("rebuilds in the background when a watched file changes, until disposed", async () => { + const entry = write("a.test.ts", "a"); + const { cache } = createCache({ browser: [ entry ], worker: [] }, () => [], true); + + cache.get("browser"); + fs.writeFileSync(entry, "a changed", "utf8"); + touch(entry, 10); + assert.isTrue(await waitFor(() => cache.getGeneration("browser") === 2), "rebuilt by the watcher"); + + cache.dispose(); + fs.writeFileSync(entry, "a changed again", "utf8"); + touch(entry, 20); + await delay(500); + assert.equal(cache.getGeneration("browser"), 2, "the watcher was closed"); + }); + + it("does not rebuild when a watched file's change leaves it as it was built", async () => { + const pinned = new Date(2020, 0, 1); + const entry = write("a.test.ts", "a"); + fs.utimesSync(entry, pinned, pinned); + const { cache, counts } = createCache({ browser: [ entry ], worker: [] }, () => [], true); + + cache.get("browser"); + const resolves = counts.resolves; + fs.writeFileSync(entry, "a", "utf8"); + fs.utimesSync(entry, pinned, pinned); + + assert.isTrue(await waitFor(() => counts.resolves > resolves), "the watcher checked the session"); + await delay(300); + assert.equal(cache.getGeneration("browser"), 1, "and kept it"); + }); + }); +}); diff --git a/internal/runner/test/src/reporters/addReporters.test.ts b/internal/runner/test/src/reporters/addReporters.test.ts new file mode 100644 index 00000000..d032d2c1 --- /dev/null +++ b/internal/runner/test/src/reporters/addReporters.test.ts @@ -0,0 +1,61 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import { assert, createNotifyMgr } from "@nevware21/tripwire"; +import { addCargoReporter } from "../../../src/reporters/cargoReporter"; +import { addConsoleReporter } from "../../../src/reporters/consoleReporter"; +import { addDocReporter } from "../../../src/reporters/docReporter"; +import { addDotReporter } from "../../../src/reporters/dotReporter"; +import { addJsonReporter } from "../../../src/reporters/jsonReporter"; +import { addJsonStreamReporter } from "../../../src/reporters/jsonStreamReporter"; +import { addLandingReporter } from "../../../src/reporters/landingReporter"; +import { addListReporter } from "../../../src/reporters/listReporter"; +import { addMarkdownReporter } from "../../../src/reporters/markdownReporter"; +import { addMinReporter } from "../../../src/reporters/minReporter"; +import { addNyanReporter } from "../../../src/reporters/nyanReporter"; +import { addProgressReporter } from "../../../src/reporters/progressReporter"; +import { addTapReporter } from "../../../src/reporters/tapReporter"; +import { addXunitReporter } from "../../../src/reporters/xunitReporter"; +import { makeSink } from "../support/reporterHarness"; + +/** + * Each reporter also has an `add...Reporter` helper, which creates it and registers it with a + * notification manager in one step. Each is checked here against a real manager: registering adds a + * listener, and the handle it returns takes it away again. + */ +describe("reporter registration", () => { + const adders: { [name: string]: (mgr: any, options?: any) => { rm(): void } } = { + cargo: addCargoReporter, + console: addConsoleReporter, + doc: addDocReporter, + dot: addDotReporter, + json: addJsonReporter, + "json-stream": addJsonStreamReporter, + landing: addLandingReporter, + list: addListReporter, + markdown: addMarkdownReporter, + min: addMinReporter, + nyan: addNyanReporter, + progress: addProgressReporter, + tap: addTapReporter, + xunit: addXunitReporter + }; + + Object.keys(adders).forEach((name) => { + it("registers the " + name + " reporter, and removes it with the handle it returns", () => { + const mgr = createNotifyMgr(); + assert.isFalse(mgr.hasListeners()); + + const handle = adders[name](mgr, { console: makeSink() }); + assert.isTrue(mgr.hasListeners()); + + handle.rm(); + assert.isFalse(mgr.hasListeners()); + }); + }); +}); diff --git a/internal/runner/test/src/rollup/circularImports.test.ts b/internal/runner/test/src/rollup/circularImports.test.ts new file mode 100644 index 00000000..f6177d8e --- /dev/null +++ b/internal/runner/test/src/rollup/circularImports.test.ts @@ -0,0 +1,135 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { assert } from "@nevware21/tripwire"; + +// rollup is one of the runner's own dev dependencies, and loadConfigFile is how its CLI loads a config, +// so each project's real rollup.config.js is checked exactly as `rollup -c --bundleConfigAsCjs` sees it. +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { loadConfigFile } = require("rollup/loadConfigFile"); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { rollup } = require("rollup"); + +const REPO = path.resolve(__dirname, "..", "..", "..", "..", ".."); + +const PROJECTS = [ + { name: "core", config: path.join(REPO, "core", "rollup.config.js") }, + { name: "shim/chai", config: path.join(REPO, "shim", "chai", "rollup.config.js") }, + { name: "runner", config: path.join(REPO, "internal", "runner", "rollup.config.js") } +]; + +function circularWarning(ids: string[]): any { + return { + code: "CIRCULAR_DEPENDENCY", + ids: ids, + message: "Circular dependency: " + ids.join(" -> ") + }; +} + +/** + * Writes `a.js` and `b.js` importing each other under `dir`, with `a.js` as the entry. + */ +function writeCycle(dir: string): string { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "a.js"), "import { b } from \"./b.js\";\nexport const a = () => b();\n", "utf8"); + fs.writeFileSync(path.join(dir, "b.js"), "import { a } from \"./a.js\";\nexport const b = () => typeof a;\n", "utf8"); + + return path.join(dir, "a.js"); +} + +/** + * Every rollup config in the repository turns a circular import between this repository's own modules + * into a build failure, while a cycle wholly inside a dependency under node_modules stays a warning. + * The check goes through rollup itself: the configs are loaded the way the rollup CLI loads them, and + * the warnings are delivered through the merged options rollup builds from them. + */ +describe("rollup circular import guard", function () { + // Loading a config bundles it first, and the fixture builds run rollup for real + this.timeout(120000); + + let scratch: string; + + before(() => { + scratch = fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-cycles-")); + }); + + after(() => { + fs.rmSync(scratch, { recursive: true, force: true }); + }); + + PROJECTS.forEach((project) => { + describe(project.name, () => { + let options: any[]; + + before(async () => { + const loaded = await loadConfigFile(project.config, { bundleConfigAsCjs: true }); + options = loaded.options; + }); + + it("fails every bundle the config defines on a cycle between the repository's own modules, naming every module in it", () => { + const ids = [ path.join(REPO, "core", "src", "a.ts"), path.join(REPO, "core", "src", "b.ts"), path.join(REPO, "core", "src", "a.ts") ]; + + assert.isTrue(options.length > 0, "the config defines at least one bundle"); + options.forEach((opts, idx) => { + assert.throws(() => opts.onLog("warn", circularWarning(ids)), "Circular import: " + ids.join(" -> "), "bundle " + idx); + }); + }); + + it("falls back to rollup's message when the warning carries no module ids", () => { + assert.throws(() => options[0].onLog("warn", { code: "CIRCULAR_DEPENDENCY", message: "Circular dependency: x -> y -> x" }), + "Circular import: Circular dependency: x -> y -> x"); + }); + + it("leaves a cycle inside a dependency, or any other warning, as a warning", () => { + const inDependency = [ "/repo/node_modules/dep/a.js", "/repo/node_modules/dep/b.js", "/repo/node_modules/dep/a.js" ]; + const onWindows = [ "C:\\repo\\node_modules\\dep\\a.js", "C:\\repo\\node_modules\\dep\\b.js" ]; + + options.forEach((opts) => { + opts.onLog("warn", circularWarning(inDependency)); + opts.onLog("warn", circularWarning(onWindows)); + opts.onLog("warn", { code: "THIS_IS_UNDEFINED", message: "this is undefined" }); + }); + }); + + it("stops a real build that imports its way round in a circle", async () => { + const entry = writeCycle(path.join(scratch, project.name.replace(/\W/g, "-"), "src")); + const opts = Object.assign({}, options[0], { input: entry, plugins: [], external: [] }); + delete opts.output; + + let failure: any = null; + try { + await rollup(opts); + } catch (e) { + failure = e; + } + + assert.isNotNull(failure, "the build fails"); + assert.includes(failure.message, "Circular import: "); + assert.includes(failure.message, "a.js -> "); + assert.includes(failure.message, "b.js"); + }); + + it("still builds when the cycle is inside a package under node_modules", async () => { + const entry = writeCycle(path.join(scratch, project.name.replace(/\W/g, "-"), "node_modules", "dep")); + const opts = Object.assign({}, options[0], { input: entry, plugins: [], external: [] }); + delete opts.output; + + const bundle = await rollup(opts); + try { + const output = await bundle.generate({ format: "es" }); + assert.includes(output.output[0].code, "typeof a", "the bundle was produced"); + } finally { + await bundle.close(); + } + }); + }); + }); +}); diff --git a/internal/runner/test/src/server/server.test.ts b/internal/runner/test/src/server/server.test.ts new file mode 100644 index 00000000..1277bb82 --- /dev/null +++ b/internal/runner/test/src/server/server.test.ts @@ -0,0 +1,618 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import * as fs from "fs"; +import * as http from "http"; +import * as os from "os"; +import * as path from "path"; +import { replaceAnsi } from "@nevware21/chromacon"; +import { assert } from "@nevware21/tripwire"; +import { _failRequest, _startServer } from "../../../src/server/server"; +import { _readDirEntries } from "../../../src/server/handlers/fs"; +import { IServer } from "../../../src/interfaces/IServer"; + +interface IResponse { + status: number; + headers: http.IncomingHttpHeaders; + body: string; +} + +/** + * Sends one request to the server and collects the whole response. The path is sent exactly as given, + * so encoded traversal attempts reach the server undecoded. + */ +function send(server: IServer, method: string, reqPath: string, agent?: http.Agent): Promise { + const target = new URL(server.url); + + return new Promise((resolve, reject) => { + const req = http.request({ + host: target.hostname, + port: target.port, + method: method, + path: reqPath, + agent: agent || false + }, (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks).toString("utf8") })); + res.on("error", reject); + }); + + req.on("error", reject); + req.end(); + }); +} + +function makeContext(overrides?: any): any { + const logged: string[] = []; + + return Object.assign({ + logged: logged, + logMsg: (msg: any) => { + logged.push(replaceAnsi(String(msg), "")); + }, + getApiHandler: (): any => undefined, + getAsset: (): any => undefined, + onComplete: () => {} + }, overrides || {}); +} + +/** + * The runner's HTTP server, started for real on loopback: request routing, every built-in handler, the + * filesystem fallback with its containment check, failure handling and shutdown. + */ +describe("server", function () { + this.timeout(30000); + + let fsRoot: string; + + before(() => { + fsRoot = fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-server-")); + fs.writeFileSync(path.join(fsRoot, "hello.txt"), "hello world", "utf8"); + fs.writeFileSync(path.join(fsRoot, "README.md"), "# readme", "utf8"); + fs.writeFileSync(path.join(fsRoot, "data.bin"), "bin", "utf8"); + fs.mkdirSync(path.join(fsRoot, "sub")); + fs.writeFileSync(path.join(fsRoot, "sub", "nested.json"), "{\"a\":1}", "utf8"); + fs.mkdirSync(path.join(fsRoot, "empty")); + }); + + after(() => { + fs.rmSync(fsRoot, { recursive: true, force: true }); + }); + + async function withServer(context: any, fn: (server: IServer) => Promise): Promise { + const server = await _startServer(context); + try { + await fn(server); + } finally { + await server.stop(); + } + } + + describe("lifecycle", () => { + it("listens on loopback, reports its url and port, and stops", async () => { + const context = makeContext({ fsRoot }); + const server = await _startServer(context); + + assert.isTrue(server.isRunning); + assert.isTrue(server.port > 0); + assert.equal(server.url, "http://127.0.0.1:" + server.port + "/"); + assert.includes(context.logged.join("\n"), "Server URL: " + server.url); + + await server.stop(); + assert.isFalse(server.isRunning); + assert.isUndefined(server.stop(), "stopping again has nothing left to close"); + }); + + it("does not wait for a client that keeps its connection open", async () => { + const agent = new http.Agent({ keepAlive: true }); + const server = await _startServer(makeContext({ fsRoot })); + + try { + const res = await send(server, "GET", "/api/status", agent); + assert.equal(res.status, 200); + + const started = Date.now(); + await server.stop(); + assert.isTrue(Date.now() - started < 4000, "the kept-alive socket is destroyed rather than waited for"); + } finally { + agent.destroy(); + } + }); + + it("rejects when it cannot listen on the requested address", async () => { + const context = makeContext({ fsRoot, bindAddress: "999.999.999.999" }); + + let failure: any = null; + try { + await _startServer(context); + } catch (e) { + failure = e; + } + + assert.isNotNull(failure); + assert.ok(failure instanceof Error); + assert.includes(context.logged.join("\n"), "Error in browser runner server: "); + }); + + it("sends every response uncached and embeddable by the same origin", async () => { + await withServer(makeContext({ fsRoot }), async (server) => { + const res = await send(server, "GET", "/hello.txt"); + + assert.equal(res.headers["cache-control"], "no-store"); + assert.equal(res.headers["cross-origin-resource-policy"], "same-origin"); + }); + }); + }); + + describe("root", () => { + it("lists the served folder with the built-in shortcuts", async () => { + await withServer(makeContext({ fsRoot }), async (server) => { + const res = await send(server, "GET", "/"); + + assert.equal(res.status, 200); + assert.equal(res.headers["content-type"], "text/html; charset=utf-8"); + assert.includes(res.body, "Index of /"); + assert.includes(res.body, "[FILE] hello.txt"); + assert.includes(res.body, "[DIR] sub"); + assert.includes(res.body, "href=\"/__tripwire__/browser\""); + assert.includes(res.body, "href=\"/__tripwire__/worker\""); + assert.includes(res.body, "href=\"/status\">Status"); + assert.isFalse(res.body.indexOf("well-known") !== -1, "hidden actions are not listed"); + assert.isFalse(res.body.indexOf("href=\"/api\"") !== -1, "the api is not a page to visit"); + }); + }); + + it("ignores the query string and uses the context's own listing page when it has one", async () => { + let call: any[] = null; + const context = makeContext({ + fsRoot, + browserStyle: "slate", + getFsListingHtml: (...args: any[]) => { + call = args; + return ""; + } + }); + + await withServer(context, async (server) => { + const res = await send(server, "GET", "/?view=1"); + + assert.equal(res.body, ""); + assert.equal(call[0], "slate"); + assert.equal(call[1], ""); + assert.isTrue(call[2].some((e: any) => e.name === "hello.txt" && !e.isDir)); + assert.includes(call[3], "fs-shortcuts"); + }); + }); + + it("answers anything but a GET of / with 404", async () => { + await withServer(makeContext({ fsRoot }), async (server) => { + assert.equal((await send(server, "POST", "/")).status, 404); + }); + }); + + it("reports a folder it cannot read", async () => { + await withServer(makeContext({ fsRoot: path.join(fsRoot, "does-not-exist") }), async (server) => { + const res = await send(server, "GET", "/"); + + assert.equal(res.status, 500); + assert.equal(res.body, "Internal Server Error"); + }); + }); + + it("escapes action descriptions and links in the shortcut list", async () => { + const context = makeContext({ + fsRoot, + getServerActions: () => [ { + name: "tools", + handler: (request: any) => { + request.res.end("tools"); + }, + description: "dangerous & \"quoted\"", + href: "/tools?a=1&b=\"2\"" + } ] + }); + + await withServer(context, async (server) => { + const res = await send(server, "GET", "/"); + + assert.includes(res.body, "<b>dangerous</b> & "quoted""); + assert.includes(res.body, "href=\"/tools?a=1&b="2"\">Tools"); + }); + }); + }); + + describe("status", () => { + it("describes itself with examples when no code is given", async () => { + await withServer(makeContext({ fsRoot }), async (server) => { + const res = await send(server, "GET", "/status"); + + assert.equal(res.status, 200); + assert.includes(res.body, "

Status Endpoint

"); + assert.includes(res.body, "/status/404 — 404: Not Found"); + assert.includes(res.body, "/status/404,500 — 404: Not Found or 500: Internal Server Error"); + }); + }); + + it("answers with the requested status and its reason phrase", async () => { + await withServer(makeContext({ fsRoot }), async (server) => { + const res = await send(server, "GET", "/status/418"); + + assert.equal(res.status, 418); + assert.equal(res.body, http.STATUS_CODES[418]); + assert.equal(res.headers["content-type"], "text/plain; charset=utf-8"); + }); + }); + + it("picks one of several comma separated codes", async () => { + await withServer(makeContext({ fsRoot }), async (server) => { + const seen = new Set(); + for (let lp = 0; lp < 12; lp++) { + seen.add((await send(server, "GET", "/status/201,202")).status); + } + + seen.forEach((code) => assert.isTrue(code === 201 || code === 202, "only a requested code is used")); + }); + }); + + it("rejects codes that are malformed, out of range or not standard", async () => { + await withServer(makeContext({ fsRoot }), async (server) => { + assert.equal((await send(server, "GET", "/status/abc")).body, "Invalid status code: abc"); + assert.equal((await send(server, "GET", "/status/99")).body, "Invalid status code: 99"); + assert.equal((await send(server, "GET", "/status/404.5")).body, "Invalid status code: 404.5"); + assert.equal((await send(server, "GET", "/status/404,")).body, "Invalid status code: "); + + const unsupported = await send(server, "GET", "/status/599"); + assert.equal(unsupported.status, 400); + assert.equal(unsupported.body, "Unsupported status code: 599"); + }); + }); + + it("only answers GET", async () => { + await withServer(makeContext({ fsRoot }), async (server) => { + assert.equal((await send(server, "POST", "/status/200")).status, 404); + }); + }); + }); + + describe("well-known", () => { + it("answers Chrome's DevTools project probe with an empty project", async () => { + await withServer(makeContext({ fsRoot }), async (server) => { + const res = await send(server, "GET", "/.well-known/appspecific/com.chrome.devtools.json"); + + assert.equal(res.status, 200); + assert.equal(res.body, "{}"); + assert.equal(res.headers["content-type"], "application/json; charset=utf-8"); + }); + }); + + it("answers anything else with 404", async () => { + await withServer(makeContext({ fsRoot }), async (server) => { + assert.equal((await send(server, "GET", "/.well-known/other.json")).status, 404); + assert.equal((await send(server, "POST", "/.well-known/appspecific/com.chrome.devtools.json")).status, 404); + }); + }); + }); + + describe("api", () => { + it("reports that it is running", async () => { + await withServer(makeContext({ fsRoot }), async (server) => { + const res = await send(server, "GET", "/api/status"); + + assert.equal(res.status, 200); + assert.deepEqual(JSON.parse(res.body), { status: "running" }); + }); + }); + + it("hands any other api url to the context's handler for it, with the parsed query", async () => { + let seen: any = null; + const context = makeContext({ + fsRoot, + getApiHandler: (apiUrl: string) => apiUrl === "/echo" ? (request: any) => { + seen = { url: apiUrl, query: request.query, method: request.method }; + request.res.end("echoed"); + } : undefined + }); + + await withServer(context, async (server) => { + const res = await send(server, "GET", "/api/echo?name=a%20b&flag&=ignored"); + + assert.equal(res.body, "echoed"); + assert.deepEqual(seen.query, { name: "a b", flag: "" }, "a pair without a key is dropped"); + assert.deepEqual(seen.method, { name: "GET", isGet: true, isPost: false }); + assert.equal((await send(server, "GET", "/api/unknown")).status, 404); + }); + }); + }); + + describe("runtime assets", () => { + const assets: { [url: string]: any } = { + "/runner.js": { contentType: "application/javascript", content: Buffer.from("runner()") }, + "/favicon.ico": { contentType: "image/x-icon", content: Buffer.from("ico") }, + "/runner-client.js.map": { contentType: "application/json", content: Buffer.from("{\"map\":1}") }, + "/src/app.ts": { contentType: "text/x-typescript", content: Buffer.from("export {}") } + }; + + function assetContext(): any { + return makeContext({ fsRoot, getAsset: (url: string) => assets[url] }); + } + + it("serves the runner's own assets under /__tripwire__/", async () => { + await withServer(assetContext(), async (server) => { + const res = await send(server, "GET", "/__tripwire__/runner.js"); + + assert.equal(res.status, 200); + assert.equal(res.headers["content-type"], "application/javascript"); + assert.equal(res.body, "runner()"); + assert.equal((await send(server, "GET", "/__tripwire__/missing.js")).status, 404); + assert.equal((await send(server, "POST", "/__tripwire__/runner.js")).status, 404); + }); + }); + + it("routes the favicon, the client source maps and /src/ asked for at the top level to the runner's assets", async () => { + await withServer(assetContext(), async (server) => { + assert.equal((await send(server, "GET", "/favicon.ico")).body, "ico"); + assert.equal((await send(server, "GET", "/runner-client.js.map")).body, "{\"map\":1}"); + assert.equal((await send(server, "GET", "/__tripwire__/nested/runner-client.js.map")).body, "{\"map\":1}", "a nested path under /__tripwire__/ is reduced to the asset name"); + assert.equal((await send(server, "GET", "/src/app.ts")).body, "export {}"); + }); + }); + + it("does not treat a source map name inside another folder as a runtime asset", async () => { + await withServer(assetContext(), async (server) => { + assert.equal((await send(server, "GET", "/some/page/runner-client.js.map")).status, 404, "served from the folder, where it does not exist"); + }); + }); + + it("passes a status code list through undecoded, so an encoded space is rejected", async () => { + await withServer(assetContext(), async (server) => { + assert.equal((await send(server, "GET", "/status/201,%20202")).body, "Invalid status code: %20202"); + }); + }); + }); + + describe("filesystem fallback", () => { + it("serves a file from the served folder with its content type", async () => { + await withServer(makeContext({ fsRoot }), async (server) => { + const text = await send(server, "GET", "/hello.txt"); + const json = await send(server, "GET", "/sub/nested.json"); + const unknown = await send(server, "GET", "/data.bin"); + + assert.equal(text.status, 200); + assert.equal(text.body, "hello world"); + assert.equal(text.headers["content-type"], "text/plain; charset=utf-8"); + assert.equal(json.headers["content-type"], "application/json; charset=utf-8"); + assert.equal(unknown.headers["content-type"], "application/octet-stream"); + }); + }); + + it("serves a top-level file whose name is not an action, rather than the root page", async () => { + await withServer(makeContext({ fsRoot }), async (server) => { + const res = await send(server, "GET", "/README.md"); + + assert.equal(res.status, 200); + assert.equal(res.body, "# readme"); + }); + }); + + it("lists a folder, with file sizes", async () => { + await withServer(makeContext({ fsRoot }), async (server) => { + const res = await send(server, "GET", "/sub/"); + + assert.equal(res.status, 200); + assert.includes(res.body, "Index of /sub"); + assert.includes(res.body, "[FILE] nested.json (7 bytes)"); + assert.includes((await send(server, "GET", "/empty/")).body, "Index of /empty"); + }); + }); + + it("reports a listing page that fails to render", async () => { + const context = makeContext({ + fsRoot, + getFsListingHtml: () => { + throw new Error("template broke"); + } + }); + + await withServer(context, async (server) => { + assert.equal((await send(server, "GET", "/sub/")).status, 500); + }); + }); + + it("prefers the context's generated asset, and honours an explicit refusal to serve the raw file", async () => { + const context = makeContext({ + fsRoot, + getAsset: (url: string) => { + if (url === "/hello.txt") { + return { contentType: "text/x-generated", content: Buffer.from("generated") }; + } + + return url === "/sub/nested.json" ? null : undefined; + } + }); + + await withServer(context, async (server) => { + const generated = await send(server, "GET", "/hello.txt"); + assert.equal(generated.body, "generated"); + assert.equal(generated.headers["content-type"], "text/x-generated"); + + assert.equal((await send(server, "GET", "/sub/nested.json")).status, 404, "null means the raw file is never served"); + }); + }); + + it("answers a missing file with 404 and anything but GET with 405", async () => { + await withServer(makeContext({ fsRoot }), async (server) => { + assert.equal((await send(server, "GET", "/no-such-file.txt")).status, 404); + + const post = await send(server, "POST", "/hello.txt"); + assert.equal(post.status, 405); + assert.equal(post.headers.allow, "GET"); + }); + }); + + it("refuses paths that escape the served folder, however they are encoded", async () => { + await withServer(makeContext({ fsRoot }), async (server) => { + assert.equal((await send(server, "GET", "/..%2f..%2fsecret.txt")).status, 403); + assert.equal((await send(server, "GET", "/sub/..%5c..%5csecret.txt")).status, 403); + assert.equal((await send(server, "GET", "/hello%00.txt")).status, 403); + }); + }); + + it("rejects a malformed percent encoding", async () => { + await withServer(makeContext({ fsRoot }), async (server) => { + const res = await send(server, "GET", "/sub/%E0%A4%A"); + + assert.equal(res.status, 400); + assert.includes(res.body, "malformed URL encoding"); + }); + }); + }); + + describe("custom actions", () => { + it("adds new actions, replaces built-in ones and ignores incomplete ones", async () => { + const context = makeContext({ + fsRoot, + getServerActions: () => [ + null, + { name: "", handler: null }, + { name: "broken" }, + { name: "status", handler: (request: any) => request.res.end("replaced status") }, + { name: "hello", handler: (request: any) => request.res.end("hello " + request.actionUrl) } + ] + }); + + await withServer(context, async (server) => { + assert.equal((await send(server, "GET", "/status/500")).body, "replaced status"); + assert.equal((await send(server, "GET", "/hello")).body, "hello /", "a bare action segment gets the root url"); + assert.equal((await send(server, "GET", "/HELLO/world")).body, "hello /world", "actions match case-insensitively"); + assert.equal((await send(server, "GET", "/broken/x")).status, 404, "the incomplete action was never registered"); + }); + }); + + it("answers 500 when an action throws or rejects, and logs the failure", async () => { + const context = makeContext({ + fsRoot, + getServerActions: () => [ + { + name: "throws", + handler: () => { + throw new Error("sync failure"); + } + }, + { name: "rejects", handler: () => Promise.reject(new Error("async failure")) } + ] + }); + + await withServer(context, async (server) => { + const thrown = await send(server, "GET", "/throws/x"); + assert.equal(thrown.status, 500); + assert.equal(thrown.body, "sync failure"); + + const rejected = await send(server, "GET", "/rejects/x"); + assert.equal(rejected.status, 500); + assert.equal(rejected.body, "Internal Server Error"); + assert.includes(context.logged.join("\n"), "Request failed: async failure"); + }); + }); + + it("answers 500 when the context's asset lookup rejects", async () => { + const context = makeContext({ fsRoot, getAsset: () => Promise.reject(new Error("registry failed")) }); + + await withServer(context, async (server) => { + assert.equal((await send(server, "GET", "/hello.txt")).status, 500); + assert.includes(context.logged.join("\n"), "Request failed: registry failed"); + }); + }); + }); + + describe("verbose logging", () => { + it("logs each request with its status", async () => { + const context = makeContext({ fsRoot, verbose: true }); + + await withServer(context, async (server) => { + await send(server, "GET", "/hello.txt"); + await send(server, "GET", "/no-such-file.txt"); + await send(server, "GET", "/status/503"); + }); + + const log = context.logged.join("\n"); + assert.includes(log, "[http] GET /hello.txt 200"); + assert.includes(log, "[http] GET /no-such-file.txt 404"); + assert.includes(log, "[http] GET /status/503 503"); + }); + }); + + describe("_failRequest", () => { + function fakeRes(overrides?: any): any { + return Object.assign({ statusCode: 200, headersSent: false, writableEnded: false, ended: null, end(body: any) { + this.ended = body; + } }, overrides || {}); + } + + it("answers 500 and logs the error", () => { + const context = makeContext(); + const res = fakeRes(); + + _failRequest(res, context, new Error("boom")); + + assert.equal(res.statusCode, 500); + assert.equal(res.ended, "Internal Server Error"); + assert.includes(context.logged[0], "Request failed: boom"); + }); + + it("leaves a response that has already started alone", () => { + const context = makeContext(); + const started = fakeRes({ headersSent: true }); + const ended = fakeRes({ writableEnded: true }); + + _failRequest(started, context, "late"); + _failRequest(ended, context, "late"); + + assert.equal(started.statusCode, 200); + assert.equal(ended.statusCode, 200); + assert.deepEqual(context.logged, []); + }); + + it("never throws while failing, and copes without a logger", () => { + const res = fakeRes({ + end: () => { + throw new Error("socket gone"); + } + }); + + _failRequest(res, null as any, "plain string error"); + _failRequest(fakeRes(), {} as any, "no logger"); + }); + }); + + describe("_readDirEntries", () => { + it("describes each entry, and resolves an empty folder to no entries", async () => { + const entries = await _readDirEntries(fsRoot); + const sub = entries.filter((e) => e.name === "sub")[0]; + const hello = entries.filter((e) => e.name === "hello.txt")[0]; + + assert.isTrue(sub.isDir); + assert.isUndefined(sub.size); + assert.isFalse(hello.isDir); + assert.equal(hello.size, 11); + assert.ok(hello.modified instanceof Date); + assert.deepEqual(await _readDirEntries(path.join(fsRoot, "empty")), []); + }); + + it("rejects for a folder that does not exist", async () => { + let failure: any = null; + try { + await _readDirEntries(path.join(fsRoot, "missing")); + } catch (e) { + failure = e; + } + + assert.equal(failure && failure.code, "ENOENT"); + }); + }); +}); diff --git a/internal/runner/test/src/support/cliFromSource.js b/internal/runner/test/src/support/cliFromSource.js new file mode 100644 index 00000000..0ba077cb --- /dev/null +++ b/internal/runner/test/src/support/cliFromSource.js @@ -0,0 +1,245 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +/* + * Runs the tripwire CLI from the runner's TypeScript sources, rather than from the bundle the runner's + * own tests run under, so a coverage run that spawns it measures src/tripwire.ts and src/execute. + * + * node cliFromSource.js + * + * The plan is a list of { cwd, args, report? } invocations. They run one after another in this one + * process, the way --watch re-runs, so the runner's sources are only compiled once. What each invocation + * logged, and the exit code it left, are written to the results file. + * + * `report` plays the part of the person at the browser for an open or manual launch: once the CLI + * prints its runner URL, the runner page is loaded, which builds its tests, and `report` is posted + * to that server as the page's results. + * + * `env` sets environment variables for the step only. + * `tty` makes stdout look like a terminal for the step, which turns on the progress output. + * `stubSpawn` keeps an open launch from starting the system browser: its spawn calls are recorded + * in the step's results as `spawned` instead. + * + * `watch` drives a --watch run, which never returns: { edits: [ { file, text } ] }. Each time the run + * says it is watching, the next edit is written, relative to `cwd`, and the run goes round again. Once + * it is watching after the last edit, the launcher moves on and leaves that run waiting, so a watch + * step's exit code means nothing. The launcher exits once every step is done. + */ +"use strict"; + +const fs = require("fs"); +const http = require("http"); +const path = require("path"); + +const runnerDir = process.argv[2]; +const plan = JSON.parse(fs.readFileSync(process.argv[3], "utf8")); +const resultsFile = process.argv[4]; +const extensions = require.extensions; + +// The browser UX imports its templates and icon as modules, which the rollup build inlines as strings +function _textModule(ext, read) { + extensions[ext] = function (module, filename) { + module.exports = read(filename); + }; +} + +_textModule(".html", (filename) => fs.readFileSync(filename, "utf8")); +_textModule(".css", (filename) => fs.readFileSync(filename, "utf8")); +_textModule(".ico", (filename) => fs.readFileSync(filename).toString("base64")); + +// A throwaway transpile, just long enough to load the runner's own compile module, which then installs +// the real hook over the runner's test project -- a whole-program compile, as the runner's own tests +// get, so the const enums it imports from core are inlined. +// +// Under nyc, `.ts` already has nyc's instrumenting wrapper. The throwaway hook is set *past* that +// wrapper, so this bootstrap copy is never measured: compiled differently, it would not line up with +// the coverage of the same files compiled by the real hook. nyc's wrapper is then put back before the +// real hook is installed, and the bootstrap copy dropped from the cache, so every runner file that is +// measured is compiled one way. +const ts = require(require.resolve("typescript", { paths: [ runnerDir ] })); +const srcDir = path.join(runnerDir, "src"); +const nycTsHook = Object.getOwnPropertyDescriptor(extensions, ".ts"); + +Object.defineProperty(extensions, ".ts", { + configurable: true, + enumerable: true, + writable: true, + value: function (module, filename) { + const output = ts.transpileModule(fs.readFileSync(filename, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2018, + esModuleInterop: true + }, + fileName: filename + }); + + module._compile(output.outputText, filename); + } +}); + +const bootCompile = require(path.join(srcDir, "compile/tsCompile.ts")); + +if (nycTsHook) { + Object.defineProperty(extensions, ".ts", nycTsHook); +} else { + delete extensions[".ts"]; +} + +Object.keys(require.cache).forEach((key) => { + if (key.indexOf(srcDir) === 0) { + delete require.cache[key]; + } +}); + +bootCompile.registerTypeScriptHook(path.join(runnerDir, "test/tsconfig.test.json"), runnerDir); + +const cliPath = path.join(runnerDir, "src/tripwire.ts"); +const cli = require(cliPath); + +function _format(args) { + return Array.prototype.map.call(args, (arg) => (arg && arg.stack) ? arg.stack : String(arg)).join(" "); +} + +// Loads the runner page at `runnerUrl`, as a browser would, then posts `report` as its run's results +function _postReport(runnerUrl, report) { + const target = new URL(runnerUrl); + const body = JSON.stringify(report); + + function send(method, reqPath, data, done) { + const req = http.request({ + host: target.hostname, + port: target.port, + method: method, + path: reqPath, + agent: false, + headers: data ? { "content-type": "application/json", "content-length": Buffer.byteLength(data) } : {} + }, (res) => { + res.resume(); + res.on("end", done); + }); + + req.on("error", () => { + // The run then never completes, and the test waiting on it times out with the output so far + }); + req.end(data); + } + + send("GET", target.pathname, null, () => send("POST", "/api/result", body, () => {})); +} + +// Forget the modules an invocation loaded from its project, so the next run registers its tests again +function _forgetProject(cwd) { + const prefix = path.resolve(cwd); + Object.keys(require.cache).forEach((key) => { + if (key.indexOf(prefix) === 0 && key.indexOf("node_modules") === -1) { + delete require.cache[key]; + } + }); +} + +(async function () { + const results = []; + const orgConsole = { log: console.log, warn: console.warn, error: console.error }; + + for (let lp = 0; lp < plan.length; lp++) { + const step = plan[lp]; + const out = []; + const err = []; + const edits = (step.watch && step.watch.edits) || []; + let reported = false; + let watching = 0; + let watchDone = null; + const watched = step.watch ? new Promise((resolve) => { + watchDone = resolve; + }) : null; + + console.log = function () { + const text = _format(arguments); + const at = text.indexOf("Runner URL: "); + if (step.report && !reported && at !== -1) { + reported = true; + _postReport(text.substring(at + "Runner URL: ".length).trim(), step.report); + } + + if (watched && text.indexOf("Watching ") !== -1) { + watching++; + if (watching > edits.length) { + watchDone(); + } else { + // The run starts its file watcher straight after saying so -- give it a moment + const edit = edits[watching - 1]; + setTimeout(() => fs.writeFileSync(path.resolve(step.cwd, edit.file), edit.text, "utf8"), 500); + } + } + + out.push(text); + }; + console.warn = function () { + err.push(_format(arguments)); + }; + console.error = function () { + err.push(_format(arguments)); + }; + + process.chdir(step.cwd); + process.argv = [ process.argv[0], cliPath ].concat(step.args); + process.exitCode = undefined; + + const orgEnv = {}; + Object.keys(step.env || {}).forEach((name) => { + orgEnv[name] = process.env[name]; + process.env[name] = step.env[name]; + }); + + const orgIsTTY = process.stdout.isTTY; + if (step.tty) { + process.stdout.isTTY = true; + } + + const childProcess = require("child_process"); + const orgSpawn = childProcess.spawn; + const spawned = []; + if (step.stubSpawn) { + childProcess.spawn = function (command, args) { + spawned.push([ command ].concat(args || [])); + + return { unref: () => {} }; + }; + } + + try { + const running = cli.main(); + await (watched ? Promise.race([ running, watched ]) : running); + } catch (e) { + err.push("launcher: " + _format([ e ])); + } + + console.log = orgConsole.log; + console.warn = orgConsole.warn; + console.error = orgConsole.error; + process.stdout.isTTY = orgIsTTY; + childProcess.spawn = orgSpawn; + Object.keys(orgEnv).forEach((name) => { + if (orgEnv[name] === undefined) { + delete process.env[name]; + } else { + process.env[name] = orgEnv[name]; + } + }); + + results.push({ args: step.args, exitCode: process.exitCode === undefined ? 0 : process.exitCode, out: out, err: err, spawned: spawned }); + process.exitCode = undefined; + _forgetProject(step.cwd); + } + + fs.writeFileSync(resultsFile, JSON.stringify(results, null, 2), "utf8"); + + // A watch step's run is still waiting on its files + process.exit(0); +})(); diff --git a/internal/runner/test/src/support/textImports.ts b/internal/runner/test/src/support/textImports.ts new file mode 100644 index 00000000..1ababe21 --- /dev/null +++ b/internal/runner/test/src/support/textImports.ts @@ -0,0 +1,31 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import * as fs from "fs"; +import { createRequire } from "module"; + +/** + * Lets the runner's browser UX load from source under node, the way its bundle does. The UX imports its + * html and css templates and its icon as modules, which the rollup build inlines as strings (the + * inlineText and inlineBinary plugins in rollup.config.js). Importing this module before anything that + * reaches the UX gives node the same loaders, so a test can import the browser runner, and everything + * that imports it, straight from src. + */ +const _extensions: any = (createRequire(__filename) as any).extensions; + +function _register(ext: string, read: (filename: string) => string): void { + if (!_extensions[ext]) { + _extensions[ext] = (module: any, filename: string) => { + module.exports = read(filename); + }; + } +} + +_register(".html", (filename) => fs.readFileSync(filename, "utf8")); +_register(".css", (filename) => fs.readFileSync(filename, "utf8")); +_register(".ico", (filename) => fs.readFileSync(filename).toString("base64")); diff --git a/internal/runner/test/src/support/uxDom.ts b/internal/runner/test/src/support/uxDom.ts new file mode 100644 index 00000000..cf883011 --- /dev/null +++ b/internal/runner/test/src/support/uxDom.ts @@ -0,0 +1,378 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +/** + * The parts of a browser the interactive page's modules (src/ux/browser/ui) touch, as plain objects, + * so those modules can be tested on node. + */ + +/** A listener added to a stand-in element or document. */ +interface IListener { + fn: (evt: any) => void; + capture: boolean; +} + +/** A small DOM event: its type, plus whatever the test sets on it (`key`, ...). */ +export function stubEvent(type: string, extra?: { [name: string]: any }): any { + const evt: any = { + type: type, + target: null, + defaultPrevented: false, + propagationStopped: false, + preventDefault: () => { + evt.defaultPrevented = true; + }, + stopPropagation: () => { + evt.propagationStopped = true; + } + }; + + return Object.assign(evt, extra || {}); +} + +function _listen(listeners: { [type: string]: IListener[] }, type: string, fn: (evt: any) => void, options?: any): void { + const capture = options === true || !!(options && options.capture); + (listeners[type] = listeners[type] || []).push({ fn: fn, capture: capture }); +} + +function _unlisten(listeners: { [type: string]: IListener[] }, type: string, fn: (evt: any) => void): void { + const found = listeners[type] || []; + for (let lp = found.length - 1; lp >= 0; lp--) { + if (found[lp].fn === fn) { + found.splice(lp, 1); + } + } +} + +function _fire(owner: any, evt: any, capture: boolean): void { + const found = ((owner && owner.listeners && owner.listeners[evt.type]) || []).slice(0); + for (let lp = 0; lp < found.length && !evt.propagationStopped; lp++) { + if (found[lp].capture === capture) { + found[lp].fn(evt); + } + } +} + +/** + * Whether `el` matches one simple selector: a tag name, `.class`, `#id`, or an attribute test such as + * `[role='menuitem']` or `[hidden]`. + */ +function _matches(el: any, selector: string): boolean { + const sel = selector.trim(); + const attr = /^\[([\w-]+)(?:=['"]?([^'"\]]*)['"]?)?\]$/.exec(sel); + if (attr) { + return attr[2] === undefined ? el.hasAttribute(attr[1]) : el.getAttribute(attr[1]) === attr[2]; + } + + if (sel.charAt(0) === ".") { + return el.classList.contains(sel.substring(1)); + } + + if (sel.charAt(0) === "#") { + return el.id === sel.substring(1); + } + + return String(el.tagName || "").toLowerCase() === sel.toLowerCase(); +} + +/** + * A plain object with the element members the ui modules under test touch. `kids` holds its children; + * setting `textContent` clears them, as a real element does, while reading it returns only the text + * set on this element (see {@link textOf} for the text of a whole subtree). + */ +export function stubElement(id: string, tagName?: string): any { + const attrs: { [name: string]: string } = {}; + const classes: string[] = []; + const listeners: { [type: string]: IListener[] } = {}; + let text = ""; + let html = ""; + + function _adopt(child: any): void { + if (child && child.parentNode && child.parentNode !== el && child.parentNode.removeChild) { + child.parentNode.removeChild(child); + } + + if (child) { + child.parentNode = el; + } + } + + const el: any = { + id: id, + tagName: (tagName || "").toUpperCase(), + value: "", + checked: false, + disabled: false, + open: false, + style: {}, + attrs: attrs, + kids: [] as any[], + listeners: listeners, + parentNode: null, + scrollTop: 0, + scrollHeight: 0, + clientHeight: 0, + scrolledIntoView: [] as any[], + setAttribute: (name: string, value: any) => { + attrs[name] = String(value); + }, + getAttribute: (name: string) => (name in attrs ? attrs[name] : null), + hasAttribute: (name: string) => name in attrs, + removeAttribute: (name: string) => { + delete attrs[name]; + }, + classList: { + add: (...names: string[]) => { + names.forEach((cls) => { + if (classes.indexOf(cls) === -1) { + classes.push(cls); + } + }); + }, + remove: (...names: string[]) => { + names.forEach((cls) => { + const idx = classes.indexOf(cls); + if (idx !== -1) { + classes.splice(idx, 1); + } + }); + }, + contains: (cls: string) => classes.indexOf(cls) !== -1, + toggle: (cls: string, force?: boolean) => { + const on = force === undefined ? classes.indexOf(cls) === -1 : !!force; + if (on) { + el.classList.add(cls); + } else { + el.classList.remove(cls); + } + + return on; + } + }, + appendChild: (child: any) => { + const added = child && child.fragment ? child.kids.slice(0) : [ child ]; + added.forEach(_adopt); + el.kids.push.apply(el.kids, added); + + return child; + }, + insertBefore: (child: any, ref: any) => { + _adopt(child); + const idx = el.kids.indexOf(ref); + el.kids.splice(idx === -1 ? el.kids.length : idx, 0, child); + + return child; + }, + removeChild: (child: any) => { + el.kids.splice(el.kids.indexOf(child), 1); + if (child) { + child.parentNode = null; + } + + return child; + }, + remove: () => { + if (el.parentNode) { + el.parentNode.removeChild(el); + } + }, + addEventListener: (type: string, fn: (evt: any) => void, options?: any) => _listen(listeners, type, fn, options), + removeEventListener: (type: string, fn: (evt: any) => void) => _unlisten(listeners, type, fn), + // Capture from the top down to this element, then bubble back up, then the document's listeners + dispatchEvent: (evt: any) => { + const path: any[] = []; + for (let node = el; node; node = node.parentNode) { + path.push(node); + } + + evt.target = evt.target || el; + for (let lp = path.length - 1; lp >= 0 && !evt.propagationStopped; lp--) { + _fire(path[lp], evt, true); + } + + for (let lp = 0; lp < path.length && !evt.propagationStopped; lp++) { + _fire(path[lp], evt, false); + } + + const doc = (globalThis as any).document; + if (doc && doc.listeners && !evt.propagationStopped) { + _fire(doc, evt, false); + } + + return !evt.defaultPrevented; + }, + click: () => el.dispatchEvent(stubEvent("click")), + focus: () => { + const doc = (globalThis as any).document; + if (doc) { + doc.activeElement = el; + } + }, + matches: (selector: string) => _matches(el, selector), + closest: (selector: string) => { + for (let node = el; node && node.matches; node = node.parentNode) { + if (node.matches(selector)) { + return node; + } + } + + return null; + }, + contains: (node: any) => { + for (let current = node; current; current = current.parentNode) { + if (current === el) { + return true; + } + } + + return false; + }, + querySelectorAll: (selector: string) => { + const found: any[] = []; + const visit = (node: any) => { + (node.kids || []).forEach((child: any) => { + if (child && child.matches && child.matches(selector)) { + found.push(child); + } + + if (child) { + visit(child); + } + }); + }; + visit(el); + + return found; + }, + querySelector: (selector: string) => el.querySelectorAll(selector)[0] || null, + scrollIntoView: (options?: any) => { + el.scrolledIntoView.push(options); + } + }; + + Object.defineProperty(el, "textContent", { + get: () => text, + set: (value: string) => { + text = value; + el.kids.length = 0; + } + }); + Object.defineProperty(el, "innerHTML", { + get: () => html, + set: (value: string) => { + html = value; + el.kids.length = 0; + } + }); + Object.defineProperty(el, "className", { + get: () => classes.join(" "), + set: (value: string) => { + classes.length = 0; + String(value || "").split(/\s+/).filter(Boolean).forEach((cls) => classes.push(cls)); + } + }); + Object.defineProperty(el, "children", { get: () => el.kids }); + Object.defineProperty(el, "parentElement", { get: () => el.parentNode }); + Object.defineProperty(el, "childElementCount", { get: () => el.kids.length }); + Object.defineProperty(el, "firstElementChild", { get: () => el.kids[0] || null }); + + return el; +} + +/** The text of an element and everything under it, the way a page would show it. */ +export function textOf(el: any): string { + if (!el) { + return ""; + } + + return (el.textContent || "") + (el.kids || []).map(textOf).join(""); +} + +/** + * A stand-in `document` for code that builds elements while it runs. `getElementById` reads the same + * registry as {@link uxDom} -- creating a missing element when `createMissing` is set, as the page's + * `./dom` needs when it loads -- and document-level listeners are kept so a test can fire them. + */ +export function stubDocument(registry?: { [id: string]: any }, createMissing?: boolean): any { + const ids = registry || {}; + const listeners: { [type: string]: IListener[] } = {}; + const doc: any = { + body: stubElement("body", "body"), + activeElement: null, + listeners: listeners, + createElement: (tag: string) => stubElement("", tag), + createDocumentFragment: () => { + const fragment: any = { fragment: true, kids: [] as any[] }; + fragment.appendChild = (child: any) => { + fragment.kids.push(child); + }; + + return fragment; + }, + getElementById: (id: string) => ids[id] || (createMissing ? (ids[id] = stubElement(id)) : null), + addEventListener: (type: string, fn: (evt: any) => void, options?: any) => _listen(listeners, type, fn, options), + removeEventListener: (type: string, fn: (evt: any) => void) => _unlisten(listeners, type, fn) + }; + + return doc; +} + +/** + * Installs a {@link stubDocument} as the global `document` and returns what puts the previous one + * back. Meant for a beforeEach/afterEach pair: nothing outside the test should see a document. + */ +export function useStubDocument(registry?: { [id: string]: any }): { document: any, restore: () => void } { + const g = globalThis as any; + const had = Object.prototype.hasOwnProperty.call(g, "document"); + const previous = g.document; + const doc = stubDocument(registry); + g.document = doc; + + return { + document: doc, + restore: () => { + if (had) { + g.document = previous; + } else { + delete g.document; + } + } + }; +} + +/** + * `./dom` looks every element it exports up from `document` once, as it loads, and node has no + * `document`. This hands it the smallest stand-in that lets it load -- one plain object per id, + * created on demand -- and takes `document` away again straight afterwards so nothing else in the + * process can mistake this for a browser. The objects live in a registry on `globalThis` because + * `./dom` loads once per process, for whichever test file gets there first: every file that needs + * them has to read the same objects back, whoever created them. + */ +export function uxDom(): { [id: string]: any } { + const g = globalThis as any; + const registry = g.__tripwireUxDomStub || (g.__tripwireUxDomStub = {}); + const hadDocument = !!g.document; + + if (!hadDocument) { + g.document = { + body: null, + getElementById: (id: string) => registry[id] || (registry[id] = stubElement(id)) + }; + } + + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + require("../../../src/ux/browser/ui/dom"); + } finally { + if (!hadDocument) { + delete g.document; + } + } + + return registry; +} diff --git a/internal/runner/test/src/ux/assertions.test.ts b/internal/runner/test/src/ux/assertions.test.ts index 8ca84d44..0bb1787e 100644 --- a/internal/runner/test/src/ux/assertions.test.ts +++ b/internal/runner/test/src/ux/assertions.test.ts @@ -11,104 +11,18 @@ import { assertionDomQueue, assertionFlushScheduled, clearAllState, logSendQueue, setAssertionFlushScheduled, setCurrentExecutingTestKey, setLastObservedTestKey, testState } from "../../../src/ux/browser/ui/state"; +import { useStubDocument, uxDom } from "../support/uxDom"; /** * How an assertion reported by the running tests finds the test node it belongs to and lands in * the render queue. The tests here stop at that queue: the queued entry is what the rendering * module draws, and everything up to it is plain bookkeeping over the shared state. * - * `_handleAssertionEvent` with `suiteParts`/`testName` is not covered -- it builds the test's - * elements through `ensureTestNode`, which needs a real `document`. + * `_handleAssertionEvent` with `suiteParts`/`testName` builds the test's elements through + * `ensureTestNode`, so that one case runs with the stand-in `document` of ../support/uxDom.ts. */ -/** - * `./dom` looks every element it exports up from `document` once, as it loads, and node has no - * `document`. This hands it the smallest stand-in that lets it load -- one plain object per id, - * created on demand -- and takes `document` away again straight afterwards so nothing else in the - * process can mistake this for a browser. The objects live in a registry on `globalThis` because - * `./dom` loads once per process, for whichever test file gets there first: every file that needs - * them has to read the same objects back, whoever created them. - */ -function _uxDom(): { [id: string]: any } { - const g = globalThis as any; - const registry = g.__tripwireUxDomStub || (g.__tripwireUxDomStub = {}); - const hadDocument = !!g.document; - - if (!hadDocument) { - g.document = { - body: null, - getElementById: (id: string) => registry[id] || (registry[id] = _stubElement(id)) - }; - } - - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - require("../../../src/ux/browser/ui/dom"); - } finally { - if (!hadDocument) { - delete g.document; - } - } - - return registry; -} - -/** A plain object with the handful of element members the ui modules under test touch. */ -function _stubElement(id: string): any { - const attrs: { [name: string]: string } = {}; - const classes: string[] = []; - let text = ""; - const el: any = { - id: id, - value: "", - checked: false, - disabled: false, - style: {}, - attrs: attrs, - kids: [] as any[], - setAttribute: (name: string, value: any) => { - attrs[name] = String(value); - }, - getAttribute: (name: string) => (name in attrs ? attrs[name] : null), - removeAttribute: (name: string) => { - delete attrs[name]; - }, - classList: { - add: (cls: string) => { - if (classes.indexOf(cls) === -1) { - classes.push(cls); - } - }, - remove: (cls: string) => { - const idx = classes.indexOf(cls); - if (idx !== -1) { - classes.splice(idx, 1); - } - }, - contains: (cls: string) => classes.indexOf(cls) !== -1 - }, - appendChild: (child: any) => { - el.kids.push.apply(el.kids, child && child.fragment ? child.kids : [child]); - }, - removeChild: (child: any) => { - el.kids.splice(el.kids.indexOf(child), 1); - } - }; - - Object.defineProperty(el, "textContent", { - get: () => text, - set: (value: string) => { - text = value; - el.kids.length = 0; - } - }); - Object.defineProperty(el, "childElementCount", { get: () => el.kids.length }); - Object.defineProperty(el, "firstElementChild", { get: () => el.kids[0] || null }); - - return el; -} - -_uxDom(); +uxDom(); // eslint-disable-next-line @typescript-eslint/no-var-requires const assertions: typeof import("../../../src/ux/browser/ui/assertions") = require("../../../src/ux/browser/ui/assertions"); // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -253,6 +167,22 @@ describe("ux assertions", () => { assert.equal(assertionDomQueue.length, 0); assert.deepEqual(logSendQueue, [{ level: "warn", message: "Assertion received without active test context" }]); }); + + it("should draw the test an addressed assertion names, and attribute it there", () => { + const doc = useStubDocument(); + let queued: any[]; + try { + setCurrentExecutingTestKey("some other :: test"); + assertions._handleAssertionEvent({ suiteParts: [ "outer" ], testName: "named", passed: true, label: "equal" }); + queued = assertionDomQueue.slice(0); + } finally { + doc.restore(); + // The drawn test is not this file's to keep + uiRendering.clearResults(null); + } + + assert.deepEqual(queued, [{ testKey: "outer :: named", passed: true, label: "equal", errorText: "" }]); + }); }); describe("handleAssertion", () => { diff --git a/internal/runner/test/src/ux/client.test.ts b/internal/runner/test/src/ux/client.test.ts new file mode 100644 index 00000000..90fbd1bb --- /dev/null +++ b/internal/runner/test/src/ux/client.test.ts @@ -0,0 +1,619 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import * as path from "path"; +import { assert } from "@nevware21/tripwire"; +import { hashSelectionValue } from "../../../src/ux/browser/ui/urlState"; +import { stubDocument, stubEvent, textOf } from "../support/uxDom"; + +/** + * The interactive runner page itself: it starts the test frame (browser target) or the worker (worker + * target), runs the tests once they are found, draws what they report, and sends the results back to + * the CLI. The page starts as it loads and reads its target from the URL once, so each test loads a + * fresh copy of the page's modules, with its own elements, URL, `fetch`, timers and workers stood in + * for. The copies this suite's other files loaded are put back afterwards. + */ + +const UI_DIR = path.resolve(__dirname, "../../../src/ux/browser/ui"); + +const DISCOVERY = { + suites: [ "frame suite" ], + tests: [ + { suiteTitle: "frame suite", testName: "passes", fullTitle: "frame suite > passes" }, + { suiteTitle: "frame suite", testName: "fails", fullTitle: "frame suite > fails" } + ] +}; + +const LIFECYCLE = [ + { kind: "suiteStart", suiteParts: [ "frame suite" ] }, + { kind: "testStart", suiteParts: [ "frame suite" ], testName: "passes" }, + { kind: "assert", suiteParts: [ "frame suite" ], testName: "passes", passed: true, label: "equal" }, + { kind: "testDone", suiteParts: [ "frame suite" ], testName: "passes", endStatus: "passed", durationMs: 1 }, + { kind: "testStart", suiteParts: [ "frame suite" ], testName: "fails" }, + { kind: "testDone", suiteParts: [ "frame suite" ], testName: "fails", endStatus: "failed", durationMs: 2, endErrorText: "boom" }, + { kind: "suiteDone", suiteParts: [ "frame suite" ], durationMs: 3 }, + null +]; + +interface IPage { + registry: { [id: string]: any }; + document: any; + state: any; + logging: any; + listeners: { [type: string]: (evt: any) => void }; + parentPosts: any[]; + framePosts: any[]; + fetches: Array<{ url: string, init: any }>; + intervals: Array<() => void>; + workers: any[]; + hashWrites: string[]; + location: any; + generation: number | null | "fail" | "error"; + workerThrows: string; + frame(): any; + status(): string; + message(data: any): void; +} + +describe("ux client", function () { + this.timeout(30000); + + const g = globalThis as any; + const GLOBALS = [ "document", "window", "location", "history", "fetch", "requestAnimationFrame", "Worker" ]; + let saved: { [name: string]: any }; + let orgConsole: { [level: string]: any }; + let pages: IPage[]; + + // Lets the page's promises, and the zero-delay timers they wait on, run + async function settle(): Promise { + for (let round = 0; round < 2; round++) { + for (let lp = 0; lp < 10; lp++) { + await new Promise((resolve) => setImmediate(resolve)); + } + + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + + interface ILoadOptions { + // The URL hash the page loads with + hash?: string; + // Elements the page does not have + omit?: string[]; + // Sending the result to the CLI fails + resultFails?: boolean; + } + + function loadPage(pathname: string, search?: string, bodyAttrs?: { [name: string]: string }, options?: ILoadOptions): IPage { + const opts = options || {}; + const page: IPage = { + registry: {}, + document: null, + state: null, + logging: null, + listeners: {}, + parentPosts: [], + framePosts: [], + fetches: [], + intervals: [], + workers: [], + hashWrites: [], + location: null, + generation: null, + workerThrows: "", + frame: () => page.registry.testFrameContainer.kids[page.registry.testFrameContainer.kids.length - 1], + status: () => page.registry.status.textContent, + message: (data: any) => page.listeners.message({ data: data }) + }; + + page.location = { href: "http://localhost:4321" + pathname + (search || "") + (opts.hash || ""), pathname: pathname, search: search || "", hash: opts.hash || "" }; + page.document = stubDocument(page.registry, true); + const getElementById = page.document.getElementById; + page.document.getElementById = (id: string) => ((opts.omit || []).indexOf(id) !== -1 ? null : getElementById(id)); + Object.keys(bodyAttrs || {}).forEach((name) => page.document.body.setAttribute(name, bodyAttrs[name])); + + g.document = page.document; + g.location = page.location; + g.history = { replaceState: (_state: any, _title: string, url: string) => page.hashWrites.push(url) }; + g.window = { + addEventListener: (type: string, fn: (evt: any) => void) => { + page.listeners[type] = fn; + }, + parent: { postMessage: (msg: any) => page.parentPosts.push(msg) } + }; + g.requestAnimationFrame = (cb: () => void) => { + Promise.resolve().then(cb); + return 1; + }; + g.fetch = (url: string, init?: any) => { + page.fetches.push({ url: url, init: init }); + if (url.indexOf("/__tripwire__/check-fresh") !== -1) { + if (page.generation === "error") { + return Promise.reject(new Error("offline")); + } + + return Promise.resolve({ ok: page.generation !== "fail", json: () => Promise.resolve({ generation: page.generation }) }); + } + + if (opts.resultFails && url.indexOf("/api/result") !== -1) { + return Promise.reject(new Error("the CLI went away")); + } + + if (url.indexOf("/api/payload-") !== -1) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ tripwireModuleId: "/tripwire", entryIds: [], settings: {} }) }); + } + + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + }; + g.Worker = function (this: any, url: string) { + if (page.workerThrows) { + throw new Error(page.workerThrows); + } + + const worker: any = this; + worker.url = url; + worker.posted = []; + worker.terminated = false; + worker.postMessage = (msg: any) => worker.posted.push(msg); + worker.terminate = () => { + worker.terminated = true; + }; + page.workers.push(worker); + }; + + // A fresh copy of the page, against elements of its own; this suite's other files keep theirs + const loaded: { [key: string]: any } = {}; + Object.keys(require.cache).forEach((key) => { + if (key.indexOf(UI_DIR) === 0) { + loaded[key] = require.cache[key]; + delete require.cache[key]; + } + }); + const orgRegistry = g.__tripwireUxDomStub; + const orgSetInterval = g.setInterval; + g.__tripwireUxDomStub = page.registry; + g.setInterval = (fn: () => void) => { + page.intervals.push(fn); + return page.intervals.length; + }; + + try { + require(path.join(UI_DIR, "client.ts")); + page.state = require(path.join(UI_DIR, "state.ts")); + page.logging = require(path.join(UI_DIR, "logging.ts")); + pages.push(page); + } finally { + g.setInterval = orgSetInterval; + g.__tripwireUxDomStub = orgRegistry; + Object.keys(require.cache).forEach((key) => { + if (key.indexOf(UI_DIR) === 0) { + delete require.cache[key]; + } + }); + Object.keys(loaded).forEach((key) => { + require.cache[key] = loaded[key]; + }); + + // The page copies the console into its log panel, for good + Object.keys(orgConsole).forEach((level) => { + (console as any)[level] = orgConsole[level]; + }); + } + + // The test frame answers through its window + if (page.registry.testFrameContainer && page.registry.testFrameContainer.kids.length) { + page.frame().contentWindow = { postMessage: (msg: any) => page.framePosts.push(msg) }; + } + + return page; + } + + function posted(page: IPage, urlPart: string): any[] { + return page.fetches.filter((call) => call.url.indexOf(urlPart) !== -1 && call.init && call.init.method === "POST") + .map((call) => JSON.parse(call.init.body)); + } + + async function readyAndRun(page: IPage): Promise { + page.message({ kind: "ready", discovery: DISCOVERY }); + page.message({ kind: "lifecycleBatch", events: LIFECYCLE }); + page.message({ kind: "runDone", summary: { total: 2, passed: 1, failed: 1 }, coverage: null }); + await settle(); + } + + beforeEach(() => { + saved = {}; + GLOBALS.forEach((name) => { + saved[name] = g[name]; + }); + orgConsole = { log: console.log, info: console.info, warn: console.warn, error: console.error }; + pages = []; + }); + + afterEach(async () => { + // Anything the page left scheduled runs while its stand-ins are still there + await settle(); + + // A page sends what it logged to the CLI 50ms later, from a timer of its own -- by then the + // stand-ins below are gone, and with no location the log URL cannot be resolved (an unhandled + // rejection that ends the run). So each page sends it now, while they are still here. + for (let lp = 0; lp < pages.length; lp++) { + await pages[lp].logging.flushLogsNow(); + } + GLOBALS.forEach((name) => { + if (saved[name] === undefined) { + delete g[name]; + } else { + g[name] = saved[name]; + } + }); + }); + + describe("browser target", () => { + it("starts the test frame, hidden, and polls for changed files", () => { + const page = loadPage("/__tripwire__/browser"); + const frame = page.frame(); + + assert.equal(frame.tagName, "IFRAME"); + assert.equal(frame.id, "test-frame"); + assert.equal(frame.src, "http://localhost:4321/__tripwire__/test-frame?target=browser"); + assert.equal(frame.style.display, "none"); + assert.equal(page.status(), "Initializing..."); + assert.equal(page.intervals.length, 1); + assert.equal(page.registry.targetSelect.value, "browser"); + }); + + it("runs once the frame is ready, draws the results, and sends them to the CLI on Finish", async () => { + const page = loadPage("/__tripwire__/browser"); + + page.message({ kind: "ready", discovery: DISCOVERY }); + assert.deepEqual(page.framePosts, [ { kind: "run", selection: { suiteTitle: "", testTitle: "" } } ]); + assert.isTrue(page.state.running); + assert.equal(page.status(), "Running tests in browser mode..."); + + page.message({ kind: "lifecycleBatch", events: LIFECYCLE }); + assert.includes(textOf(page.registry.results), "frame suite"); + assert.includes(textOf(page.registry.results), "Test failed -> boom"); + + page.message({ kind: "runDone", summary: { total: 2 }, coverage: { a: 1 } }); + await settle(); + assert.equal(page.status(), "Run complete. You can run again or click Finish to return to CLI."); + assert.deepEqual(page.state.lastResult, { summary: { total: 2 }, coverage: { a: 1 } }); + + page.registry.finishBtn.click(); + await settle(); + assert.deepEqual(posted(page, "/api/result"), [ { summary: { total: 2 }, coverage: { a: 1 } } ]); + assert.equal(page.status(), "Completed. You can close this tab."); + + page.registry.finishBtn.click(); + await settle(); + assert.equal(posted(page, "/api/result").length, 1, "the result is only sent once"); + }); + + it("asks the frame to stop when Run is clicked mid-run, and runs again when idle", async () => { + const page = loadPage("/__tripwire__/browser"); + page.message({ kind: "ready", discovery: DISCOVERY }); + + page.registry.runBtn.click(); + assert.deepEqual(page.framePosts[1], { kind: "stop" }); + assert.equal(page.status(), "Stopping run..."); + + page.message({ kind: "runDone", summary: {} }); + await settle(); + assert.equal(page.status(), "Run stopped. You can run again or finish."); + + page.registry.runBtn.click(); + assert.deepEqual(page.framePosts[2], { kind: "run", selection: { suiteTitle: "", testTitle: "" } }); + }); + + it("logs a run the frame failed, and ignores results nobody is waiting for", async () => { + const page = loadPage("/__tripwire__/browser"); + page.message({ kind: "runDone", summary: {} }); + page.message({ kind: "runError", error: "early" }); + page.message({ kind: "ready", discovery: DISCOVERY }); + + page.message({ kind: "runError" }); + await settle(); + + assert.equal(page.status(), "Run failed. See logs below."); + assert.includes(textOf(page.registry.logs), "Unknown frame error"); + }); + + it("draws progress sent one message at a time, logs what the frame logs, and ignores anything else", async () => { + const page = loadPage("/__tripwire__/browser"); + page.message({ kind: "ready", discovery: DISCOVERY }); + + LIFECYCLE.filter(Boolean).forEach((evt) => page.message(evt)); + page.message({ kind: "log", level: "warn", message: "from the frame" }); + page.message({ kind: "log" }); + page.message({ kind: "unknown" }); + page.listeners.message({}); + page.listeners.message(null); + await settle(); + + assert.includes(textOf(page.registry.results), "passes"); + assert.includes(textOf(page.registry.logs), "from the frame"); + }); + + it("runs a single test again from its rerun button, keeping the choice in the URL", async () => { + const page = loadPage("/__tripwire__/browser"); + await readyAndRun(page); + const buttons = page.registry.results.querySelectorAll(".rerun-btn"); + const testButton = buttons.filter((btn: any) => btn.getAttribute("data-rerun-test") === "frame suite > passes")[0]; + + const evt = stubEvent("click"); + testButton.dispatchEvent(evt); + + assert.isTrue(evt.defaultPrevented); + assert.deepEqual(page.framePosts[page.framePosts.length - 1], { kind: "run", selection: { suiteTitle: "", testTitle: "frame suite > passes" } }); + assert.equal(page.registry.scopeSelect.value, "frame suite > passes"); + assert.equal(page.hashWrites.length, 1); + + const runs = page.framePosts.length; + testButton.click(); + assert.equal(page.framePosts.length, runs, "not while a run is going"); + page.message({ kind: "runDone", summary: {} }); + await settle(); + + buttons.filter((btn: any) => !btn.getAttribute("data-rerun-test"))[0].click(); + assert.deepEqual(page.framePosts[page.framePosts.length - 1].selection, { suiteTitle: "frame suite", testTitle: "" }); + + page.message({ kind: "lifecycleBatch", events: LIFECYCLE }); + page.message({ kind: "runDone", summary: {} }); + await settle(); + page.registry.results.querySelector(".test-label").click(); + page.registry.results.click(); + assert.equal(page.framePosts.length, runs + 1, "a click that is not on a rerun button runs nothing"); + }); + + it("restores the scope from the URL once the tests are found", () => { + const page = loadPage("/__tripwire__/browser", "", {}, { hash: "#test=" + hashSelectionValue("frame suite > passes") }); + + page.message({ kind: "ready", discovery: DISCOVERY }); + + assert.deepEqual(page.framePosts[0].selection, { suiteTitle: "", testTitle: "frame suite > passes" }); + }); + + it("puts the test frame in the body of a page without a frame container", () => { + const page = loadPage("/__tripwire__/browser", "", {}, { omit: [ "testFrameContainer" ] }); + + assert.equal(page.document.body.kids[0].id, "test-frame"); + }); + + it("offers the suites and tests it found as scope suggestions", () => { + const page = loadPage("/__tripwire__/browser"); + page.message({ kind: "ready", discovery: DISCOVERY }); + + page.registry.scopeSelect.value = "pass"; + page.registry.scopeSelect.dispatchEvent(stubEvent("input")); + + assert.includes(textOf(page.registry.scopeSelectList), "frame suite > passes"); + }); + + it("keeps Finish available when the result cannot be sent to the CLI", async () => { + const page = loadPage("/__tripwire__/browser", "", {}, { resultFails: true }); + await readyAndRun(page); + + page.registry.finishBtn.click(); + await settle(); + + assert.equal(page.status(), "Failed to send result to CLI. Check connection and retry."); + assert.isFalse(page.state.completed); + }); + + it("reloads the frame when the build changes, unless auto reload is off or a run is going", async () => { + const page = loadPage("/__tripwire__/browser"); + const poll = page.intervals[0]; + await readyAndRun(page); + const firstFrame = page.frame(); + + page.generation = 1; + poll(); + await settle(); + assert.equal(page.frame(), firstFrame, "the first answer is only remembered"); + + page.generation = "fail"; + poll(); + page.generation = "error"; + poll(); + await settle(); + assert.equal(page.frame(), firstFrame); + + page.registry.autoReloadToggle.checked = false; + page.registry.autoReloadToggle.dispatchEvent(stubEvent("change")); + page.generation = 2; + poll(); + await settle(); + assert.isFalse(page.state.autoReloadEnabled); + assert.equal(page.frame(), firstFrame, "auto reload is off"); + + page.registry.autoReloadToggle.checked = true; + page.registry.autoReloadToggle.dispatchEvent(stubEvent("change")); + poll(); + await settle(); + assert.notEqual(page.frame(), firstFrame); + assert.equal(page.registry.testFrameContainer.kids.length, 1, "the old frame is gone"); + assert.equal(page.status(), "Reloading tests..."); + }); + + it("reloads on the Reload button, giving up the run in progress", async () => { + const page = loadPage("/__tripwire__/browser"); + page.message({ kind: "ready", discovery: DISCOVERY }); + const firstFrame = page.frame(); + + page.registry.reloadBtn.click(); + await settle(); + + assert.isFalse(page.state.running); + assert.notEqual(page.frame(), firstFrame, "a new frame"); + }); + + it("clears the results and the logs, and filters to failed tests, from the toolbar", async () => { + const page = loadPage("/__tripwire__/browser"); + await readyAndRun(page); + + page.registry.failedOnlyToggle.checked = true; + page.registry.failedOnlyToggle.dispatchEvent(stubEvent("change")); + page.registry.clearResultsBtn.click(); + page.registry.clearLogsBtn.click(); + + assert.equal(page.registry.results.kids.length, 0); + assert.equal(page.registry.assertCount.textContent, "0 assertions"); + }); + + it("switches to the other target, keeping the rest of the URL", () => { + const page = loadPage("/__tripwire__/browser", "?x=1"); + + page.registry.targetSelect.dispatchEvent(stubEvent("change")); + assert.equal(page.location.href, "http://localhost:4321/__tripwire__/browser?x=1", "the same target changes nothing"); + + page.registry.targetSelect.value = "worker"; + page.registry.targetSelect.dispatchEvent(stubEvent("change")); + assert.equal(page.location.href, "/__tripwire__/worker?x=1"); + assert.equal(page.status(), "Switching to worker target..."); + }); + + it("keeps the scope picker's choice in the URL, and clears it", async () => { + const page = loadPage("/__tripwire__/browser"); + await readyAndRun(page); + + page.registry.scopeSelect.value = "frame suite"; + page.registry.scopeSelect.dispatchEvent(stubEvent("change")); + assert.equal(page.hashWrites.length, 1); + + page.registry.clearScopeBtn.click(); + assert.equal(page.registry.scopeSelect.value, ""); + assert.equal(page.hashWrites.length, 2); + assert.equal(page.document.activeElement, page.registry.runBtn); + + page.registry.runBtn.click(); + page.registry.scopeSelect.value = "frame suite"; + page.registry.clearScopeBtn.click(); + assert.equal(page.registry.scopeSelect.value, "frame suite", "not while a run is going"); + }); + + it("sends the result to the CLI by itself when it finishes on its own", async () => { + const page = loadPage("/__tripwire__/browser", "?autofinish=1"); + + assert.equal(page.frame().src, "http://localhost:4321/__tripwire__/test-frame?target=browser&autofinish=1"); + assert.equal(page.intervals.length, 0, "nothing to reload"); + + await readyAndRun(page); + assert.deepEqual(posted(page, "/api/result"), [ { summary: { total: 2, passed: 1, failed: 1 }, coverage: null } ]); + }); + + it("sends a failed run to the CLI by itself when it finishes on its own", async () => { + const page = loadPage("/__tripwire__/browser", "?autofinish=1"); + + page.message({ kind: "ready", discovery: DISCOVERY }); + page.message({ kind: "runError", error: "frame crashed" }); + await settle(); + + assert.includes(posted(page, "/api/result")[0].error, "frame crashed"); + assert.isTrue(page.state.completed); + }); + + it("runs the frame's own code when it is the test frame", async () => { + const page = loadPage("/__tripwire__/test-frame", "", { "data-runner-mode": "frame" }); + await settle(); + + assert.equal(page.registry.testFrameContainer.kids.length, 0, "no frame inside the frame"); + assert.isTrue(page.parentPosts.length > 0, "it reports to the page above it"); + }); + + it("reports ready without looking for tests when the test frame finishes on its own", async () => { + const page = loadPage("/__tripwire__/test-frame", "?autofinish=1", { "data-runner-mode": "frame" }); + await settle(); + + assert.deepEqual(page.parentPosts[0], { kind: "ready", discovery: { suites: [], tests: [] } }); + }); + }); + + describe("worker target", () => { + it("discovers the tests in a worker, then runs them in another", async () => { + const page = loadPage("/__tripwire__/worker"); + await settle(); + + const discover = page.workers[0]; + assert.equal(discover.url, "http://localhost:4321/api/worker-script"); + assert.equal(discover.posted[0].kind, "discover"); + discover.onmessage({ data: { kind: "log", level: "info", message: "discovering" } }); + discover.onmessage({ data: { kind: "other" } }); + discover.onmessage({ data: { kind: "discovery", discovery: DISCOVERY } }); + discover.onmessage({ data: { kind: "discovery", discovery: DISCOVERY } }); + await settle(); + assert.isTrue(discover.terminated); + + const run = page.workers[1]; + assert.deepEqual(run.posted[0].selection, { suiteTitle: "", testTitle: "" }); + assert.isFalse(run.posted[0].autoFinish); + assert.equal(page.state.activeWorker, run); + + LIFECYCLE.filter(Boolean).forEach((evt) => run.onmessage({ data: evt })); + run.onmessage({ data: { kind: "log", message: "from the worker" } }); + run.onmessage({ data: { summary: { total: 2 } } }); + await settle(); + + assert.includes(textOf(page.registry.results), "frame suite"); + assert.includes(textOf(page.registry.logs), "from the worker"); + assert.includes(textOf(page.registry.logs), "discovering"); + assert.isNull(page.state.activeWorker); + assert.equal(page.status(), "Run complete. You can run again or click Finish to return to CLI."); + }); + + it("warns when discovery fails, and reports a worker that crashes", async () => { + const page = loadPage("/__tripwire__/worker"); + await settle(); + + page.workers[0].onmessage({ data: { kind: "discovery", error: "no such module" } }); + await settle(); + page.workers[1].onerror({ message: "worker crashed" }); + await settle(); + + assert.includes(textOf(page.registry.logs), "Test discovery failed, the Scope picker will be empty: no such module"); + assert.includes(textOf(page.registry.logs), "worker crashed"); + assert.equal(page.status(), "Run failed. See logs below."); + }); + + it("carries on without discovery when its worker errors", async () => { + const page = loadPage("/__tripwire__/worker"); + await settle(); + + page.workers[0].onerror({}); + await settle(); + page.workers[1].onerror(null); + await settle(); + + assert.includes(textOf(page.registry.logs), "Test discovery failed, the Scope picker will be empty: Worker execution failed."); + assert.includes(textOf(page.registry.logs), "Worker execution failed."); + }); + + it("reports that a worker cannot be started", async () => { + const page = loadPage("/__tripwire__/worker", "", {}); + page.workerThrows = "workers are blocked"; + page.registry.reloadBtn.click(); + await settle(); + + assert.includes(textOf(page.registry.logs), "Unable to discover tests: workers are blocked"); + assert.equal(page.status(), "Run failed. See logs below."); + }); + + it("skips discovery and the progress messages when it finishes on its own", async () => { + const page = loadPage("/__tripwire__/worker", "?autofinish=1"); + await settle(); + + const run = page.workers[0]; + assert.isTrue(run.posted[0].autoFinish); + LIFECYCLE.filter(Boolean).forEach((evt) => run.onmessage({ data: evt })); + run.onmessage({ data: { summary: { total: 2 } } }); + await settle(); + + assert.equal(page.registry.results.kids.length, 0, "nothing drawn"); + assert.deepEqual(posted(page, "/api/result"), [ { summary: { total: 2 } } ]); + }); + }); +}); diff --git a/internal/runner/test/src/ux/filtering.test.ts b/internal/runner/test/src/ux/filtering.test.ts index 1395a229..01004f37 100644 --- a/internal/runner/test/src/ux/filtering.test.ts +++ b/internal/runner/test/src/ux/filtering.test.ts @@ -9,6 +9,7 @@ import { assert } from "@nevware21/tripwire"; import { clearAllState, suiteState, testState } from "../../../src/ux/browser/ui/state"; import { getSuiteKeyChain, makeSuiteKey, makeTestKey } from "../../../src/shared/suiteNames"; +import { uxDom } from "../support/uxDom"; /** * The interactive page's "Failed only" view filter. @@ -23,94 +24,7 @@ import { getSuiteKeyChain, makeSuiteKey, makeTestKey } from "../../../src/shared * view no longer changes the result"). */ -/** - * `./dom` looks every element it exports up from `document` once, as it loads, and node has no - * `document`. This hands it the smallest stand-in that lets it load -- one plain object per id, - * created on demand -- and takes `document` away again straight afterwards so nothing else in the - * process can mistake this for a browser. The objects live in a registry on `globalThis` because - * `./dom` loads once per process, for whichever test file gets there first: every file that needs - * them has to read the same objects back, whoever created them. - */ -function _uxDom(): { [id: string]: any } { - const g = globalThis as any; - const registry = g.__tripwireUxDomStub || (g.__tripwireUxDomStub = {}); - const hadDocument = !!g.document; - - if (!hadDocument) { - g.document = { - body: null, - getElementById: (id: string) => registry[id] || (registry[id] = _stubElement(id)) - }; - } - - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - require("../../../src/ux/browser/ui/dom"); - } finally { - if (!hadDocument) { - delete g.document; - } - } - - return registry; -} - -/** A plain object with the handful of element members the ui modules under test touch. */ -function _stubElement(id: string): any { - const attrs: { [name: string]: string } = {}; - const classes: string[] = []; - let text = ""; - const el: any = { - id: id, - value: "", - checked: false, - disabled: false, - style: {}, - attrs: attrs, - kids: [] as any[], - setAttribute: (name: string, value: any) => { - attrs[name] = String(value); - }, - getAttribute: (name: string) => (name in attrs ? attrs[name] : null), - removeAttribute: (name: string) => { - delete attrs[name]; - }, - classList: { - add: (cls: string) => { - if (classes.indexOf(cls) === -1) { - classes.push(cls); - } - }, - remove: (cls: string) => { - const idx = classes.indexOf(cls); - if (idx !== -1) { - classes.splice(idx, 1); - } - }, - contains: (cls: string) => classes.indexOf(cls) !== -1 - }, - appendChild: (child: any) => { - el.kids.push.apply(el.kids, child && child.fragment ? child.kids : [child]); - }, - removeChild: (child: any) => { - el.kids.splice(el.kids.indexOf(child), 1); - } - }; - - Object.defineProperty(el, "textContent", { - get: () => text, - set: (value: string) => { - text = value; - el.kids.length = 0; - } - }); - Object.defineProperty(el, "childElementCount", { get: () => el.kids.length }); - Object.defineProperty(el, "firstElementChild", { get: () => el.kids[0] || null }); - - return el; -} - -const dom = _uxDom(); +const dom = uxDom(); // eslint-disable-next-line @typescript-eslint/no-var-requires const filtering: typeof import("../../../src/ux/browser/ui/filtering") = require("../../../src/ux/browser/ui/filtering"); diff --git a/internal/runner/test/src/ux/frameMode.test.ts b/internal/runner/test/src/ux/frameMode.test.ts new file mode 100644 index 00000000..2abdc9b0 --- /dev/null +++ b/internal/runner/test/src/ux/frameMode.test.ts @@ -0,0 +1,245 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import * as tripwire from "@nevware21/tripwire"; +import { assert } from "@nevware21/tripwire"; +import { installDefineRegistry } from "../../../src/shared/moduleLoader"; +import { uxDom } from "../support/uxDom"; + +/** + * The test frame: the page the interactive runner loads in an iframe to run the browser target's tests. + * It fetches the run payload, reports the suites it finds to the page above it, runs them when asked, + * and streams their progress back in batches. Here its window, `fetch` and parent are stood in for, + * and the real tripwire runs the tests. + */ + +uxDom(); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const frameMode: typeof import("../../../src/ux/browser/ui/frame-mode") = require("../../../src/ux/browser/ui/frame-mode"); + +describe("ux frame mode", function () { + this.timeout(30000); + + const g = globalThis as any; + const saved: { [name: string]: any } = {}; + let posted: any[]; + let listeners: { [type: string]: (evt: any) => void }; + let frameWindow: any; + let orgConsole: { [level: string]: any }; + + const PAYLOAD = { + tripwireModuleId: "/tripwire", + entryIds: [ "/test/a" ], + coverageOnlyIds: [ "/src/extra", "/not-in-the-bundle" ], + settings: { reporter: [ "dot" ] } + }; + + // The page's modules, as the frame's bundle would have defined them + function defineModules(win: any): void { + installDefineRegistry(win); + win.__tripwireDefine__("/tripwire", (_require: any, module: any) => { + module.exports = tripwire; + }); + win.__tripwireDefine__("/test/a", (require: any) => { + const tw = require("/tripwire"); + tw.describe("frame suite", () => { + tw.it("passes", () => { + tw.assert.equal(1, 1); + }); + tw.it("fails", () => { + tw.assert.equal(1, 2); + }); + }); + }); + win.__tripwireDefine__("/test/failFirst", (require: any) => { + const tw = require("/tripwire"); + tw.describe("fails first", () => { + tw.it("fails", () => { + tw.assert.equal(1, 2); + }); + tw.it("would pass", () => { + tw.assert.equal(1, 1); + }); + }); + }); + win.__tripwireDefine__("/src/extra", (_require: any, module: any) => { + module.exports = "loaded for coverage"; + }); + } + + async function waitFor(kind: string): Promise { + for (let lp = 0; lp < 500; lp++) { + const found = posted.filter((msg) => msg.kind === kind)[0]; + if (found) { + return found; + } + + await new Promise((resolve) => setTimeout(resolve, 5)); + } + + throw new Error("the frame never posted " + kind + ": " + JSON.stringify(posted.map((msg) => msg.kind))); + } + + function lifecycle(): any[] { + const events: any[] = []; + posted.filter((msg) => msg.kind === "lifecycleBatch").forEach((msg) => events.push(...msg.events)); + + return events; + } + + async function initialize(payload: any, fetchError?: Error): Promise { + g.fetch = (url: string) => { + if (url !== "http://localhost:4321/api/payload-browser") { + return Promise.reject(new Error("unexpected fetch " + url)); + } + + return fetchError ? Promise.reject(fetchError) : Promise.resolve({ json: () => Promise.resolve(payload) }); + }; + + await frameMode.frameInitialize(); + } + + beforeEach(() => { + posted = []; + listeners = {}; + [ "window", "fetch", "location" ].forEach((name) => { + saved[name] = g[name]; + }); + + frameWindow = { + parent: { postMessage: (msg: any, origin: string) => posted.push(Object.assign({ origin: origin }, msg)) }, + addEventListener: (type: string, fn: (evt: any) => void) => { + listeners[type] = fn; + } + }; + defineModules(frameWindow); + g.window = frameWindow; + g.location = { href: "http://localhost:4321/__tripwire__/test-frame" }; + + // The frame sends the console to the page for good + orgConsole = { log: console.log, info: console.info, warn: console.warn, error: console.error }; + }); + + afterEach(() => { + Object.keys(orgConsole).forEach((level) => { + (console as any)[level] = orgConsole[level]; + }); + Object.keys(saved).forEach((name) => { + if (saved[name] === undefined) { + delete g[name]; + } else { + g[name] = saved[name]; + } + }); + }); + + it("tells the page it is ready, with the suites and tests it found", async () => { + await initialize(PAYLOAD); + + const ready = posted[0]; + assert.equal(ready.kind, "ready"); + assert.equal(ready.origin, "*"); + assert.includes(JSON.stringify(ready.discovery), "frame suite"); + assert.includes(JSON.stringify(ready.discovery), "fails"); + assert.isFunction(listeners.message); + }); + + it("runs the tests when asked, streaming their progress and then the results", async () => { + await initialize(PAYLOAD); + frameWindow.__coverage__ = { "a.js": {} }; + + listeners.message({ data: { kind: "run", selection: {} } }); + const done = await waitFor("runDone"); + + assert.equal(done.summary.total, 2); + assert.equal(done.summary.passed, 1); + assert.equal(done.summary.failed, 1); + assert.deepEqual(done.coverage, { "a.js": {} }); + + const events = lifecycle(); + assert.deepEqual(events.filter((evt) => evt.kind === "testStart").map((evt) => evt.testName), [ "passes", "fails" ]); + assert.deepEqual(events.filter((evt) => evt.kind === "testDone").map((evt) => [ evt.testName, evt.endStatus ]), [ [ "passes", "passed" ], [ "fails", "failed" ] ]); + assert.includes(events.filter((evt) => evt.kind === "testDone")[1].endErrorText, "expected 1 to equal 2"); + assert.isTrue(events.filter((evt) => evt.kind === "suiteStart").length >= 1); + assert.isTrue(events.filter((evt) => evt.kind === "suiteDone").length >= 1); + + const asserts = events.filter((evt) => evt.kind === "assert"); + assert.isTrue(asserts[0].passed); + assert.includes(asserts.filter((evt) => !evt.passed)[0].errorText, "expected 1 to equal 2"); + }); + + it("sends the reporter's console output to the page", async () => { + await initialize(Object.assign({}, PAYLOAD, { settings: { reporter: [ "spec" ], slow: 10, trace: true } })); + + listeners.message({ data: { kind: "run" } }); + await waitFor("runDone"); + + const logged = posted.filter((msg) => msg.kind === "log").map((msg) => msg.message).join("\n"); + assert.includes(logged, "frame suite"); + }); + + it("runs only the selected test", async () => { + await initialize(PAYLOAD); + + listeners.message({ data: { kind: "run", selection: { testTitle: "frame suite > passes" } } }); + const done = await waitFor("runDone"); + + assert.equal(done.summary.passed, 1); + assert.equal(done.summary.failed, 0); + }); + + it("stops at the first failure with bail", async () => { + await initialize(Object.assign({}, PAYLOAD, { entryIds: [ "/test/failFirst" ], settings: { reporter: [ "dot" ], bail: true } })); + + listeners.message({ data: { kind: "run" } }); + const done = await waitFor("runDone"); + + assert.equal(done.summary.failed, 1); + assert.equal(done.summary.passed, 0, "nothing runs after the failure"); + }); + + it("stops a run the page asks it to stop", async () => { + await initialize(PAYLOAD); + + listeners.message({ data: { kind: "run" } }); + listeners.message({ data: { kind: "stop" } }); + const done = await waitFor("runDone"); + + assert.equal(done.summary.passed, 0, "nothing ran once stopped"); + }); + + it("ignores a message that is not for it", async () => { + await initialize(PAYLOAD); + + listeners.message({ data: null }); + listeners.message({ data: { other: true } }); + + assert.deepEqual(posted.map((msg) => msg.kind), [ "ready" ]); + }); + + it("reports a payload it cannot fetch", async () => { + await initialize(PAYLOAD, new Error("connection refused")); + + assert.equal(posted[0].kind, "runError"); + assert.includes(posted[0].error, "connection refused"); + }); + + it("reports the module scripts that failed to load, before or during a run", async () => { + frameWindow.__tripwireModuleLoadErrors__ = [ "/test/broken.js" ]; + await initialize(PAYLOAD); + assert.includes(posted[0].error, "Failed to load 1 module script(s): /test/broken.js"); + + posted = []; + delete frameWindow.__tripwireModuleLoadErrors__; + await initialize(PAYLOAD); + frameWindow.__tripwireModuleLoadErrors__ = [ "/test/late.js" ]; + listeners.message({ data: { kind: "run" } }); + + assert.includes((await waitFor("runError")).error, "Failed to load 1 module script(s): /test/late.js"); + }); +}); diff --git a/internal/runner/test/src/ux/logging.test.ts b/internal/runner/test/src/ux/logging.test.ts index a4172872..f00b74f6 100644 --- a/internal/runner/test/src/ux/logging.test.ts +++ b/internal/runner/test/src/ux/logging.test.ts @@ -10,6 +10,7 @@ import { assert } from "@nevware21/tripwire"; import { logDomQueue, logSendQueue } from "../../../src/ux/browser/ui/state"; import { MAX_LOG_LINES } from "../../../src/ux/browser/ui/shared"; import { safeToString } from "../../../src/ux/browser/ui/utils"; +import { stubElement, uxDom } from "../support/uxDom"; /** * The interactive page's log panel and its mirror to the node console: every entry is queued @@ -17,97 +18,10 @@ import { safeToString } from "../../../src/ux/browser/ui/utils"; * trimmed past `MAX_LOG_LINES`) and once for the server (POSTed to /api/log in batches, with at * most one request in flight). Node has no `requestAnimationFrame`, `fetch` or `location`, so * each test installs a recording stand-in for the three and takes them away again; the DOM the - * flush writes into is a plain object (see `_uxDom`). + * flush writes into is a plain object (see `uxDom` in ../support/uxDom.ts). */ -/** - * `./dom` looks every element it exports up from `document` once, as it loads, and node has no - * `document`. This hands it the smallest stand-in that lets it load -- one plain object per id, - * created on demand -- and takes `document` away again straight afterwards so nothing else in the - * process can mistake this for a browser. The objects live in a registry on `globalThis` because - * `./dom` loads once per process, for whichever test file gets there first: every file that needs - * them has to read the same objects back, whoever created them. - */ -function _uxDom(): { [id: string]: any } { - const g = globalThis as any; - const registry = g.__tripwireUxDomStub || (g.__tripwireUxDomStub = {}); - const hadDocument = !!g.document; - - if (!hadDocument) { - g.document = { - body: null, - getElementById: (id: string) => registry[id] || (registry[id] = _stubElement(id)) - }; - } - - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - require("../../../src/ux/browser/ui/dom"); - } finally { - if (!hadDocument) { - delete g.document; - } - } - - return registry; -} - -/** A plain object with the handful of element members the ui modules under test touch. */ -function _stubElement(id: string): any { - const attrs: { [name: string]: string } = {}; - const classes: string[] = []; - let text = ""; - const el: any = { - id: id, - value: "", - checked: false, - disabled: false, - style: {}, - attrs: attrs, - kids: [] as any[], - setAttribute: (name: string, value: any) => { - attrs[name] = String(value); - }, - getAttribute: (name: string) => (name in attrs ? attrs[name] : null), - removeAttribute: (name: string) => { - delete attrs[name]; - }, - classList: { - add: (cls: string) => { - if (classes.indexOf(cls) === -1) { - classes.push(cls); - } - }, - remove: (cls: string) => { - const idx = classes.indexOf(cls); - if (idx !== -1) { - classes.splice(idx, 1); - } - }, - contains: (cls: string) => classes.indexOf(cls) !== -1 - }, - appendChild: (child: any) => { - el.kids.push.apply(el.kids, child && child.fragment ? child.kids : [child]); - }, - removeChild: (child: any) => { - el.kids.splice(el.kids.indexOf(child), 1); - } - }; - - Object.defineProperty(el, "textContent", { - get: () => text, - set: (value: string) => { - text = value; - el.kids.length = 0; - } - }); - Object.defineProperty(el, "childElementCount", { get: () => el.kids.length }); - Object.defineProperty(el, "firstElementChild", { get: () => el.kids[0] || null }); - - return el; -} - -const dom = _uxDom(); +const dom = uxDom(); // eslint-disable-next-line @typescript-eslint/no-var-requires const logging: typeof import("../../../src/ux/browser/ui/logging") = require("../../../src/ux/browser/ui/logging"); @@ -174,7 +88,7 @@ function _drainFrames(): void { frames = []; g.document = { - createElement: (tag: string) => _stubElement(tag), + createElement: (tag: string) => stubElement(tag), createDocumentFragment: () => { const fragment: any = { fragment: true, kids: [] as any[] }; fragment.appendChild = (child: any) => { diff --git a/internal/runner/test/src/ux/menu.test.ts b/internal/runner/test/src/ux/menu.test.ts index d3820927..093d9a03 100644 --- a/internal/runner/test/src/ux/menu.test.ts +++ b/internal/runner/test/src/ux/menu.test.ts @@ -7,18 +7,176 @@ */ import { assert } from "@nevware21/tripwire"; -import { nextMenuIndex } from "../../../src/ux/browser/ui/menu"; +import { createMenu, nextMenuIndex } from "../../../src/ux/browser/ui/menu"; +import { stubElement, stubEvent, useStubDocument } from "../support/uxDom"; /** - * The arrow-key movement behind the interactive page's "..." actions menu. - * - * `createMenu` itself needs a live document and is exercised end to end against the real page; - * this is the part of it that is pure, and the part that breaks silently -- an off-by-one in the - * wrapping leaves a keyboard user stuck at one end of the menu with no visible sign anything is - * wrong. + * The interactive page's "..." actions menu: the arrow-key movement, which breaks silently -- an + * off-by-one in the wrapping leaves a keyboard user stuck at one end of the menu with no visible + * sign anything is wrong -- and the open, close and focus handling around it, run against the + * stand-in elements and `document` of ../support/uxDom.ts. It is also exercised end to end against + * the real page. */ describe("ux menu", () => { + describe("createMenu", () => { + let doc: any; + let restoreDocument: () => void; + let button: any; + let menu: any; + let items: any[]; + + function key(target: any, name: string): any { + const evt = stubEvent("keydown", { key: name }); + target.dispatchEvent(evt); + + return evt; + } + + function isOpen(): boolean { + return !menu.hasAttribute("hidden"); + } + + beforeEach(() => { + const installed = useStubDocument(); + doc = installed.document; + restoreDocument = installed.restore; + + button = stubElement("actionsBtn", "button"); + menu = stubElement("actionsMenu", "div"); + menu.setAttribute("hidden", ""); + items = [ "Run", "Finish", "Reload" ].map((label) => { + const item = stubElement("", "button"); + item.setAttribute("role", "menuitem"); + item.textContent = label; + menu.appendChild(item); + + return item; + }); + items[1].setAttribute("aria-disabled", "true"); + createMenu(button, menu); + }); + + afterEach(() => { + restoreDocument(); + }); + + it("opens on a click, focusing the first item, and closes on the next", () => { + const evt = stubEvent("click"); + button.dispatchEvent(evt); + + assert.isTrue(isOpen()); + assert.isTrue(evt.defaultPrevented); + assert.equal(button.getAttribute("aria-expanded"), "true"); + assert.equal(doc.activeElement, items[0]); + assert.deepEqual(items.map((item) => item.getAttribute("tabindex")), [ "0", "-1", "-1" ]); + + button.click(); + assert.isFalse(isOpen()); + assert.equal(button.getAttribute("aria-expanded"), "false"); + }); + + it("opens from the button's keys at the first or the last item, and closes on Escape", () => { + [ "ArrowDown", "Enter", " " ].forEach((name) => { + menu.setAttribute("hidden", ""); + assert.isTrue(key(button, name).defaultPrevented, name); + assert.isTrue(isOpen(), name); + assert.equal(doc.activeElement, items[0], name); + }); + + menu.setAttribute("hidden", ""); + key(button, "ArrowUp"); + assert.equal(doc.activeElement, items[2]); + + key(button, "Escape"); + assert.isFalse(isOpen()); + + key(button, "Tab"); + assert.isFalse(isOpen(), "other keys do nothing"); + }); + + it("moves through the items with the arrow, Home and End keys", () => { + button.click(); + + key(menu, "ArrowDown"); + assert.equal(doc.activeElement, items[1]); + key(menu, "End"); + assert.equal(doc.activeElement, items[2]); + key(menu, "ArrowDown"); + assert.equal(doc.activeElement, items[0], "wraps round"); + key(menu, "ArrowUp"); + assert.equal(doc.activeElement, items[2]); + key(menu, "Home"); + assert.equal(doc.activeElement, items[0]); + + assert.isFalse(key(menu, "a").defaultPrevented, "other keys are left alone"); + assert.isTrue(isOpen()); + }); + + it("closes on Escape, giving focus back to the button, and on Tab without", () => { + button.click(); + const evt = key(menu, "Escape"); + + assert.isFalse(isOpen()); + assert.isTrue(evt.defaultPrevented); + assert.equal(doc.activeElement, button); + + button.click(); + doc.activeElement = items[0]; + key(menu, "Tab"); + assert.isFalse(isOpen()); + assert.equal(doc.activeElement, items[0]); + + key(menu, "Escape"); + assert.isFalse(isOpen(), "closing a closed menu changes nothing"); + }); + + it("closes when an item is chosen, but not for a disabled one", () => { + button.click(); + const disabled = stubEvent("click"); + items[1].dispatchEvent(disabled); + + assert.isTrue(isOpen()); + assert.isTrue(disabled.defaultPrevented); + assert.isTrue(disabled.propagationStopped); + + items[2].click(); + assert.isFalse(isOpen()); + assert.equal(doc.activeElement, button); + }); + + it("stays open for a click inside it that is not on an item", () => { + const gap = stubElement("", "div"); + menu.appendChild(gap); + button.click(); + + gap.click(); + + assert.isTrue(isOpen()); + }); + + it("closes for a click anywhere else on the page", () => { + button.click(); + + stubElement("", "div").click(); + + assert.isFalse(isOpen()); + }); + + it("closes once focus has left both the menu and the button", async () => { + button.click(); + doc.activeElement = items[0]; + menu.dispatchEvent(stubEvent("focusout")); + await new Promise((resolve) => setTimeout(resolve, 5)); + assert.isTrue(isOpen(), "focus is still inside"); + + doc.activeElement = stubElement("", "input"); + menu.dispatchEvent(stubEvent("focusout")); + await new Promise((resolve) => setTimeout(resolve, 5)); + assert.isFalse(isOpen()); + }); + }); + describe("nextMenuIndex", () => { it("should step down through the items", () => { assert.equal(nextMenuIndex(4, 0, "ArrowDown"), 1); diff --git a/internal/runner/test/src/ux/rendering.test.ts b/internal/runner/test/src/ux/rendering.test.ts new file mode 100644 index 00000000..e9316d63 --- /dev/null +++ b/internal/runner/test/src/ux/rendering.test.ts @@ -0,0 +1,400 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import { assert } from "@nevware21/tripwire"; +import { + assertionDomQueue, assertionFlushScheduled, clearAllState, setAssertionFlushScheduled, setHighlightSelection, suiteState, testState +} from "../../../src/ux/browser/ui/state"; +import { MAX_ASSERTIONS_PER_TEST } from "../../../src/ux/browser/ui/shared"; +import { textOf, useStubDocument, uxDom } from "../support/uxDom"; + +/** + * How the interactive page draws a run: a collapsible node per suite and per test under the results + * panel, each with its status, duration, counts and assertions, and how the progress the runner + * reports moves them along. The page's elements are the plain objects of ../support/uxDom.ts, and a + * stand-in `document` builds the new ones. + */ + +const dom = uxDom(); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const rendering: typeof import("../../../src/ux/browser/ui/ui-rendering") = require("../../../src/ux/browser/ui/ui-rendering"); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const execution: typeof import("../../../src/ux/browser/ui/test-execution") = require("../../../src/ux/browser/ui/test-execution"); + +function noFilter(): void { + // The filtering module decides visibility; it is tested on its own +} + +/** A stand-in element's children. */ +function kids(el: any): any[] { + return el.kids; +} + +/** The options each `scrollIntoView` call on a stand-in element was given. */ +function scrolled(el: any): any[] { + return el.scrolledIntoView; +} + +/** The element whose class list contains `cls`, anywhere under `el`. */ +function find(el: any, cls: string): any { + return el.querySelector("." + cls); +} + +describe("ux rendering", () => { + let restoreDocument: () => void; + + beforeEach(() => { + restoreDocument = useStubDocument(dom).restore; + rendering.clearResults(dom.assertCount); + clearAllState(); + setHighlightSelection("", ""); + dom.results.scrollTop = 0; + dom.results.clientHeight = 0; + dom.results.scrollHeight = 0; + }); + + afterEach(() => { + restoreDocument(); + }); + + describe("suite nodes", () => { + it("draws a running suite under the results panel", () => { + const suite = rendering.ensureSuiteNode([ "outer" ]); + + assert.equal(kids(dom.results)[0], suite.details); + assert.isTrue(suite.details.open); + assert.isTrue(suite.details.classList.contains("suite-running")); + assert.equal(textOf(find(suite.details, "suite-name")), "outer"); + assert.equal(suite.meta.textContent, "0/0 tests"); + assert.equal(suite.chip.textContent, "running"); + assert.equal(suite.marker.getAttribute("aria-hidden"), "true"); + assert.equal(find(suite.details, "rerun-btn").getAttribute("data-rerun-suite"), "outer"); + assert.equal(rendering.ensureSuiteNode([ "outer" ]), suite, "drawn once"); + }); + + it("nests a suite in its parent, counting it there", () => { + const inner = rendering.ensureSuiteNode([ "outer", "inner" ]); + const outer = suiteState["outer"]; + + assert.equal(kids(outer.children)[0], inner.details); + assert.equal(outer.childSuiteCount, 1); + assert.equal(outer.meta.textContent, "0/0 tests • 1 child suite"); + + rendering.ensureSuiteNode([ "outer", "second" ]); + assert.equal(outer.meta.textContent, "0/0 tests • 2 child suites"); + }); + + it("draws the default suite without a rerun button", () => { + const root = rendering.ensureSuiteNode([]); + + assert.equal(textOf(find(root.details, "suite-name")), "(default suite)"); + assert.isNull(find(root.details, "rerun-btn")); + }); + + it("marks a suite's status", () => { + const suite = rendering.ensureSuiteNode([ "marked" ]); + const marks: any = {}; + + [ "passed", "failed", "pending", "running" ].forEach((status) => { + rendering.setSuiteMarker(suite, status); + marks[status] = [ suite.marker.className, suite.marker.textContent ]; + }); + + assert.deepEqual(marks, { + passed: [ "suite-marker marker-passed", "✓" ], + failed: [ "suite-marker marker-failed", "✗" ], + pending: [ "suite-marker marker-pending", "○" ], + running: [ "suite-marker marker-running", "" ] + }); + + rendering.setSuiteMarker(null as any, "passed"); + rendering.updateSuiteMeta(null as any); + }); + }); + + describe("test nodes", () => { + it("draws a running test in its suite, counting it in every enclosing suite", () => { + const test = rendering.ensureTestNode([ "outer", "inner" ], "works"); + + assert.equal(kids(suiteState["outer > inner"].children)[0], test.details); + assert.equal(test.label.textContent, "… works"); + assert.equal(test.chip.textContent, "running"); + assert.equal(find(test.details, "rerun-btn").getAttribute("data-rerun-test"), "outer > inner > works"); + assert.equal(suiteState["outer"].total, 1); + assert.equal(suiteState["outer > inner"].meta.textContent, "0/1 tests"); + assert.equal(rendering.ensureTestNode([ "outer", "inner" ], "works"), test, "drawn once"); + }); + + it("names a test without a name", () => { + assert.equal(rendering.ensureTestNode([], "").testName, "(unnamed test)"); + }); + + it("collapses a passed test and keeps a failed one open, with its duration", () => { + const passed = rendering.ensureTestNode([ "s" ], "passes"); + const failed = rendering.ensureTestNode([ "s" ], "fails"); + const filtered: any[] = []; + passed.durationMs = 12; + + rendering.setTestStatus(passed, "passed", (node) => filtered.push(node)); + rendering.setTestStatus(failed, "failed", (node) => filtered.push(node)); + + assert.isFalse(passed.details.open); + assert.equal(passed.label.textContent, "✓ passes"); + assert.equal(passed.chip.className, "status-chip status-passed"); + assert.isTrue(passed.details.classList.contains("test-passed")); + assert.isFalse(passed.details.classList.contains("test-running")); + assert.equal(passed.duration.textContent, "12ms"); + assert.equal(passed.duration.style.display, "inline-flex"); + assert.isTrue(failed.details.open); + assert.equal(failed.duration.style.display, "none"); + assert.deepEqual(filtered, [ passed, failed ]); + }); + + it("keeps the highlighted test open and scrolls to it", () => { + setHighlightSelection("s", "s > chosen"); + const chosen = rendering.ensureTestNode([ "s" ], "chosen"); + + rendering.setTestStatus(chosen, "passed", noFilter); + + assert.isTrue(chosen.details.open); + assert.deepEqual(scrolled(chosen.details), [ { block: "center" } ]); + }); + }); + + describe("suite status", () => { + it("shows a suite as running until all its tests are done", () => { + rendering.ensureTestNode([ "s" ], "a"); + const suite = suiteState["s"]; + + rendering.updateSuiteStatus("s"); + + assert.equal(suite.chip.textContent, "running 0/1"); + assert.equal(suite.marker.className, "suite-marker marker-running"); + rendering.updateSuiteStatus("no such suite"); + }); + + it("opens a failed suite and collapses a passed one", () => { + const failing = rendering.ensureSuiteNode([ "failing" ]); + const passing = rendering.ensureSuiteNode([ "passing" ]); + failing.failed = 1; + passing.durationMs = 5; + + rendering.updateSuiteStatus("failing"); + rendering.updateSuiteStatus("passing"); + + assert.equal(failing.chip.textContent, "failed"); + assert.isTrue(failing.details.open); + assert.isTrue(failing.details.classList.contains("suite-failed")); + assert.equal(passing.chip.textContent, "passed"); + assert.isFalse(passing.details.open); + assert.equal(passing.duration.textContent, "5ms"); + }); + + it("keeps a passed suite open on the way to the highlighted test, and scrolls to a highlighted suite", () => { + setHighlightSelection("outer > inner", ""); + const outer = rendering.ensureSuiteNode([ "outer" ]); + const inner = rendering.ensureSuiteNode([ "outer", "inner" ]); + + rendering.updateSuiteStatus("outer"); + rendering.updateSuiteStatus("outer > inner"); + + assert.isTrue(outer.details.open, "an ancestor of the selection"); + assert.deepEqual(scrolled(outer.details), []); + assert.isTrue(inner.details.open); + assert.deepEqual(scrolled(inner.details), [ { block: "center" } ]); + }); + + it("keeps the default suite open for a highlighted test of its own", () => { + setHighlightSelection("", "top level test"); + const root = rendering.ensureSuiteNode([]); + + rendering.updateSuiteStatus(root.key); + + assert.isTrue(root.details.open); + }); + }); + + describe("assertions", () => { + it("draws the queued assertions under their tests, and the running total", () => { + const test = rendering.ensureTestNode([ "s" ], "t"); + assertionDomQueue.push( + { testKey: test.key, passed: true, label: "equal(1, 1)", errorText: "" }, + { testKey: test.key, passed: false, label: "equal(1, 2)", errorText: "expected 1 to equal 2" }, + { testKey: "a test that is not drawn", passed: true, label: "x", errorText: "" }, + null + ); + setAssertionFlushScheduled(true); + + rendering.flushAssertionDomQueue(dom.assertCount); + + assert.deepEqual(kids(test.assertions).map((item: any) => [ item.className, item.textContent ]), [ + [ "assert-item assert-pass", "✓ equal(1, 1)" ], + [ "assert-item assert-fail", "✗ equal(1, 2) -> expected 1 to equal 2" ] + ]); + assert.equal(test.assertionCount, 2); + assert.equal(dom.assertCount.textContent, "2 assertions"); + assert.equal(assertionDomQueue.length, 0); + assert.isFalse(assertionFlushScheduled); + }); + + it("keeps only the latest assertions of a very long test", () => { + const test = rendering.ensureTestNode([ "s" ], "long"); + for (let lp = 0; lp <= MAX_ASSERTIONS_PER_TEST; lp++) { + assertionDomQueue.push({ testKey: test.key, passed: true, label: "check " + lp, errorText: "" }); + } + + rendering.flushAssertionDomQueue(null); + + assert.equal(test.assertions.childElementCount, MAX_ASSERTIONS_PER_TEST); + assert.equal(kids(test.assertions)[0].textContent, "✓ check 1", "the first was dropped"); + assert.equal(test.assertionCount, MAX_ASSERTIONS_PER_TEST + 1); + }); + + it("only clears the flag when nothing is queued", () => { + setAssertionFlushScheduled(true); + + rendering.flushAssertionDomQueue(dom.assertCount); + + assert.isFalse(assertionFlushScheduled); + }); + + it("adds an assertion of its own for a test that reported none", () => { + const test = rendering.ensureTestNode([ "s" ], "t"); + + rendering.appendSyntheticAssertion(test, false, "", "boom", dom.assertCount); + + assert.equal(kids(test.assertions)[0].textContent, "✗ (assertion) -> boom"); + assert.equal(dom.assertCount.textContent, "1 assertion"); + rendering.appendSyntheticAssertion(null as any, true, "x", "", dom.assertCount); + }); + + it("drops the oldest synthetic assertion past the limit", () => { + const test = rendering.ensureTestNode([ "s" ], "t"); + for (let lp = 0; lp <= MAX_ASSERTIONS_PER_TEST; lp++) { + rendering.appendSyntheticAssertion(test, true, "synthetic " + lp, "", null); + } + + assert.equal(test.assertions.childElementCount, MAX_ASSERTIONS_PER_TEST); + }); + }); + + describe("scrolling", () => { + it("follows new results while the panel is scrolled to the bottom, for a whole batch", () => { + dom.results.clientHeight = 100; + dom.results.scrollHeight = 100; + + rendering.beginRenderBatch(); + rendering.ensureSuiteNode([ "a" ]); + dom.results.scrollHeight = 400; + rendering.ensureTestNode([ "a" ], "t"); + rendering.endRenderBatch(); + + assert.equal(dom.results.scrollTop, 400); + }); + + it("leaves the panel where it is when it was scrolled up", () => { + dom.results.clientHeight = 100; + dom.results.scrollHeight = 1000; + + rendering.ensureSuiteNode([ "a" ]); + + assert.equal(dom.results.scrollTop, 0); + rendering.endRenderBatch(); + }); + }); + + it("clears every drawn result and the counts", () => { + rendering.ensureTestNode([ "s" ], "t"); + assertionDomQueue.push({ testKey: "x", passed: true, label: "", errorText: "" }); + + rendering.clearResults(dom.assertCount); + + assert.deepEqual(Object.keys(suiteState), []); + assert.deepEqual(Object.keys(testState), []); + assert.equal(assertionDomQueue.length, 0); + assert.equal(kids(dom.results).length, 0); + assert.equal(dom.assertCount.textContent, "0 assertions"); + rendering.clearResults(null); + }); + + describe("test execution", () => { + it("finds the most recent running test", () => { + assert.equal(execution.findActiveTestKey(), ""); + + const first = execution._handleTestStart([ "s" ], "first", noFilter); + const second = execution._handleTestStart([ "s" ], "second", noFilter); + assert.equal(execution.findActiveTestKey(), second.key); + + execution._handleTestDone([ "s" ], "second", "passed", 1, "", noFilter); + assert.equal(execution.findActiveTestKey(), first.key); + }); + + it("draws a suite as it starts, and its status and duration as it ends", () => { + execution._handleSuiteStart([ "suite" ]); + execution._handleSuiteDone([ "suite" ], 7); + + const suite = suiteState["suite"]; + assert.equal(suite.chip.textContent, "passed"); + assert.equal(suite.duration.textContent, "7ms"); + + execution._handleSuiteStart(null); + execution._handleSuiteDone(null, 0); + assert.isObject(suiteState["(default suite)"]); + }); + + it("counts each finished test once, in every enclosing suite", () => { + execution._handleTestStart([ "outer", "inner" ], "t", noFilter); + + execution._handleTestDone([ "outer", "inner" ], "t", "failed", 3, "boom", noFilter); + execution._handleTestDone([ "outer", "inner" ], "t", "failed", 3, "boom", noFilter); + + assert.equal(suiteState["outer"].done, 1); + assert.equal(suiteState["outer"].failed, 1); + assert.equal(suiteState["outer > inner"].chip.textContent, "failed"); + }); + + it("treats skipped and pending tests as passed", () => { + const skipped = execution._handleTestDone([ "s" ], "skipped", "skipped", 0, "", noFilter); + const pending = execution._handleTestDone([ "s" ], "pending", "", 0, "", noFilter); + + assert.equal(skipped.status, "passed"); + assert.equal(pending.status, "passed"); + assert.equal(suiteState["s"].failed, 0); + }); + + it("adds a failure of its own to a failed test that reported no assertion", () => { + const failed = execution._handleTestDone([ "s" ], "failed", "failed", 0, "expected true", noFilter); + const timedOut = execution._handleTestDone([ "s" ], "slow", "failed", 0, "Timeout of 2000ms exceeded", noFilter); + const silent = execution._handleTestDone([ "s" ], "silent", "failed", 0, null, noFilter); + + assert.equal(kids(failed.assertions)[0].textContent, "✗ Test failed -> expected true"); + assert.equal(kids(timedOut.assertions)[0].textContent, "✗ Test timed out -> Timeout of 2000ms exceeded"); + assert.equal(kids(silent.assertions)[0].textContent, "✗ Test failed"); + }); + + it("leaves a failed test's own assertions, drawn or still queued, to explain it", () => { + const drawn = execution._handleTestStart([ "s" ], "drawn", noFilter); + rendering.appendSyntheticAssertion(drawn, false, "equal", "no", null); + const queued = execution._handleTestStart([ "s" ], "queued", noFilter); + assertionDomQueue.push({ testKey: queued.key, passed: false, label: "equal", errorText: "no" }); + + execution._handleTestDone([ "s" ], "drawn", "failed", 0, "no", noFilter); + execution._handleTestDone([ "s" ], "queued", "failed", 0, "no", noFilter); + + assert.equal(kids(drawn.assertions).length, 1); + assert.equal(kids(queued.assertions).length, 0); + }); + + it("names unnamed tests and suites", () => { + const node = execution._handleTestStart(null, null, noFilter); + + assert.equal(node.testName, "(unnamed test)"); + assert.equal(execution._handleTestDone(null, null, "passed", 0, "", noFilter), node); + }); + }); +}); diff --git a/internal/runner/test/src/ux/selection.test.ts b/internal/runner/test/src/ux/selection.test.ts index bb1ff410..84e9b4c2 100644 --- a/internal/runner/test/src/ux/selection.test.ts +++ b/internal/runner/test/src/ux/selection.test.ts @@ -9,6 +9,7 @@ import { assert } from "@nevware21/tripwire"; import { AUTOCOMPLETE_KIND_ATTR } from "../../../src/ux/browser/ui/autocomplete"; import { ALL_SCOPE_LABEL } from "../../../src/shared/scopeOptions"; +import { uxDom } from "../support/uxDom"; /** * The half of the Scope picker that reads and writes the live input element. The searching and @@ -18,94 +19,7 @@ import { ALL_SCOPE_LABEL } from "../../../src/shared/scopeOptions"; * picked), and that restoring a selection that no longer exists leaves the picker blank. */ -/** - * `./dom` looks every element it exports up from `document` once, as it loads, and node has no - * `document`. This hands it the smallest stand-in that lets it load -- one plain object per id, - * created on demand -- and takes `document` away again straight afterwards so nothing else in the - * process can mistake this for a browser. The objects live in a registry on `globalThis` because - * `./dom` loads once per process, for whichever test file gets there first: every file that needs - * them has to read the same objects back, whoever created them. - */ -function _uxDom(): { [id: string]: any } { - const g = globalThis as any; - const registry = g.__tripwireUxDomStub || (g.__tripwireUxDomStub = {}); - const hadDocument = !!g.document; - - if (!hadDocument) { - g.document = { - body: null, - getElementById: (id: string) => registry[id] || (registry[id] = _stubElement(id)) - }; - } - - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - require("../../../src/ux/browser/ui/dom"); - } finally { - if (!hadDocument) { - delete g.document; - } - } - - return registry; -} - -/** A plain object with the handful of element members the ui modules under test touch. */ -function _stubElement(id: string): any { - const attrs: { [name: string]: string } = {}; - const classes: string[] = []; - let text = ""; - const el: any = { - id: id, - value: "", - checked: false, - disabled: false, - style: {}, - attrs: attrs, - kids: [] as any[], - setAttribute: (name: string, value: any) => { - attrs[name] = String(value); - }, - getAttribute: (name: string) => (name in attrs ? attrs[name] : null), - removeAttribute: (name: string) => { - delete attrs[name]; - }, - classList: { - add: (cls: string) => { - if (classes.indexOf(cls) === -1) { - classes.push(cls); - } - }, - remove: (cls: string) => { - const idx = classes.indexOf(cls); - if (idx !== -1) { - classes.splice(idx, 1); - } - }, - contains: (cls: string) => classes.indexOf(cls) !== -1 - }, - appendChild: (child: any) => { - el.kids.push.apply(el.kids, child && child.fragment ? child.kids : [child]); - }, - removeChild: (child: any) => { - el.kids.splice(el.kids.indexOf(child), 1); - } - }; - - Object.defineProperty(el, "textContent", { - get: () => text, - set: (value: string) => { - text = value; - el.kids.length = 0; - } - }); - Object.defineProperty(el, "childElementCount", { get: () => el.kids.length }); - Object.defineProperty(el, "firstElementChild", { get: () => el.kids[0] || null }); - - return el; -} - -const dom = _uxDom(); +const dom = uxDom(); // eslint-disable-next-line @typescript-eslint/no-var-requires const selection: typeof import("../../../src/ux/browser/ui/selection") = require("../../../src/ux/browser/ui/selection"); diff --git a/internal/runner/test/src/ux/ui-control.test.ts b/internal/runner/test/src/ux/ui-control.test.ts index 1e501c53..14db9d49 100644 --- a/internal/runner/test/src/ux/ui-control.test.ts +++ b/internal/runner/test/src/ux/ui-control.test.ts @@ -8,102 +8,16 @@ import { assert } from "@nevware21/tripwire"; import { clearAllState, setCompleted, setLastResult, setPayload, setRunning } from "../../../src/ux/browser/ui/state"; +import { uxDom } from "../support/uxDom"; /** * The interactive page's toolbar controls as a function of the run state: what the Run button * says and whether it is enabled, when Finish is available, and which pickers are locked while a - * run is in flight. The elements are plain objects (see `_uxDom`), since all the module does to - * them is set `disabled`, `textContent`, a class and an attribute. + * run is in flight. The elements are plain objects (see `uxDom` in ../support/uxDom.ts), since all + * the module does to them is set `disabled`, `textContent`, a class and an attribute. */ -/** - * `./dom` looks every element it exports up from `document` once, as it loads, and node has no - * `document`. This hands it the smallest stand-in that lets it load -- one plain object per id, - * created on demand -- and takes `document` away again straight afterwards so nothing else in the - * process can mistake this for a browser. The objects live in a registry on `globalThis` because - * `./dom` loads once per process, for whichever test file gets there first: every file that needs - * them has to read the same objects back, whoever created them. - */ -function _uxDom(): { [id: string]: any } { - const g = globalThis as any; - const registry = g.__tripwireUxDomStub || (g.__tripwireUxDomStub = {}); - const hadDocument = !!g.document; - - if (!hadDocument) { - g.document = { - body: null, - getElementById: (id: string) => registry[id] || (registry[id] = _stubElement(id)) - }; - } - - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - require("../../../src/ux/browser/ui/dom"); - } finally { - if (!hadDocument) { - delete g.document; - } - } - - return registry; -} - -/** A plain object with the handful of element members the ui modules under test touch. */ -function _stubElement(id: string): any { - const attrs: { [name: string]: string } = {}; - const classes: string[] = []; - let text = ""; - const el: any = { - id: id, - value: "", - checked: false, - disabled: false, - style: {}, - attrs: attrs, - kids: [] as any[], - setAttribute: (name: string, value: any) => { - attrs[name] = String(value); - }, - getAttribute: (name: string) => (name in attrs ? attrs[name] : null), - removeAttribute: (name: string) => { - delete attrs[name]; - }, - classList: { - add: (cls: string) => { - if (classes.indexOf(cls) === -1) { - classes.push(cls); - } - }, - remove: (cls: string) => { - const idx = classes.indexOf(cls); - if (idx !== -1) { - classes.splice(idx, 1); - } - }, - contains: (cls: string) => classes.indexOf(cls) !== -1 - }, - appendChild: (child: any) => { - el.kids.push.apply(el.kids, child && child.fragment ? child.kids : [child]); - }, - removeChild: (child: any) => { - el.kids.splice(el.kids.indexOf(child), 1); - } - }; - - Object.defineProperty(el, "textContent", { - get: () => text, - set: (value: string) => { - text = value; - el.kids.length = 0; - } - }); - Object.defineProperty(el, "childElementCount", { get: () => el.kids.length }); - Object.defineProperty(el, "firstElementChild", { get: () => el.kids[0] || null }); - - return el; -} - -const dom = _uxDom(); +const dom = uxDom(); // eslint-disable-next-line @typescript-eslint/no-var-requires const uiControl: typeof import("../../../src/ux/browser/ui/ui-control") = require("../../../src/ux/browser/ui/ui-control"); diff --git a/shim/chai/rollup.config.js b/shim/chai/rollup.config.js index 9e37726c..43708ac4 100644 --- a/shim/chai/rollup.config.js +++ b/shim/chai/rollup.config.js @@ -176,6 +176,20 @@ const rollupConfigMainEntry = (srcPath, destPath, path, format = "umd") => { return taskRollupConfig; }; +// A circular import between this repository's own modules fails the build: which module's exports +// exist when another reads them then depends on which file happens to load first. A cycle entirely +// inside a third-party package is left as a warning, since it is not ours to fix. +function failOnCircularImport(warning, warn) { + if (warning.code === "CIRCULAR_DEPENDENCY") { + const ids = warning.ids || []; + if (!ids.some((id) => /[\\/]node_modules[\\/]/.test(id))) { + throw new Error("Circular import: " + (ids.length ? ids.join(" -> ") : warning.message)); + } + } + + warn(warning); +} + export default [ rollupConfigMainEntry("build/es5/mod", "dist/es5", "main", "umd"), rollupConfigMainEntry("build/es6/mod", "dist/es6", "main", "umd"), @@ -196,4 +210,4 @@ export default [ rollupConfigFactory("build/es6/mod", "bundle/es6", true, "iife", "iife"), rollupConfigFactory("build/es6/mod", "bundle/es6", false, "umd", "umd"), rollupConfigFactory("build/es6/mod", "bundle/es6", true, "umd", "umd") -]; +].map((config) => Object.assign(config, { onwarn: failOnCircularImport })); From 53bd09b6e54308367b3cd139d1be8c9a8c723158 Mon Sep 17 00:00:00 2001 From: nevware21-bot <252503968+nevware21-bot@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:19:03 -0700 Subject: [PATCH 2/2] chore: syncing versions and shrinkwrap --- common/config/rush/npm-shrinkwrap.json | 240 ++++++++++++------------- 1 file changed, 120 insertions(+), 120 deletions(-) diff --git a/common/config/rush/npm-shrinkwrap.json b/common/config/rush/npm-shrinkwrap.json index e2e26512..26b54b53 100644 --- a/common/config/rush/npm-shrinkwrap.json +++ b/common/config/rush/npm-shrinkwrap.json @@ -485,9 +485,9 @@ } }, "node_modules/@microsoft/api-extractor": { - "version": "7.59.4", - "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.59.4.tgz", - "integrity": "sha512-XqseDKIw736qlhQwS9SOpbY4veOiP2Nc7YiWmgXXBmeA3kY9qGXw+Tu7DFpU9FvXywE1z8SvLJKUVxFpcX0kig==", + "version": "7.60.0", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.60.0.tgz", + "integrity": "sha512-z744eKZNJ75h4WlsJiEa8oqbRUfczNe5MexweXe9kcm/phhZBZqfkDZCzHR5dDf8FdZvNapYK/wRPnSUAKuoug==", "dependencies": { "@microsoft/api-extractor-model": "7.33.15", "@microsoft/tsdoc": "~0.17.0", @@ -501,7 +501,7 @@ "resolve": "~1.22.1", "semver": "~7.7.4", "source-map": "~0.6.1", - "typescript": "5.9.3" + "typescript": "6.0.3" }, "bin": { "api-extractor": "bin/api-extractor" @@ -524,9 +524,9 @@ } }, "node_modules/@microsoft/api-extractor/node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1003,9 +1003,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.65.0.tgz", - "integrity": "sha512-t2AnVba25baFdq7gczzS13JyVcdubLVbFX1X04mxrggDrTQPUXNv3XM0k9H1hr5tqvg7AVvtQ720QHAj0iYqhg==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.65.1.tgz", + "integrity": "sha512-5coxNSjqOI2J3NkVhlp3u+zGcyjnowQFsU9sg7MMz4UPXY4Dv63kFGLt7ConXz9CNq2i0yuOJZAF/2RMrgI2OA==", "cpu": [ "arm" ], @@ -1015,9 +1015,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.65.0.tgz", - "integrity": "sha512-dNONUaEwPGhl/nuO2KV8VABWlcjYyU6KtZGN8dQv5OS5JU7JSCxPijVNKXbRJd0iuTzTiJ4TjPt/4bbvDRNiMQ==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.65.1.tgz", + "integrity": "sha512-K0HFf6XMB3IYs+jDZ1/RRhZaVOwxNXrl0n4MDEyGDjuK7+B6K/nDJp8DCBitO3S1sK/fzNxAf9QZAny4AIUPtg==", "cpu": [ "arm64" ], @@ -1027,9 +1027,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.65.0.tgz", - "integrity": "sha512-zWQqUH+9zavr2Bxuzfpuv2SIF3X31cr58TvSQC9o1GhpOvkqRwr60GkZdQcudyT/BEgdpdSafjgd2I13MbTL7g==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.65.1.tgz", + "integrity": "sha512-zkZrXYwNlIcSUmcwY2DpsaDHr0XjVUeQHCuSunG3eQD7/5Wn/DvI7OMUE8NRRweK87yG8ebJ1xGOzmwpMsbZQA==", "cpu": [ "arm64" ], @@ -1039,9 +1039,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.65.0.tgz", - "integrity": "sha512-RQYreBFWtO4//hIuOaZkYUnF/lPU4lz35dveDimDshWCJvVisVtJ/shmT9bkXK8wFJY+FJF89X6F3Cq1qI3LCw==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.65.1.tgz", + "integrity": "sha512-Zaed+R3cpkkA3aiNd7NO0GV5KFh/LVf8j6bGcoRdpoXkSm/mWH8M0oX2/3u/nyGcx/bHEovD9CzX+w9/gcpI4A==", "cpu": [ "x64" ], @@ -1051,9 +1051,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.65.0.tgz", - "integrity": "sha512-A0NfMj6Cuh/9BE5k3awJKv6HoxFVXIcLAb6SZqAMoxUHPA1ZNNK4RsGuUPD/vL7nEAm9LG+ZzXWWTrnC0XtkpA==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.65.1.tgz", + "integrity": "sha512-h12p3WJWhyJ9kir0zqAPr0rKNmBzOWIjoaOMT9OULs9KZQZCeOMKd+mGSvERjd9VxJYShd3zIxtrVxkYz6BEVA==", "cpu": [ "arm64" ], @@ -1063,9 +1063,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.65.0.tgz", - "integrity": "sha512-BI6/H7q3aP0I7S7GOrjyxTjgEJlSnovXE38+bdxBpuKN6z+d//uF+T+VQ3Q7ltJL/71p17ufartARX9Ja5gOGQ==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.65.1.tgz", + "integrity": "sha512-yRBwh/5imKY4DydDulVtrjy8Jf7h7NQJUqZbgmZ3zFsCD9QqlnWvZ7RtEF+ecO1b+IukKjXlu5Bb9fehV3tIbg==", "cpu": [ "x64" ], @@ -1075,9 +1075,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.65.0.tgz", - "integrity": "sha512-W1vUprf09RWaVWnsFP1vlUkEWJzTZlpObOEAYZXt9Fq0UVAOYEkeGr7wBX7McKqqIQskXdBnpiOMgwPujordgA==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.65.1.tgz", + "integrity": "sha512-1XuPiWYYaLE1UDOqhbKVOfsqxvfmFS/lYPSk92U1hEX1qRfSYH0hNeAlbxFIgjp4d6+YQmL95EPRgdvjbZEddA==", "cpu": [ "arm" ], @@ -1087,9 +1087,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.65.0.tgz", - "integrity": "sha512-s8QTcZWgrl9haFzEGIqyE5BWyo89pFDGLMBFZ752WYxk8DJwNU/O1X6pZgPw829d9/sm6CCHyrMP3aurt76p1Q==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.65.1.tgz", + "integrity": "sha512-Y3C5e55Yrf6p2r5rBcc4P9hFWVNi6oqWy/53I/AyCJJGlp3KRSpTP8eoUmVntchTbEnhgBgAgLO9flj9AvOdUw==", "cpu": [ "arm" ], @@ -1099,9 +1099,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.65.0.tgz", - "integrity": "sha512-ke1f6dLRzkqveyjjF2fkEwQYZvxHtNNwjegY8nJrwcO0Bdqr8CzFO/pKE0Bns13cx0qz2Ba2Y0Hs3dSwMdnHxA==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.65.1.tgz", + "integrity": "sha512-Itj6coSswkYPIcVLczObXmwdplHNQQxyfnK1ppBg4czDaX8RiEzLKqU7EMc64BiBuiDZEUS+M93ktM18rv5llw==", "cpu": [ "arm64" ], @@ -1111,9 +1111,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.65.0.tgz", - "integrity": "sha512-926SMeUcy4y18Rz/w2aJiUKOnyAnm6czg72bXkQEaeemsll+Wr0hnsjb2BGRpHvWgwAMYy5ACJCotbvfLCfj0A==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.65.1.tgz", + "integrity": "sha512-KeO8jTEqdrnhvcahufbimyMoI44vedRUp1bVz2QUnRX2vDm/hxzCPt5F8fERVD5fsHyTW/vXmCElT054VOSVOA==", "cpu": [ "arm64" ], @@ -1123,9 +1123,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.65.0.tgz", - "integrity": "sha512-6T7JNRP1hc4hqF2dUsEoVLOt9K8tmODoCLldtQRYnrddGGQY8Gqe2pM/qMMUo7EMowpP7oa2N95k0/8sYK/ipw==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.65.1.tgz", + "integrity": "sha512-dDOz5VZZ+ZGnglpmET6VJefamWXFsgaVtuhSClf2/URZBp048jprD6z1hddsIMN6n1w+oUzGZEBp9u6Mlszsfg==", "cpu": [ "loong64" ], @@ -1135,9 +1135,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.65.0.tgz", - "integrity": "sha512-SImGFl1Je5kpyAgOCPONVT7h6ASV8mAUwGRjY7eZvVjPg//PfHOXgI+P241RF/+HqsA4PP3qD527ysWFKNQyhQ==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.65.1.tgz", + "integrity": "sha512-JbfRKz8nJCuo8gn1Dqx8lQWEHw4qhZ3uRaHphw5vrSjnSptJTKTRC/bLKHh8QQMsEnhL4uq+bdg3tkiXbJ2q/A==", "cpu": [ "loong64" ], @@ -1147,9 +1147,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.65.0.tgz", - "integrity": "sha512-XeDTbmPF1Yu5asfXGPrMkWoxTDhgryQbmw5Q1+R582O51VNCk23J6f2pDrsAp5YkneBVBwtExx6ZGOb9D2aQ6A==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.65.1.tgz", + "integrity": "sha512-3oQpe1q0emSeCAssVHMQdOz5fz3PBH5my/Fr0ZekV2UMNL4ZiRa1ciikYlGQXyDkeqseYYv1InwOY8YPqY7RIQ==", "cpu": [ "ppc64" ], @@ -1159,9 +1159,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.65.0.tgz", - "integrity": "sha512-VS55JUI/1nsljmV/jfqNkLZXFw8uMO8nNlfoHmSevNf6fO3neuhyDvx9dv3c/Ytt8r/431SL8Qax1dpYvOMZTg==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.65.1.tgz", + "integrity": "sha512-nNV3HHi23o1LtpGGwH+Bav4hqnc4+klQtC96tSvCfvql8bLlNFTnTFiGl/jai2nve3eEr/vT2ocuyf+h+eMnYQ==", "cpu": [ "ppc64" ], @@ -1171,9 +1171,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.65.0.tgz", - "integrity": "sha512-Vblw84TbX7RS4la1Y4+yQ7aore/oSyaNtFbppuBr5Vs/4LfBeXKq9dIfoExw6zOvRERqd2rRAjnZRaMhH/0x5w==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.65.1.tgz", + "integrity": "sha512-b5zab0FryBxuocuDeqOsYGAg2EzjKw4mKA6LjQXCyewXjGv/BXqACBsY2cO45xM4hV2weYtdjGThP2VB2ByAYg==", "cpu": [ "riscv64" ], @@ -1183,9 +1183,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.65.0.tgz", - "integrity": "sha512-7+gHjtONvKgwR9InTKFaLq53f6YzBJkgeMRBENdWTsQr+spahGqQm8HF5aXpq8i4JaRrOqYyv6Zn1WltDTxaZQ==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.65.1.tgz", + "integrity": "sha512-Rh08xZakiUxKNgI3hbgH9mivlx478lSqckS9s+Oc3/TX1VrAH3W8z/5wpJXR/v5VjqcJ/jJNVBg64PU1+iQsTQ==", "cpu": [ "riscv64" ], @@ -1195,9 +1195,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.65.0.tgz", - "integrity": "sha512-KvNDC0J6s4vVHhtJ6KrDXU0Er0au/hmRIBq/+1maCV8TseGupeKizq0bWcrbp2HE1T8xoknvN0Q/KZ/YF0qX3w==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.65.1.tgz", + "integrity": "sha512-qDgtS6juN3/k97y4kcD6XLFpXEo4f6CnG8lTOGYwlhrUYTrMyhN0WHdZCPAW5rqS0MqQG7qI7dxnjugt6QB39Q==", "cpu": [ "s390x" ], @@ -1219,9 +1219,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.65.0.tgz", - "integrity": "sha512-8sT9PemIZfIMED4Vd4KGoOXVDj5CMSWNcGdbheRUx9TYS03yOWdT2Q0lXPyEyNBB9YTilwewIQKNIBk6Nxi0Kg==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.65.1.tgz", + "integrity": "sha512-tvYWZ/h2PCHs558Xu7B6LptuRCV8lXV2xI75ONMcldII1m2zlFwuI4BJZi9M6rSMkYqcPLLtPUPrjyqfbzLOdg==", "cpu": [ "x64" ], @@ -1231,9 +1231,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.65.0.tgz", - "integrity": "sha512-pO6Ehi0jo5yE0R6PltKhJhv9mlIiyzBJk5N5NUF2OF6xtVKI+1bdXxj/rb0jGXy3hck1ELVT0QPY3tzkAm360w==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.65.1.tgz", + "integrity": "sha512-gdyeucgZCnei3pXwadKVTEAOgRx0B+c847DUPR/PdOMmp3olhfkYo6r0J4PzRFZB5ZICZQTxntyvgmWH5xT7rg==", "cpu": [ "x64" ], @@ -1243,9 +1243,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.65.0.tgz", - "integrity": "sha512-vo7N3/+cjjPDYr8vtxC+qSNgWa9sDvJ7g8N8R5SWqFY4WiNH1ULcAU70SKVyHeiIZOYYwNP9pTipmhN5sl1NPw==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.65.1.tgz", + "integrity": "sha512-Wf/NEKdn2SAL4Nvt8HyDtdkseWTwiLbZEOoNYnEaHkbso0B9eHO42vKnZh+OYsn+IMhAMcWcuIwPlBOgOXsUzg==", "cpu": [ "arm64" ], @@ -1255,9 +1255,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.65.0.tgz", - "integrity": "sha512-ArwvgFAbjKcDM7qqotZ4OVYf+UMT9xH2YzkCzK4+AuZSDihMlz1Ekoe4INwBVXzn2mnJzuIYOcSx07iTBijGRQ==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.65.1.tgz", + "integrity": "sha512-x9VSsDmsMsEWy4cr/mFeaEsXnVJ6Kc4NCAU/lCnwml3s7LQbZoa9+/qwc9etWHbuuDT5mYxdyyKUwo76njtjZw==", "cpu": [ "arm64" ], @@ -1267,9 +1267,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.65.0.tgz", - "integrity": "sha512-QGMmK4/cxKE/36FoLMSHQBCyymv/eFTnsCvvsBF0rgmOvVbvX78VWZRerL3dHa77f4pM6Xcc2iGDhJ10w/svKQ==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.65.1.tgz", + "integrity": "sha512-RIDbiVvvLkN45mOFpbo6eYMgnuiBEB62BGR/0s4QJgGg2AvVxucDDNn67kpAcUoYyfmY8iX8/RifMcmxmmnejA==", "cpu": [ "ia32" ], @@ -1279,9 +1279,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.65.0.tgz", - "integrity": "sha512-XfKYFWHIPs4HPODIMsGF1IKlS3H1e3WokUZIgpmn4+uNnZBlnRDDHEylNyazbQXwhMKZre68OjtRYbKqb5+j8w==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.65.1.tgz", + "integrity": "sha512-+KN8HIr88vN59JNxlrM2fMimzY43Y0lQB7+0xdVsFbvFCAA74CLKCdfzIHzzvndJryBc94Pm+MvwtukKjaS2ug==", "cpu": [ "x64" ], @@ -1291,9 +1291,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.65.0.tgz", - "integrity": "sha512-wPWi++K4Bs1VR4XihOhfGi4IGX6Cub7FlerTf8X2v+2BtDHddgSqWCXwGD+h2yAe4/niIhNDwAltioOC+wEogQ==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.65.1.tgz", + "integrity": "sha512-+Y1Sd49cSDq60UOl8/U2HI0ebUvnSl/hDhMHQn+ouw17yb8fYwMWIazzJ4JFpSkjPE7sj5NTNSX9IoyEqcFfyQ==", "cpu": [ "x64" ], @@ -2132,9 +2132,9 @@ } }, "node_modules/browserslist": { - "version": "4.29.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.4.tgz", - "integrity": "sha512-2TcbbiwXEq+N0kD36tykpCfyyQigUXmuwNQbnPp4Xk4PX9JOqVIKjJlIDjpH7uY6tmRGoAd+apeT1p60T8wsdA==", + "version": "4.29.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.5.tgz", + "integrity": "sha512-khTmKu5NGYTotqcYo8Bew2k4RAE9PuXRSFRXG2lQBrCEQHyeQ/vX6gKMxfokwywBsLiXZMnHsmWMG1pnO8J7uQ==", "funding": [ { "type": "opencollective", @@ -2150,10 +2150,10 @@ } ], "dependencies": { - "baseline-browser-mapping": "^2.11.29", - "caniuse-lite": "^1.0.30001816", - "electron-to-chromium": "^1.5.459", - "node-releases": "^2.0.59", + "baseline-browser-mapping": "^2.11.33", + "caniuse-lite": "^1.0.30001819", + "electron-to-chromium": "^1.5.464", + "node-releases": "^2.0.60", "update-browserslist-db": "^1.3.4" }, "bin": { @@ -2877,9 +2877,9 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "node_modules/electron-to-chromium": { - "version": "1.5.464", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.464.tgz", - "integrity": "sha512-nRa65aFge40viPAKxMKULIY8tcXKwKboJWNn4RoKwyWXJ6SP7BnrDGKUTzc64818jIciH0PK7LSF8QzZes19GA==" + "version": "1.5.465", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.465.tgz", + "integrity": "sha512-zg4iUpbeGw7YNZsswc2xKr2uiBex2d8dELAXo92Icw3but1ULbrvK8USe6wVLaWjgJnKBARv0pchx9Owktx6wQ==" }, "node_modules/elliptic": { "version": "6.6.1", @@ -6231,9 +6231,9 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, "node_modules/rollup": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.65.0.tgz", - "integrity": "sha512-QVCvQUTGDN6JRwi3jVGCY3H4aTHvi9rX0LyoBMkGUvdh/Z6O51CzcLz85BWZaTCDGhl6ySGqYZs+xI1jmPqGcw==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.65.1.tgz", + "integrity": "sha512-HzD4IdphaPMvuAacnL/vtoq5B8FwqewKvxTtqYibWvP6VDyZ+UsTtlw5F8HIOIIpLQAkN2NOP/0sM3puo9xN/A==", "dependencies": { "@types/estree": "1.0.9" }, @@ -6246,31 +6246,31 @@ }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", - "@rollup/rollup-android-arm-eabi": "4.65.0", - "@rollup/rollup-android-arm64": "4.65.0", - "@rollup/rollup-darwin-arm64": "4.65.0", - "@rollup/rollup-darwin-x64": "4.65.0", - "@rollup/rollup-freebsd-arm64": "4.65.0", - "@rollup/rollup-freebsd-x64": "4.65.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.65.0", - "@rollup/rollup-linux-arm-musleabihf": "4.65.0", - "@rollup/rollup-linux-arm64-gnu": "4.65.0", - "@rollup/rollup-linux-arm64-musl": "4.65.0", - "@rollup/rollup-linux-loong64-gnu": "4.65.0", - "@rollup/rollup-linux-loong64-musl": "4.65.0", - "@rollup/rollup-linux-ppc64-gnu": "4.65.0", - "@rollup/rollup-linux-ppc64-musl": "4.65.0", - "@rollup/rollup-linux-riscv64-gnu": "4.65.0", - "@rollup/rollup-linux-riscv64-musl": "4.65.0", - "@rollup/rollup-linux-s390x-gnu": "4.65.0", - "@rollup/rollup-linux-x64-gnu": "4.65.0", - "@rollup/rollup-linux-x64-musl": "4.65.0", - "@rollup/rollup-openbsd-x64": "4.65.0", - "@rollup/rollup-openharmony-arm64": "4.65.0", - "@rollup/rollup-win32-arm64-msvc": "4.65.0", - "@rollup/rollup-win32-ia32-msvc": "4.65.0", - "@rollup/rollup-win32-x64-gnu": "4.65.0", - "@rollup/rollup-win32-x64-msvc": "4.65.0", + "@rollup/rollup-android-arm-eabi": "4.65.1", + "@rollup/rollup-android-arm64": "4.65.1", + "@rollup/rollup-darwin-arm64": "4.65.1", + "@rollup/rollup-darwin-x64": "4.65.1", + "@rollup/rollup-freebsd-arm64": "4.65.1", + "@rollup/rollup-freebsd-x64": "4.65.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.65.1", + "@rollup/rollup-linux-arm-musleabihf": "4.65.1", + "@rollup/rollup-linux-arm64-gnu": "4.65.1", + "@rollup/rollup-linux-arm64-musl": "4.65.1", + "@rollup/rollup-linux-loong64-gnu": "4.65.1", + "@rollup/rollup-linux-loong64-musl": "4.65.1", + "@rollup/rollup-linux-ppc64-gnu": "4.65.1", + "@rollup/rollup-linux-ppc64-musl": "4.65.1", + "@rollup/rollup-linux-riscv64-gnu": "4.65.1", + "@rollup/rollup-linux-riscv64-musl": "4.65.1", + "@rollup/rollup-linux-s390x-gnu": "4.65.1", + "@rollup/rollup-linux-x64-gnu": "4.65.1", + "@rollup/rollup-linux-x64-musl": "4.65.1", + "@rollup/rollup-openbsd-x64": "4.65.1", + "@rollup/rollup-openharmony-arm64": "4.65.1", + "@rollup/rollup-win32-arm64-msvc": "4.65.1", + "@rollup/rollup-win32-ia32-msvc": "4.65.1", + "@rollup/rollup-win32-x64-gnu": "4.65.1", + "@rollup/rollup-win32-x64-msvc": "4.65.1", "fsevents": "~2.3.2" } }, @@ -6343,9 +6343,9 @@ "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==" }, "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.65.0.tgz", - "integrity": "sha512-GExdD15WPvu/x/drELjVam7vX8EsPgvTMOa/iJL/p/4m/oOqzbb7JXZg8fXR4hlXtg/sF2zyTZ3bR0AEtEMlhQ==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.65.1.tgz", + "integrity": "sha512-44IKzBPqXXVm0kt1O+F5eW4juR5FyIxGNlFkpP8qwIDzucBsjM1HCbdYUQrQw8rU/g6HRl/tfBm0+Qq4H4Ro3w==", "cpu": [ "x64" ],