diff --git a/README.md b/README.md index fd8ca55..0711fda 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,9 @@ The assertion side offers both `assert.*` functions and a Chai-style `expect(x). dependencies to a minimum so that a dependency update elsewhere cannot break your test run -- which is how the project started, when the frameworks we tested Worker code with stopped working. -**New here?** Read [Why tripwire](https://nevware21.github.io/tripwire/why-tripwire.html) for what is automatic, what is fast, how it +**New here?** See [Tripwire in action](https://nevware21.github.io/tripwire/tour.html) -- tripwire's own suite run once on node, in +Chrome and in a web worker, with coverage and the interactive browser page -- then read +[Why tripwire](https://nevware21.github.io/tripwire/why-tripwire.html) for what is automatic, what is fast, how it compares with Mocha, Karma, Jest and Vitest, and when it is not the right tool. Coming from one of those? There is a [migration guide](#documentation) for each. diff --git a/common/config/rush/npm-shrinkwrap.json b/common/config/rush/npm-shrinkwrap.json index 26b54b5..26e730e 100644 --- a/common/config/rush/npm-shrinkwrap.json +++ b/common/config/rush/npm-shrinkwrap.json @@ -1920,9 +1920,9 @@ ] }, "node_modules/baseline-browser-mapping": { - "version": "2.11.33", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.33.tgz", - "integrity": "sha512-yiVKDX+qCMRKBE084lXvq9EcwFUhKZ2nU8TK1EQQuOgPNR/lmbGxXOeDdlGUqC2Zz11Cs9rk1d/8NfgBmlkt0w==", + "version": "2.11.34", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.34.tgz", + "integrity": "sha512-Aqr8mA1kIvyJ3UY5YAY7ib8LSo1DmDMbMnVqGPtsKW1LxaFQFwn9Eb3yvq2fUKJsRvaXjnAV7R5osqETapzNiA==", "bin": { "baseline-browser-mapping": "dist/cli.cjs" }, @@ -2877,9 +2877,9 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "node_modules/electron-to-chromium": { - "version": "1.5.465", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.465.tgz", - "integrity": "sha512-zg4iUpbeGw7YNZsswc2xKr2uiBex2d8dELAXo92Icw3but1ULbrvK8USe6wVLaWjgJnKBARv0pchx9Owktx6wQ==" + "version": "1.5.467", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.467.tgz", + "integrity": "sha512-Nw9X371rtZmYcBSnEijnCDONILjs93biCZO34juC7MBOmK8E+GFnyD4T/WvyrlJkppul7oxg6TwvtKst9SODTw==" }, "node_modules/elliptic": { "version": "6.6.1", @@ -5944,9 +5944,9 @@ } }, "node_modules/qs": { - "version": "6.16.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", - "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", + "version": "6.16.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.2.tgz", + "integrity": "sha512-VYdnB5S7/0IeXzBacUtwNAaM6d7Y/PgYnNC7bJCI6meqA/OgnsQbZMtVW8Dt2D1PHgbHGtYmhSoEIgvt7xZ2ZA==", "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" diff --git a/docs/README.md b/docs/README.md index 15be45c..cf04c32 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,6 +14,9 @@ ### Documentation +- [Tripwire in action](tour.md) - tripwire's own suite run once on node, in Chrome and in a web + worker, coverage from the command line, and the interactive browser page, with screenshots of + each - [Why tripwire](why-tripwire.md) - what is automatic across node, browser and worker, what is fast, how it compares with Mocha, Karma, Jest and Vitest, and when not to use it - [Performance](performance.md) - the runner's mechanisms, measured benchmark results against diff --git a/docs/images/tour/browser-dark.png b/docs/images/tour/browser-dark.png new file mode 100644 index 0000000..c0e4ff4 Binary files /dev/null and b/docs/images/tour/browser-dark.png differ diff --git a/docs/images/tour/browser-failure.png b/docs/images/tour/browser-failure.png new file mode 100644 index 0000000..4cd83a6 Binary files /dev/null and b/docs/images/tour/browser-failure.png differ diff --git a/docs/images/tour/browser-phone.png b/docs/images/tour/browser-phone.png new file mode 100644 index 0000000..ebb79e0 Binary files /dev/null and b/docs/images/tour/browser-phone.png differ diff --git a/docs/images/tour/browser-runner.png b/docs/images/tour/browser-runner.png new file mode 100644 index 0000000..14694b9 Binary files /dev/null and b/docs/images/tour/browser-runner.png differ diff --git a/docs/images/tour/browser-scope.png b/docs/images/tour/browser-scope.png new file mode 100644 index 0000000..e475e5b Binary files /dev/null and b/docs/images/tour/browser-scope.png differ diff --git a/docs/images/tour/coverage.svg b/docs/images/tour/coverage.svg new file mode 100644 index 0000000..6fef8f5 --- /dev/null +++ b/docs/images/tour/coverage.svg @@ -0,0 +1,33 @@ + + Coverage of tripwire's core: --coverage in Chrome, nyc on node + + + $ npx tripwire --target browser --coverage --coverage-reporter text-summary -p test/tsconfig.test.json --reporter min "test/src/!(node|worker)/**/*.test.ts" + Launching browser target via Puppeteer... + + 2921 passing 1 skipped (4.93s) + + Wrote coverage: ./coverage/browser/coverage-final.json + + =============================== Coverage summary =============================== + Statements : 96.01% ( 5087/5298 ) + Branches : 87.14% ( 2209/2535 ) + Functions : 97.1% ( 805/829 ) + Lines : 96% ( 5001/5209 ) + ================================================================================ + + Total elapsed: 17967ms (prep: 2008ms, compile: 6567ms, tests: 8564ms, reporting: 775ms) + + $ npx nyc --reporter=text-summary tripwire -p test/tsconfig.test.json --reporter min "test/src/!(browser|worker)/**/*.test.ts" + 2921 passing 2 skipped (7.03s) + + Total elapsed: 8917ms (prep: 256ms, compile: 1630ms, tests: 7028ms) + + =============================== Coverage summary =============================== + Statements : 92.63% ( 4820/5203 ) + Branches : 83.19% ( 2114/2541 ) + Functions : 93.03% ( 775/833 ) + Lines : 92.61% ( 4728/5105 ) + ================================================================================ + + diff --git a/docs/images/tour/one-file-node.svg b/docs/images/tour/one-file-node.svg new file mode 100644 index 0000000..cf3aae3 --- /dev/null +++ b/docs/images/tour/one-file-node.svg @@ -0,0 +1,30 @@ + + One of tripwire's own test files, run on node + + + $ npx tripwire -p test/tsconfig.test.json test/src/assert/assert.isString.test.ts + isString + ✓ should pass when the value is a string 4ms + ✓ should throw an error when the value is not a string 7ms + ✓ should throw an error with a custom message when the value is not a string 4ms + ✓ should pass when the value is an empty string 1ms + ✓ should throw an error when the value is an object 3ms + ✓ should throw an error when the value is an array 3ms + ✓ should throw an error when the value is null 2ms + ✓ should throw an error when the value is undefined 4ms + isNotString + ✓ should pass when the value is a number 1ms + ✓ should pass when the value is an object + ✓ should throw an error when the value is a string 4ms + ✓ should pass when the value is null + ✓ should pass when the value is undefined 1ms + ✓ should throw an error with a custom message when the value is a string 3ms + ✓ should pass when the value is a boolean + ✓ should pass when the value is an array + ✓ should pass when the value is a function 1ms + + 17 passing (45ms) + + Total elapsed: 395ms (prep: 259ms, compile: 88ms, tests: 46ms) + + diff --git a/docs/images/tour/one-file-three-targets.svg b/docs/images/tour/one-file-three-targets.svg new file mode 100644 index 0000000..d6be04a --- /dev/null +++ b/docs/images/tour/one-file-three-targets.svg @@ -0,0 +1,37 @@ + + The same test file on node, in Chrome and in a web worker + + + $ npx tripwire -p test/tsconfig.test.json --reporter dot test/src/assert/assert.isString.test.ts + ..... + ..... + ..... + .. + + 17 passing (32ms) + + Total elapsed: 366ms (prep: 240ms, compile: 91ms, tests: 33ms) + + $ npx tripwire --target browser -p test/tsconfig.test.json --reporter dot test/src/assert/assert.isString.test.ts + Launching browser target via Puppeteer... + ..... + ..... + ..... + .. + + 17 passing (48ms) + + Total elapsed: 6967ms (prep: 716ms, compile: 2655ms, tests: 3165ms) + + $ npx tripwire --target worker -p test/tsconfig.test.json --reporter dot test/src/assert/assert.isString.test.ts + Launching worker target via Puppeteer... + ..... + ..... + ..... + .. + + 17 passing (25ms) + + Total elapsed: 6794ms (prep: 629ms, compile: 2851ms, tests: 3164ms) + + diff --git a/docs/images/tour/whole-suite.svg b/docs/images/tour/whole-suite.svg new file mode 100644 index 0000000..c93a213 --- /dev/null +++ b/docs/images/tour/whole-suite.svg @@ -0,0 +1,24 @@ + + tripwire's core suite on node, in Chrome and in a web worker + + + $ npx tripwire --type-check -p test/tsconfig.test.json --reporter min "test/src/!(browser|worker)/**/*.test.ts" + 2921 passing 2 skipped (4.89s) + + Total elapsed: 5440ms (prep: 258ms, compile: 286ms, tests: 4893ms) + + $ npx tripwire --target browser --type-check -p test/tsconfig.test.json --reporter min "test/src/!(node|worker)/**/*.test.ts" + Launching browser target via Puppeteer... + + 2921 passing 1 skipped (3.57s) + + Total elapsed: 12421ms (prep: 855ms, compile: 4723ms, tests: 6788ms) + + $ npx tripwire --target worker --type-check -p test/tsconfig.test.json --reporter min "test/src/!(node|browser)/**/*.test.ts" + Launching worker target via Puppeteer... + + 2921 passing 1 skipped (3.09s) + + Total elapsed: 12578ms (prep: 2237ms, compile: 5137ms, tests: 5149ms) + + diff --git a/docs/llms.txt b/docs/llms.txt index aa38057..838a9de 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -6,13 +6,14 @@ Conventions worth knowing before reading the rest: `expect(x).to.be.true()` term ## Start here +- [Tripwire in action](https://nevware21.github.io/tripwire/tour.html): tripwire's own core suite run on node, in Chrome and in a web worker from one set of test files, coverage with --coverage (browser/worker) or nyc (node), and the interactive browser page, with screenshots from real runs - [Why tripwire](https://nevware21.github.io/tripwire/why-tripwire.html): what is automatic across node/browser/worker, what is fast, comparison with Mocha, Karma, Jest and Vitest, and when not to use it - [AGENTS.md](https://github.com/nevware21/tripwire/blob/main/AGENTS.md): entry points for agents -- migrating a project, using the runner, changing the repository - [Documentation index](https://nevware21.github.io/tripwire/): every guide, feature table and quick start ## Using the runner -- [Test Runner, CLI & Configuration](https://nevware21.github.io/tripwire/test-runner.html): every CLI flag and every `.tripwirerc` field in two tables, targets, the interactive browser page (scope box, run again, Failed only, Auto-reload, actions menu, URL-hash scope), file discovery, coverage, compile caching, hook failures +- [Test Runner, CLI & Configuration](https://nevware21.github.io/tripwire/test-runner.html): every CLI flag and every `.tripwirerc` field in two tables, targets, the interactive browser page (scope box, run again, Failed only, Auto-reload, actions menu, the Appearance panel with a searchable theme, color-blind safe colors and a zoom of the page alone, URL-hash scope), file discovery, coverage, compile caching, hook failures - [Reporters](https://nevware21.github.io/tripwire/reporters.html): the 14 built-in reporters with sample output, selecting and combining them, custom reporter modules - [Test Execution Flow](https://nevware21.github.io/tripwire/test-execution-flow.html): execution order, timeouts, hook failure handling, runner metrics - [Runner Troubleshooting](https://nevware21.github.io/tripwire/runner-troubleshooting.html): known issues, opting in to the global `describe`/`it` names, the `@types/mocha` / `@types/jest` global type clash diff --git a/docs/migration/migrating-from-mocha.md b/docs/migration/migrating-from-mocha.md index 6a3e50c..dfbe6a1 100644 --- a/docs/migration/migrating-from-mocha.md +++ b/docs/migration/migrating-from-mocha.md @@ -425,6 +425,7 @@ For automated migrations. Every entry is derived from the tables above. "configPrecedence": { "listValuedFlagsAppendToConfig": ["--spec", "--ignore", "--grep", "--fgrep", "--require", "--file", "--coverage-reporter", "--coverage-src"], "replacesConfigList": ["--reporter"], + "replacesOnlyTheConfigPartsItNames": ["--browser-theme"], "configKeysConcatenatedAcrossDirectories": ["grep", "ignore", "spec", "require"], "otherSettings": "the CLI flag replaces the config value; a config file closer to the cwd replaces one higher up" }, diff --git a/docs/scripts/genTourImages.js b/docs/scripts/genTourImages.js new file mode 100644 index 0000000..fd7b29e --- /dev/null +++ b/docs/scripts/genTourImages.js @@ -0,0 +1,330 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +/* + * Regenerates the images used by docs/tour.md. + * + * node docs/scripts/genTourImages.js + * + * Every image is a real run, nothing is mocked up: + * + * - the terminal images run the built CLI over tripwire's own core test suite -- one test file on + * each target, the whole suite on each target as core's own npm scripts run it, and coverage on + * the browser and node targets -- with FORCE_COLOR=1, and render the captured ANSI to SVG with + * genReporterImages.js's renderer; + * - the browser images drive the interactive page with puppeteer, over the small project the + * runner's own interactive-page tests use (internal/runner/test/fixtures/interactive). + * + * Pass `terminal` or `browser` to make just that set: `node docs/scripts/genTourImages.js browser`. + * + * The terminal captures are cleaned before they are drawn: lines that only report how the run was + * set up (server ports, absolute paths, module counts) are dropped or made relative, so the images + * carry nothing of the machine they were made on. Timings are left as measured. The browser images + * show the page as it is, loopback address and port included. + * + * Needs the runner built (core/bin/tripwire.js) and puppeteer's Chrome installed (see + * internal/runner's install script); it overwrites core/coverage, which is generated output. + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { spawn, spawnSync } = require("child_process"); +const { createRequire } = require("module"); +const { pathToFileURL } = require("url"); +const { toSvg } = require("./genReporterImages"); + +const REPO = path.resolve(__dirname, "..", ".."); +const CORE = path.join(REPO, "core"); +const CLI = path.join(CORE, "bin", "tripwire.js"); +const OUT_DIR = path.join(REPO, "docs", "images", "tour"); +const FIXTURE = path.join(REPO, "internal", "runner", "test", "fixtures", "interactive"); +const RUNNER = path.join(REPO, "internal", "runner"); + +// One of core's own test files: small enough to read, and run as-is on every target +const DEMO_FILE = "test/src/assert/assert.isString.test.ts"; +const PROJECT = [ "-p", "test/tsconfig.test.json" ]; + +// The globs core's own test:node, test:browser and test:worker scripts run: everything but the two +// folders that hold the other targets' own tests +const SUITE_GLOBS = { + node: "test/src/!(browser|worker)/**/*.test.ts", + browser: "test/src/!(node|worker)/**/*.test.ts", + worker: "test/src/!(node|browser)/**/*.test.ts" +}; + +const PROMPT = "\x1b[90m$\x1b[39m "; + +// The window the wide browser images start from; each is then made as tall as the page (fitWindow) +const WIDE = { width: 1200, height: 720 }; + +// Lines that say nothing about the run itself: how it was set up on this machine +const DROP = [ + /^Matched \d+ file/, + /^\s*Running tests\.\.\.\s*$/, + /^Run summary:/, + /^Server URL:/, + /^\s*Module graph:/, + /^Ready to run tests after/, + /^Compile cache:/, + /^Type-check(ing TypeScript project:| completed in)/, + /^Coverage (written|report)/ +]; + +/** `args` as they would be typed, quoting the ones a shell would expand. */ +function displayCommand(command, args) { + return [ command ].concat(args.map((arg) => (/[*!(|)]/.test(arg) ? "\"" + arg + "\"" : arg))).join(" "); +} + +/** + * The captured output, ready to draw: the set-up chatter in DROP gone, `cwd` (in either slash + * direction) shown as ".", and blank lines trimmed at the ends and never more than one in a row. + */ +function cleanOutput(raw, cwd) { + const roots = [ cwd, cwd.replace(/\\/g, "/") ]; + const lines = []; + + String(raw).split(/\r?\n/).forEach((line) => { + const plain = line.replace(/\x1b\[[0-9;]*m/g, ""); + if (DROP.some((re) => re.test(plain))) { + return; + } + + let shown = line; + roots.forEach((root) => { + shown = shown.split(root).join("."); + }); + + // A path made relative reads the same on every platform + shown = shown.replace(/\.\\[^\s]*/g, (found) => found.replace(/\\/g, "/")); + + if (!shown.trim() && (!lines.length || !lines[lines.length - 1].trim())) { + return; + } + + lines.push(shown.replace(/\s+$/, "")); + }); + + while (lines.length && !lines[lines.length - 1].trim()) { + lines.pop(); + } + + return lines; +} + +/** Each step's command after a prompt, then its cleaned output, with a blank line between steps. */ +function transcript(steps) { + const lines = []; + steps.forEach((step, idx) => { + if (idx) { + lines.push(""); + } + + lines.push(PROMPT + step.display); + Array.prototype.push.apply(lines, cleanOutput(step.output, step.cwd)); + }); + + return lines; +} + +/** Runs the built CLI from core/, or through nyc when `nyc` is set, and returns what it printed. */ +function runCore(args, nyc) { + const command = nyc + ? [ require.resolve("nyc/bin/nyc.js", { paths: [ CORE ] }), "--reporter=text-summary", process.execPath, CLI ].concat(args) + : [ CLI ].concat(args); + const result = spawnSync(process.execPath, command, { + cwd: CORE, + encoding: "utf8", + env: Object.assign({}, process.env, { FORCE_COLOR: "1" }), + maxBuffer: 64 * 1024 * 1024 + }); + + return (result.stdout || "") + (result.stderr || ""); +} + +function writeSvg(name, lines, title) { + fs.writeFileSync(path.join(OUT_DIR, name), toSvg(lines, title), "utf8"); + console.log(" wrote " + name); +} + +function terminalImages() { + // One file, spec reporter, on node: what the tests are + const nodeArgs = PROJECT.concat([ DEMO_FILE ]); + writeSvg("one-file-node.svg", transcript([ + { display: displayCommand("npx tripwire", nodeArgs), output: runCore(nodeArgs), cwd: CORE } + ]), "One of tripwire's own test files, run on node"); + + // The same file on all three targets + writeSvg("one-file-three-targets.svg", transcript([ "node", "browser", "worker" ].map((target) => { + const args = (target === "node" ? [] : [ "--target", target ]).concat(PROJECT, [ "--reporter", "dot", DEMO_FILE ]); + return { display: displayCommand("npx tripwire", args), output: runCore(args), cwd: CORE }; + })), "The same test file on node, in Chrome and in a web worker"); + + // The whole suite, as core's own scripts run it + writeSvg("whole-suite.svg", transcript([ "node", "browser", "worker" ].map((target) => { + const args = (target === "node" ? [] : [ "--target", target ]).concat([ "--type-check" ], PROJECT, [ "--reporter", "min", SUITE_GLOBS[target] ]); + return { display: displayCommand("npx tripwire", args), output: runCore(args), cwd: CORE }; + })), "tripwire's core suite on node, in Chrome and in a web worker"); + + // Coverage: a flag on the browser target, the nyc wrapper on node + const browserCoverage = [ "--target", "browser", "--coverage", "--coverage-reporter", "text-summary" ].concat(PROJECT, [ "--reporter", "min", SUITE_GLOBS.browser ]); + const nodeCoverage = PROJECT.concat([ "--reporter", "min", SUITE_GLOBS.node ]); + writeSvg("coverage.svg", transcript([ + { display: displayCommand("npx tripwire", browserCoverage), output: runCore(browserCoverage), cwd: CORE }, + { display: displayCommand("npx nyc --reporter=text-summary tripwire", nodeCoverage), output: runCore(nodeCoverage, true), cwd: CORE } + ]), "Coverage of tripwire's core: --coverage in Chrome, nyc on node"); +} + +/** Starts the CLI in manual launch mode on the fixture, resolving with its runner URL and a stop(). */ +function startRunner() { + const child = spawn(process.execPath, [ CLI, "--target", "browser", "--browser-launch", "manual", "-p", "./tsconfig.json", "./specs/**/*.spec.ts" ], { + cwd: FIXTURE, + stdio: [ "ignore", "pipe", "pipe" ], + windowsHide: true + }); + let output = ""; + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + child.kill(); + reject(new Error("The runner printed no URL:\n" + output)); + }, 60000); + + child.stdout.on("data", (data) => { + output += data; + const match = /Runner URL:\s*(http:\/\/\S+\/__tripwire__\/browser)/.exec(output.replace(/\x1b\[[0-9;]*m/g, "")); + if (match) { + clearTimeout(timer); + resolve({ url: match[1], stop: () => child.kill() }); + } + }); + }); +} + +function wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Makes the window as tall as the page, scrolled to its top. The page follows a run down to its + * last result and its background is fixed to the window, so a capture of more than the window (a + * fullPage screenshot) shows a blank band wherever the window was not. + */ +async function fitWindow(page) { + const height = await page.evaluate(() => { + window.scrollTo(0, 0); + return document.documentElement.scrollHeight; + }); + await page.setViewport({ width: WIDE.width, height: height }); + await wait(300); +} + +async function browserImages() { + const runnerRequire = createRequire(path.join(RUNNER, "package.json")); + const module = await import(pathToFileURL(runnerRequire.resolve("puppeteer")).href); + const puppeteer = module.default || module; + const runner = await startRunner(); + const browser = await puppeteer.launch({ headless: true }); + + const shot = (name) => path.join(OUT_DIR, name); + const loaded = async (page) => { + await page.waitForFunction(() => /Run complete/.test((document.getElementById("status") || {}).textContent || ""), { timeout: 60000 }); + await wait(400); + }; + + try { + const page = await browser.newPage(); + await page.emulateMediaFeatures([ { name: "prefers-color-scheme", value: "light" } ]); + await page.setViewport(WIDE); + await page.goto(runner.url, { waitUntil: "domcontentloaded" }); + await loaded(page); + await fitWindow(page); + + await page.screenshot({ path: shot("browser-runner.png") }); + console.log(" wrote browser-runner.png"); + + const failed = await page.$("#results .test-node.test-failed"); + await failed.screenshot({ path: shot("browser-failure.png") }); + console.log(" wrote browser-failure.png"); + + await page.click("#scopeSelect"); + await page.type("#scopeSelect", "add"); + await wait(300); + await page.screenshot({ path: shot("browser-scope.png"), clip: { x: 0, y: 0, width: WIDE.width, height: 250 } }); + console.log(" wrote browser-scope.png"); + await page.keyboard.press("Escape"); + await page.evaluate(() => { + const input = document.getElementById("scopeSelect"); + input.value = ""; + input.blur(); + }); + + // Dark, then a phone in low light with the color-blind palette, listing the themes in the + // Appearance panel + await page.evaluate(() => localStorage.setItem("tripwire.theme", "dark")); + await page.setViewport(WIDE); + await page.reload({ waitUntil: "domcontentloaded" }); + await loaded(page); + await fitWindow(page); + await page.screenshot({ path: shot("browser-dark.png") }); + console.log(" wrote browser-dark.png"); + + await page.evaluate(() => { + localStorage.setItem("tripwire.theme", "dim"); + localStorage.setItem("tripwire.vision", "colorblind"); + }); + await page.setViewport({ width: 390, height: 760 }); + await page.reload({ waitUntil: "domcontentloaded" }); + await loaded(page); + await page.evaluate(() => window.scrollTo(0, 0)); + await page.click("#actionsBtn"); + await page.click("#appearanceItem"); + await page.waitForFunction(() => document.activeElement && document.activeElement.id === "themeInput"); + await page.keyboard.press("ArrowDown"); + await wait(300); + await page.screenshot({ path: shot("browser-phone.png") }); + console.log(" wrote browser-phone.png"); + } finally { + await browser.close(); + runner.stop(); + } +} + +async function main() { + if (!fs.existsSync(CLI)) { + console.error("Built CLI not found at " + CLI + " -- run the runner build first."); + process.exitCode = 1; + return; + } + + const only = process.argv[2]; + fs.mkdirSync(OUT_DIR, { recursive: true }); + if (only !== "browser") { + terminalImages(); + } + + if (only !== "terminal") { + await browserImages(); + } + console.log("Done -> " + path.relative(REPO, OUT_DIR)); +} + +// Only regenerate the images when executed as a script, so the tests can load the helpers without +// overwriting the committed images. +if (require.main === module) { + main().catch((e) => { + console.error(e); + process.exitCode = 1; + }); +} + +module.exports = { + REPO, CORE, CLI, OUT_DIR, DEMO_FILE, SUITE_GLOBS, PROMPT, DROP, + displayCommand, cleanOutput, transcript +}; diff --git a/docs/test-runner.md b/docs/test-runner.md index 1cc1c02..eaf49ad 100644 --- a/docs/test-runner.md +++ b/docs/test-runner.md @@ -60,35 +60,70 @@ opened (default: `puppeteer`): | `open` | Starts the embedded server and opens it in the OS's default browser (`start`/`open`/`xdg-open`). Requires the server to stay running, so `--no-server` is ignored (with a warning) in this mode. | | `manual` | Starts the server and prints its URL; you open it yourself. Useful for switching between the `browser`/`worker` tabs from the same session: nothing is built up front -- each target's module graph is built the first time that target is requested from the page, and rebuilt on a later request if its sources have changed. | -An optional `:style` suffix (`classic` or `slate`, default `classic`) picks the UI theme for -`open`/`manual` sessions -- `--browser-style` sets the same thing independently. +An optional `:style` suffix (`classic` or `slate`, default `classic`) picks the UI style for +`open`/`manual` sessions -- `--browser-style` sets the same thing independently. Both styles come +in every theme -- light, dark, low light and color-blind safe, at any zoom from 50% to 300% -- +which `--browser-theme` picks and the page's own Appearance panel changes: see +[Themes and accessibility](#themes-and-accessibility). ### The interactive page `open` and `manual` launches serve a page from which you run and re-run tests without restarting the CLI. One session serves both targets -- `/browser` and `/worker` -- and the page is the same -on each. Everything on it is keyboard-reachable; the controls, left to right: +on each. Everything on it is keyboard-reachable; the **Target** picker and the **…** (More actions) +menu sit at the top right, beside the title, and the controls below them are, left to right: | Control | What it does | |---|---| -| **Scope** box | Type to search every suite and test at once; terms match in any order, and each row says whether it is a suite or a test. Pick a row (click, or ArrowUp/ArrowDown then Enter; Escape closes the list) to stage it as the scope for the next run. Blank, or text naming no suite or test, means run everything. | -| **All tests** | Clears the scope without running, then moves focus to Run. Disabled when there is nothing to clear. | | **Target** | Switches the page between the browser and worker targets. It is a navigation to the other target's page, not a CLI restart, and the current scope travels with it. | -| **Failed only** | Hides passing results. A test that is running stays visible until it passes; a suite stays visible while anything inside it does. | -| **Auto-reload** | On by default. The page asks the server whether the current target's test modules have changed on disk and, when they have, reloads the test frame and re-runs with the current scope. Turn it off to run on demand only; it is a per-page setting, not saved. (`--watch` has no effect on these launches -- this is what replaces it.) | -| **Run** | Runs the staged scope. The status line reports progress and the assertion count updates live. | -| **…** (More actions) | A menu: **Reload Tests** discards and reloads the test frame, then re-runs; **Clear Results**; **Clear Logs**; **Finish & Stop Server** sends the last run's result back to the CLI, which prints its report, exits with that run's exit code and stops the server (the page then says it can be closed). Finish is disabled until a run has produced a result. Keyboard: ArrowUp/ArrowDown move and wrap, Home/End jump to the ends, Escape closes and returns focus to the button, and the whole menu is one tab stop. | - -Below the controls: the status line and assertion count, the results tree, and a collapsible -**Runner Log** panel. Every suite and test row in the results has a **run again** control that -scopes the page to that node and runs it immediately -- the quickest way to iterate on one failing -test. To get back, use **All tests**. +| **…** (More actions) | A menu: **Reload Tests** discards and reloads the test frame, then re-runs; **Auto-reload**, ticked while it is on (by default): the page asks the server whether the current target's test modules have changed on disk and, when they have, reloads the test frame and re-runs with the current scope -- untick it to run on demand only; it is a per-page setting, not saved (`--watch` has no effect on these launches -- this is what replaces it); **Clear Logs**; **Appearance…** opens the panel for the theme, color-blind safe colors and zoom (see [Themes and accessibility](#themes-and-accessibility)); **Finish & Stop Server** sends the last run's result back to the CLI, which prints its report, exits with that run's exit code and stops the server (the page then says it can be closed). Finish is disabled until a run has produced a result. Keyboard: ArrowUp/ArrowDown move and wrap, Home/End jump to the ends, Escape closes and returns focus to the button, and the whole menu is one tab stop. | +| **Scope** box | Type to search every suite and test at once; terms match in any order, and each row says whether it is a suite or a test. Pick a row (click, or ArrowUp/ArrowDown then Enter; Escape closes the list) to stage it as the scope for the next run. Blank, or text naming no suite or test, means run everything. | +| **✕** (Clear scope) | At the end of the Scope box while there is a scope: clears it without running, then moves focus to Run. | +| **Failed only** | Hides passing and skipped results, and says above the results how many it is hiding. A test that is running stays visible until it passes; a suite stays visible while anything inside it does. The failed count in the run summary turns it on and off too. | +| **Run** | Runs the staged scope. The status line reports progress, the run summary beside it counts the passed, failed and skipped tests as they finish (and adds the run's duration once it ends), and the assertion count updates live. The icon at the end of the status line clears the results. | + +When the window is too narrow for a row, its controls fold into the **…** menu rather than the row +wrapping: **Failed only** from the Scope row, and **Target** from the title row. Each comes back as +the window widens. In the menu they work as they do on the page -- **Failed only** as a ticked +entry, **Target** as a choice of the two targets. + +The Scope row and the status line stay at the top of the window as the results scroll. Below them: +the results tree and a collapsible **Runner Log** panel, which shows the reporter's output in the +colours the CLI's terminal shows it in. Each suite says how many of its tests +passed, failed and were skipped; a failed assertion shows its message with the expected and actual +values it compared, and the rest of its description folded under **Details**. Every suite and test +row in the results has a **run again** control that scopes the page to that node and runs it +immediately -- the quickest way to iterate on one failing test. To get back, use the **✕** in the Scope box. The scope lives in the URL hash (`#suite=&test=`, as short hashed ids rather than the titles), so a scoped page can be bookmarked, shared, or simply reloaded, and the scope is restored once the page has discovered the suite. The hash is replaced in place rather than pushed, so picking scopes does not fill the browser's history. +#### Themes and accessibility + +The page works from the keyboard alone and with a screen reader. **Tab** moves through everything +in the order it appears -- the first stop is a **Skip to the results** link -- and results rows open +and close with **Enter** or **Space**. Every icon button is named; the Scope box announces each +suggestion as the arrow keys highlight it; the status line announces each change; and an +assertion is announced as passed or failed, not by its ✓ or ✗. + +How the page looks is three settings, in the **Appearance** panel that **…** › **Appearance…** +opens. The panel is a small dialog rather than more menu entries, so that the theme can be a box +you search: it opens with focus on the theme, Tab moves through the rest, and Escape (or a click +outside it) closes it, putting focus back on the **…** button. + +| Setting | Choices | `--browser-theme` words | +|---|---|---| +| **Theme** | A searchable box: type any part of a theme's name, in any order, or press ArrowDown to list them all, then pick one with Enter or a click. **System** follows the operating system's light or dark setting; **Light**; **Dark**; **Low light** is dark and dimmer, with lower contrast and warmer colors, for a dark room. | `system`, `light`, `dark`, `dim` | +| **Color-blind safe** | A checkbox: shows passed and failed in blue and orange -- a pair every kind of color blindness tells apart -- in place of green and red: in the results, the run summary and the log. Works with every theme. | `colorblind` (on), `standard` (off) | +| **Zoom** | **−** and **+** step through 50, 67, 75, 80, 90, 100, 110, 125, 150, 175, 200, 250 and 300%, and **Reset** goes back to the run's zoom. It scales the page's own controls, results and log, and nothing else: it is not the browser's zoom, and never scales a test's own frame or content. A page zoomed past what the window holds takes its narrow layout, as a smaller window would. | Any whole percentage from `50%` to `300%`, eg. `125%` | + +`--browser-theme` (or `browserTheme` in a config file) sets how the page opens -- eg. +`tripwire --target browser --manual-browser --browser-theme dim,colorblind,125%` -- naming any of +the three settings it wants to change. A choice made in the panel is remembered in that browser and +wins over the run's setting from then on; choosing what the run set forgets it again. + ## CLI Reference ``` @@ -116,6 +151,7 @@ argument before a `--`, prints the help text and exits 0. | `--target `, `--env` | Runtime target. Default: `node`. | | `--browser-launch ` | Browser launch mode + optional style for `browser`/`worker` targets. See [Test Targets](#test-targets). | | `--browser-style ` | Browser UI style for interactive sessions. Default: `classic`. | +| `--browser-theme ` | How the interactive page opens: a comma separated list of a color scheme (`system`, `light`, `dark`, or `dim` for low light), a color vision setting (`standard`, `colorblind`) and a zoom, as a whole percentage from `50%` to `300%`, eg. `dim,colorblind,125%`. Replaces only the parts of the config file's `browserTheme` it names. See [Themes and accessibility](#themes-and-accessibility). | | `--open-browser` | Alias for `--browser-launch open`. | | `--manual-browser` | Alias for `--browser-launch manual`. | | `--ux-provider ` | Custom browser UX provider module, overriding the built-in one. | @@ -555,6 +591,7 @@ All fields are optional; every default matches the CLI defaults documented above | `target` | `"node" \| "browser" \| "worker"` | Default runtime target. | | `browserLaunch` | `"puppeteer" \| "open" \| "manual"` | Default browser launch mode. | | `browserStyle` | `"classic" \| "slate"` | Default browser UI style. | +| `browserTheme` | `string \| string[]` | How the interactive page opens, in the words `--browser-theme` takes: `"dim,colorblind,125%"` or `["dim", "colorblind", "125%"]`. An unknown word, or a zoom outside 50% to 300%, is an error. | | `uxProvider` | `string` | Browser UX provider module name. | | `project` | `string` | Path to the TypeScript project file. | | `tsconfig` | `string \| object` | As a string: an alternate way to set `project` from config (still loses to an explicit CLI `--project`). As an object: compiler-option overrides layered on top of whichever project loads, applied even when `project` came from the CLI. | diff --git a/docs/tour.md b/docs/tour.md new file mode 100644 index 0000000..512c164 --- /dev/null +++ b/docs/tour.md @@ -0,0 +1,179 @@ +# Tripwire in action + +Tripwire's promise is short: **write your tests once, and run them on node, in a real browser and +inside a web worker** -- with TypeScript, coverage and an interactive page for debugging built in, +and without a bundler, a TypeScript loader or a browser launcher to configure. + +This page shows that on a real project: tripwire's own. Every terminal image below is a run of +tripwire's `core` test suite (2,900+ tests), and every browser image is the interactive page the +runner's own tests drive. Nothing is mocked up -- the images are regenerated from actual runs by +[`docs/scripts/genTourImages.js`](#regenerating-these-images). + +- [Write the tests once](#write-the-tests-once) +- [Run them on node, in a browser and in a web worker](#run-them-on-node-in-a-browser-and-in-a-web-worker) +- [A real project: tripwire's own suite](#a-real-project-tripwires-own-suite) +- [Coverage from the command line](#coverage-from-the-command-line) +- [Debug in a real browser](#debug-in-a-real-browser) +- [And it is fast](#and-it-is-fast) +- [Regenerating these images](#regenerating-these-images) + +## Write the tests once + +A tripwire test file is a plain `describe`/`it` suite, with `assert` and `expect` built in. This is +the start of one of tripwire's own, `core/test/src/assert/assert.isString.test.ts`: + +```ts +import { assert } from "../../../src/assert/assertClass"; +import { expect } from "../../../src/assert/expect"; +import { checkError } from "../support/checkError"; + +describe("isString", () => { + it("should pass when the value is a string", () => { + assert.isString("test"); + expect(() => assert.isString("test")).to.not.throw(); + }); + + it("should throw an error when the value is not a string", () => { + checkError(() => { + assert.isString(123); + }, "expected 123 to be a string"); + + expect(() => assert.isString(123)).to.throw(); + }); +``` + +(It imports from `src/` because it tests tripwire itself; your tests import +`@nevware21/tripwire`.) There is nothing in it about where it runs: no environment checks, no +browser or worker adapter, no build step. Run it: + +![The test file run on node with the spec reporter](images/tour/one-file-node.svg) + +## Run them on node, in a browser and in a web worker + +The same file, unchanged, on all three targets -- the only difference is `--target`: + +![The same test file on node, in Chrome and in a web worker](images/tour/one-file-three-targets.svg) + +- **node** runs it in-process, compiling the TypeScript against your `tsconfig.json` on the fly. +- **browser** builds the module graph, serves it from the runner's own loopback server and runs it + in **real headless Chrome**, launched by puppeteer -- not a simulated DOM. +- **worker** runs it inside a **real `Worker`** in that Chrome, and reports back over the runner's + own channel. + +Same tests, same 17 results. Nothing was configured for the browser or the worker: no webpack or +rollup, no karma, no launcher plugins, no `ts-node` or `ts-jest`, no worker adapter -- the runner +reads the `tsconfig.json` and does the rest. See [Test Targets](test-runner.md#test-targets) for +the launch modes, including `--browser-launch open` (your own browser) and `manual` (a URL for any +browser or device). + +## A real project: tripwire's own suite + +Tripwire tests itself this way. These are `core/package.json`'s own scripts -- one glob per target, +and the same test files in each: + +```json +"test:node": "nyc --cache node ./bin/tripwire.js --type-check -p ./test/tsconfig.test.json \"./test/src/!(browser|worker)/**/*.test.ts\" ...", +"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\"" +``` + +The globs only leave out the two small folders that hold each *other* target's own checks +(`test/src/node`, `test/src/browser` and `test/src/worker` hold two files each, such as +`isBrowser.test.ts`). Everything else -- 120-odd files -- runs everywhere: + +![tripwire's core suite on node, in Chrome and in a web worker](images/tour/whole-suite.svg) + +2,921 tests passing on each target, from one set of files. The `shim/chai` package (tripwire's +Chai-compatible API) does the same with its own suite. + +## Coverage from the command line + +On the **browser and worker** targets, coverage is one flag: `--coverage`. The runner instruments +the module graph itself, also loads the sources no test imported (`--coverage-src`, default +`src/**/*.ts`) so an untested file is reported at 0% rather than silently missing, writes +`coverage//coverage-final.json`, and prints each `--coverage-reporter` you ask for. + +On the **node** target, coverage is the standard `nyc` wrapper -- `nyc tripwire ...` -- so it is one +command rather than one flag, and uses your existing `.nycrc`. + +![Coverage of tripwire's core: --coverage in Chrome, nyc on node](images/tour/coverage.svg) + +Each report covers the code that ran on its target, which is why the two differ: code that only +runs in a browser counts towards the browser's figure, and node-only code towards node's. Both are +what `core`'s own `npm test` produces. See [Coverage](test-runner.md#coverage) for the reporters, +`--coverage-src` and how files TypeScript erased entirely are counted. + +## Debug in a real browser + +`--browser-launch open` (or `--open-browser`) runs the browser or worker target in your own browser +and keeps the page open; `--browser-launch manual` prints its URL for any browser or device. The +page runs the suite, shows the results as they arrive, and sends the final result back to the CLI +when you choose **Finish & Stop Server**: + +```bash +npx tripwire --target browser --open-browser "test/**/*.test.ts" +``` + +![The interactive runner page after a run, with one failure](images/tour/browser-runner.png) + +A failure shows its message once, the values it compared, and the rest of the error folded under +**Details**: + +![A failed test, with its expected and actual values](images/tour/browser-failure.png) + +The **Scope** box searches every suite and test, in any order of words, and the next run covers +just that; each result also has a **run again** button for exactly that suite or test, and the +scope is kept in the URL so a reload or a bookmark brings it back: + +![Searching for a scope](images/tour/browser-scope.png) + +**Auto-reload** re-runs when a test file changes on disk, **Failed only** hides the noise, the +**Runner Log** shows the reporter's output in its terminal colours, and the page switches between +the browser and worker targets in place. It follows your system's light or dark setting: + +![The page in its dark theme](images/tour/browser-dark.png) + +and it is built for everyone: usable from the keyboard alone and with a screen reader, with a low +light theme, a color-blind safe palette (blue and orange in place of green and red) and a zoom from +50% to 300% that scales the page's own controls and nothing else -- never the browser, or a test's +own content. All three are in the **Appearance** panel, whose theme is a box you search, or set +with `--browser-theme dim,colorblind,125%` on the command line. On a narrow window, controls fold +into the menu rather than crowding the page: + +![The page on a phone, in low light with the color-blind safe palette, searching the themes](images/tour/browser-phone.png) + +Everything on the page is described in [The interactive page](test-runner.md#the-interactive-page) +and [Themes and accessibility](test-runner.md#themes-and-accessibility). + +## And it is fast + +Measured on this repository's own suites (2026-10-18; full method, machine and caveats on the +[performance page](performance.md)): + +| Target | Suite | tripwire | Alternative | +|---|---|---|---| +| node | `core` | **6.07 s** | 9.13 s (ts-mocha) | +| node | `shim/chai` | **1.06 s** | 2.70 s (ts-mocha) | +| browser | `core` | **14.00 s** | 19.42 s (karma) | +| browser | `shim/chai` | **10.07 s** | 11.19 s (karma) | +| worker | `core` | **12.57 s** | 19.24 s (karma) | +| worker | `shim/chai` | **9.69 s** | 10.98 s (karma) | + +Where it comes from: one TypeScript program per run, emitting only the files the run loads; a +`--type-check` that is a walk over that same program rather than a `tsc` process; a compile cache +that takes a repeat node run over an unchanged tree from 9.3 s to 5.2 s; and Chrome launched while +the module graph is still building. Coverage on keeps tripwire ahead in all six comparisons. The +[performance history](performance/history.md) charts every recorded run. + +## Regenerating these images + +Every image here comes from a real run, so it can be made again whenever the runner changes: + +```bash +npm run docs-tour-images +``` + +This runs the built CLI over `core`'s suite for the terminal images and drives the interactive +page with puppeteer for the browser ones; it needs the runner built and puppeteer's Chrome +installed. Timings in the images are as measured on the machine that made them. Add +`-- terminal` or `-- browser` to make just that set. diff --git a/docs/why-tripwire.md b/docs/why-tripwire.md index 004ec7b..f70e06d 100644 --- a/docs/why-tripwire.md +++ b/docs/why-tripwire.md @@ -11,6 +11,8 @@ npx tripwire --target worker "test/**/*.test.ts" # a real Worker inside Chr ``` This page says what that buys you, what it costs, and when another tool is the better choice. +[Tripwire in action](tour.md) shows it on tripwire's own suite, with screenshots of each target, +coverage and the interactive browser page. ## What is automatic @@ -89,6 +91,8 @@ dependencies, and can be used under any runner. ## Where to go next +- See it run: [Tripwire in action](tour.md) -- tripwire's own suite on all three targets, coverage + and the interactive page, with screenshots. - Coming from somewhere else: [Mocha](migration/migrating-from-mocha.md), [Karma](migration/migrating-from-karma.md), [Jest or Vitest](migration/migrating-from-jest.md), [Chai](migration/migrating-from-chai.md), [node's `assert`](migration/migrating-from-node-assert.md). diff --git a/internal/runner/rollup.config.js b/internal/runner/rollup.config.js index b079068..70194a6 100644 --- a/internal/runner/rollup.config.js +++ b/internal/runner/rollup.config.js @@ -101,10 +101,16 @@ function _doMinify(code, filename, outputOptions, inputMap, preamble) { var minifyOptions = { ie8: false, - toplevel: true, + // Only a CommonJS bundle's top level is its own, as node wraps each module in a function. + // An iife bundle is a classic script in a page or a worker, sharing the global scope with + // the tests: with toplevel, uglify unwraps the iife into a bare block, whose `var`s and + // functions are globals under two-letter names -- such as `it`, which the tests' own `it` + // then replaces. + toplevel: !outputOptions || outputOptions.format !== "iife", + // Not `unsafe`: among other things it writes String(x) as ""+x, which throws for a Symbol -- + // and these bundles turn the tests' own values into text, symbols included compress: { - passes: 3, - unsafe: true + passes: 3 }, output: { // Must go through uglify rather than being concatenated onto the result: uglify diff --git a/internal/runner/src/cli/parseArgs.ts b/internal/runner/src/cli/parseArgs.ts index 4ccb774..9c866d8 100644 --- a/internal/runner/src/cli/parseArgs.ts +++ b/internal/runner/src/cli/parseArgs.ts @@ -15,6 +15,8 @@ import { loadConfig } from "../config/loader"; import { ITripwireConfig } from "../interfaces/ITripwireConfig"; +import { IBrowserUiTheme } from "../ux/types/BrowserUiTheme"; +import { mergeBrowserThemes, parseBrowserTheme } from "../shared/browserTheme"; import { REPORTER_NAMES } from "../reporters"; import { _REPORTER_MODULE_PREFIX, _isReporterModuleSpec } from "../internal/reporterSpec"; @@ -132,6 +134,20 @@ export function _parseArgs(args: string[], config?: ITripwireConfig) { let target = fileConfig.target || "node"; let browserLaunch = fileConfig.browserLaunch || "puppeteer"; let browserStyle = fileConfig.browserStyle || "classic"; + // How the interactive page looks: the config file's, with --browser-theme's parts in place of + // the ones it names. An unknown word is an error rather than silently ignored. + let browserTheme: IBrowserUiTheme | undefined; + if (fileConfig.browserTheme !== undefined && fileConfig.browserTheme !== null) { + const configTheme = parseBrowserTheme(fileConfig.browserTheme); + if (configTheme.error) { + return { + error: "Invalid browserTheme value in the config file: " + configTheme.error + }; + } + + browserTheme = configTheme.theme; + } + let uxProvider = fileConfig.uxProvider; let serverRoot = fileConfig.serverRoot || ""; let bindAddress = fileConfig.bindAddress || ""; @@ -624,6 +640,26 @@ export function _parseArgs(args: string[], config?: ITripwireConfig) { continue; } + if (value === "--browser-theme" || value.indexOf("--browser-theme=") === 0) { + const consumed = _consumeValue("browser-theme", value, lp); + if (consumed.error) { + return { + error: consumed.error + }; + } + + const cliTheme = parseBrowserTheme(consumed.value || ""); + if (cliTheme.error) { + return { + error: "Invalid value for --browser-theme: " + cliTheme.error + }; + } + + browserTheme = mergeBrowserThemes(browserTheme, cliTheme.theme); + lp = consumed.nextIndex; + continue; + } + if (value === "--ux-provider" || value.indexOf("--ux-provider=") === 0) { const consumed = _consumeValue("ux-provider", value, lp); if (consumed.error) { @@ -856,6 +892,7 @@ export function _parseArgs(args: string[], config?: ITripwireConfig) { target, browserLaunch, browserStyle, + browserTheme, uxProvider, compile: compile !== undefined ? compile : false, strictTypeCheck, diff --git a/internal/runner/src/client/workerRunner.ts b/internal/runner/src/client/workerRunner.ts index 101c415..d9ddc83 100644 --- a/internal/runner/src/client/workerRunner.ts +++ b/internal/runner/src/client/workerRunner.ts @@ -9,6 +9,7 @@ import { isNumber } from "@nevware21/ts-utils"; import { createReporter } from "../reporters"; import { getSuitePartsFromNode, createAssertionLabel } from "../shared/suiteNames"; +import { addAssertionValues } from "../shared/assertionValues"; import { installDefineRegistry, createRequire, getWorkerLoaderContext, setTripwireGlobals, buildSuite } from "../shared/moduleLoader"; import { createSettingsGrepFilter, createSelectionFilter, applyFilters } from "../shared/filters"; import { createBailListener } from "../shared/bailListener"; @@ -204,14 +205,14 @@ export function startWorkerRunner(): void { }, onAssert: function(context: any) { const actx = context && context.v; - (self as any).postMessage({ + (self as any).postMessage(addAssertionValues({ kind: "assert", suiteParts: currentTestRef ? currentTestRef.suiteParts : [], testName: currentTestRef ? currentTestRef.testName : "", passed: !!(actx && actx.passed), label: createAssertionLabel(actx), errorText: actx && actx.error ? String(actx.error.message || actx.error) : "" - }); + }, actx && actx.error)); } }); } diff --git a/internal/runner/src/execute/browserRunner.ts b/internal/runner/src/execute/browserRunner.ts index a917520..d7a40e1 100644 --- a/internal/runner/src/execute/browserRunner.ts +++ b/internal/runner/src/execute/browserRunner.ts @@ -164,7 +164,7 @@ export async function _startBrowserRunnerSession(graphBuilders: IGraphBuilders, } payload.settings.server = server ? { url: server.url, port: server.port, isRunning: !!server.isRunning } : undefined; - const html = uxProvider.createBrowserRunnerHtml(target as any, parsed.browserStyle || "classic"); + const html = uxProvider.createBrowserRunnerHtml(target as any, parsed.browserStyle || "classic", parsed.browserTheme); // Only the "browser" target's frame executes test modules directly in its own document // -- the "worker" target's frame just hosts the postMessage bridge to a real Worker, diff --git a/internal/runner/src/index.ts b/internal/runner/src/index.ts index ed85be7..4b1544d 100644 --- a/internal/runner/src/index.ts +++ b/internal/runner/src/index.ts @@ -37,6 +37,7 @@ export { IFsEntry } from "./ux/interfaces/IFsEntry"; export { BrowserUiStyle } from "./ux/types/BrowserUiStyle"; +export { BrowserUiColorScheme, BrowserUiVision, IBrowserUiTheme } from "./ux/types/BrowserUiTheme"; export { IRunnerUxProvider } from "./interfaces/IRunnerUxProvider"; export { ITripwireModule } from "./interfaces/ITripwireModule"; export { IParsedArgs } from "./interfaces/IParsedArgs"; diff --git a/internal/runner/src/interfaces/IParsedArgs.ts b/internal/runner/src/interfaces/IParsedArgs.ts index 3afb644..dacc870 100644 --- a/internal/runner/src/interfaces/IParsedArgs.ts +++ b/internal/runner/src/interfaces/IParsedArgs.ts @@ -8,6 +8,7 @@ import { BrowserLaunchMode, TestTarget } from "@nevware21/tripwire"; import { BrowserUiStyle } from "../ux/types/BrowserUiStyle"; +import { IBrowserUiTheme } from "../ux/types/BrowserUiTheme"; /** * The parsed command-line arguments for the runner. @@ -55,6 +56,12 @@ export interface IParsedArgs { */ browserStyle?: BrowserUiStyle; + /** + * How the interactive browser page looks when it opens (`--browser-theme`, over the config file's + * `browserTheme`). + */ + browserTheme?: IBrowserUiTheme; + /** * Whether to compile the tests before running. */ diff --git a/internal/runner/src/interfaces/IRunnerUxProvider.ts b/internal/runner/src/interfaces/IRunnerUxProvider.ts index 91076ec..8f294f9 100644 --- a/internal/runner/src/interfaces/IRunnerUxProvider.ts +++ b/internal/runner/src/interfaces/IRunnerUxProvider.ts @@ -8,13 +8,15 @@ import { TestTarget } from "@nevware21/tripwire"; import { BrowserUiStyle } from "../ux/types/BrowserUiStyle"; +import { IBrowserUiTheme } from "../ux/types/BrowserUiTheme"; import { IFsEntry } from "../ux/interfaces/IFsEntry"; /** * Browser UX provider contract consumed by the runner package. */ export interface IRunnerUxProvider { - createBrowserRunnerHtml(target: TestTarget, style: BrowserUiStyle): string; + // `theme` is how the page looks when it opens (`--browser-theme`); a provider may ignore it + createBrowserRunnerHtml(target: TestTarget, style: BrowserUiStyle, theme?: IBrowserUiTheme): string; createFsDirListingHtml(style: BrowserUiStyle, relativePath: string, entries: IFsEntry[], extraHtml?: string): string; createTestFrameHtml?: (target: TestTarget, style: BrowserUiStyle, moduleScriptUrls: string[]) => string; faviconIco?: string; diff --git a/internal/runner/src/interfaces/ITripwireConfig.ts b/internal/runner/src/interfaces/ITripwireConfig.ts index da538a9..866c1b8 100644 --- a/internal/runner/src/interfaces/ITripwireConfig.ts +++ b/internal/runner/src/interfaces/ITripwireConfig.ts @@ -49,6 +49,15 @@ export interface ITripwireConfig { */ browserStyle?: BrowserUiStyle; + /** + * How the interactive browser page looks when it opens: a comma separated list (or an array) of + * words, each naming one part -- a color scheme (`system`, `light`, `dark`, `dim`), a color + * vision setting (`standard`, `colorblind`) or the page's zoom, as a whole percentage from 50% to + * 300% -- eg. `"dim,colorblind,125%"`. `--browser-theme` replaces the parts it names. A choice + * made in the page's own Appearance panel takes precedence in that browser. + */ + browserTheme?: string | string[]; + /** * Path to the TypeScript project file (default: "tsconfig.json"). */ diff --git a/internal/runner/src/shared/assertionValues.ts b/internal/runner/src/shared/assertionValues.ts new file mode 100644 index 0000000..5f456cf --- /dev/null +++ b/internal/runner/src/shared/assertionValues.ts @@ -0,0 +1,186 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +/** + * The expected and actual values of a failed assertion, as text the interactive page can show side by + * side. Only the values the error carries are present. + */ +export interface IAssertionValues { + expected?: string; + actual?: string; +} + +// Long enough for a typical object, short enough that one huge value cannot swamp the page +const _MAX_VALUE_LENGTH = 1000; + +// How many levels into nested objects and arrays a value is written before the rest reads [Object] +// or [Array] +const _MAX_DEPTH = 4; + +function _limit(text: string): string { + return text.length > _MAX_VALUE_LENGTH ? text.substring(0, _MAX_VALUE_LENGTH) + "..." : text; +} + +/** `target`'s own property `key`, read without running a getter on it -- or undefined. */ +function _ownProp(target: any, key: string): PropertyDescriptor | undefined { + try { + return Object.getOwnPropertyDescriptor(target, key); + } catch (e) { + // A proxy whose trap throws + return undefined; + } +} + +/** A property as {@link _format} writes it: its value, or what kind of accessor it is. */ +function _formatProp(desc: PropertyDescriptor | undefined, depth: number, seen: any[]): string { + if (!desc) { + return "undefined"; + } + + if (desc.get || desc.set) { + return desc.get && desc.set ? "[Getter/Setter]" : (desc.get ? "[Getter]" : "[Setter]"); + } + + return _format(desc.value, depth, seen); +} + +/** Each entry `write` gives, joined by commas, ending in "..." once the text is already too long to show. */ +function _entries(count: number, write: (idx: number) => string): string { + var parts: string[] = []; + var length = 0; + for (var lp = 0; lp < count; lp++) { + if (length > _MAX_VALUE_LENGTH) { + parts.push("..."); + break; + } + + var part = write(lp); + length += part.length + 1; + parts.push(part); + } + + return parts.join(","); +} + +function _format(value: any, depth: number, seen: any[]): string { + var type = typeof value; + if (type === "string") { + return JSON.stringify(value); + } + + if (type === "symbol") { + // Explicitly, as Symbol(...) concatenated into a string throws + return Symbol.prototype.toString.call(value); + } + + if (value === null || value === undefined || type === "number" || type === "boolean" || type === "bigint") { + return String(value); + } + + if (type === "function") { + var name = _ownProp(value, "name"); + return "[Function" + (name && typeof name.value === "string" && name.value ? ": " + name.value : "") + "]"; + } + + if (seen.indexOf(value) !== -1) { + return "[Circular]"; + } + + var isArray = Array.isArray(value); + if (depth >= _MAX_DEPTH) { + return isArray ? "[Array]" : "[Object]"; + } + + if (value instanceof Date) { + var time = Date.prototype.getTime.call(value); + return time === time ? Date.prototype.toISOString.call(value) : "Invalid Date"; + } + + if (value instanceof RegExp) { + return RegExp.prototype.toString.call(value); + } + + var inner = seen.concat([ value ]); + if (isArray) { + return "[" + _entries(value.length, function(idx) { + return _formatProp(_ownProp(value, String(idx)), depth + 1, inner); + }) + "]"; + } + + var keys = Object.keys(value); + return "{" + _entries(keys.length, function(idx) { + return JSON.stringify(keys[idx]) + ":" + _formatProp(_ownProp(value, keys[idx]), depth + 1, inner); + }) + "}"; +} + +/** + * A value as it reads in code: strings quoted, numbers, booleans, symbols and the like as written, and + * objects and arrays in JSON's shape. It runs nothing of the value's own -- no getter (an accessor + * reads [Getter]), no toJSON, no toString -- and never throws: it runs inside the assertion it + * describes, which must fail with its own error, and leave the test's values as they were. + */ +export function formatAssertionValue(value: any): string { + try { + return _limit(_format(value, 0, [])); + } catch (e) { + // A revoked proxy, or one whose traps throw + return "[object]"; + } +} + +/** + * The expected and actual values a failed assertion's error carries, formatted for display, or `null` + * when it carries neither -- as with an error the code under test threw rather than an assertion. An + * error whose values are all `undefined` carries nothing worth showing either: `assert.fail()` has an + * `actual`, but it is never set. + */ +export function getAssertionValues(error: any): IAssertionValues | null { + try { + if (!error || typeof error !== "object") { + return null; + } + + var hasExpected = "expected" in error; + var hasActual = "actual" in error; + if (!(hasExpected && error.expected !== undefined) && !(hasActual && error.actual !== undefined)) { + return null; + } + + var values: IAssertionValues = {}; + if (hasExpected) { + values.expected = formatAssertionValue(error.expected); + } + + if (hasActual) { + values.actual = formatAssertionValue(error.actual); + } + + return values; + } catch (e) { + // An error object of the test's own whose properties throw: there is still its message + return null; + } +} + +/** + * Adds the values a failed assertion's `error` carries (see {@link getAssertionValues}) to the assert + * event the test frame or worker sends the page, and returns the event. + */ +export function addAssertionValues(event: T, error: any): T & IAssertionValues { + var target: any = event; + var values = getAssertionValues(error); + if (values && values.expected !== undefined) { + target.expected = values.expected; + } + + if (values && values.actual !== undefined) { + target.actual = values.actual; + } + + return target; +} diff --git a/internal/runner/src/shared/browserTheme.ts b/internal/runner/src/shared/browserTheme.ts new file mode 100644 index 0000000..9325f3b --- /dev/null +++ b/internal/runner/src/shared/browserTheme.ts @@ -0,0 +1,100 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import { IBrowserUiTheme } from "../ux/types/BrowserUiTheme"; + +/** The smallest and largest zoom the interactive page takes, as a whole percentage. */ +export const BROWSER_ZOOM_MIN = 50; +export const BROWSER_ZOOM_MAX = 300; + +/** Which part of the theme each word names. */ +const _PARTS: { [word: string]: { part: keyof IBrowserUiTheme, value: string } } = { + "system": { part: "scheme", value: "system" }, + "light": { part: "scheme", value: "light" }, + "dark": { part: "scheme", value: "dark" }, + "dim": { part: "scheme", value: "dim" }, + "standard": { part: "vision", value: "standard" }, + "colorblind": { part: "vision", value: "colorblind" } +}; + +const _PART_NAMES: { [part: string]: string } = { + scheme: "color scheme", + vision: "color vision setting", + zoom: "zoom" +}; + +const _ZOOM = /^(\d+)%$/; + +/** Every word a browser theme can be made of, besides a zoom, for an error message or the help text. */ +export const BROWSER_THEME_WORDS = Object.keys(_PARTS); + +/** + * Reads a browser theme -- `--browser-theme`'s value, or `browserTheme` from the config file -- as a + * comma separated list of words (or, from the config file, an array of them), each naming one part: + * a color scheme (`system`, `light`, `dark`, `dim`), a color vision setting (`standard`, + * `colorblind`) or a zoom, as a whole percentage from {@link BROWSER_ZOOM_MIN} to + * {@link BROWSER_ZOOM_MAX} (`125%`). Case and spaces are ignored. Returns an error for a word it + * does not know, a zoom out of range, or two words for the same part. + */ +export function parseBrowserTheme(value: string | string[]): { theme?: IBrowserUiTheme, error?: string } { + var words = (Array.isArray(value) ? value : String(value).split(",")) + .map(function(word) { + return String(word).trim().toLowerCase(); + }) + .filter(function(word) { + return !!word; + }); + + var theme: any = {}; + for (var lp = 0; lp < words.length; lp++) { + var found: { part: keyof IBrowserUiTheme, value: any } | null = _PARTS.hasOwnProperty(words[lp]) ? _PARTS[words[lp]] : null; + var zoom = _ZOOM.exec(words[lp]); + if (zoom) { + var percent = parseInt(zoom[1], 10); + if (percent < BROWSER_ZOOM_MIN || percent > BROWSER_ZOOM_MAX) { + return { error: "zoom \"" + words[lp] + "\" is outside " + BROWSER_ZOOM_MIN + "% to " + BROWSER_ZOOM_MAX + "%" }; + } + + found = { part: "zoom", value: percent }; + } + + if (!found) { + return { + error: "unknown word \"" + words[lp] + "\" (expected any of " + BROWSER_THEME_WORDS.join(", ") + + ", or a zoom from " + BROWSER_ZOOM_MIN + "% to " + BROWSER_ZOOM_MAX + "%)" + }; + } + + if (theme[found.part] !== undefined && theme[found.part] !== found.value) { + return { error: "\"" + _word(found.part, theme[found.part]) + "\" and \"" + _word(found.part, found.value) + "\" are both a " + _PART_NAMES[found.part] }; + } + + theme[found.part] = found.value; + } + + return { theme: theme }; +} + +/** A part's value as it was written: a zoom with its "%". */ +function _word(part: keyof IBrowserUiTheme, value: any): string { + return part === "zoom" ? value + "%" : String(value); +} + +/** `override`'s parts in place of `base`'s, and `base`'s for the parts `override` leaves out. */ +export function mergeBrowserThemes(base: IBrowserUiTheme | undefined, override: IBrowserUiTheme | undefined): IBrowserUiTheme { + var merged: IBrowserUiTheme = {}; + [ base, override ].forEach(function(theme) { + if (theme) { + (Object.keys(theme) as Array).forEach(function(part) { + (merged as any)[part] = theme[part]; + }); + } + }); + + return merged; +} diff --git a/internal/runner/src/tripwire.ts b/internal/runner/src/tripwire.ts index aac978c..e892880 100644 --- a/internal/runner/src/tripwire.ts +++ b/internal/runner/src/tripwire.ts @@ -147,6 +147,7 @@ function _printUsage() { console.log(" --env Alias for --target"); console.log(" --browser-launch Browser launch mode for browser/worker targets (mode: puppeteer|open|manual; style: classic|slate)"); console.log(" --browser-style Browser UI style for interactive mode (default: classic)"); + console.log(" --browser-theme How the interactive page opens: any of system|light|dark|dim, standard|colorblind, and a zoom from 50% to 300% (eg. 125%), comma separated"); console.log(" --ux-provider Custom browser UX provider module, overriding the built-in one"); console.log(" --open-browser Alias for --browser-launch open"); console.log(" --manual-browser Alias for --browser-launch manual"); diff --git a/internal/runner/src/ux/browser/resources/runner.css b/internal/runner/src/ux/browser/resources/runner.css deleted file mode 100644 index ddad984..0000000 --- a/internal/runner/src/ux/browser/resources/runner.css +++ /dev/null @@ -1,39 +0,0 @@ -:root { --bg: #f3f4f6; --panel: #ffffff; --ink: #0f172a; --muted: #475569; --accent: #0b6bcb; --border: #d0d7de; --ok: #0f7b37; --warn: #946200; --bad: #b42318; } -* { box-sizing: border-box; } -body { margin: 0; padding: 24px; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: linear-gradient(160deg, #f8fafc 0%, #e2e8f0 100%); color: var(--ink); } -.shell { max-width: 1200px; margin: 0 auto; background: var(--panel); border: 1px solid var(--border); border-radius: 14px; padding: 18px; box-shadow: 0 10px 26px rgba(15, 23, 42, 0.08); } -h1 { margin: 0 0 10px; font-size: 1.3rem; letter-spacing: 0.02em; } -p { margin: 0 0 12px; color: var(--muted); } -.row { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; } -.controls { display: grid; grid-template-columns: 1fr 1fr auto auto auto auto; gap: 8px; margin-bottom: 12px; } -.controls select, .controls button { min-height: 34px; } -select { border: 1px solid var(--border); border-radius: 8px; padding: 6px 10px; background: #fff; } -button { border: 1px solid var(--accent); background: var(--accent); color: #fff; border-radius: 8px; padding: 7px 12px; font-size: 0.95rem; cursor: pointer; } -.ghost { background: #fff; color: var(--ink); border-color: var(--border); } -.tiny-btn { padding: 4px 8px; font-size: 0.78rem; background: #fff; color: var(--ink); border: 1px solid var(--border); border-radius: 6px; } -button:disabled { opacity: 0.7; cursor: default; } -#status { font-weight: 600; color: #0f5132; } -.layout { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } -.panel { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; background: #fff; } -.panel-head { display: flex; justify-content: space-between; align-items: center; padding: 8px 10px; background: #f8fafc; border-bottom: 1px solid var(--border); font-weight: 600; } -#logs { background: #0b1020; color: #d8e4ff; min-height: 280px; max-height: 68vh; overflow: auto; font-family: Consolas, 'Courier New', monospace; font-size: 12px; line-height: 1.35; padding: 10px; } -.log-line { white-space: pre-wrap; word-break: break-word; } -.log-error { color: #ffd7d7; } -.log-warn { color: #ffe7b3; } -#results { min-height: 280px; max-height: 68vh; overflow: auto; padding: 8px; background: #fbfdff; } -.test-card { border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px; background: #fff; } -.test-card-header { display: flex; gap: 8px; align-items: center; padding: 8px 10px; border-bottom: 1px solid #eef2f6; } -.test-title { flex: 1; font-size: 0.9rem; word-break: break-word; } -.status-dot { width: 10px; height: 10px; border-radius: 999px; background: #94a3b8; } -.status-running { background: #2563eb; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); } -.status-passed { background: var(--ok); } -.status-failed { background: var(--bad); } -.status-skipped { background: var(--warn); } -.assert-list { list-style: none; margin: 0; padding: 6px 10px 8px; font-family: Consolas, 'Courier New', monospace; font-size: 12px; } -.assert-list li { padding: 2px 0; word-break: break-word; } -.assert-pass { color: #0f7b37; } -.assert-fail { color: #b42318; } -@media (max-width: 980px) { - .controls { grid-template-columns: 1fr 1fr; } - .layout { grid-template-columns: 1fr; } -} diff --git a/internal/runner/src/ux/browser/resources/runner.html b/internal/runner/src/ux/browser/resources/runner.html deleted file mode 100644 index 69294da..0000000 --- a/internal/runner/src/ux/browser/resources/runner.html +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - __RUNNER_TITLE__ - - - -
-

__RUNNER_TITLE__

-

__RUNNER_DESCRIPTION__

-
- - - - - - -
-
- Initializing... -
-
-
-
Runner LogSuite/Test level on CLI
-
-
-
-
Assertions0 assertions
-
-
-
-
- - - diff --git a/internal/runner/src/ux/browser/resources/styles/base.css b/internal/runner/src/ux/browser/resources/styles/base.css index ad45f87..dede932 100644 --- a/internal/runner/src/ux/browser/resources/styles/base.css +++ b/internal/runner/src/ux/browser/resources/styles/base.css @@ -1,24 +1,113 @@ * { box-sizing: border-box; } html, body { margin: 0; padding: 0; min-width: 320px; } +/* + * The background is fixed to the viewport, so a page shorter or longer than the window is one + * gradient rather than a repeat with a seam where the first copy ends. + */ body { font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; line-height: 1.4; color: var(--ink); background: var(--bg); + background-attachment: fixed; + min-height: 100vh; +} +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +/* Text for a screen reader only: what a glyph or a colour says to everyone else */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} +/* The first thing Tab reaches: past the header and the controls, straight to the results */ +.skip-link { + position: absolute; + top: 8px; + left: 8px; + z-index: 40; + padding: 8px 12px; + border-radius: 8px; + background: var(--accent); + color: var(--on-accent); + transform: translateY(-200%); +} +.skip-link:focus { + transform: none; +} +.results-tree:focus { + outline: none; +} +/* + * The zoom chosen in the Appearance panel (the body's --ui-zoom, see ui/theme.ts) scales the page's + * own controls, results and log -- and nothing outside them, such as a test's own frame, so it is + * never the browser's zoom. Inside, a viewport unit is zoomed too: divide one by --ui-zoom to keep + * it to the window. + */ +.app-shell, +.skip-link { + zoom: var(--ui-zoom, 1); } .app-shell { width: 100%; min-width: 320px; padding: 14px; } +/* + * The title on the left, and the Target picker and the actions menu on the right: which target this + * page runs is part of what the page is, and the menu holds what is not part of a run, rather than + * either being one of the controls for a run. + */ +.app-header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0 16px; +} +.app-title { + min-width: 0; +} +/* Kept on the right, as one group, when a narrow window wraps it under the title */ +.app-actions { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; +} .app-header h1 { margin: 0; font-size: 1.25rem; } .app-header p { - margin: 6px 0 10px; + margin: 4px 0 8px; color: var(--muted); } +.app-meta { + font-size: 0.85rem; +} +/* + * The controls and the status line stay at the top of the window while the results scroll under + * them, so Run, Stop and the run's totals are never a scroll away. + */ +.toolbar { + position: sticky; + top: 0; + z-index: 20; + margin: 0 -14px 10px; + padding: 8px 14px 0; + background: var(--toolbar-bg); + border-bottom: 1px solid var(--border-subtle); + backdrop-filter: blur(6px); +} .controls { display: flex; flex-direction: column; @@ -75,7 +164,7 @@ body { border-radius: 8px; background: var(--panel); color: var(--ink); - padding: 6px 10px; + padding: 6px 32px 6px 10px; min-height: 34px; width: 100%; } @@ -92,13 +181,34 @@ body { flex: 0 0 auto; } /* - * Resets the Scope box back to "every test". Sits outside the input rather than as an "x" inside it - * so that it says what it does -- the case it exists for is a scope arrived at by clicking a - * result's "run again" button, where the question being asked is "how do I get back?". + * Resets the Scope box back to "every test": the x at the end of the box, as in a search field, + * named "Clear scope" for a screen reader and in its tooltip. It is only there while there is a + * scope to clear -- the case it exists for is a scope arrived at by clicking a result's "run again" + * button, where the question being asked is "how do I get back?". */ -.scope-clear { - flex: 0 0 auto; - white-space: nowrap; +.filter-input-wrap .scope-clear { + position: absolute; + top: 50%; + right: 5px; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + min-height: 0; + padding: 0; + border: none; + border-radius: 50%; + background: transparent; + color: var(--muted); +} +.filter-input-wrap .scope-clear:hover { + background: var(--state-running-bg); + color: var(--ink); +} +.filter-input-wrap .scope-clear:disabled { + visibility: hidden; } .control-select { border: 1px solid var(--border); @@ -112,8 +222,8 @@ body { opacity: 0.65; } /* - * The "..." actions menu. Everything that is not "what do I run" and "run it" lives in here, so - * the controls stay one row at any width rather than reflowing into two. + * The "..." actions menu, at the top right after the Target picker. Everything that is not "what do + * I run" and "run it" lives in here, along with the controls a narrow window folds away. */ .menu-wrap { position: relative; @@ -121,6 +231,7 @@ body { } button.icon-btn { min-width: 34px; + min-height: 34px; padding: 6px 9px; font-size: 1.05rem; line-height: 1; @@ -137,6 +248,9 @@ button.icon-btn { border-radius: 8px; box-shadow: 0 8px 20px rgba(0, 0, 0, 0.22); z-index: 30; + /* Scrolls rather than running off the bottom of a short window */ + max-height: calc(100vh / var(--ui-zoom, 1) - 80px); + overflow-y: auto; } /* * The menu is shown and hidden with the `hidden` attribute so it leaves the accessibility tree @@ -185,6 +299,99 @@ button.menu-item[aria-disabled="true"]:hover { margin: 4px 2px; background: var(--border); } +/* Sets the folded Target apart from the folded settings above it, when both are in the menu */ +#foldedControls .menu-check:not([hidden]) ~ .menu-group::before { + content: ""; + display: block; + height: 1px; + margin: 4px 2px; + background: var(--border); +} +.menu-group-label { + padding: 4px 10px 2px; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted); +} +/* + * Choices and settings -- the target and settings folded in from a narrow window, and Auto-reload: + * the one in use, or a setting that is on, carries a tick, and the others leave its space empty + */ +button.menu-radio, +button.menu-check { + position: relative; + padding-left: 28px; +} +button.menu-radio[aria-checked="true"] { + font-weight: 600; +} +button.menu-radio[aria-checked="true"]::before, +button.menu-check[aria-checked="true"]::before { + content: "✓"; + position: absolute; + left: 10px; + color: var(--accent); +} +/* + * The Appearance panel (ui/appearance.ts): a dialog the actions menu opens, in the menu's place and + * drawn like it. The theme's list overlays the rows below it, so the panel itself never scrolls. + */ +.appearance-panel { + position: absolute; + top: 100%; + right: 0; + z-index: 30; + display: grid; + gap: 10px; + width: 280px; + max-width: calc(100vw / var(--ui-zoom, 1) - 28px); + margin-top: 4px; + padding: 6px 12px 12px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.22); +} +.appearance-panel[hidden] { + display: none; +} +/* It takes focus itself only to keep a click on its background inside it, not to be seen */ +.appearance-panel:focus { + outline: none; +} +.appearance-panel .menu-group-label { + padding: 4px 0 0; +} +.appearance-field { + display: grid; + gap: 4px; +} +.appearance-panel .filter-input { + padding-right: 10px; +} +.appearance-check { + justify-self: start; +} +.zoom-control { + display: flex; + align-items: center; + gap: 6px; +} +.zoom-value { + min-width: 3.5em; + text-align: center; + font-variant-numeric: tabular-nums; +} +.zoom-reset { + margin-left: auto; +} +/* At either end of the zoom: still focusable, so a keyboard on it stays put (see ui/appearance.ts) */ +.zoom-control button[aria-disabled="true"] { + opacity: 0.5; + cursor: default; +} /* * Custom-styled suggestion dropdown, deliberately not a native popup -- datalist * suggestion lists render with OS-native chrome that can't be styled or themed at all in any @@ -225,6 +432,12 @@ button.menu-item[aria-disabled="true"]:hover { overflow: hidden; text-overflow: ellipsis; } +/* The typed words, picked out in each suggestion (see autocomplete.ts's _fillHighlighted) */ +.autocomplete-text mark { + background: var(--mark-bg); + color: inherit; + border-radius: 2px; +} /* * Says whether a row is a suite or a single test. The two are searched together in one list, and * a suite's title is a prefix of every test title under it, so without this the rows for @@ -256,20 +469,39 @@ button.menu-item[aria-disabled="true"]:hover { button { border: 1px solid var(--accent); background: var(--accent); - color: #fff; + color: var(--on-accent); border-radius: 8px; padding: 7px 12px; cursor: pointer; + font: inherit; + font-size: 0.9rem; } button.stop-mode { border-color: var(--bad); background: var(--bad); - color: #fff; + color: var(--on-bad); } button:disabled { opacity: 0.65; cursor: default; } +/* Inputs and selects take the page's font, so they read like everything else on it */ +input, +select { + font: inherit; + font-size: 0.9rem; +} +button[hidden] { + display: none; +} +/* Its label changes between Run, Stop and Run Again; one width keeps the toolbar from shifting */ +#runBtn { + min-width: 6.5em; +} +/* A control folded into the actions menu because its row is too narrow (see ui/overflow.ts) */ +.app-shell .is-folded { + display: none; +} button.ghost { background: var(--panel); color: var(--ink); @@ -285,74 +517,159 @@ button.tiny-btn { } .status-row { display: flex; - justify-content: space-between; + flex-wrap: wrap; align-items: center; - gap: 8px; - margin-bottom: 10px; + gap: 6px 10px; + padding-bottom: 8px; } #status { font-weight: 600; + flex: 1 1 240px; + min-width: 0; +} +/* The run's totals, the assertion count and Clear Results: on the right, even when the line wraps */ +.status-meta { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 6px 10px; + margin-left: auto; } #assertCount { color: var(--muted); - font-size: 0.9rem; + font-size: 0.85rem; + white-space: nowrap; +} +/* Clear Results, at the end of the status line it clears: an icon, named for a screen reader */ +button.status-clear { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + min-height: 28px; + padding: 4px; + color: var(--muted); +} +button.status-clear:hover { + color: var(--ink); + background: var(--state-running-bg); +} +/* + * The run's totals. The failed count is a button: it turns "Failed only" on and off, so the way + * from "3 failed" to the three failures is one click. + */ +.run-summary { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} +.run-summary[hidden] { + display: none; +} +.summary-chip, +button.summary-chip { + font-size: 0.78rem; + font-weight: 600; + line-height: 1.3; + border: 1px solid var(--border); + border-radius: 999px; + padding: 2px 9px; + min-height: 0; + white-space: nowrap; + background: var(--panel); + color: var(--muted); +} +.summary-passed { + color: var(--ok); + border-color: var(--ok); +} +button.summary-failed { + color: var(--on-bad); + background: var(--bad); + border-color: var(--bad); +} +button.summary-failed[aria-pressed="true"] { + box-shadow: 0 0 0 2px var(--panel), 0 0 0 4px var(--bad); +} +.summary-duration { + font-weight: 400; + border-style: dashed; } .results-main { width: 100%; } +/* Says what "Failed only" is hiding, so an empty list does not read as "nothing ran" */ +.filter-note { + margin: 0 0 8px; + color: var(--muted); + font-size: 0.85rem; + font-style: italic; +} +.filter-note[hidden] { + display: none; +} +/* + * The results grow with the page and scroll with it -- a scrolling panel of its own put a second + * scrollbar inside the page's. + */ .results-tree { width: 100%; border: 1px solid var(--border); border-radius: 10px; background: var(--panel); - min-height: 52vh; - max-height: 74vh; - overflow: auto; + min-height: calc(40vh / var(--ui-zoom, 1)); + overflow-x: auto; padding: 10px; } +/* A node's left edge is its state: running, passed, failed or skipped */ .suite-node, .test-node { border: 1px solid var(--border-subtle); - border-left-width: 6px; + border-left-width: 4px; border-radius: 8px; margin-bottom: 8px; background: var(--panel-soft); } -.suite-tone-1 { border-left-color: var(--suite-tone-1); } -.suite-tone-2 { border-left-color: var(--suite-tone-2); } -.suite-tone-3 { border-left-color: var(--suite-tone-3); } -.test-tone-1 { border-left-color: var(--test-tone-1); } -.test-tone-2 { border-left-color: var(--test-tone-2); } -.test-tone-3 { border-left-color: var(--test-tone-3); } - .suite-running, .test-running { + border-left-color: var(--running); background: var(--state-running-bg); } - .suite-passed { + border-left-color: var(--ok); background: var(--suite-state-passed-bg, var(--state-passed-bg)); } - .test-passed { + border-left-color: var(--ok); background: var(--test-state-passed-bg, var(--state-passed-bg)); } - .suite-failed { + border-left-color: var(--bad); background: var(--suite-state-failed-bg, var(--state-failed-bg)); } - .test-failed { + border-left-color: var(--bad); background: var(--test-state-failed-bg, var(--state-failed-bg)); } +.suite-skipped, +.test-skipped { + border-left-color: var(--border); +} +/* + * A row's name takes the room there is and its statuses wrap under it when there is not, rather + * than the two overlapping on a narrow window. + */ .suite-summary, .test-summary { list-style: none; cursor: pointer; display: flex; + flex-wrap: wrap; align-items: center; justify-content: space-between; - gap: 10px; + gap: 6px 10px; padding: 7px 10px; } .suite-summary { @@ -367,7 +684,7 @@ button.tiny-btn { flex-direction: column; gap: 1px; min-width: 0; - flex: 1; + flex: 1 1 220px; } .suite-title { display: inline-flex; @@ -375,8 +692,22 @@ button.tiny-btn { gap: 8px; font-weight: 700; letter-spacing: 0.01em; + min-width: 0; } -.suite-marker { +.suite-name, +.test-name { + min-width: 0; + overflow-wrap: anywhere; +} +.test-label { + display: inline-flex; + align-items: center; + gap: 8px; + flex: 1 1 220px; + min-width: 0; +} +.suite-marker, +.test-marker { width: 14px; height: 14px; display: inline-flex; @@ -387,17 +718,20 @@ button.tiny-btn { font-weight: 700; flex: 0 0 14px; } -.suite-marker.marker-passed { +.marker-passed { color: var(--ok); } -.suite-marker.marker-failed { +.marker-failed { color: var(--bad); } -.suite-marker.marker-pending { +.marker-pending, +.marker-skipped { color: var(--muted); } -.suite-marker.marker-running { +.marker-running { color: var(--running); +} +.suite-marker.marker-running { border: 2px solid var(--border-subtle); border-top-color: var(--running); border-radius: 50%; @@ -411,22 +745,19 @@ button.tiny-btn { transform: rotate(360deg); } } -.suite-path { - color: var(--muted); - font-size: 0.76rem; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; +@media (prefers-reduced-motion: reduce) { + .suite-marker.marker-running { + animation: none; + } } -.suite-statuses { +.suite-statuses, +.test-statuses { display: inline-flex; + flex-wrap: wrap; align-items: center; + justify-content: flex-end; gap: 6px; -} -.suite-label, -.test-label { - flex: 1; - word-break: break-word; + margin-left: auto; } .suite-children { padding: 0 8px 8px 16px; @@ -439,26 +770,70 @@ button.tiny-btn { margin: 0; padding: 0; font-family: Consolas, "Courier New", monospace; - font-size: 12px; + font-size: 0.75rem; border: 1px solid var(--border-subtle); border-radius: 6px; overflow: hidden; - background: white; + background: var(--assert-bg); +} +.assert-list:empty { + display: none; } .assert-item { display: block; padding: 3px 8px 3px 10px; border-left: 3px solid transparent; - word-break: break-word; + overflow-wrap: anywhere; } .assert-item:nth-child(even) { - background: rgba(127, 127, 127, 0.14); + background: rgba(127, 127, 127, 0.1); } .assert-item:hover { - background: rgba(127, 127, 127, 0.24); + background: rgba(127, 127, 127, 0.2); } .assert-pass { color: var(--ok); border-left-color: var(--ok); } .assert-fail { color: var(--bad); border-left-color: var(--bad); } +.assert-message { + font-weight: 600; +} +/* A failure's expected and actual values, one above the other so the two line up */ +.assert-values { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + align-items: baseline; + gap: 2px 10px; + margin: 4px 0 2px 16px; + color: var(--ink); +} +.assert-key { + color: var(--muted); + font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.assert-value { + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.assert-details { + margin: 2px 0 2px 16px; + color: var(--muted); +} +.assert-details > summary { + cursor: pointer; + font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; + font-size: 0.75rem; +} +.assert-details pre { + margin: 4px 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + max-height: 260px; + overflow-y: auto; + color: var(--ink); +} .status-chip { font-size: 0.76rem; border: 1px solid var(--border); @@ -483,7 +858,9 @@ button.tiny-btn { .status-running { color: var(--running); border-color: var(--running); } .status-passed { color: var(--ok); border-color: var(--ok); } .status-failed { color: var(--bad); border-color: var(--bad); } -.status-pending { color: var(--muted); border-color: var(--border); } +.status-pending, +.status-skipped { color: var(--muted); border-color: var(--border); } +.status-skipped { border-style: dashed; } .log-panel { margin-top: 10px; border: 1px solid var(--border); @@ -492,13 +869,11 @@ button.tiny-btn { } .log-panel > summary { cursor: pointer; - list-style: none; padding: 8px 10px; font-weight: 600; - border-bottom: 1px solid var(--border-subtle); } -.log-panel > summary::-webkit-details-marker { - display: none; +.log-panel[open] > summary { + border-bottom: 1px solid var(--border-subtle); } #logs { background: var(--log-bg); @@ -506,15 +881,80 @@ button.tiny-btn { max-height: 220px; overflow: auto; font-family: Consolas, "Courier New", monospace; - font-size: 12px; + font-size: 0.75rem; line-height: 1.35; padding: 10px; + border-radius: 0 0 10px 10px; } -.log-line { white-space: pre-wrap; word-break: break-word; } +.log-line { white-space: pre-wrap; word-break: break-word; min-height: 1.35em; } .log-error { color: var(--log-error); } .log-warn { color: var(--log-warn); } -@media (max-width: 980px) { - .controls { - grid-template-columns: 1fr 1fr; - } +/* + * The terminal colours the reporters write for the CLI, drawn the same in the log panel (see + * ui/ansi.ts). The panel is dark in every theme, so this is one palette: GitHub's dark-theme ANSI + * colours, the ones the docs' reporter images use (docs/scripts/genReporterImages.js). + */ +#logs .ansi-fg-30 { color: #484f58; } +#logs .ansi-fg-31 { color: #f85149; } +#logs .ansi-fg-32 { color: #3fb950; } +#logs .ansi-fg-33 { color: #d29922; } +#logs .ansi-fg-34 { color: #58a6ff; } +#logs .ansi-fg-35 { color: #bc8cff; } +#logs .ansi-fg-36 { color: #39c5cf; } +#logs .ansi-fg-37 { color: #b1bac4; } +#logs .ansi-fg-90 { color: #8b949e; } +#logs .ansi-fg-91 { color: #ff7b72; } +#logs .ansi-fg-92 { color: #56d364; } +#logs .ansi-fg-93 { color: #e3b341; } +#logs .ansi-fg-94 { color: #79c0ff; } +#logs .ansi-fg-95 { color: #d2a8ff; } +#logs .ansi-fg-96 { color: #56d4dd; } +#logs .ansi-fg-97 { color: #f0f6fc; } +#logs .ansi-bg-40 { background-color: #484f58; } +#logs .ansi-bg-41 { background-color: #f85149; } +#logs .ansi-bg-42 { background-color: #3fb950; } +#logs .ansi-bg-43 { background-color: #d29922; } +#logs .ansi-bg-44 { background-color: #58a6ff; } +#logs .ansi-bg-45 { background-color: #bc8cff; } +#logs .ansi-bg-46 { background-color: #39c5cf; } +#logs .ansi-bg-47 { background-color: #b1bac4; } +#logs .ansi-bg-100 { background-color: #8b949e; } +#logs .ansi-bg-101 { background-color: #ff7b72; } +#logs .ansi-bg-102 { background-color: #56d364; } +#logs .ansi-bg-103 { background-color: #e3b341; } +#logs .ansi-bg-104 { background-color: #79c0ff; } +#logs .ansi-bg-105 { background-color: #d2a8ff; } +#logs .ansi-bg-106 { background-color: #56d4dd; } +#logs .ansi-bg-107 { background-color: #f0f6fc; } +#logs .ansi-bold { font-weight: 700; } +#logs .ansi-dim { opacity: 0.7; } +#logs .ansi-italic { font-style: italic; } +#logs .ansi-underline { text-decoration: underline; } +/* + * The compact layout, for a window that is small for the page at its zoom: the body's layout-short + * and layout-narrow, set by runner.html and ui/theme.ts's fitLayout. They stand in for media queries, + * which measure the window alone -- at 200% a 1200px window holds only a 600px page. + * + * On a small screen the controls wrap to several rows, and kept at the top they would take a third + * of it -- there they scroll away with the page instead. + */ +body.layout-short .toolbar { + position: static; +} +body.layout-narrow .app-shell { + padding: 10px; +} +body.layout-narrow .toolbar { + position: static; + margin: 0 -10px 10px; + padding: 6px 10px 0; +} +/* Narrow enough to share a line with Run once Failed only has folded away */ +body.layout-narrow .filter-inline { + flex-basis: 200px; + min-width: 0; +} +body.layout-narrow .suite-children, +body.layout-narrow .test-body { + padding-left: 8px; } diff --git a/internal/runner/src/ux/browser/resources/styles/classic.css b/internal/runner/src/ux/browser/resources/styles/classic.css index d0fd3e5..e6af0ce 100644 --- a/internal/runner/src/ux/browser/resources/styles/classic.css +++ b/internal/runner/src/ux/browser/resources/styles/classic.css @@ -1,21 +1,19 @@ body.style-classic { + color-scheme: light; --bg: linear-gradient(160deg, #f8fafc 0%, #e2e8f0 100%); --ink: #0f172a; --muted: #475569; --panel: #ffffff; --panel-soft: #f8fafc; + --toolbar-bg: rgba(248, 250, 252, 0.94); --border: #d0d7de; --border-subtle: #e2e8f0; --accent: #0b6bcb; + --on-accent: #ffffff; --running: #2563eb; --ok: #0f7b37; --bad: #b42318; - --suite-tone-1: #0b6bcb; - --suite-tone-2: #117a65; - --suite-tone-3: #b45309; - --test-tone-1: #2563eb; - --test-tone-2: #059669; - --test-tone-3: #d97706; + --on-bad: #ffffff; --state-running-bg: #eff6ff; --state-passed-bg: #f0fdf4; --state-failed-bg: #fef2f2; @@ -23,8 +21,79 @@ body.style-classic { --suite-state-failed-bg: #fef2f2; --test-state-passed-bg: #ffffff; --test-state-failed-bg: #fee2e2; + --assert-bg: #ffffff; + --mark-bg: rgba(250, 204, 21, 0.45); --log-bg: #0b1020; --log-ink: #d8e4ff; --log-error: #ffd7d7; --log-warn: #ffe7b3; } + +/* + * Dark, chosen in the page's menu (Theme > Dark), or by the system setting while the choice is + * System. The two rule sets are the same colours: CSS has no way to share them between a media + * query and an attribute selector. + */ +body.style-classic[data-theme="dark"] { + color-scheme: dark; + --bg: linear-gradient(160deg, #0b1220 0%, #111a2e 100%); + --ink: #e5e7eb; + --muted: #9aa4b2; + --panel: #131c2e; + --panel-soft: #172238; + --toolbar-bg: rgba(11, 18, 32, 0.94); + --border: #2b3a55; + --border-subtle: #22304a; + --accent: #5aa2ff; + --on-accent: #0b1220; + --running: #60a5fa; + --ok: #4ade80; + --bad: #f87171; + --on-bad: #1a0b0b; + --state-running-bg: #13233d; + --state-passed-bg: #10261c; + --state-failed-bg: #2a1518; + --suite-state-passed-bg: #10261c; + --suite-state-failed-bg: #2a1518; + --test-state-passed-bg: #131c2e; + --test-state-failed-bg: #3a1a1e; + --assert-bg: #0f1726; + --mark-bg: rgba(250, 204, 21, 0.3); + --log-bg: #070b14; + --log-ink: #d8e4ff; + --log-error: #ffb4b4; + --log-warn: #ffe08a; +} + +@media (prefers-color-scheme: dark) { + body.style-classic:not([data-theme]) { + color-scheme: dark; + --bg: linear-gradient(160deg, #0b1220 0%, #111a2e 100%); + --ink: #e5e7eb; + --muted: #9aa4b2; + --panel: #131c2e; + --panel-soft: #172238; + --toolbar-bg: rgba(11, 18, 32, 0.94); + --border: #2b3a55; + --border-subtle: #22304a; + --accent: #5aa2ff; + --on-accent: #0b1220; + --running: #60a5fa; + --ok: #4ade80; + --bad: #f87171; + --on-bad: #1a0b0b; + --state-running-bg: #13233d; + --state-passed-bg: #10261c; + --state-failed-bg: #2a1518; + --suite-state-passed-bg: #10261c; + --suite-state-failed-bg: #2a1518; + --test-state-passed-bg: #131c2e; + --test-state-failed-bg: #3a1a1e; + --assert-bg: #0f1726; + --mark-bg: rgba(250, 204, 21, 0.3); + --log-bg: #070b14; + --log-ink: #d8e4ff; + --log-error: #ffb4b4; + --log-warn: #ffe08a; + } +} diff --git a/internal/runner/src/ux/browser/resources/styles/slate.css b/internal/runner/src/ux/browser/resources/styles/slate.css index 485b553..2d36a1b 100644 --- a/internal/runner/src/ux/browser/resources/styles/slate.css +++ b/internal/runner/src/ux/browser/resources/styles/slate.css @@ -1,21 +1,19 @@ body.style-slate { + color-scheme: light; --bg: linear-gradient(140deg, #edf2f7 0%, #dbe4ee 100%); --ink: #111827; --muted: #4b5563; --panel: #f9fbff; --panel-soft: #f3f6fb; + --toolbar-bg: rgba(237, 242, 247, 0.94); --border: #c7d2df; --border-subtle: #d9e1eb; --accent: #0f766e; + --on-accent: #ffffff; --running: #0f766e; --ok: #047857; --bad: #b91c1c; - --suite-tone-1: #0f766e; - --suite-tone-2: #1d4ed8; - --suite-tone-3: #7c3aed; - --test-tone-1: #0284c7; - --test-tone-2: #059669; - --test-tone-3: #9333ea; + --on-bad: #ffffff; --state-running-bg: #f0f9ff; --state-passed-bg: #ecfdf5; --state-failed-bg: #fef2f2; @@ -23,8 +21,79 @@ body.style-slate { --suite-state-failed-bg: #fef2f2; --test-state-passed-bg: #ffffff; --test-state-failed-bg: #fee2e2; + --assert-bg: #ffffff; + --mark-bg: rgba(250, 204, 21, 0.45); --log-bg: #111827; --log-ink: #d1d5db; --log-error: #fecaca; --log-warn: #fde68a; } + +/* + * Dark, chosen in the page's menu (Theme > Dark), or by the system setting while the choice is + * System. The two rule sets are the same colours: CSS has no way to share them between a media + * query and an attribute selector. + */ +body.style-slate[data-theme="dark"] { + color-scheme: dark; + --bg: linear-gradient(140deg, #0f1418 0%, #151d24 100%); + --ink: #e2e8f0; + --muted: #94a3b8; + --panel: #161f27; + --panel-soft: #1a242d; + --toolbar-bg: rgba(15, 20, 24, 0.94); + --border: #2d3b47; + --border-subtle: #24313b; + --accent: #2dd4bf; + --on-accent: #0b1014; + --running: #2dd4bf; + --ok: #34d399; + --bad: #f87171; + --on-bad: #1a0b0b; + --state-running-bg: #10262a; + --state-passed-bg: #0f2620; + --state-failed-bg: #2a1618; + --suite-state-passed-bg: #0f2620; + --suite-state-failed-bg: #2a1618; + --test-state-passed-bg: #161f27; + --test-state-failed-bg: #3a1a1e; + --assert-bg: #111a21; + --mark-bg: rgba(250, 204, 21, 0.3); + --log-bg: #0b1014; + --log-ink: #d1d5db; + --log-error: #fecaca; + --log-warn: #fde68a; +} + +@media (prefers-color-scheme: dark) { + body.style-slate:not([data-theme]) { + color-scheme: dark; + --bg: linear-gradient(140deg, #0f1418 0%, #151d24 100%); + --ink: #e2e8f0; + --muted: #94a3b8; + --panel: #161f27; + --panel-soft: #1a242d; + --toolbar-bg: rgba(15, 20, 24, 0.94); + --border: #2d3b47; + --border-subtle: #24313b; + --accent: #2dd4bf; + --on-accent: #0b1014; + --running: #2dd4bf; + --ok: #34d399; + --bad: #f87171; + --on-bad: #1a0b0b; + --state-running-bg: #10262a; + --state-passed-bg: #0f2620; + --state-failed-bg: #2a1618; + --suite-state-passed-bg: #0f2620; + --suite-state-failed-bg: #2a1618; + --test-state-passed-bg: #161f27; + --test-state-failed-bg: #3a1a1e; + --assert-bg: #111a21; + --mark-bg: rgba(250, 204, 21, 0.3); + --log-bg: #0b1014; + --log-ink: #d1d5db; + --log-error: #fecaca; + --log-warn: #fde68a; + } +} diff --git a/internal/runner/src/ux/browser/resources/styles/themes.css b/internal/runner/src/ux/browser/resources/styles/themes.css new file mode 100644 index 0000000..64314aa --- /dev/null +++ b/internal/runner/src/ux/browser/resources/styles/themes.css @@ -0,0 +1,109 @@ +/* + * The accessibility themes, layered over either style (classic or slate) and loaded after it, so + * each of these overrides the style's own colours. The page applies them as attributes, from the + * run's --browser-theme or the choice made in its Appearance panel (see ui/theme.ts): + * + * - data-theme="dim" on the body: low light -- dark, dimmer and warmer, with lower contrast; + * - data-vision="colorblind" on the body: passed and failed in blue and orange (the Okabe-Ito + * pair, which every kind of color blindness tells apart) in place of green and red, in light + * and dark. + * + * The panel's zoom is not a theme: it is the body's --ui-zoom, which .app-shell in base.css follows. + */ + +body[data-theme="dim"] { + color-scheme: dark; + --bg: #121110; + --ink: #b8b1a6; + --muted: #8c867c; + --panel: #1b1a18; + --panel-soft: #1f1e1b; + --toolbar-bg: rgba(18, 17, 16, 0.95); + --border: #36332e; + --border-subtle: #2a2824; + --accent: #c39a5c; + --on-accent: #17140f; + --running: #c39a5c; + --ok: #8fae7c; + --bad: #d48b7b; + --on-bad: #17140f; + --state-running-bg: #221f1a; + --state-passed-bg: #1d211a; + --state-failed-bg: #281e1b; + --suite-state-passed-bg: #1d211a; + --suite-state-failed-bg: #281e1b; + --test-state-passed-bg: #1b1a18; + --test-state-failed-bg: #30221e; + --assert-bg: #171614; + --mark-bg: rgba(195, 154, 92, 0.3); + --log-bg: #141311; + --log-ink: #b8b1a6; + --log-error: #dca596; + --log-warn: #d4b679; +} + +/* The log's terminal colours, toned down to match */ +body[data-theme="dim"] #logs [class*="ansi-fg-"], +body[data-theme="dim"] #logs [class*="ansi-bg-"] { + filter: saturate(0.6) brightness(0.85); +} + +body[data-vision="colorblind"] { + --running: #9e4f86; + --ok: #0072b2; + --bad: #b34a00; + --on-bad: #ffffff; + --state-running-bg: #f7eef4; + --state-passed-bg: #eaf3fa; + --state-failed-bg: #fdf0e6; + --suite-state-passed-bg: #eaf3fa; + --suite-state-failed-bg: #fdf0e6; + --test-state-passed-bg: #ffffff; + --test-state-failed-bg: #fbe3d1; +} + +/* + * The same, on a dark or low-light page -- chosen in the menu, or by the system setting while the + * choice is System. The two rule sets are the same colours: CSS has no way to share them between a + * media query and an attribute selector. + */ +body[data-vision="colorblind"][data-theme="dark"], +body[data-vision="colorblind"][data-theme="dim"] { + --running: #cc79a7; + --ok: #56b4e9; + --bad: #e69f00; + --on-bad: #1a1206; + --state-running-bg: #271a24; + --state-passed-bg: #0f2230; + --state-failed-bg: #2b2210; + --suite-state-passed-bg: #0f2230; + --suite-state-failed-bg: #2b2210; + --test-state-passed-bg: var(--panel); + --test-state-failed-bg: #3a2b0e; +} + +@media (prefers-color-scheme: dark) { + body[data-vision="colorblind"]:not([data-theme]) { + --running: #cc79a7; + --ok: #56b4e9; + --bad: #e69f00; + --on-bad: #1a1206; + --state-running-bg: #271a24; + --state-passed-bg: #0f2230; + --state-failed-bg: #2b2210; + --suite-state-passed-bg: #0f2230; + --suite-state-failed-bg: #2b2210; + --test-state-passed-bg: var(--panel); + --test-state-failed-bg: #3a2b0e; + } +} + +/* The log panel is dark in every theme, so its red and green have one replacement each */ +body[data-vision="colorblind"] #logs .ansi-fg-31, +body[data-vision="colorblind"] #logs .ansi-fg-91 { color: #e69f00; } +body[data-vision="colorblind"] #logs .ansi-fg-32, +body[data-vision="colorblind"] #logs .ansi-fg-92 { color: #56b4e9; } +body[data-vision="colorblind"] #logs .ansi-bg-41, +body[data-vision="colorblind"] #logs .ansi-bg-101 { background-color: #e69f00; } +body[data-vision="colorblind"] #logs .ansi-bg-42, +body[data-vision="colorblind"] #logs .ansi-bg-102 { background-color: #56b4e9; } diff --git a/internal/runner/src/ux/browser/resources/templates/runner.html b/internal/runner/src/ux/browser/resources/templates/runner.html index 341c927..1efa482 100644 --- a/internal/runner/src/ux/browser/resources/templates/runner.html +++ b/internal/runner/src/ux/browser/resources/templates/runner.html @@ -1,5 +1,5 @@ - + @@ -8,66 +8,165 @@ __RUNNER_CSS__ - + + +
-
-

__RUNNER_TITLE__

-

__RUNNER_DESCRIPTION__

-
- -
-
-
- -
- -
-
- -
-
+
+
+

__RUNNER_TITLE__

+

__RUNNER_DESCRIPTION__

+
+ +
+
-
- - -
-
+ + +
+
+
+
+ +
+ + +
+
+
+ + +
+
-
- Initializing... - 0 assertions +
+ Initializing... + +
+ + 0 assertions + +
+
-
-
+
+ +
Runner Log -
+
diff --git a/internal/runner/src/ux/browser/ui/ansi.ts b/internal/runner/src/ux/browser/ui/ansi.ts new file mode 100644 index 0000000..4bd7242 --- /dev/null +++ b/internal/runner/src/ux/browser/ui/ansi.ts @@ -0,0 +1,242 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +/** + * Terminal colour codes, drawn in the page. The reporters running in the page colour their output for + * the CLI's terminal, which receives every line the page logs; the page's log panel shows the same + * lines in the same colours. + * + * Only the select graphic rendition (SGR, `ESC [ ... m`) codes change how text looks: bold, dim, + * italic and underline, the 8 standard and 8 bright foreground and background colours (as classes, + * which the stylesheet colours for the log panel's dark background), and 256 and 24-bit colours (as + * the CSS colour itself). Every other escape sequence -- cursor movement, erasing, a hyperlink's + * OSC wrapper -- is dropped. + */ + +import { parseAnsi } from "@nevware21/chromacon"; + +/** A run of text drawn one way. */ +export interface IAnsiSegment { + text: string; + // The `ansi-*` classes it is drawn with + classes: string[]; + // A 256 or 24-bit colour, as CSS, in place of a colour class + color?: string; + background?: string; +} + +interface IAnsiState { + fg: string; + bg: string; + color: string; + background: string; + bold: boolean; + dim: boolean; + italic: boolean; + underline: boolean; +} + +// The levels of each channel in the xterm 256 colour cube (codes 16 to 231) +const _CUBE_LEVELS = [ 0, 95, 135, 175, 215, 255 ]; + +function _resetState(state: IAnsiState) { + state.fg = ""; + state.bg = ""; + state.color = ""; + state.background = ""; + state.bold = false; + state.dim = false; + state.italic = false; + state.underline = false; +} + +function _isByte(value: number) { + return value >= 0 && value <= 255; +} + +/** One of the 256 xterm colours: the first 16 as their class, the rest as CSS. */ +function _palette(n: number, isFg: boolean): { cls: string, css: string } | null { + if (!_isByte(n)) { + return null; + } + + if (n < 16) { + return { cls: (isFg ? "ansi-fg-" : "ansi-bg-") + ((n < 8 ? (isFg ? 30 : 40) : (isFg ? 82 : 92)) + n), css: "" }; + } + + if (n >= 232) { + var grey = 8 + (n - 232) * 10; + return { cls: "", css: "rgb(" + grey + "," + grey + "," + grey + ")" }; + } + + var index = n - 16; + return { + cls: "", + css: "rgb(" + _CUBE_LEVELS[Math.floor(index / 36)] + "," + _CUBE_LEVELS[Math.floor(index / 6) % 6] + "," + _CUBE_LEVELS[index % 6] + ")" + }; +} + +function _setColor(state: IAnsiState, isFg: boolean, cls: string, css: string) { + if (isFg) { + state.fg = cls; + state.color = css; + } else { + state.bg = cls; + state.background = css; + } +} + +/** Applies one SGR sequence's parameters ("1;31", "38;5;208", or "" for a reset) to `state`. */ +function _applySgr(state: IAnsiState, params: string) { + var codes = params.split(";").map(function(param) { + return param === "" ? 0 : parseInt(param, 10); + }); + + for (var lp = 0; lp < codes.length; lp++) { + var code = codes[lp]; + if (code === 0) { + _resetState(state); + } else if (code === 1) { + state.bold = true; + } else if (code === 2) { + state.dim = true; + } else if (code === 3) { + state.italic = true; + } else if (code === 4) { + state.underline = true; + } else if (code === 22) { + state.bold = false; + state.dim = false; + } else if (code === 23) { + state.italic = false; + } else if (code === 24) { + state.underline = false; + } else if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) { + _setColor(state, true, "ansi-fg-" + code, ""); + } else if (code === 39) { + _setColor(state, true, "", ""); + } else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) { + _setColor(state, false, "ansi-bg-" + code, ""); + } else if (code === 49) { + _setColor(state, false, "", ""); + } else if (code === 38 || code === 48) { + // 38;5;n and 38;2;r;g;b (48 for the background); a malformed one ends the sequence + var isFg = code === 38; + if (codes[lp + 1] === 5 && lp + 2 < codes.length) { + var picked = _palette(codes[lp + 2], isFg); + if (picked) { + _setColor(state, isFg, picked.cls, picked.css); + } + + lp += 2; + } else if (codes[lp + 1] === 2 && lp + 4 < codes.length) { + var rgb = codes.slice(lp + 2, lp + 5); + if (_isByte(rgb[0]) && _isByte(rgb[1]) && _isByte(rgb[2])) { + _setColor(state, isFg, "", "rgb(" + rgb.join(",") + ")"); + } + + lp += 4; + } else { + break; + } + } + } +} + +// A select graphic rendition sequence -- ESC [ (or the one-byte CSI) then its parameters, then m -- +// the only kind of escape sequence that changes how the text after it looks +// eslint-disable-next-line no-control-regex +const _SGR = /^(?:\u001b\[|\u009b)([0-9;]*)m$/; + +// eslint-disable-next-line no-control-regex +const _ESCAPE = /^[\u001b\u009b]/; + +/** + * `text` split into runs, each with how it is drawn. chromacon's `parseAnsi` finds the escape + * sequences -- every kind, OSC hyperlinks included -- and this works out what each SGR one does to + * the text after it; the sequences themselves are gone. Neighbouring runs drawn the same way are one. + */ +export function toAnsiSegments(text: string): IAnsiSegment[] { + var segments: IAnsiSegment[] = []; + var state: IAnsiState = {} as IAnsiState; + _resetState(state); + + function push(part: string) { + var classes: string[] = []; + if (state.fg) { + classes.push(state.fg); + } + + if (state.bg) { + classes.push(state.bg); + } + + [ "bold", "dim", "italic", "underline" ].forEach(function(name) { + if ((state as any)[name]) { + classes.push("ansi-" + name); + } + }); + + var last = segments[segments.length - 1]; + if (last && last.classes.join(" ") === classes.join(" ") && last.color === (state.color || undefined) && + last.background === (state.background || undefined)) { + last.text += part; + return; + } + + var segment: IAnsiSegment = { text: part, classes: classes }; + if (state.color) { + segment.color = state.color; + } + + if (state.background) { + segment.background = state.background; + } + + segments.push(segment); + } + + var parts = parseAnsi(String(text)); + for (var lp = 0; lp < parts.length; lp++) { + var part = parts[lp]; + if (_ESCAPE.test(part)) { + var sgr = _SGR.exec(part); + if (sgr) { + _applySgr(state, sgr[1]); + } + } else if (part) { + push(part); + } + } + + return segments; +} + +/** Appends `segments` to `target`: plain runs as text, the others as styled spans. */ +export function appendAnsiSegments(target: HTMLElement, segments: IAnsiSegment[]) { + for (var lp = 0; lp < segments.length; lp++) { + var segment = segments[lp]; + if (!segment.classes.length && !segment.color && !segment.background) { + target.appendChild(document.createTextNode(segment.text)); + continue; + } + + var span = document.createElement("span"); + span.className = segment.classes.join(" "); + if (segment.color) { + span.style.color = segment.color; + } + + if (segment.background) { + span.style.backgroundColor = segment.background; + } + + span.textContent = segment.text; + target.appendChild(span); + } +} diff --git a/internal/runner/src/ux/browser/ui/appearance.ts b/internal/runner/src/ux/browser/ui/appearance.ts new file mode 100644 index 0000000..4a61e8e --- /dev/null +++ b/internal/runner/src/ux/browser/ui/appearance.ts @@ -0,0 +1,271 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import { createAutocomplete, IAutocomplete, IAutocompleteItem } from "./autocomplete"; +import { + applyPageSetting, findTheme, fitLayout, IThemeInfo, PageSettingName, readPageSetting, readRunSetting, readThemeChoice, readZoom, + searchThemes, stepZoom, THEMES, ZOOM_LEVELS +} from "./theme"; + +/** + * The Appearance panel the actions menu opens: the theme, color vision and the zoom (theme.ts says + * what each does to the page). + * + * It is a small non-modal dialog rather than more entries in the menu because a menu may only hold + * menu items, and the theme is a searchable combobox -- so however many themes there come to be, + * any one is a few letters away. Built to the WAI-ARIA dialog pattern: + * + * - "Appearance..." in the menu opens it with focus on the theme, whose list stays closed until + * asked for (a click, ArrowDown or typing); + * - Escape closes the theme's list while it is open, and otherwise the panel, putting focus back on + * the menu button; a click outside the panel, or focus leaving it, closes it too; + * - the zoom's buttons are aria-disabled rather than disabled at either end, so the one a keyboard + * is on never loses focus from under it. + */ + +/** The panel's elements, and what to call when the zoom changes. */ +export interface IAppearanceParts { + // The menu entry that opens the panel, and the button focus goes back to when it closes + item: HTMLElement | null; + button: HTMLElement | null; + panel: HTMLElement | null; + themeInput: HTMLInputElement | null; + themeList: HTMLElement | null; + colorblindToggle: HTMLInputElement | null; + zoomOut: HTMLElement | null; + zoomIn: HTMLElement | null; + zoomReset: HTMLElement | null; + zoomValue: HTMLElement | null; + // Called once the zoom has changed, for whatever has to fit the page again + onZoom?: () => void; +} + +/** What opens and closes the panel. */ +export interface IAppearancePanel { + open: () => void; + close: (refocus: boolean) => void; + isOpen: () => boolean; +} + +/** + * The theme rows for `query`: every theme while the input holds nothing or the current theme's + * name, so the list opens on the whole choice, and otherwise the ones a search for it finds. + */ +export function themeCandidates(query: string): IAutocompleteItem[] { + var current = findTheme(readThemeChoice()); + var text = String(query || "").trim().toLowerCase(); + var themes = !text || (current && current.name.toLowerCase() === text) ? THEMES : searchThemes(text); + + return themes.map(function(theme) { + return { value: theme.name, label: theme.name }; + }); +} + +/** The theme `text` names in full, by its name or its id, ignoring case; or null. */ +function _themeNamed(text: string): IThemeInfo | null { + var lower = String(text || "").trim().toLowerCase(); + for (var lp = 0; lp < THEMES.length; lp++) { + if (THEMES[lp].name.toLowerCase() === lower || THEMES[lp].id === lower) { + return THEMES[lp]; + } + } + + return null; +} + +function _isDisabled(el: HTMLElement): boolean { + return el.getAttribute("aria-disabled") === "true"; +} + +function _setDisabled(el: HTMLElement | null, disabled: boolean) { + if (el) { + el.setAttribute("aria-disabled", disabled ? "true" : "false"); + } +} + +/** + * Wires the panel up, and shows each setting as the page has it: the choice kept in this browser, + * else the run's `--browser-theme`, else the default. + */ +export function createAppearancePanel(parts: IAppearanceParts): IAppearancePanel { + var panel = parts.panel; + var themeList: IAutocomplete | null = null; + + function isOpen(): boolean { + return !!panel && !panel.hasAttribute("hidden"); + } + + function syncTheme() { + var theme = findTheme(readThemeChoice()); + if (parts.themeInput && theme) { + parts.themeInput.value = theme.name; + } + } + + function syncZoom() { + var zoom = readZoom(); + var runZoom = +readRunSetting("zoom"); + if (parts.zoomValue) { + parts.zoomValue.textContent = zoom + "%"; + } + + _setDisabled(parts.zoomOut, zoom <= ZOOM_LEVELS[0]); + _setDisabled(parts.zoomIn, zoom >= ZOOM_LEVELS[ZOOM_LEVELS.length - 1]); + _setDisabled(parts.zoomReset, zoom === runZoom); + if (parts.zoomReset) { + parts.zoomReset.setAttribute("title", "Back to " + runZoom + "%"); + } + } + + function setZoom(zoom: number) { + if (applyPageSetting("zoom", String(zoom), true)) { + syncZoom(); + if (parts.onZoom) { + parts.onZoom(); + } + } + } + + function open() { + if (!panel) { + return; + } + + panel.removeAttribute("hidden"); + if (parts.item) { + parts.item.setAttribute("aria-expanded", "true"); + } + + syncTheme(); + syncZoom(); + if (parts.themeInput) { + parts.themeInput.focus(); + // Typing starts a new search rather than adding to the current theme's name + if (parts.themeInput.select) { + parts.themeInput.select(); + } + } + } + + function close(refocus: boolean) { + if (!panel || !isOpen()) { + return; + } + + if (themeList) { + themeList.close(); + } + + syncTheme(); + panel.setAttribute("hidden", ""); + if (parts.item) { + parts.item.setAttribute("aria-expanded", "false"); + } + + if (refocus && parts.button) { + parts.button.focus(); + } + } + + // runner.html applied each setting before the page first painted; this applies them again so + // the panel and the page agree, whatever the inline script made of them + ([ "theme", "vision", "zoom" ] as PageSettingName[]).forEach(function(name) { + applyPageSetting(name, readPageSetting(name), false); + }); + syncTheme(); + syncZoom(); + + if (parts.themeInput && parts.themeList) { + var input = parts.themeInput; + themeList = createAutocomplete(input, parts.themeList, themeCandidates, { openOnFocus: false }); + + // A theme picked from the list or typed out in full is applied; anything else is put back + // to the current theme's name + input.addEventListener("change", function() { + var theme = _themeNamed(input.value); + if (theme) { + applyPageSetting("theme", theme.id, true); + } + + syncTheme(); + }); + } + + if (parts.colorblindToggle) { + var toggle = parts.colorblindToggle; + toggle.checked = readPageSetting("vision") === "colorblind"; + toggle.addEventListener("change", function() { + applyPageSetting("vision", toggle.checked ? "colorblind" : "standard", true); + }); + } + + [ { el: parts.zoomOut, direction: -1 }, { el: parts.zoomIn, direction: 1 } ].forEach(function(step) { + var el = step.el; + if (el) { + el.addEventListener("click", function() { + if (!_isDisabled(el)) { + setZoom(stepZoom(readZoom(), step.direction)); + } + }); + } + }); + + if (parts.zoomReset) { + var reset = parts.zoomReset; + reset.addEventListener("click", function() { + if (!_isDisabled(reset)) { + setZoom(+readRunSetting("zoom")); + } + }); + } + + if (parts.item) { + parts.item.addEventListener("click", function() { + // Once the menu has handled the same click -- closing, and putting focus on its button + setTimeout(open, 0); + }); + } + + if (panel) { + var shown = panel; + + // Capture, so the theme's list is seen open before its own handler closes it on this Escape + shown.addEventListener("keydown", function(evt: KeyboardEvent) { + var listOpen = !!parts.themeInput && evt.target === parts.themeInput && parts.themeInput.getAttribute("aria-expanded") === "true"; + if (evt.key === "Escape" && !listOpen) { + evt.preventDefault(); + close(true); + } + }, true); + + // Focus leaving the panel by any route -- Tab past its end, a click into another field -- + // closes it, so it is never left open behind where the user is. The panel takes focus itself + // (tabindex -1) for a click on its background, which so stays inside. + shown.addEventListener("focusout", function() { + setTimeout(function() { + if (isOpen() && !shown.contains(document.activeElement)) { + close(false); + } + }, 0); + }); + + document.addEventListener("click", function(evt) { + var target = evt.target as Node; + if (isOpen() && !shown.contains(target) && target !== parts.item) { + close(false); + } + }); + } + + // The compact layout follows the window as well as the zoom + if (typeof window !== "undefined" && window.addEventListener) { + window.addEventListener("resize", fitLayout); + } + + return { open: open, close: close, isOpen: isOpen }; +} diff --git a/internal/runner/src/ux/browser/ui/assertions.ts b/internal/runner/src/ux/browser/ui/assertions.ts index 1c315fc..789abd7 100644 --- a/internal/runner/src/ux/browser/ui/assertions.ts +++ b/internal/runner/src/ux/browser/ui/assertions.ts @@ -14,14 +14,29 @@ import { ensureTestNode } from "./ui-rendering"; import { assertCountEl } from "./dom"; import { safeToString } from "./utils"; import { createAssertionLabel } from "../../../shared/suiteNames"; +import { addAssertionValues, IAssertionValues } from "../../../shared/assertionValues"; -export function addAssertion(testKey: any, passed: any, label: any, errorText: any) { - assertionDomQueue.push({ +/** + * Queues one assertion for drawing. `values` holds a failure's expected and actual values, as the + * test frame or worker formatted them (see getAssertionValues); only the ones present are kept. + */ +export function addAssertion(testKey: any, passed: any, label: any, errorText: any, values?: IAssertionValues | null) { + var entry: any = { testKey: testKey, passed: !!passed, label: label || "(assertion)", errorText: errorText || "" - }); + }; + + if (values && values.expected !== undefined) { + entry.expected = values.expected; + } + + if (values && values.actual !== undefined) { + entry.actual = values.actual; + } + + assertionDomQueue.push(entry); if (!assertionFlushScheduled) { setAssertionFlushScheduled(true); @@ -50,15 +65,15 @@ export function _handleAssertionEvent(payload: any) { return; } - addAssertion(testKey, !!payload.passed, payload.label || "(assertion)", payload.errorText || ""); + addAssertion(testKey, !!payload.passed, payload.label || "(assertion)", payload.errorText || "", payload); } export function handleAssertion(contextLike: any) { var actx = contextLike && contextLike.v ? contextLike.v : contextLike; var errorText = (actx && actx.error) ? safeToString(actx.error && actx.error.message ? actx.error.message : actx.error) : ""; - _handleAssertionEvent({ + _handleAssertionEvent(addAssertionValues({ passed: !!(actx && actx.passed), label: createAssertionLabel(actx), errorText: errorText - }); + }, actx && actx.error)); } diff --git a/internal/runner/src/ux/browser/ui/autocomplete.ts b/internal/runner/src/ux/browser/ui/autocomplete.ts index 02602c0..6ffc33c 100644 --- a/internal/runner/src/ux/browser/ui/autocomplete.ts +++ b/internal/runner/src/ux/browser/ui/autocomplete.ts @@ -23,6 +23,63 @@ export interface IAutocompleteItem { kind?: string; } +/** How a {@link createAutocomplete} list behaves. */ +export interface IAutocompleteOptions { + // Open the list as soon as the input takes focus (the default), rather than only on a click, + // ArrowDown or typing -- false for an input that takes focus as a dialog opens + openOnFocus?: boolean; +} + +/** A {@link createAutocomplete} list, for its owner to close -- when the dialog holding it closes, say. */ +export interface IAutocomplete { + close: () => void; +} + +/** + * Fills `target` with `label`, marking (in `` elements) every place a term of `query` appears + * in it, so a row shows why it matched. The picker matches terms in any order, so each term is marked + * wherever it occurs. Without a match, or a query, the label is plain text. + */ +export function _fillHighlighted(target: HTMLElement, label: string, query: string): void { + const terms = String(query || "").toLowerCase().split(/\s+/).filter((term) => !!term); + const lower = label.toLowerCase(); + const marked: boolean[] = []; + let any = false; + + terms.forEach((term) => { + let at = lower.indexOf(term); + while (at !== -1) { + for (let lp = at; lp < at + term.length; lp++) { + marked[lp] = true; + } + + // From the next character, not the end of this match, so an overlapping one is marked too + any = true; + at = lower.indexOf(term, at + 1); + } + }); + + if (!any) { + target.textContent = label; + return; + } + + target.textContent = ""; + let start = 0; + while (start < label.length) { + const on = !!marked[start]; + let end = start; + while (end < label.length && !!marked[end] === on) { + end++; + } + + const part = document.createElement(on ? "mark" : "span"); + part.textContent = label.substring(start, end); + target.appendChild(part); + start = end; + } +} + /** * Wires a plain text input up to a custom, in-page suggestion dropdown -- deliberately not * `` + ``, whose suggestion popup is OS-native chrome with no CSS hook in @@ -38,8 +95,11 @@ export interface IAutocompleteItem { * {@link AUTOCOMPLETE_KIND_ATTR}) and dispatches a real "change" event so existing "change" * listeners on `input` keep working unchanged -- deliberately not "input" too, since that would * immediately re-trigger this module's own input listener and reopen the list it just closed. + * + * A closed list opens again on a click in the input or on ArrowDown, as the WAI-ARIA combobox + * pattern has it, so Escape never leaves the keyboard without a way back to the suggestions. */ -export function createAutocomplete(input: HTMLInputElement, listEl: HTMLElement, getCandidates: (query: string) => IAutocompleteItem[]): void { +export function createAutocomplete(input: HTMLInputElement, listEl: HTMLElement, getCandidates: (query: string) => IAutocompleteItem[], options?: IAutocompleteOptions): IAutocomplete { let items: IAutocompleteItem[] = []; let activeIndex = -1; @@ -48,9 +108,16 @@ export function createAutocomplete(input: HTMLInputElement, listEl: HTMLElement, activeIndex = -1; listEl.textContent = ""; + // A value picked from the list is a choice rather than a search: the list reopened on it + // offers the other rows too, and marking the choice's words in them would read as a match + const query = candidates.some((item) => item.value === input.value) ? "" : input.value; + if (!candidates.length) { const empty = document.createElement("div"); empty.className = "autocomplete-empty"; + // An option that cannot be chosen, so the listbox holds only options and says why it is empty + empty.setAttribute("role", "option"); + empty.setAttribute("aria-disabled", "true"); empty.textContent = "No matches"; listEl.appendChild(empty); return; @@ -60,11 +127,12 @@ export function createAutocomplete(input: HTMLInputElement, listEl: HTMLElement, const item = items[i]; const row = document.createElement("div"); row.className = "autocomplete-item"; + row.id = (listEl.id || "autocomplete") + "-option-" + i; row.setAttribute("role", "option"); const text = document.createElement("span"); text.className = "autocomplete-text"; - text.textContent = item.label; + _fillHighlighted(text, item.label, query); row.appendChild(text); if (item.kind) { @@ -87,6 +155,8 @@ export function createAutocomplete(input: HTMLInputElement, listEl: HTMLElement, if (candidates.length > items.length) { const more = document.createElement("div"); more.className = "autocomplete-more"; + more.setAttribute("role", "option"); + more.setAttribute("aria-disabled", "true"); more.textContent = (candidates.length - items.length) + " more -- keep typing to narrow"; listEl.appendChild(more); } @@ -116,11 +186,16 @@ export function createAutocomplete(input: HTMLInputElement, listEl: HTMLElement, const rows = listEl.querySelectorAll(".autocomplete-item"); for (let i = 0; i < rows.length; i++) { rows[i].classList.toggle("active", i === index); + rows[i].setAttribute("aria-selected", i === index ? "true" : "false"); } activeIndex = index; const activeRow = rows[index]; if (activeRow) { (activeRow as HTMLElement).scrollIntoView({ block: "nearest" }); + // Focus stays in the input, so this is how a screen reader learns which row is highlighted + input.setAttribute("aria-activedescendant", (activeRow as HTMLElement).id); + } else { + input.removeAttribute("aria-activedescendant"); } } @@ -139,6 +214,7 @@ export function createAutocomplete(input: HTMLInputElement, listEl: HTMLElement, function close() { _setOpen(false); activeIndex = -1; + input.removeAttribute("aria-activedescendant"); } function select(item: IAutocompleteItem) { @@ -161,10 +237,23 @@ export function createAutocomplete(input: HTMLInputElement, listEl: HTMLElement, _setOpen(true); }); - input.addEventListener("focus", open); + if (!options || options.openOnFocus !== false) { + input.addEventListener("focus", open); + } + + input.addEventListener("click", () => { + if (!listEl.classList.contains("open")) { + open(); + } + }); input.addEventListener("keydown", (evt) => { if (!listEl.classList.contains("open")) { + if (evt.key === "ArrowDown") { + evt.preventDefault(); + open(); + } + return; } @@ -192,4 +281,6 @@ export function createAutocomplete(input: HTMLInputElement, listEl: HTMLElement, close(); } }); + + return { close: close }; } diff --git a/internal/runner/src/ux/browser/ui/client.ts b/internal/runner/src/ux/browser/ui/client.ts index d54d708..80c47c5 100644 --- a/internal/runner/src/ux/browser/ui/client.ts +++ b/internal/runner/src/ux/browser/ui/client.ts @@ -9,7 +9,7 @@ import * as state from "./state"; import * as dom from "./dom"; import { queueLog, clearLogs, installConsoleBridge, flushLogsNow } from "./logging"; -import { clearResults, beginRenderBatch, endRenderBatch } from "./ui-rendering"; +import { clearResults, beginRenderBatch, endRenderBatch, updateRunSummary } from "./ui-rendering"; import { setStatus, setButtonsState } from "./ui-control"; import { applyFailedOnlyFilter, updateFailedOnlyForTest } from "./filtering"; import { getSelectionFromUi, populateAndRestoreSelection, getScopeCandidates, setUiSelection } from "./selection"; @@ -17,7 +17,9 @@ import { createAutocomplete } from "./autocomplete"; import { createMenu } from "./menu"; import { buildTargetUrl } from "./target"; import { readSelectionIdsFromHash, resolveSelectionFromHashIds, writeSelectionToHash, HashSelectionIds } from "./urlState"; -import { resolveUrl, safeToString } from "./utils"; +import { fireEvent, formatCount, resolveUrl, safeToString } from "./utils"; +import { fitToolbar, initToolbarOverflow } from "./overflow"; +import { createAppearancePanel } from "./appearance"; import { _handleSuiteStart, _handleSuiteDone, _handleTestStart, _handleTestDone } from "./test-execution"; import { _handleAssertionEvent } from "./assertions"; import { frameInitialize } from "./frame-mode"; @@ -278,6 +280,7 @@ async function _initWorkerHost() { } state.setDiscovery(discovery); + _updateAppMeta(); populateAndRestoreSelection(state.discovery, _resolvePendingSelection(state.discovery)); setButtonsState(); setStatus("Ready. Starting run..."); @@ -285,6 +288,37 @@ async function _initWorkerHost() { _runSelection(getSelectionFromUi(state.discovery), false); } +/** + * The line under the title: which target this is, the server it was loaded from and how many tests + * the run found -- in place of a description of the page itself. + */ +function _updateAppMeta() { + if (!dom.appMetaEl) { + return; + } + + var parts = [ dom.TARGET === "worker" ? "Worker target" : "Browser target" ]; + var loc = globalThis.location; + if (loc && loc.host) { + parts.push(loc.host); + } + + var tests = state.discovery && state.discovery.tests ? state.discovery.tests.length : 0; + if (tests) { + parts.push(formatCount(tests, "test", "tests")); + } + + dom.appMetaEl.textContent = parts.join(" · "); +} + +/** Keeps the run summary's failed count, which also toggles the filter, showing whether it is on. */ +function _syncFailedOnlyChip() { + var chip = dom.runSummaryEl ? dom.runSummaryEl.querySelector(".summary-failed") : null; + if (chip) { + chip.setAttribute("aria-pressed", dom.failedOnlyToggle && dom.failedOnlyToggle.checked ? "true" : "false"); + } +} + function _onFrameMessage(evt: MessageEvent) { var data = evt && evt.data ? evt.data : {}; if (!data || !data.kind) { @@ -294,6 +328,7 @@ function _onFrameMessage(evt: MessageEvent) { if (data.kind === "ready") { state.setPayload({ ready: true }); state.setDiscovery(data.discovery || { suites: [], tests: [] }); + _updateAppMeta(); populateAndRestoreSelection(state.discovery, _resolvePendingSelection(state.discovery)); setButtonsState(); setStatus("Ready. Starting run..."); @@ -408,10 +443,14 @@ async function _runSelection(selection: any, preserveResults: any) { } state.setLastResult(result || {}); + if (result && result.summary) { + updateRunSummary(result.summary); + } + if (state.stopRequested) { - setStatus("Run stopped. You can run again or finish."); + setStatus("Run stopped. Run again, or choose Finish & Stop Server from the ⋮ menu to send the result to the CLI."); } else { - setStatus("Run complete. You can run again or click Finish to return to CLI."); + setStatus("Run complete. Run again, or choose Finish & Stop Server from the ⋮ menu to send the result to the CLI."); } if (dom.AUTO_FINISH && !state.stopRequested) { @@ -472,7 +511,7 @@ async function _finishRun() { // have been rebuilt since this page loaded (see targetSessionCache.ts's per-target build // "generation" counter and the /__tripwire__/check-fresh endpoint) and, if so, reloads and // re-runs automatically via the same path the manual "Reload Tests" button uses. Gated behind -// the autoReloadToggle checkbox (state.autoReloadEnabled, default on) and skipped entirely for +// the menu's Auto-reload setting (state.autoReloadEnabled, default on) and skipped entirely for // AUTO_FINISH (headless/puppeteer) runs, which have no user to reload for and exit shortly // after their one run completes anyway. // ----------------------------------------------------------------- @@ -671,6 +710,20 @@ function _hostInitialize() { dom.finishBtn.addEventListener("click", _finishRun); } + // The summary's failed count is a shortcut for the "Failed only" checkbox + if (dom.runSummaryEl) { + dom.runSummaryEl.addEventListener("click", function(evt: MouseEvent) { + var target = evt.target as HTMLElement | null; + if (!dom.failedOnlyToggle || !target || !target.closest || !target.closest(".summary-failed")) { + return; + } + + // As a click on the checkbox would: its own "change" handler below does the rest + dom.failedOnlyToggle.checked = !dom.failedOnlyToggle.checked; + fireEvent(dom.failedOnlyToggle, "change"); + }); + } + if (dom.reloadBtn) { dom.reloadBtn.addEventListener("click", _reloadFrame); } @@ -683,6 +736,21 @@ function _hostInitialize() { createMenu(dom.actionsBtn, dom.actionsMenu); } + createAppearancePanel({ + item: dom.appearanceItem, + button: dom.actionsBtn, + panel: dom.appearancePanel, + themeInput: dom.themeInput, + themeList: dom.themeList, + colorblindToggle: dom.colorblindToggle, + zoomOut: dom.zoomOutBtn, + zoomIn: dom.zoomInBtn, + zoomReset: dom.zoomResetBtn, + zoomValue: dom.zoomValueEl, + // A zoomed page holds fewer controls on a row + onZoom: fitToolbar + }); + // Target switch. Both targets are served by this same session (see ui/target.ts), so moving // between them is a plain navigation -- the CLI keeps running and the scope in the hash comes // along, so "the same tests, in a Worker instead" is one click away and one click back. @@ -709,16 +777,34 @@ function _hostInitialize() { } if (dom.failedOnlyToggle) { - dom.failedOnlyToggle.addEventListener("change", applyFailedOnlyFilter); + dom.failedOnlyToggle.addEventListener("change", function() { + applyFailedOnlyFilter(); + _syncFailedOnlyChip(); + }); } - if (dom.autoReloadToggle) { - dom.autoReloadToggle.checked = state.autoReloadEnabled; - dom.autoReloadToggle.addEventListener("change", function() { - state.setAutoReloadEnabled(!!(dom.autoReloadToggle && dom.autoReloadToggle.checked)); + // Auto-reload is a setting in the actions menu, ticked while it is on + const autoReloadItem = dom.autoReloadItem; + if (autoReloadItem) { + autoReloadItem.setAttribute("aria-checked", state.autoReloadEnabled ? "true" : "false"); + autoReloadItem.addEventListener("click", function() { + state.setAutoReloadEnabled(!state.autoReloadEnabled); + autoReloadItem.setAttribute("aria-checked", state.autoReloadEnabled ? "true" : "false"); + }); + } + + // The skip link moves focus to the results without following its href: the hash holds the scope + if (dom.skipLink && dom.resultsEl) { + dom.skipLink.addEventListener("click", function(evt: MouseEvent) { + evt.preventDefault(); + if (dom.resultsEl) { + dom.resultsEl.focus(); + } }); } + // Last, once every control it may fold is set up and showing its state + initToolbarOverflow(); _startAutoReloadPolling(); } diff --git a/internal/runner/src/ux/browser/ui/dom.ts b/internal/runner/src/ux/browser/ui/dom.ts index 6b07fbc..22ecd66 100644 --- a/internal/runner/src/ux/browser/ui/dom.ts +++ b/internal/runner/src/ux/browser/ui/dom.ts @@ -34,7 +34,31 @@ export const scopeSelect = document.getElementById("scopeSelect") as HTMLInputEl export const scopeSelectList = document.getElementById("scopeSelectList") as HTMLElement | null; export const clearScopeBtn = document.getElementById("clearScopeBtn") as HTMLButtonElement | null; export const failedOnlyToggle = document.getElementById("failedOnlyToggle") as HTMLInputElement | null; -export const autoReloadToggle = document.getElementById("autoReloadToggle") as HTMLInputElement | null; export const resultsEl = document.getElementById("results") as HTMLElement | null; export const assertCountEl = document.getElementById("assertCount") as HTMLElement | null; +export const runSummaryEl = document.getElementById("runSummary") as HTMLElement | null; +export const filterNoteEl = document.getElementById("filterNote") as HTMLElement | null; +export const appMetaEl = document.getElementById("appMeta") as HTMLElement | null; +export const skipLink = document.getElementById("skipLink") as HTMLElement | null; +// The rows whose controls fold into the actions menu when the window is too narrow, those controls, +// and what stands in for them in the menu (see overflow.ts) +export const appHeaderEl = document.getElementById("appHeader") as HTMLElement | null; +export const controlsRowEl = document.getElementById("controlsRow") as HTMLElement | null; +export const targetControl = document.getElementById("targetControl") as HTMLElement | null; +export const failedOnlyControl = document.getElementById("failedOnlyControl") as HTMLElement | null; +export const foldedControlsEl = document.getElementById("foldedControls") as HTMLElement | null; +export const targetMenuGroup = document.getElementById("targetMenuGroup") as HTMLElement | null; +export const failedOnlyItem = document.getElementById("failedOnlyItem") as HTMLElement | null; +// Auto-reload has no control on the page: it is always this entry in the actions menu +export const autoReloadItem = document.getElementById("autoReloadItem") as HTMLElement | null; +// The Appearance panel, which the actions menu opens (see appearance.ts) +export const appearanceItem = document.getElementById("appearanceItem") as HTMLElement | null; +export const appearancePanel = document.getElementById("appearancePanel") as HTMLElement | null; +export const themeInput = document.getElementById("themeInput") as HTMLInputElement | null; +export const themeList = document.getElementById("themeList") as HTMLElement | null; +export const colorblindToggle = document.getElementById("colorblindToggle") as HTMLInputElement | null; +export const zoomOutBtn = document.getElementById("zoomOutBtn") as HTMLElement | null; +export const zoomInBtn = document.getElementById("zoomInBtn") as HTMLElement | null; +export const zoomResetBtn = document.getElementById("zoomResetBtn") as HTMLElement | null; +export const zoomValueEl = document.getElementById("zoomValue") as HTMLElement | null; export const testFrameContainer = document.getElementById("testFrameContainer") as HTMLElement | null; diff --git a/internal/runner/src/ux/browser/ui/filtering.ts b/internal/runner/src/ux/browser/ui/filtering.ts index 98ee35c..83f8113 100644 --- a/internal/runner/src/ux/browser/ui/filtering.ts +++ b/internal/runner/src/ux/browser/ui/filtering.ts @@ -15,15 +15,36 @@ * with the node and worker targets. */ -import { failedOnlyToggle } from "./dom"; -import { testState, suiteState } from "./state"; -import { _setNodeHidden } from "./utils"; +import { failedOnlyToggle, filterNoteEl } from "./dom"; +import { testState, suiteState, runCounts } from "./state"; +import { _setNodeHidden, formatCount } from "./utils"; import { getSuiteKeyChain } from "../../../shared/suiteNames"; export function _isFailedOnlyEnabled() { return !!(failedOnlyToggle && failedOnlyToggle.checked); } +/** + * Says how many finished tests "Failed only" is hiding, so a nearly empty results panel reads as + * filtered rather than broken. It reads the run's counts rather than walking the results: with the + * filter on, every finished test that did not fail is hidden. + */ +export function updateFilterNote() { + if (!filterNoteEl) { + return; + } + + var hidden = _isFailedOnlyEnabled() ? runCounts.passed + runCounts.skipped : 0; + if (hidden > 0) { + var kind = runCounts.skipped ? ["passed or skipped test", "passed or skipped tests"] : ["passing test", "passing tests"]; + filterNoteEl.textContent = formatCount(hidden, kind[0], kind[1]) + " hidden by Failed only"; + filterNoteEl.hidden = false; + } else { + filterNoteEl.textContent = ""; + filterNoteEl.hidden = true; + } +} + export function _shouldShowTestNode(node: any) { if (!_isFailedOnlyEnabled()) { return true; @@ -85,6 +106,8 @@ export function applyFailedOnlyFilter() { _refreshSuiteVisibility(suiteNode); } + + updateFilterNote(); } /** diff --git a/internal/runner/src/ux/browser/ui/frame-mode.ts b/internal/runner/src/ux/browser/ui/frame-mode.ts index d577f72..defb36d 100644 --- a/internal/runner/src/ux/browser/ui/frame-mode.ts +++ b/internal/runner/src/ux/browser/ui/frame-mode.ts @@ -12,6 +12,7 @@ import { installDefineRegistry, createRequire, getBrowserLoaderContext, buildSui import { createSettingsGrepFilter, createSelectionFilter, applyFilters } from "../../../shared/filters"; import { createBailListener } from "../../../shared/bailListener"; import { getSuitePartsFromNode, createAssertionLabel } from "../../../shared/suiteNames"; +import { addAssertionValues } from "../../../shared/assertionValues"; import { collectSuiteAndTests, createEmptyDiscovery } from "../../../shared/discovery"; import { resolveUrl, safeToString } from "./utils"; import { AUTO_FINISH } from "./dom"; @@ -206,12 +207,12 @@ async function _runBrowserInFrame(payloadObj: any, selection: any) { onAssert: function(context: any) { var actx = context && context.v ? context.v : context; var errorText = (actx && actx.error) ? safeToString(actx.error && actx.error.message ? actx.error.message : actx.error) : ""; - _queueLifecycleEvent({ + _queueLifecycleEvent(addAssertionValues({ kind: "assert", passed: !!(actx && actx.passed), label: createAssertionLabel(actx), errorText: errorText - }); + }, actx && actx.error)); } }); } diff --git a/internal/runner/src/ux/browser/ui/logging.ts b/internal/runner/src/ux/browser/ui/logging.ts index e871ad3..61a0934 100644 --- a/internal/runner/src/ux/browser/ui/logging.ts +++ b/internal/runner/src/ux/browser/ui/logging.ts @@ -10,6 +10,7 @@ import { logsEl } from "./dom"; import { MAX_LOG_LINES } from "./shared"; import { logDomQueue, logSendQueue } from "./state"; import { safeToString, resolveUrl, _wasAtBottom, _stickToBottom } from "./utils"; +import { appendAnsiSegments, toAnsiSegments } from "./ansi"; let logFlushScheduled = false; let sendFlushScheduled = false; @@ -55,8 +56,18 @@ function flushLogDomQueue() { while (logDomQueue.length) { var entry = logDomQueue.shift() as any; var line = document.createElement("div"); + // In the colours the CLI's terminal shows the same line in. A reporter's blank spacing + // line -- which may still carry a colour reset -- stays blank, without the level tag. + var segments = toAnsiSegments(String(entry.message)); + var blank = !segments.some(function(segment) { + return !!segment.text.trim(); + }); line.className = "log-line log-" + entry.level; - line.textContent = "[" + entry.level + "] " + entry.message; + line.textContent = blank ? "" : "[" + entry.level + "] "; + if (!blank) { + appendAnsiSegments(line, segments); + } + fragment.appendChild(line); } diff --git a/internal/runner/src/ux/browser/ui/menu.ts b/internal/runner/src/ux/browser/ui/menu.ts index 6797375..c2d4663 100644 --- a/internal/runner/src/ux/browser/ui/menu.ts +++ b/internal/runner/src/ux/browser/ui/menu.ts @@ -56,22 +56,41 @@ export function nextMenuIndex(count: number, current: number, key: string): numb return -1; } +// The menu's entries: plain actions, the radio entries of a choice such as the target, and the +// checkbox entries of a setting +const _ITEM_SELECTOR = "[role='menuitem'], [role='menuitemradio'], [role='menuitemcheckbox']"; + function _isDisabled(item: HTMLElement): boolean { return item.getAttribute("aria-disabled") === "true"; } +/** Whether `item` is showing: neither it nor anything between it and `menu` is `hidden`. */ +function _isShown(item: HTMLElement, menu: HTMLElement): boolean { + for (let node: any = item; node && node !== menu; node = node.parentNode) { + if (node.hasAttribute && node.hasAttribute("hidden")) { + return false; + } + } + + return true; +} + /** - * Wires `button` up as the menu button for `menu`, whose children carry `role="menuitem"`. + * Wires `button` up as the menu button for `menu`, whose entries carry `role="menuitem"` (or + * `menuitemradio`, for one of a set of choices, or `menuitemcheckbox`, for a setting). * The menu is shown and hidden with the `hidden` attribute, so it is removed from the - * accessibility tree while closed rather than merely painted out of sight. + * accessibility tree while closed rather than merely painted out of sight. Entries can be hidden + * the same way, alone or in a group, and the keys then pass over them. */ export function createMenu(button: HTMLElement, menu: HTMLElement): void { function items(): HTMLElement[] { - const found = menu.querySelectorAll("[role='menuitem']"); + const found = menu.querySelectorAll(_ITEM_SELECTOR); const result: HTMLElement[] = []; for (let lp = 0; lp < found.length; lp++) { - result.push(found[lp] as HTMLElement); + if (_isShown(found[lp] as HTMLElement, menu)) { + result.push(found[lp] as HTMLElement); + } } return result; @@ -171,7 +190,7 @@ export function createMenu(button: HTMLElement, menu: HTMLElement): void { // page registered on that button sits in the bubble phase, on the button itself. menu.addEventListener("click", (evt) => { const target = evt.target as HTMLElement; - const item = target && target.closest ? (target.closest("[role='menuitem']") as HTMLElement) : null; + const item = target && target.closest ? (target.closest(_ITEM_SELECTOR) as HTMLElement) : null; if (item && _isDisabled(item)) { evt.preventDefault(); @@ -182,7 +201,7 @@ export function createMenu(button: HTMLElement, menu: HTMLElement): void { // Bubble, so this runs *after* the item's own handler has done its work. menu.addEventListener("click", (evt) => { const target = evt.target as HTMLElement; - if (target && target.closest && target.closest("[role='menuitem']")) { + if (target && target.closest && target.closest(_ITEM_SELECTOR)) { close(true); } }); diff --git a/internal/runner/src/ux/browser/ui/overflow.ts b/internal/runner/src/ux/browser/ui/overflow.ts new file mode 100644 index 0000000..9aefaac --- /dev/null +++ b/internal/runner/src/ux/browser/ui/overflow.ts @@ -0,0 +1,197 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +/** + * Folds controls into the "..." actions menu when the window is too narrow for their row, rather than + * letting the row wrap: the Target picker beside the title, and the Failed only setting on the + * toolbar. Each row folds its controls in order -- the least needed first -- until it fits on one + * line, and unfolds them again as the window widens. + * + * In the menu a folded control has a stand-in that drives the real one: Failed only is a checkbox + * item that toggles its checkbox, and Target is a choice of the two targets that sets the picker. Either + * fires the control's own "change", so everything listening to the control carries on as before. + */ + +import { + appHeaderEl, controlsRowEl, foldedControlsEl, targetControl, targetSelect, targetMenuGroup, + failedOnlyControl, failedOnlyToggle, failedOnlyItem +} from "./dom"; +import { fireEvent } from "./utils"; + +/** A control that folds into the menu, and what stands in for it there. */ +export interface IFoldEntry { + control: HTMLElement; + standIn: HTMLElement; + // Shows the control's current state on its stand-in + sync(): void; +} + +/** A row of controls, and the ones in it that fold -- in the order they fold, the least needed first. */ +export interface IFoldRow { + row: HTMLElement; + entries: IFoldEntry[]; +} + +export interface IToolbarOverflow { + // Unfolds every control, then folds as many as each row needs to fit on one line + fit(): void; +} + +function _setHidden(el: HTMLElement, hidden: boolean) { + if (hidden) { + el.setAttribute("hidden", ""); + } else { + el.removeAttribute("hidden"); + } +} + +function _isShown(el: HTMLElement) { + return el.offsetWidth > 0 || el.offsetHeight > 0; +} + +/** + * Whether a row's controls have wrapped onto a second line. A wrapping row's last control is on its + * last line, so it is enough to see whether that one sits below the first. + */ +export function _rowWraps(row: HTMLElement): boolean { + var shown: HTMLElement[] = []; + for (var lp = 0; lp < row.children.length; lp++) { + var child = row.children[lp] as HTMLElement; + if (_isShown(child)) { + shown.push(child); + } + } + + if (shown.length < 2) { + return false; + } + + var first = shown[0]; + return shown[shown.length - 1].offsetTop > first.offsetTop + first.offsetHeight / 2; +} + +/** A checkbox's stand-in: checked as the checkbox is, and toggling it when chosen. */ +export function foldCheckbox(control: HTMLElement, input: HTMLInputElement, item: HTMLElement): IFoldEntry { + function sync() { + item.setAttribute("aria-checked", input.checked ? "true" : "false"); + } + + input.addEventListener("change", sync); + item.addEventListener("click", function() { + input.checked = !input.checked; + fireEvent(input, "change"); + }); + + return { control: control, standIn: item, sync: sync }; +} + +/** + * A select's stand-in: the radio items in `group`, each carrying one of the select's values in its + * `choiceAttr` attribute. The current value is checked, every item is disabled while the select is, + * and choosing another value sets the select to it. + */ +export function foldSelect(control: HTMLElement, select: HTMLSelectElement, group: HTMLElement, choiceAttr: string): IFoldEntry { + var found = group.querySelectorAll("[" + choiceAttr + "]"); + var items: HTMLElement[] = []; + for (var lp = 0; lp < found.length; lp++) { + items.push(found[lp] as HTMLElement); + } + + function sync() { + items.forEach(function(item) { + item.setAttribute("aria-checked", item.getAttribute(choiceAttr) === select.value ? "true" : "false"); + item.setAttribute("aria-disabled", select.disabled ? "true" : "false"); + }); + } + + items.forEach(function(item) { + item.addEventListener("click", function() { + var choice = item.getAttribute(choiceAttr); + if (!select.disabled && choice && choice !== select.value) { + select.value = choice; + fireEvent(select, "change"); + } + }); + }); + + return { control: control, standIn: group, sync: sync }; +} + +/** + * Folds the entries of each of `rows` as the row needs, showing their stand-ins in the menu, and + * `folded` -- the part of the menu that holds them -- only while any of them is folded. + */ +export function createToolbarOverflow(rows: IFoldRow[], folded: HTMLElement | null): IToolbarOverflow { + function fold(entry: IFoldEntry, isFolded: boolean) { + entry.control.classList.toggle("is-folded", isFolded); + _setHidden(entry.standIn, !isFolded); + } + + return { + fit: function() { + var anyFolded = false; + rows.forEach(function(row) { + row.entries.forEach(function(entry) { + fold(entry, false); + }); + + for (var lp = 0; lp < row.entries.length && _rowWraps(row.row); lp++) { + fold(row.entries[lp], true); + anyFolded = true; + } + + row.entries.forEach(function(entry) { + entry.sync(); + }); + }); + + if (folded) { + _setHidden(folded, !anyFolded); + } + } + }; +} + +var _overflow: IToolbarOverflow | null = null; + +/** Sets the page's controls up to fold, and keeps them fitted to the window as it is resized. */ +export function initToolbarOverflow() { + var rows: IFoldRow[] = []; + if (appHeaderEl && targetControl && targetSelect && targetMenuGroup) { + rows.push({ row: appHeaderEl, entries: [ foldSelect(targetControl, targetSelect, targetMenuGroup, "data-target-choice") ] }); + } + + if (controlsRowEl && failedOnlyControl && failedOnlyToggle && failedOnlyItem) { + rows.push({ row: controlsRowEl, entries: [ foldCheckbox(failedOnlyControl, failedOnlyToggle, failedOnlyItem) ] }); + } + + _overflow = createToolbarOverflow(rows, foldedControlsEl); + _overflow.fit(); + + var win: any = typeof window !== "undefined" ? window : null; + var pending = false; + if (win && win.addEventListener) { + // Once per frame however many resize events arrive in it: each fit is a few layouts + win.addEventListener("resize", function() { + if (!pending) { + pending = true; + requestAnimationFrame(function() { + pending = false; + fitToolbar(); + }); + } + }); + } +} + +/** Fits the controls to the window again -- after a control's width has changed, say. */ +export function fitToolbar() { + if (_overflow) { + _overflow.fit(); + } +} diff --git a/internal/runner/src/ux/browser/ui/resources.ts b/internal/runner/src/ux/browser/ui/resources.ts index 0b1602c..52135d8 100644 --- a/internal/runner/src/ux/browser/ui/resources.ts +++ b/internal/runner/src/ux/browser/ui/resources.ts @@ -17,8 +17,10 @@ import frameHtmlTemplate from "../resources/templates/frame.html"; import cssBase from "../resources/styles/base.css"; import cssClassic from "../resources/styles/classic.css"; import cssSlate from "../resources/styles/slate.css"; +import cssThemes from "../resources/styles/themes.css"; import { TestTarget } from "@nevware21/tripwire"; import { BrowserUiStyle } from "../../types/BrowserUiStyle"; +import { IBrowserUiTheme } from "../../types/BrowserUiTheme"; import { IFsEntry } from "../../interfaces/IFsEntry"; const _cssMap: Record = { @@ -46,20 +48,40 @@ export function createTestFrameHtml(target: TestTarget, _style: BrowserUiStyle, .replace(/__FRAME_SCRIPT_URL__/g, scriptUrl); } -export function createBrowserRunnerHtml(target: TestTarget, style: BrowserUiStyle): string { +/** + * The body attributes that carry `theme` to the page, which applies them before it first paints + * unless the page's own menu has saved a choice in this browser (see the template's inline script). + */ +function _themeAttrs(theme: IBrowserUiTheme | undefined): string { + const parts: Array<[ keyof IBrowserUiTheme, string ]> = [ + [ "scheme", "data-default-theme" ], + [ "vision", "data-default-vision" ], + [ "zoom", "data-default-zoom" ] + ]; + + return parts.map((part) => { + const value = theme ? theme[part[0]] : undefined; + return value ? " " + part[1] + "=\"" + _escapeHtmlAttr(String(value)) + "\"" : ""; + }).join(""); +} + +export function createBrowserRunnerHtml(target: TestTarget, style: BrowserUiStyle, theme?: IBrowserUiTheme): string { const title = target === "worker" ? "Tripwire Worker Runner" : "Tripwire Browser Runner"; - const description = "This page runs tests using tripwire's native runner and reporter pipeline, with suite/test filtering and assertion timeline UI."; + // The page fills in the server and the test count once it knows them (client.ts's _updateAppMeta) + const description = target === "worker" ? "Worker target" : "Browser target"; const scriptUrl = "/__tripwire__/runner-client.js"; const styleClassName = "style-" + style; const cssStyle = _cssMap[style] || cssClassic; - const css = cssBase + "\n\n" + cssStyle; + // The themes come last: each overrides the style's colours + const css = cssBase + "\n\n" + cssStyle + "\n\n" + cssThemes; return htmlTemplate .replace(/__RUNNER_TITLE__/g, title) .replace(/__RUNNER_DESCRIPTION__/g, description) .replace(/__RUNNER_STYLE_CLASS__/g, styleClassName) .replace(/__RUNNER_TARGET__/g, target) + .replace(/__RUNNER_THEME_ATTRS__/g, _themeAttrs(theme)) .replace(/__RUNNER_CSS__/g, css) .replace(/__RUNNER_SCRIPT_URL__/g, scriptUrl); } diff --git a/internal/runner/src/ux/browser/ui/shared.ts b/internal/runner/src/ux/browser/ui/shared.ts index 7d16637..8844d38 100644 --- a/internal/runner/src/ux/browser/ui/shared.ts +++ b/internal/runner/src/ux/browser/ui/shared.ts @@ -28,6 +28,7 @@ export interface SuiteNode { total: number; done: number; failed: number; + skipped: number; childSuiteCount: number; durationMs?: number; } @@ -37,6 +38,7 @@ export interface TestNode { suiteKey: string; details: HTMLDetailsElement; label: HTMLElement; + marker: HTMLElement; testName: string; suiteParts: string[]; duration: HTMLElement; @@ -53,6 +55,8 @@ export interface AssertionEntry { passed: boolean; label: string; errorText: string; + expected?: string; + actual?: string; } export interface Selection { diff --git a/internal/runner/src/ux/browser/ui/state.ts b/internal/runner/src/ux/browser/ui/state.ts index 18963c3..a0c194a 100644 --- a/internal/runner/src/ux/browser/ui/state.ts +++ b/internal/runner/src/ux/browser/ui/state.ts @@ -31,6 +31,16 @@ export const testState: { [key: string]: TestNode } = {}; export let currentExecutingTestKey = ""; export let lastObservedTestKey = ""; +// How many of the drawn tests have finished, by outcome -- kept as each one finishes, so the run +// summary never has to recount every test (see updateRunSummary in ui-rendering.ts). +export const runCounts = { passed: 0, failed: 0, skipped: 0 }; + +export function resetRunCounts() { + runCounts.passed = 0; + runCounts.failed = 0; + runCounts.skipped = 0; +} + // User preference, not per-run state -- deliberately left out of clearAllState() below so a // frame/worker reload (including one triggered by this same setting) doesn't reset it. export let autoReloadEnabled = true; @@ -135,6 +145,7 @@ export function clearAllState() { assertionCount = 0; Object.keys(suiteState).forEach(key => delete suiteState[key]); Object.keys(testState).forEach(key => delete testState[key]); + resetRunCounts(); currentExecutingTestKey = ""; lastObservedTestKey = ""; highlightSuiteTitle = ""; diff --git a/internal/runner/src/ux/browser/ui/test-execution.ts b/internal/runner/src/ux/browser/ui/test-execution.ts index a710870..2d39f00 100644 --- a/internal/runner/src/ux/browser/ui/test-execution.ts +++ b/internal/runner/src/ux/browser/ui/test-execution.ts @@ -6,8 +6,8 @@ * Licensed under the MIT license. */ -import { testState, suiteState, setCurrentExecutingTestKey, setLastObservedTestKey, assertionDomQueue } from "./state"; -import { ensureTestNode, ensureSuiteNode, setTestStatus, updateSuiteStatus, appendSyntheticAssertion } from "./ui-rendering"; +import { testState, suiteState, setCurrentExecutingTestKey, setLastObservedTestKey, assertionDomQueue, runCounts } from "./state"; +import { ensureTestNode, ensureSuiteNode, setTestStatus, updateSuiteStatus, appendSyntheticAssertion, updateRunSummary } from "./ui-rendering"; import { getSuiteKeyChain } from "../../../shared/suiteNames"; import { assertCountEl } from "./dom"; import { TestNode } from "./shared"; @@ -55,7 +55,8 @@ function _hasQueuedAssertion(testKey: string): boolean { export function _handleTestDone(suiteParts: any, testName: any, endStatus: any, durationMs: any, endErrorText: any, updateFilter: (node: TestNode) => void) { var node = ensureTestNode(suiteParts || [], testName || "(unnamed test)"); var statusText = endStatus ? String(endStatus) : "pending"; - var status = (statusText === "passed" || statusText === "skipped" || statusText === "pending") ? "passed" : "failed"; + // A skipped or pending test is shown as skipped: drawing it as passed claimed it had run + var status = statusText === "passed" ? "passed" : ((statusText === "skipped" || statusText === "pending") ? "skipped" : "failed"); var suiteNode = suiteState[node.suiteKey]; setLastObservedTestKey(node.key); @@ -74,17 +75,23 @@ export function _handleTestDone(suiteParts: any, testName: any, endStatus: any, if (!node.doneCounted && suiteNode) { node.doneCounted = true; + runCounts[status as "passed" | "failed" | "skipped"]++; + var suiteChain = getSuiteKeyChain(node.suiteParts || []); for (var sc = 0; sc < suiteChain.length; sc++) { var aggSuite = suiteState[suiteChain[sc]]; if (aggSuite) { aggSuite.done++; - if (status !== "passed") { + if (status === "failed") { aggSuite.failed++; + } else if (status === "skipped") { + aggSuite.skipped = (aggSuite.skipped || 0) + 1; } updateSuiteStatus(aggSuite.key); } } + + updateRunSummary(); } setCurrentExecutingTestKey(""); diff --git a/internal/runner/src/ux/browser/ui/theme.ts b/internal/runner/src/ux/browser/ui/theme.ts new file mode 100644 index 0000000..7dcf122 --- /dev/null +++ b/internal/runner/src/ux/browser/ui/theme.ts @@ -0,0 +1,275 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import { BROWSER_ZOOM_MAX, BROWSER_ZOOM_MIN } from "../../../shared/browserTheme"; + +/** + * How the page looks, from the three settings in its Appearance panel (see appearance.ts): + * + * - the theme, as the body's `data-theme`: "System" sets none, and follows the operating system's + * light or dark setting (the stylesheets' `prefers-color-scheme` rules), while every other entry + * of {@link THEMES} overrides it; + * - color vision: "Color-blind safe" shows passed and failed in blue and orange, as the body's + * `data-vision`; + * - the zoom, a percentage, as the body's `--ui-zoom` -- which only the page's own controls, + * results and log follow (`.app-shell` in base.css), so it never needs the browser's zoom and + * never scales a test's own frame or content. + * + * Each starts as the run's `--browser-theme` set it (the body's `data-default-*` attributes), unless + * a choice made in the panel is kept in this browser's local storage -- which it is only while it + * differs from the run's, so choosing what the run set goes back to following the run. runner.html + * applies all three inline, before the page first paints, so a dark page never flashes light. + */ + +/** A theme the page offers. */ +export interface IThemeInfo { + // The body's data-theme, and the word --browser-theme names it by + id: string; + // What the Appearance panel calls it + name: string; + // More words a search finds it by + keywords?: string; +} + +/** The themes, in the order the Appearance panel lists them. */ +export const THEMES: IThemeInfo[] = [ + { id: "system", name: "System", keywords: "auto default operating" }, + { id: "light", name: "Light" }, + { id: "dark", name: "Dark" }, + { id: "dim", name: "Low light", keywords: "dim night warm" } +]; + +/** The page's settings, by name. */ +export type PageSettingName = "theme" | "vision" | "zoom"; + +/** The local storage keys runner.html's inline script reads too -- keep the two in step. */ +export const THEME_STORAGE_KEY = "tripwire.theme"; +export const VISION_STORAGE_KEY = "tripwire.vision"; +export const ZOOM_STORAGE_KEY = "tripwire.zoom"; + +/** The steps the zoom's - and + move between; the run's --browser-theme can set any whole percentage between the ends. */ +export const ZOOM_LEVELS = [ 50, 67, 75, 80, 90, 100, 110, 125, 150, 175, 200, 250, 300 ]; + +/** + * The window size, in the page's own zoomed pixels, at or below which the page takes its compact + * layout (`layout-narrow` and `layout-short` in base.css) -- the media queries they stand in for + * would measure the window alone, and a zoomed page needs its compact layout sooner. + */ +export const NARROW_WIDTH = 600; +export const SHORT_HEIGHT = 500; + +const _ZOOM_PROPERTY = "--ui-zoom"; + +interface IPageSetting { + // Where a choice made in the panel is kept + key: string; + // The body attribute carrying the run's --browser-theme for this setting + defaultAttr: string; + // The value that needs nothing set on the page: its own default + none: string; + isValue: (value: string) => boolean; + apply: (body: any, value: string) => void; +} + +function _setAttr(attr: string, none: string) { + return function(body: any, value: string) { + if (value === none) { + body.removeAttribute(attr); + } else { + body.setAttribute(attr, value); + } + }; +} + +const _SETTINGS: { [name: string]: IPageSetting } = { + theme: { + key: THEME_STORAGE_KEY, defaultAttr: "data-default-theme", none: "system", + isValue: function(value) { + return !!findTheme(value); + }, + apply: _setAttr("data-theme", "system") + }, + vision: { + key: VISION_STORAGE_KEY, defaultAttr: "data-default-vision", none: "standard", + isValue: function(value) { + return value === "standard" || value === "colorblind"; + }, + apply: _setAttr("data-vision", "standard") + }, + zoom: { + key: ZOOM_STORAGE_KEY, defaultAttr: "data-default-zoom", none: "100", + isValue: function(value) { + return /^\d+$/.test(value) && +value >= BROWSER_ZOOM_MIN && +value <= BROWSER_ZOOM_MAX; + }, + apply: function(body, value) { + if (body.style) { + if (value === "100") { + body.style.removeProperty(_ZOOM_PROPERTY); + } else { + body.style.setProperty(_ZOOM_PROPERTY, String(+value / 100)); + } + } + + fitLayout(); + } + } +}; + +function _storage(): any { + try { + return typeof localStorage !== "undefined" ? localStorage : null; + } catch (e) { + // Storage can be blocked outright (a sandboxed frame, privacy settings) + return null; + } +} + +function _body(): any { + return typeof document !== "undefined" && document ? document.body : null; +} + +function _isValue(setting: IPageSetting, value: any): value is string { + return typeof value === "string" && setting.isValue(value); +} + +/** The theme `id` names, or null for one the page does not have. */ +export function findTheme(id: string): IThemeInfo | null { + for (var lp = 0; lp < THEMES.length; lp++) { + if (THEMES[lp].id === id) { + return THEMES[lp]; + } + } + + return null; +} + +/** + * The themes a search for `query` finds: those with every one of its words, in any order, somewhere + * in their name, id or keywords -- ignoring case -- in the order {@link THEMES} lists them. + */ +export function searchThemes(query: string): IThemeInfo[] { + var terms = String(query || "").toLowerCase().split(/\s+/).filter(function(term) { + return !!term; + }); + + return THEMES.filter(function(theme) { + var text = (theme.name + " " + theme.id + " " + (theme.keywords || "")).toLowerCase(); + return terms.every(function(term) { + return text.indexOf(term) !== -1; + }); + }); +} + +/** What the run set a setting to, or the page's own default when it set nothing. */ +export function readRunSetting(name: PageSettingName): string { + var setting = _SETTINGS[name]; + var body = _body(); + var value = body && body.getAttribute ? body.getAttribute(setting.defaultAttr) : null; + + return _isValue(setting, value) ? value : setting.none; +} + +/** A setting's current value: the choice kept in this browser, else the run's, else the default. */ +export function readPageSetting(name: PageSettingName): string { + var setting = _SETTINGS[name]; + try { + var storage = _storage(); + var saved = storage ? storage.getItem(setting.key) : null; + if (_isValue(setting, saved)) { + return saved; + } + } catch (e) { + // Unreadable storage: the run's setting still applies + } + + return readRunSetting(name); +} + +/** The theme's current value, an id from {@link THEMES} (see {@link readPageSetting}). */ +export function readThemeChoice(): string { + return readPageSetting("theme"); +} + +/** The zoom's current value, as a percentage (see {@link readPageSetting}). */ +export function readZoom(): number { + return +readPageSetting("zoom"); +} + +/** + * Applies `value` to the page and, when `save` is set, keeps it in this browser -- or forgets any + * kept choice, when `value` is what the run set anyway. Returns false, and changes nothing, for a + * value the setting does not take. + */ +export function applyPageSetting(name: PageSettingName, value: string, save: boolean): boolean { + var setting = _SETTINGS[name]; + if (!_isValue(setting, value)) { + return false; + } + + var body = _body(); + if (body) { + setting.apply(body, value); + } + + if (save) { + try { + var storage = _storage(); + if (storage) { + if (value === readRunSetting(name)) { + storage.removeItem(setting.key); + } else { + storage.setItem(setting.key, value); + } + } + } catch (e) { + // Not remembered, but still applied for this page + } + } + + return true; +} + +/** + * The zoom one step in `direction` (1 in, -1 out) from `zoom` among {@link ZOOM_LEVELS} -- from a + * zoom between two steps, the next one that way -- or `zoom` itself at either end. + */ +export function stepZoom(zoom: number, direction: number): number { + if (direction > 0) { + for (var lp = 0; lp < ZOOM_LEVELS.length; lp++) { + if (ZOOM_LEVELS[lp] > zoom) { + return ZOOM_LEVELS[lp]; + } + } + } else { + for (var idx = ZOOM_LEVELS.length - 1; idx >= 0; idx--) { + if (ZOOM_LEVELS[idx] < zoom) { + return ZOOM_LEVELS[idx]; + } + } + } + + return zoom; +} + +/** + * Puts the page in its compact layout while the window is small for it at its zoom: the body's + * `layout-narrow` at or below {@link NARROW_WIDTH}, and `layout-short` at or below + * {@link SHORT_HEIGHT}, of the page's own pixels. runner.html does the same before the page first + * paints; this keeps it right as the window or the zoom changes. + */ +export function fitLayout(): void { + var body = _body(); + var win: any = typeof window !== "undefined" ? window : null; + if (!body || !win || !body.classList) { + return; + } + + var scale = (body.style && body.style.getPropertyValue ? parseFloat(body.style.getPropertyValue(_ZOOM_PROPERTY)) : 0) || 1; + body.classList.toggle("layout-narrow", win.innerWidth / scale <= NARROW_WIDTH); + body.classList.toggle("layout-short", win.innerHeight / scale <= SHORT_HEIGHT); +} diff --git a/internal/runner/src/ux/browser/ui/ui-control.ts b/internal/runner/src/ux/browser/ui/ui-control.ts index a1da640..e2c479c 100644 --- a/internal/runner/src/ux/browser/ui/ui-control.ts +++ b/internal/runner/src/ux/browser/ui/ui-control.ts @@ -7,6 +7,7 @@ */ import { statusEl, runBtn, finishBtn, scopeSelect, targetSelect, clearScopeBtn } from "./dom"; +import { fitToolbar } from "./overflow"; import { payload as getPayload, running as getRunning, lastResult as getLastResult, completed as getCompleted } from "./state"; export function setStatus(value: any) { @@ -59,4 +60,7 @@ export function setButtonsState() { // whether this run is scoped to something or covers everything. clearScopeBtn.disabled = running || !(scopeSelect && scopeSelect.value); } + + // Run's label just changed width, and Target's lock is shown on its stand-in in the menu + fitToolbar(); } diff --git a/internal/runner/src/ux/browser/ui/ui-rendering.ts b/internal/runner/src/ux/browser/ui/ui-rendering.ts index 95a4df5..43df5bd 100644 --- a/internal/runner/src/ux/browser/ui/ui-rendering.ts +++ b/internal/runner/src/ux/browser/ui/ui-rendering.ts @@ -6,16 +6,20 @@ * Licensed under the MIT license. */ -import { resultsEl } from "./dom"; +import { failedOnlyToggle, resultsEl, runSummaryEl } from "./dom"; import { SuiteNode, TestNode, MAX_ASSERTIONS_PER_TEST } from "./shared"; -import { suiteState, testState, setLastObservedTestKey, incrementAssertionCount, setAssertionCount, assertionCount, setAssertionFlushScheduled, highlightSuiteTitle, highlightTestTitle } from "./state"; +import { + suiteState, testState, setLastObservedTestKey, incrementAssertionCount, setAssertionCount, assertionCount, setAssertionFlushScheduled, + highlightSuiteTitle, highlightTestTitle, runCounts, resetRunCounts +} from "./state"; import { getSuiteDisplayName, makeSuiteKey, getSuiteKeyChain, makeTestKey } from "../../../shared/suiteNames"; -import { _wasAtBottom, _stickToBottom, _setDurationText, _setStatusClass, getStatusMarker, formatCount, createRerunButton } from "./utils"; +import { _wasAtBottom, _stickToBottom, _setDurationText, _setStatusClass, getStatusMarker, formatCount, formatDuration, createRerunButton } from "./utils"; import { assertionDomQueue } from "./state"; +import { updateFilterNote } from "./filtering"; /* - * Keeping the results list pinned to the bottom means reading its scroll position before a node - * is added and writing it back afterwards. The read is a forced synchronous layout of the whole + * Keeping the results pinned to the bottom means reading the scroll position before a node is + * added and writing it back afterwards. The read is a forced synchronous layout of the whole * results list, and the list is dirty by then -- so doing it per node is quadratic: every one of * ~2700 tests pays a full layout of a tree that ends up at ~38,000 elements. * @@ -27,25 +31,35 @@ import { assertionDomQueue } from "./state"; var _batchDepth = 0; var _batchPinned = false; +/* + * The results scroll with the page rather than inside a panel of their own (a second scrollbar + * inside the page's), so the position kept pinned to the bottom is the page's. + */ +function _scrollHost(): any { + var doc: any = typeof document !== "undefined" ? document : null; + + return doc ? (doc.scrollingElement || doc.documentElement || null) : null; +} + export function beginRenderBatch() { if (_batchDepth++ === 0) { - _batchPinned = _wasAtBottom(resultsEl); + _batchPinned = _wasAtBottom(_scrollHost()); } } export function endRenderBatch() { if (_batchDepth > 0 && --_batchDepth === 0) { - _stickToBottom(resultsEl, _batchPinned); + _stickToBottom(_scrollHost(), _batchPinned); } } function _pinStart(): boolean { - return _batchDepth > 0 ? _batchPinned : _wasAtBottom(resultsEl); + return _batchDepth > 0 ? _batchPinned : _wasAtBottom(_scrollHost()); } function _pinEnd(pinned: boolean) { if (_batchDepth === 0) { - _stickToBottom(resultsEl, pinned); + _stickToBottom(_scrollHost(), pinned); } } @@ -71,17 +85,43 @@ function _isExactHighlightTarget(nodeTitle: string): boolean { return !!nodeTitle && !!target && target === nodeTitle; } +/** + * A suite's counts, worded: how far along it is while it runs, and what happened once it is done -- + * "2 passed · 1 failed" rather than "3/3 tests", which read as all passed next to a failed suite. + */ export function updateSuiteMeta(suite: SuiteNode) { if (!suite || !suite.meta) { return; } - var text = suite.done + "/" + suite.total + " tests"; + var parts: string[] = []; + if (suite.total > 0) { + if (suite.done < suite.total) { + parts.push(suite.done + " of " + suite.total + " done"); + } else { + var skipped = suite.skipped || 0; + var passed = suite.done - suite.failed - skipped; + if (passed > 0 || (!suite.failed && !skipped)) { + parts.push(passed + " passed"); + } + + if (suite.failed > 0) { + parts.push(suite.failed + " failed"); + } + + if (skipped > 0) { + parts.push(skipped + " skipped"); + } + } + } + if (suite.childSuiteCount > 0) { - text += " \u2022 " + formatCount(suite.childSuiteCount, "child suite", "child suites"); + parts.push(formatCount(suite.childSuiteCount, "child suite", "child suites")); } - suite.meta.textContent = text; + // A suite whose tests have not been drawn yet has nothing to count + suite.meta.textContent = parts.join(" · "); + suite.meta.style.display = parts.length ? "" : "none"; } export function setSuiteMarker(suite: SuiteNode, status: any) { @@ -91,11 +131,11 @@ export function setSuiteMarker(suite: SuiteNode, status: any) { suite.marker.className = "suite-marker marker-" + status; if (status === "passed") { - suite.marker.textContent = "\u2713"; + suite.marker.textContent = "✓"; } else if (status === "failed") { - suite.marker.textContent = "\u2717"; - } else if (status === "pending") { - suite.marker.textContent = "\u25cb"; + suite.marker.textContent = "✗"; + } else if (status === "pending" || status === "skipped") { + suite.marker.textContent = "○"; } else { suite.marker.textContent = ""; } @@ -109,7 +149,7 @@ export function ensureSuiteNode(parts: any): SuiteNode { } var details = document.createElement("details"); - details.className = "suite-node suite-running suite-tone-" + ((parts.length % 3) + 1); + details.className = "suite-node suite-running"; details.open = true; var summary = document.createElement("summary"); @@ -139,7 +179,6 @@ export function ensureSuiteNode(parts: any): SuiteNode { var meta = document.createElement("span"); meta.className = "status-chip status-pending"; - meta.textContent = "0/0 tests"; var duration = document.createElement("span"); duration.className = "duration-label"; @@ -194,6 +233,7 @@ export function ensureSuiteNode(parts: any): SuiteNode { total: 0, done: 0, failed: 0, + skipped: 0, childSuiteCount: 0 }; @@ -226,15 +266,27 @@ export function ensureTestNode(suiteParts: any, testName: any): TestNode { var wasPinned = _pinStart(); var details = document.createElement("details"); - details.className = "test-node test-running test-tone-" + (((suite.total % 3) + 1)); + details.className = "test-node test-running"; details.open = true; var summary = document.createElement("summary"); summary.className = "test-summary"; + // The status glyph is its own element, so it takes the status colour the way a suite's does var label = document.createElement("span"); label.className = "test-label"; - label.textContent = getStatusMarker("running") + " " + (testName || "(unnamed test)"); + + var marker = document.createElement("span"); + marker.className = "test-marker marker-running"; + marker.setAttribute("aria-hidden", "true"); + marker.textContent = getStatusMarker("running"); + + var name = document.createElement("span"); + name.className = "test-name"; + name.textContent = testName || "(unnamed test)"; + + label.appendChild(marker); + label.appendChild(name); var chip = document.createElement("span"); chip.className = "status-chip status-running"; @@ -249,10 +301,14 @@ export function ensureTestNode(suiteParts: any, testName: any): TestNode { var testFullTitle = testSuiteTitle ? testSuiteTitle + " > " + resolvedTestName : resolvedTestName; var testRerunBtn = createRerunButton(testSuiteTitle, testFullTitle, "Run only this test again"); + var statusWrap = document.createElement("span"); + statusWrap.className = "test-statuses"; + statusWrap.appendChild(duration); + statusWrap.appendChild(chip); + statusWrap.appendChild(testRerunBtn); + summary.appendChild(label); - summary.appendChild(duration); - summary.appendChild(chip); - summary.appendChild(testRerunBtn); + summary.appendChild(statusWrap); var body = document.createElement("div"); body.className = "test-body"; @@ -280,6 +336,7 @@ export function ensureTestNode(suiteParts: any, testName: any): TestNode { suiteKey: suite.key, details: details, label: label, + marker: marker, testName: testName || "(unnamed test)", suiteParts: suiteParts.slice(0), duration: duration, @@ -299,8 +356,9 @@ export function setTestStatus(testNode: TestNode, status: any, updateFilter: (no testNode.status = status; testNode.chip.className = "status-chip status-" + status; testNode.chip.textContent = status; - if (testNode.label) { - testNode.label.textContent = getStatusMarker(status) + " " + testNode.testName; + if (testNode.marker) { + testNode.marker.className = "test-marker marker-" + status; + testNode.marker.textContent = getStatusMarker(status); } _setDurationText(testNode.duration, testNode.durationMs); _setStatusClass(testNode.details, "test", status); @@ -309,13 +367,13 @@ export function setTestStatus(testNode: TestNode, status: any, updateFilter: (no var fullTitle = suiteTitle ? suiteTitle + " > " + testNode.testName : testNode.testName; var isHighlighted = _isExactHighlightTarget(fullTitle); - // Collapse on pass so a healthy run reads as a short list of closed rows; failed/running - // tests stay expanded since that's exactly when you want the assertion trail visible - // without an extra click. Assertions are still recorded into the DOM either way (see + // Collapse on pass (or skip) so a healthy run reads as a short list of closed rows; + // failed/running tests stay expanded since that's exactly when you want the assertion trail + // visible without an extra click. Assertions are still recorded into the DOM either way (see // flushAssertionDomQueue) -- collapsing here only affects visibility, not data. The test the // current run was specifically scoped to (a "run again" click, or a URL hash a page load // restored) stays expanded regardless -- the user asked to see this exact one. - testNode.details.open = status !== "passed" || isHighlighted; + testNode.details.open = (status !== "passed" && status !== "skipped") || isHighlighted; if (isHighlighted) { testNode.details.scrollIntoView({ block: "center" }); @@ -324,6 +382,13 @@ export function setTestStatus(testNode: TestNode, status: any, updateFilter: (no updateFilter(testNode); } +function _setSuiteState(suite: SuiteNode, status: string, chipText: string) { + suite.chip.className = "status-chip status-" + status; + suite.chip.textContent = chipText; + setSuiteMarker(suite, status); + _setStatusClass(suite.details, "suite", status); +} + export function updateSuiteStatus(suiteKey: any) { var suite = suiteState[suiteKey]; if (!suite) { @@ -333,25 +398,17 @@ export function updateSuiteStatus(suiteKey: any) { updateSuiteMeta(suite); if (suite.done < suite.total) { - suite.chip.className = "status-chip status-running"; - suite.chip.textContent = "running " + suite.done + "/" + suite.total; - setSuiteMarker(suite, "running"); - _setStatusClass(suite.details, "suite", "running"); + _setSuiteState(suite, "running", "running " + suite.done + "/" + suite.total); suite.details.open = true; return; } if (suite.failed > 0) { - suite.chip.className = "status-chip status-failed"; - suite.chip.textContent = "failed"; - setSuiteMarker(suite, "failed"); - _setStatusClass(suite.details, "suite", "failed"); + _setSuiteState(suite, "failed", "failed"); suite.details.open = true; } else { - suite.chip.className = "status-chip status-passed"; - suite.chip.textContent = "passed"; - setSuiteMarker(suite, "passed"); - _setStatusClass(suite.details, "suite", "passed"); + var allSkipped = suite.done > 0 && (suite.skipped || 0) === suite.done; + _setSuiteState(suite, allSkipped ? "skipped" : "passed", allSkipped ? "skipped" : "passed"); var suiteSelectionTitle = suite.parts.join(" > "); // The synthetic root pseudo-suite (parts.length === 0, "(default suite)") has no @@ -377,6 +434,114 @@ export function updateSuiteStatus(suiteKey: any) { _setDurationText(suite.duration, suite.durationMs); } +/** + * An assertion's outcome and message: the ✓ or ✗ everyone sees, and the word a screen reader reads + * in its place -- "check mark" and "ballot x" do not say passed and failed. + */ +function _appendOutcome(target: HTMLElement, passed: boolean, message: string) { + var mark = document.createElement("span"); + mark.className = "assert-mark"; + mark.setAttribute("aria-hidden", "true"); + mark.textContent = passed ? "✓ " : "✗ "; + + var outcome = document.createElement("span"); + outcome.className = "sr-only"; + outcome.textContent = passed ? "Passed: " : "Failed: "; + + target.appendChild(mark); + target.appendChild(outcome); + target.appendChild(document.createTextNode(message)); +} + +/** + * One assertion's row. A passing one, or a failure with nothing more to show, is a single line. A + * failure's message is the error's own -- which already carries the assertion's message, so it is + * not repeated -- with the expected and actual values side by side when the error has them, and the + * rest of tripwire's description of the failure (after its " ::: " marker) folded away under + * "Details". + */ +function _createAssertItem(passed: boolean, label: string, errorText: string, expected?: string, actual?: string): HTMLElement { + var item = document.createElement("li"); + item.className = "assert-item " + (passed ? "assert-pass" : "assert-fail"); + + var text = errorText || ""; + var split = text.indexOf(" ::: "); + var errorMessage = split === -1 ? text : text.substring(0, split); + var detail = split === -1 ? "" : text.substring(split + 5); + var message = label || "(assertion)"; + if (errorMessage) { + message = (message === "(assertion)" || errorMessage.indexOf(message) !== -1) ? errorMessage : message + " -> " + errorMessage; + } + + var hasValues = expected !== undefined || actual !== undefined; + if (!hasValues && !detail) { + _appendOutcome(item, passed, message); + return item; + } + + var line = document.createElement("div"); + line.className = "assert-message"; + _appendOutcome(line, passed, message); + item.appendChild(line); + + if (hasValues) { + var values = document.createElement("div"); + values.className = "assert-values"; + [ [ "Expected", expected ], [ "Actual", actual ] ].forEach(function(pair) { + if (pair[1] === undefined) { + return; + } + + var key = document.createElement("span"); + key.className = "assert-key"; + key.textContent = pair[0]; + var value = document.createElement("code"); + value.className = "assert-value"; + value.textContent = String(pair[1]); + values.appendChild(key); + values.appendChild(value); + }); + item.appendChild(values); + } + + if (detail) { + var more = document.createElement("details"); + more.className = "assert-details"; + var summary = document.createElement("summary"); + summary.textContent = "Details"; + var pre = document.createElement("pre"); + // It scrolls when long, so it takes focus: the keyboard can scroll it too + pre.setAttribute("tabindex", "0"); + pre.textContent = detail; + more.appendChild(summary); + more.appendChild(pre); + item.appendChild(more); + } + + return item; +} + +function _appendAssertItem(testNode: TestNode, item: HTMLElement) { + testNode.assertions.appendChild(item); + testNode.assertionCount++; + + while (testNode.assertions.childElementCount > MAX_ASSERTIONS_PER_TEST) { + var firstChild = testNode.assertions.firstElementChild; + if (firstChild) { + testNode.assertions.removeChild(firstChild); + } + } + + incrementAssertionCount(); +} + +function _showAssertionCount(assertCountEl: HTMLElement | null) { + if (assertCountEl) { + var count = assertionCount; + assertCountEl.textContent = count + " assertion" + (count === 1 ? "" : "s"); + } +} + export function flushAssertionDomQueue(assertCountEl: HTMLElement | null) { if (!assertionDomQueue.length) { // Still a flush: the frame that was asked for has now run, so the next addAssertion() @@ -400,28 +565,11 @@ export function flushAssertionDomQueue(assertCountEl: HTMLElement | null) { continue; } - var item = document.createElement("li"); - item.className = "assert-item " + (entry.passed ? "assert-pass" : "assert-fail"); - item.textContent = (entry.passed ? "\u2713 " : "\u2717 ") + entry.label + (entry.errorText ? " -> " + entry.errorText : ""); - node.assertions.appendChild(item); - node.assertionCount++; - - while (node.assertions.childElementCount > MAX_ASSERTIONS_PER_TEST) { - var firstChild = node.assertions.firstElementChild; - if (firstChild) { - node.assertions.removeChild(firstChild); - } - } - - incrementAssertionCount(); + _appendAssertItem(node, _createAssertItem(entry.passed, entry.label, entry.errorText, entry.expected, entry.actual)); } _pinEnd(wasPinned); - - if (assertCountEl) { - var count = assertionCount; - assertCountEl.textContent = count + " assertion" + (count === 1 ? "" : "s"); - } + _showAssertionCount(assertCountEl); // Allow the next addAssertion() call to schedule another flush -- without this, only the // assertions queued before this very first rAF callback fired would ever get rendered; @@ -435,25 +583,57 @@ export function appendSyntheticAssertion(testNode: TestNode, passed: boolean, la } var wasPinned = _pinStart(); - var item = document.createElement("li"); - item.className = "assert-item " + (passed ? "assert-pass" : "assert-fail"); - item.textContent = (passed ? "\u2713 " : "\u2717 ") + (label || "(assertion)") + (errorText ? " -> " + errorText : ""); - testNode.assertions.appendChild(item); - testNode.assertionCount++; + _appendAssertItem(testNode, _createAssertItem(passed, label, errorText)); + _pinEnd(wasPinned); + _showAssertionCount(assertCountEl); +} - while (testNode.assertions.childElementCount > MAX_ASSERTIONS_PER_TEST) { - var firstChild = testNode.assertions.firstElementChild; - if (firstChild) { - testNode.assertions.removeChild(firstChild); - } +function _summaryChip(className: string, text: string, tagName?: string): HTMLElement { + var chip = document.createElement(tagName || "span"); + chip.className = "summary-chip " + className; + chip.textContent = text; + (runSummaryEl as HTMLElement).appendChild(chip); + + return chip; +} + +/** + * The run's totals beside the status line: passed, failed and skipped, and once the run is over how + * long it took. While it runs the counts are the page's own, kept as each test finishes; `final` is + * the summary the run reported at the end, which is authoritative. The failed count is a button that + * turns "Failed only" on and off (see client.ts). + */ +export function updateRunSummary(final?: any) { + updateFilterNote(); + if (!runSummaryEl) { + return; } - incrementAssertionCount(); - _pinEnd(wasPinned); + var passed = final ? (final.passed || 0) : runCounts.passed; + var failed = final ? (final.failed || 0) : runCounts.failed; + var skipped = final ? (final.skipped || 0) + (final.pending || 0) : runCounts.skipped; - if (assertCountEl) { - var count = assertionCount; - assertCountEl.textContent = count + " assertion" + (count === 1 ? "" : "s"); + runSummaryEl.textContent = ""; + if (!passed && !failed && !skipped) { + runSummaryEl.hidden = true; + return; + } + + runSummaryEl.hidden = false; + _summaryChip("summary-passed", passed + " passed"); + if (failed) { + var failedChip = _summaryChip("summary-failed", failed + " failed", "button"); + failedChip.setAttribute("type", "button"); + failedChip.setAttribute("title", "Show only the failed tests"); + failedChip.setAttribute("aria-pressed", failedOnlyToggle && failedOnlyToggle.checked ? "true" : "false"); + } + + if (skipped) { + _summaryChip("summary-skipped", skipped + " skipped"); + } + + if (final && typeof final.duration === "number") { + _summaryChip("summary-duration", formatDuration(final.duration)); } } @@ -465,6 +645,7 @@ export function clearResults(assertCountEl: HTMLElement | null) { Object.keys(suiteState).forEach(key => delete suiteState[key]); Object.keys(testState).forEach(key => delete testState[key]); setAssertionCount(0); + resetRunCounts(); assertionDomQueue.length = 0; // The queue is gone, so a frame already requested for it has nothing to do and the flag it // guards must not outlive the entries -- see flushAssertionDomQueue. @@ -473,4 +654,6 @@ export function clearResults(assertCountEl: HTMLElement | null) { if (assertCountEl) { assertCountEl.textContent = "0 assertions"; } + + updateRunSummary(); } diff --git a/internal/runner/src/ux/browser/ui/utils.ts b/internal/runner/src/ux/browser/ui/utils.ts index ae7858f..0c969ce 100644 --- a/internal/runner/src/ux/browser/ui/utils.ts +++ b/internal/runner/src/ux/browser/ui/utils.ts @@ -38,6 +38,16 @@ export function formatCount(value: any, singular: any, plural: any) { return value + " " + (value === 1 ? singular : plural); } +/** A run's or a test's duration for reading: milliseconds under a second, seconds above. */ +export function formatDuration(ms: number) { + return ms < 1000 ? Math.round(ms) + "ms" : (ms / 1000).toFixed(ms < 10000 ? 2 : 1) + "s"; +} + +/** Fires a bubbling `type` event at `el`, as the browser does when the user changes it. */ +export function fireEvent(el: HTMLElement, type: string) { + el.dispatchEvent(new Event(type, { bubbles: true })); +} + export function _wasAtBottom(element: any) { if (!element) { return false; @@ -58,7 +68,7 @@ export function _setDurationText(element: any, duration: any) { } if (typeof duration === "number" && duration > 0) { - element.textContent = duration + "ms"; + element.textContent = formatDuration(duration); element.style.display = "inline-flex"; } else { element.textContent = ""; @@ -67,7 +77,7 @@ export function _setDurationText(element: any, duration: any) { } export function _setStatusClass(element: any, baseName: any, status: any) { - element.classList.remove(baseName + "-running", baseName + "-passed", baseName + "-failed", baseName + "-pending"); + element.classList.remove(baseName + "-running", baseName + "-passed", baseName + "-failed", baseName + "-pending", baseName + "-skipped"); element.classList.add(baseName + "-" + status); } diff --git a/internal/runner/src/ux/index.ts b/internal/runner/src/ux/index.ts index 5412181..5195c56 100644 --- a/internal/runner/src/ux/index.ts +++ b/internal/runner/src/ux/index.ts @@ -12,4 +12,5 @@ export { createBrowserRunnerHtml, createFsDirListingHtml, createTestFrameHtml } // inlined HTML/CSS/favicon graph along with it into that bundle. export { IFsEntry } from "./interfaces/IFsEntry"; export { BrowserUiStyle } from "./types/BrowserUiStyle"; +export { BrowserUiColorScheme, BrowserUiVision, IBrowserUiTheme } from "./types/BrowserUiTheme"; export { default as faviconIco } from "./images/favicon.ico"; diff --git a/internal/runner/src/ux/types/BrowserUiTheme.ts b/internal/runner/src/ux/types/BrowserUiTheme.ts new file mode 100644 index 0000000..429ddcf --- /dev/null +++ b/internal/runner/src/ux/types/BrowserUiTheme.ts @@ -0,0 +1,35 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +/** + * The interactive page's colour scheme: `"system"` follows the operating system's light or dark + * setting, and `"dim"` is a low-light dark scheme with lower contrast and warmer colours. + */ +export type BrowserUiColorScheme = "system" | "light" | "dark" | "dim"; + +/** + * The colours the interactive page shows passed and failed in: `"colorblind"` uses blue and orange, + * which every kind of color blindness tells apart, in place of green and red. + */ +export type BrowserUiVision = "standard" | "colorblind"; + +/** + * How the interactive browser page looks when it opens. Each part is optional; a part left out is the + * page's default (`"system"`, `"standard"`, a zoom of 100). A choice made in the page's own + * Appearance panel is remembered in that browser and takes precedence over this. + */ +export interface IBrowserUiTheme { + scheme?: BrowserUiColorScheme; + vision?: BrowserUiVision; + + /** + * The page's zoom, as a whole percentage from 50 to 300. It scales the page's own controls, + * results and log -- not the browser's zoom, and not a test's own frame or content. + */ + zoom?: number; +} diff --git a/internal/runner/test/src/artifacts/binGuard.test.ts b/internal/runner/test/src/artifacts/binGuard.test.ts index 9a67c1b..a8f511f 100644 --- a/internal/runner/test/src/artifacts/binGuard.test.ts +++ b/internal/runner/test/src/artifacts/binGuard.test.ts @@ -9,11 +9,12 @@ import * as fs from "fs"; import * as path from "path"; import * as nodeModule from "module"; +import * as ts from "typescript"; import { assert } from "@nevware21/tripwire"; import { TraceMap, originalPositionFor } from "@jridgewell/trace-mapping"; /* - * BIN-GUARD. Three properties of the shipped `bin` artifacts that were each broken once and + * BIN-GUARD. Five properties of the shipped `bin` artifacts that were each broken once and * that nothing else asserts: * * 1. BIN-DEPS -- the bundles must be self-contained. Everything statically imported is @@ -34,6 +35,16 @@ import { TraceMap, originalPositionFor } from "@jridgewell/trace-mapping"; * script run `node` explicitly, which is how the first release to declare `bin` nearly * shipped without one. * + * 4. BIN-SCOPE -- the browser and worker bundles must declare nothing in the global scope they + * share with the tests. Minified with uglify's `toplevel`, each iife became a bare block + * whose `var`s were globals under two-letter names; ts-utils' "call" landed on `it`, the + * test frame's own global `it` replaced it, and every run in the page stopped at its first + * hasOwnProperty check. + * + * 5. BIN-STRING -- the minifier must leave `String(x)` alone. Its `unsafe` option rewrote every + * one as `""+x`, which throws for a Symbol, so each assertion about a symbol failed with + * "Cannot convert a Symbol value to a string" once the interactive page described its values. + * * These run against the built artifacts in `core/bin`, which are necessarily present -- the * runner suite is itself executed by `core/bin/tripwire.js`. */ @@ -145,6 +156,34 @@ describe("bin guard", () => { }); }); + describe("BIN-SCOPE -- the browser and worker bundles keep their names to themselves", () => { + it("should hold nothing at the top level but expressions, so every name is inside a function", () => { + for (let lp = 1; lp < BUNDLES.length; lp++) { + const bundle = BUNDLES[lp]; + const source = ts.createSourceFile(bundle.name, fs.readFileSync(bundle.code, "utf8"), ts.ScriptTarget.Latest, false, ts.ScriptKind.JS); + const declaring = source.statements + .filter((statement) => !ts.isExpressionStatement(statement)) + .map((statement) => ts.SyntaxKind[statement.kind]); + + assert.isTrue(source.statements.length > 0, bundle.name + " has code"); + assert.deepEqual(declaring, [], bundle.name + "'s top level: a block, `var` or function there is a global the tests can replace"); + } + }); + }); + + describe("BIN-STRING -- the bundles convert values to text as their source does", () => { + it("should keep String(x) rather than rewrite it as \"\"+x, which throws for a Symbol", () => { + for (let lp = 0; lp < BUNDLES.length; lp++) { + const bundle = BUNDLES[lp]; + const code = fs.readFileSync(bundle.code, "utf8"); + const calls = (code.match(/\bString\(/g) || []).length; + const concats = (code.match(/""\+/g) || []).length; + + assert.isTrue(calls > concats, bundle.name + " has " + calls + " String( calls and " + concats + " \"\"+ conversions: is uglify's `unsafe` back on?"); + } + }); + }); + describe("the artifacts exist", () => { it("should have all three bundles and their maps built", () => { for (let lp = 0; lp < BUNDLES.length; lp++) { diff --git a/internal/runner/test/src/cli/parseArgs.test.ts b/internal/runner/test/src/cli/parseArgs.test.ts index 54ef05e..a4e282e 100644 --- a/internal/runner/test/src/cli/parseArgs.test.ts +++ b/internal/runner/test/src/cli/parseArgs.test.ts @@ -54,7 +54,7 @@ describe("CLI argument parsing", () => { "-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", + "--env": "env", "--browser-launch": "browser-launch", "--browser-style": "browser-style", "--browser-theme": "browser-theme", "--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" }; @@ -437,6 +437,37 @@ describe("CLI argument parsing", () => { }); }); + describe("--browser-theme and browserTheme", () => { + it("should leave the page's own defaults when neither is given", () => { + assert.isUndefined(parse(["spec.ts"]).browserTheme); + }); + + it("should read each part from a comma separated list, ignoring case and spaces", () => { + assert.deepEqual(parse(["spec.ts", "--browser-theme", "Dim, COLORBLIND ,125%"]).browserTheme, { scheme: "dim", vision: "colorblind", zoom: 125 }); + assert.deepEqual(parse(["spec.ts", "--browser-theme=150%"]).browserTheme, { zoom: 150 }); + }); + + it("should take the config file's, as a list or an array", () => { + assert.deepEqual(parse(["spec.ts"], { browserTheme: "dark,80%" }).browserTheme, { scheme: "dark", zoom: 80 }); + assert.deepEqual(parse(["spec.ts"], { browserTheme: [ "light", "colorblind" ] }).browserTheme, { scheme: "light", vision: "colorblind" }); + }); + + it("should replace only the parts of the config file's that the flag names", () => { + const parsed = parse(["spec.ts", "--browser-theme", "light"], { browserTheme: "dark,colorblind,125%" }); + + assert.deepEqual(parsed.browserTheme, { scheme: "light", vision: "colorblind", zoom: 125 }); + }); + + it("should reject an unknown word, a zoom out of range, or two words for the same part, from either", () => { + assert.includes(parse(["spec.ts", "--browser-theme", "dark,neon"]).error, "Invalid value for --browser-theme: unknown word \"neon\""); + assert.includes(parse(["spec.ts", "--browser-theme", "dark,400%"]).error, "Invalid value for --browser-theme: zoom \"400%\" is outside 50% to 300%"); + assert.includes(parse(["spec.ts", "--browser-theme", "dark,light"]).error, "\"dark\" and \"light\" are both a color scheme"); + assert.includes(parse(["spec.ts"], { browserTheme: "huge" }).error, "Invalid browserTheme value in the config file: unknown word \"huge\""); + assert.includes(parse(["spec.ts"], { browserTheme: [ "dim", "25%" ] }).error, "Invalid browserTheme value in the config file: zoom \"25%\" is outside 50% to 300%"); + assert.includes(parse(["spec.ts", "--browser-theme"]).error, "Missing value for --browser-theme"); + }); + }); + describe("--project", () => { it("should default to tsconfig.json", () => { assert.equal(parse(["spec.ts"]).project, "tsconfig.json"); diff --git a/internal/runner/test/src/client/workerRunner.test.ts b/internal/runner/test/src/client/workerRunner.test.ts index 0442433..b55f113 100644 --- a/internal/runner/test/src/client/workerRunner.test.ts +++ b/internal/runner/test/src/client/workerRunner.test.ts @@ -136,6 +136,8 @@ describe("client worker runner", function () { const failed = asserts.filter((msg) => !msg.passed)[0]; assert.equal(failed.testName, "fails"); assert.includes(failed.errorText, "expected 1 to equal 2"); + assert.equal(failed.expected, "2", "the values go with the failure, for the page to show"); + assert.equal(failed.actual, "1"); }); it("sends the reporter's console output to the page", async () => { diff --git a/internal/runner/test/src/docs/genTourImages.test.ts b/internal/runner/test/src/docs/genTourImages.test.ts new file mode 100644 index 0000000..44a7aaa --- /dev/null +++ b/internal/runner/test/src/docs/genTourImages.test.ts @@ -0,0 +1,110 @@ +/* + * @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 path from "path"; +import { assert } from "@nevware21/tripwire"; + +// docs/scripts/genTourImages.js makes the images in docs/tour.md from real runs. It is plain node +// JavaScript outside any project, so it is reached with require(). +// eslint-disable-next-line @typescript-eslint/no-var-requires +const gen = require("../../../../../docs/scripts/genTourImages"); + +const ESC = "\x1b["; + +/** + * Covers the tour's image generator without running it: how a capture is cleaned of the machine it + * ran on before it is drawn, how the commands are shown, and that the committed images are the ones + * the page shows. + */ +describe("docs genTourImages", () => { + describe("displayCommand", () => { + it("quotes the arguments a shell would expand, and nothing else", () => { + assert.equal(gen.displayCommand("npx tripwire", [ "--target", "browser", "test/src/!(node|worker)/**/*.test.ts" ]), + "npx tripwire --target browser \"test/src/!(node|worker)/**/*.test.ts\""); + assert.equal(gen.displayCommand("npx tripwire", [ "test/a.test.ts" ]), "npx tripwire test/a.test.ts"); + }); + }); + + describe("cleanOutput", () => { + const cwd = "C:\\work\\repo\\core"; + + it("drops the lines that only say how the run was set up", () => { + const raw = [ + "Matched 3 file(s)", + "Launching browser target via Puppeteer...", + " Module graph: 113 modules processed, 113 unique in graph (3426ms)", + "Ready to run tests after 6217ms (prep: 2791ms, compile: 3426ms)", + "Server URL: http://127.0.0.1:55316/", + "Compile cache: reused -- nothing changed", + "Type-checking TypeScript project: " + cwd + "\\test\\tsconfig.test.json", + "Type-check completed in 231ms", + "", + " Running tests...", + "", + " " + ESC + "32m17 passing" + ESC + "39m (44ms)", + "Run summary: total=17 passed=17", + "Total elapsed: 8864ms" + ].join("\n"); + + assert.deepEqual(gen.cleanOutput(raw, cwd), [ + "Launching browser target via Puppeteer...", + "", + " " + ESC + "32m17 passing" + ESC + "39m (44ms)", + "Total elapsed: 8864ms" + ]); + }); + + it("shows the working directory as \".\", in either slash direction, with forward slashes", () => { + assert.deepEqual(gen.cleanOutput("Wrote coverage: " + cwd + "\\coverage\\browser\\coverage-final.json", cwd), + [ "Wrote coverage: ./coverage/browser/coverage-final.json" ]); + assert.deepEqual(gen.cleanOutput("see C:/work/repo/core/coverage", cwd), [ "see ./coverage" ]); + }); + + it("keeps at most one blank line in a row, and none at either end", () => { + assert.deepEqual(gen.cleanOutput("\n\na\n\n\n\nb\n\n", cwd), [ "a", "", "b" ]); + }); + }); + + describe("transcript", () => { + it("puts each step's command after a prompt, its output after that, and a blank line between steps", () => { + const lines = gen.transcript([ + { display: "npx tripwire a", output: "one\n", cwd: "/x" }, + { display: "npx tripwire b", output: "Matched 1 file(s)\ntwo", cwd: "/x" } + ]); + + assert.deepEqual(lines, [ gen.PROMPT + "npx tripwire a", "one", "", gen.PROMPT + "npx tripwire b", "two" ]); + }); + }); + + describe("the committed images", () => { + const repo = gen.REPO; + const tour = fs.readFileSync(path.join(repo, "docs", "tour.md"), "utf8"); + const images = fs.readdirSync(gen.OUT_DIR); + + it("are each shown by docs/tour.md, and every image it shows exists", () => { + images.forEach((name: string) => { + assert.isTrue(tour.indexOf("images/tour/" + name) !== -1, name + " is shown"); + }); + + const shown = (tour.match(/images\/tour\/[\w.-]+/g) || []).map((link: string) => link.substring("images/tour/".length)); + shown.forEach((name: string) => { + assert.isTrue(images.indexOf(name) !== -1, name + " exists"); + }); + }); + + it("carry nothing of the machine they were made on", () => { + images.filter((name: string) => /\.svg$/.test(name)).forEach((name: string) => { + const svg = fs.readFileSync(path.join(gen.OUT_DIR, name), "utf8"); + assert.equal(svg.indexOf(repo), -1, name + " has no absolute path"); + assert.equal(svg.indexOf(repo.replace(/\\/g, "/")), -1, name + " has no absolute path"); + assert.isFalse(/Server URL|127\.0\.0\.1/.test(svg), name + " has no server address"); + }); + }); + }); +}); diff --git a/internal/runner/test/src/execute/assets.test.ts b/internal/runner/test/src/execute/assets.test.ts index 4eacce7..a1ac1b6 100644 --- a/internal/runner/test/src/execute/assets.test.ts +++ b/internal/runner/test/src/execute/assets.test.ts @@ -98,8 +98,8 @@ describe("execute assets", () => { 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" + "browser/resources/styles/base.css": "text/css; charset=utf-8", + "browser/resources/templates/runner.html": "text/html; charset=utf-8" }; Object.keys(types).forEach((name) => { diff --git a/internal/runner/test/src/execute/browserRunner.test.ts b/internal/runner/test/src/execute/browserRunner.test.ts index 5034bfb..3d28c51 100644 --- a/internal/runner/test/src/execute/browserRunner.test.ts +++ b/internal/runner/test/src/execute/browserRunner.test.ts @@ -157,6 +157,14 @@ describe("execute browserRunner", function () { }); }); + it("opens the runner page as --browser-theme says", async () => { + await withSession({ browserTheme: { scheme: "dim", zoom: 150 } }, builders(makeGraph()), async (session) => { + const browser = await send(session.server, "GET", "/__tripwire__/browser"); + + assert.includes(browser.body, "data-default-theme=\"dim\" data-default-zoom=\"150\">"); + }); + }); + 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"); diff --git a/internal/runner/test/src/shared/assertionValues.test.ts b/internal/runner/test/src/shared/assertionValues.test.ts new file mode 100644 index 0000000..e3bcf81 --- /dev/null +++ b/internal/runner/test/src/shared/assertionValues.test.ts @@ -0,0 +1,203 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import { assert, expect } from "@nevware21/tripwire"; +import { addAssertionValues, formatAssertionValue, getAssertionValues } from "../../../src/shared/assertionValues"; + +/** + * The expected and actual values the interactive page shows beside a failed assertion. The browser + * and worker targets read them from the error where the assertion failed and send them on as text, + * so what is pinned here is that text, and which errors have values at all. + */ +describe("assertionValues", () => { + + /** What a failing assertion threw. */ + function thrownBy(fn: () => void): any { + try { + fn(); + } catch (e) { + return e; + } + + assert.fail("expected the assertion to fail"); + } + + describe("formatAssertionValue", () => { + it("should write strings, objects and arrays as JSON, and other primitives as they read in code", () => { + assert.equal(formatAssertionValue("text"), "\"text\""); + assert.equal(formatAssertionValue({ a: [ 1, "b" ] }), "{\"a\":[1,\"b\"]}"); + assert.equal(formatAssertionValue(42), "42"); + assert.equal(formatAssertionValue(NaN), "NaN", "not JSON's null"); + assert.equal(formatAssertionValue(true), "true"); + assert.equal(formatAssertionValue(null), "null"); + assert.equal(formatAssertionValue(undefined), "undefined"); + assert.equal(formatAssertionValue(Symbol("s")), "Symbol(s)"); + }); + + it("should name a function, and write a cycle or a deep value without following it", () => { + function named() { + // Only its name is shown + } + + assert.equal(formatAssertionValue(named), "[Function: named]"); + assert.equal(formatAssertionValue(() => 1), "[Function]", "an arrow function assigned nowhere has no name"); + + const cycle: any = { }; + cycle.self = cycle; + assert.equal(formatAssertionValue(cycle), "{\"self\":[Circular]}"); + assert.equal(formatAssertionValue({ a: { b: { c: { d: { e: 1 } } } } }), "{\"a\":{\"b\":{\"c\":{\"d\":[Object]}}}}"); + assert.equal(formatAssertionValue([ [ [ [ [ 1 ] ] ] ] ]), "[[[[[Array]]]]]"); + }); + + it("should write symbols, which cannot be concatenated into a string, as they read in code", () => { + assert.equal(formatAssertionValue(Symbol()), "Symbol()"); + assert.equal(formatAssertionValue(Symbol.iterator), "Symbol(Symbol.iterator)"); + assert.equal(formatAssertionValue({ s: Symbol("in") }), "{\"s\":Symbol(in)}"); + assert.equal(formatAssertionValue([ Symbol("x") ]), "[Symbol(x)]", "not JSON's null"); + }); + + it("should run nothing of the value's own -- no getter, setter, toJSON or toString -- as it runs inside the assertion", () => { + let runs = 0; + const value = { + plain: 1, + get lazy() { + runs++; + return 2; + }, + set only(_: any) { + runs++; + }, + get both() { + runs++; + return 3; + }, + set both(_: any) { + runs++; + }, + toJSON: () => { + runs++; + return "json"; + }, + toString: () => { + runs++; + return "text"; + } + }; + + assert.equal(formatAssertionValue(value), + "{\"plain\":1,\"lazy\":[Getter],\"only\":[Setter],\"both\":[Getter/Setter],\"toJSON\":[Function: toJSON],\"toString\":[Function: toString]}"); + assert.equal(runs, 0); + + const fn = function() { + // Its name is a getter that throws + }; + Object.defineProperty(fn, "name", { + get: () => { + runs++; + throw new Error("name getter throws"); + } + }); + assert.equal(formatAssertionValue(fn), "[Function]"); + assert.equal(runs, 0, "not even a function's name getter"); + }); + + it("should never throw: not for a proxy whose traps throw, nor a revoked one", () => { + const hostile = new Proxy({}, { + ownKeys: () => { + throw new Error("no keys"); + } + }); + assert.equal(formatAssertionValue(hostile), "[object]"); + + const revocable = Proxy.revocable({}, {}); + revocable.revoke(); + assert.equal(formatAssertionValue(revocable.proxy), "[object]"); + }); + + it("should write a date as its ISO time and a regular expression as its literal", () => { + assert.equal(formatAssertionValue(new Date(0)), "1970-01-01T00:00:00.000Z"); + assert.equal(formatAssertionValue(new Date(NaN)), "Invalid Date"); + assert.equal(formatAssertionValue({ at: /a+b/gi }), "{\"at\":/a+b/gi}"); + }); + + it("should cut a very long value short", () => { + const text = formatAssertionValue("x".repeat(5000)); + + assert.equal(text.length, 1003); + assert.isTrue(text.endsWith("...")); + }); + + it("should stop writing a long array or object once it is too long to show", () => { + const big: any = {}; + for (let lp = 0; lp < 20000; lp++) { + big["key" + lp] = lp; + } + + [ new Array(100000).fill(12345), big ].forEach((value) => { + const text = formatAssertionValue(value); + assert.equal(text.length, 1003); + assert.isTrue(text.endsWith("...")); + }); + }); + }); + + describe("getAssertionValues", () => { + it("should read both values from a failed equality", () => { + assert.deepEqual(getAssertionValues(thrownBy(() => assert.equal(1, 2))), { expected: "2", actual: "1" }); + assert.deepEqual(getAssertionValues(thrownBy(() => assert.deepEqual([ 1 ], [ 2 ]))), { expected: "[2]", actual: "[1]" }); + }); + + it("should read only the value an assertion has", () => { + assert.deepEqual(getAssertionValues(thrownBy(() => assert.isTrue(false))), { actual: "false" }); + }); + + it("should read a symbol an assertion failed on, which failed every such test in the page when it threw", () => { + assert.deepEqual(getAssertionValues(thrownBy(() => expect(Symbol()).is.iterable())), { actual: "Symbol()" }); + assert.deepEqual(getAssertionValues(thrownBy(() => assert.isExtensible(Symbol("s")))), { actual: "Symbol(s)" }); + }); + + it("should have nothing, rather than throw, for an error of the test's own whose values throw", () => { + const error = { + get expected(): any { + throw new Error("no"); + } + }; + + assert.isNull(getAssertionValues(error)); + }); + + it("should keep an undefined value beside a defined one", () => { + assert.deepEqual(getAssertionValues({ expected: 2, actual: undefined }), { expected: "2", actual: "undefined" }); + }); + + it("should have nothing for an error without values, or whose values were never set", () => { + assert.isNull(getAssertionValues(new Error("thrown by the code under test"))); + assert.isNull(getAssertionValues(thrownBy(() => assert.fail("nope"))), "fail() has an actual, never set"); + assert.isNull(getAssertionValues({ actual: undefined, expected: undefined })); + assert.isNull(getAssertionValues(null)); + assert.isNull(getAssertionValues("a string")); + }); + }); + + describe("addAssertionValues", () => { + it("should add the values an error has to the event, and return it", () => { + const event = { kind: "assert" }; + + const result = addAssertionValues(event, thrownBy(() => assert.equal("a", "b"))); + + assert.equal(result, event); + assert.deepEqual(event, { kind: "assert", expected: "\"b\"", actual: "\"a\"" }); + }); + + it("should leave the event alone for an error without values, or no error", () => { + assert.deepEqual(addAssertionValues({ kind: "assert" }, thrownBy(() => assert.isTrue(false))), { kind: "assert", actual: "false" }); + assert.deepEqual(addAssertionValues({ kind: "assert" }, new Error("thrown")), { kind: "assert" }); + assert.deepEqual(addAssertionValues({ kind: "assert" }, undefined), { kind: "assert" }); + }); + }); +}); diff --git a/internal/runner/test/src/shared/browserTheme.test.ts b/internal/runner/test/src/shared/browserTheme.test.ts new file mode 100644 index 0000000..67537ce --- /dev/null +++ b/internal/runner/test/src/shared/browserTheme.test.ts @@ -0,0 +1,68 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import { assert } from "@nevware21/tripwire"; +import { BROWSER_THEME_WORDS, BROWSER_ZOOM_MAX, BROWSER_ZOOM_MIN, mergeBrowserThemes, parseBrowserTheme } from "../../../src/shared/browserTheme"; + +/** + * How the interactive page opens, as `--browser-theme` and the config file's `browserTheme` say it: + * a list of words, each naming one of three parts -- the color scheme, the color vision setting and + * the zoom. + */ +describe("browserTheme", () => { + describe("parseBrowserTheme", () => { + it("should name each part by the words that belong to it", () => { + assert.deepEqual(parseBrowserTheme("system"), { theme: { scheme: "system" } }); + assert.deepEqual(parseBrowserTheme("dim,standard,125%"), { theme: { scheme: "dim", vision: "standard", zoom: 125 } }); + assert.deepEqual(parseBrowserTheme([ "80%", "colorblind" ]), { theme: { zoom: 80, vision: "colorblind" } }); + }); + + it("should take any whole percentage from the smallest zoom to the largest, and nothing outside", () => { + assert.deepEqual([ BROWSER_ZOOM_MIN, BROWSER_ZOOM_MAX ], [ 50, 300 ]); + assert.deepEqual(parseBrowserTheme("50%,"), { theme: { zoom: 50 } }); + assert.deepEqual(parseBrowserTheme("300%"), { theme: { zoom: 300 } }); + assert.deepEqual(parseBrowserTheme("133%"), { theme: { zoom: 133 } }, "not only the panel's steps"); + + assert.equal(parseBrowserTheme("49%").error, "zoom \"49%\" is outside 50% to 300%"); + assert.equal(parseBrowserTheme("dark,301%").error, "zoom \"301%\" is outside 50% to 300%"); + assert.includes(parseBrowserTheme("125").error, "unknown word \"125\"", "a zoom says it is a percentage"); + assert.includes(parseBrowserTheme("12.5%").error, "unknown word \"12.5%\"", "a whole one"); + }); + + it("should ignore case, spaces and empty entries, and the same word twice", () => { + assert.deepEqual(parseBrowserTheme(" Dark ,, 150% , dark, 150%"), { theme: { scheme: "dark", zoom: 150 } }); + assert.deepEqual(parseBrowserTheme(""), { theme: {} }); + }); + + it("should report a word it does not know, listing the ones it does", () => { + const parsed = parseBrowserTheme("dark,sepia"); + + assert.isUndefined(parsed.theme); + assert.includes(parsed.error, "unknown word \"sepia\""); + assert.includes(parsed.error, BROWSER_THEME_WORDS.join(", ") + ", or a zoom from 50% to 300%"); + assert.includes(parseBrowserTheme("constructor").error, "unknown word \"constructor\"", "not an inherited property"); + }); + + it("should report two words for the same part", () => { + assert.equal(parseBrowserTheme("light,dim").error, "\"light\" and \"dim\" are both a color scheme"); + assert.equal(parseBrowserTheme("standard,colorblind").error, "\"standard\" and \"colorblind\" are both a color vision setting"); + assert.equal(parseBrowserTheme("125%,150%").error, "\"125%\" and \"150%\" are both a zoom"); + }); + }); + + describe("mergeBrowserThemes", () => { + it("should take the override's parts, and the base's for the rest", () => { + assert.deepEqual(mergeBrowserThemes({ scheme: "dark", vision: "colorblind" }, { scheme: "light", zoom: 125 }), { scheme: "light", vision: "colorblind", zoom: 125 }); + }); + + it("should cope with either missing", () => { + assert.deepEqual(mergeBrowserThemes(undefined, { scheme: "dim" }), { scheme: "dim" }); + assert.deepEqual(mergeBrowserThemes({ zoom: 80 }, undefined), { zoom: 80 }); + }); + }); +}); diff --git a/internal/runner/test/src/support/uxDom.ts b/internal/runner/test/src/support/uxDom.ts index cf88301..9072f80 100644 --- a/internal/runner/test/src/support/uxDom.ts +++ b/internal/runner/test/src/support/uxDom.ts @@ -59,10 +59,14 @@ function _fire(owner: any, evt: any, capture: boolean): void { } /** - * Whether `el` matches one simple selector: a tag name, `.class`, `#id`, or an attribute test such as - * `[role='menuitem']` or `[hidden]`. + * Whether `el` matches a simple selector -- a tag name, `.class`, `#id`, or an attribute test such as + * `[role='menuitem']` or `[hidden]` -- or any one of a comma separated list of them. */ function _matches(el: any, selector: string): boolean { + if (selector.indexOf(",") !== -1) { + return selector.split(",").some((part) => _matches(el, part)); + } + const sel = selector.trim(); const attr = /^\[([\w-]+)(?:=['"]?([^'"\]]*)['"]?)?\]$/.exec(sel); if (attr) { @@ -80,6 +84,31 @@ function _matches(el: any, selector: string): boolean { return String(el.tagName || "").toLowerCase() === sel.toLowerCase(); } +/** + * An element's `style`: plain properties, as the page sets `style.display`, and the custom ones it + * sets through `setProperty` (`--ui-zoom`), kept in `props`. The methods are not enumerable, so a + * deep comparison of a style sees only what the page set on it directly. + */ +function _stubStyle(): any { + const style: any = {}; + const props: { [name: string]: string } = {}; + const hidden = (name: string, value: any) => Object.defineProperty(style, name, { value: value, enumerable: false }); + + hidden("props", props); + hidden("setProperty", (name: string, value: any) => { + props[name] = String(value); + }); + hidden("getPropertyValue", (name: string) => (name in props ? props[name] : "")); + hidden("removeProperty", (name: string) => { + const was = name in props ? props[name] : ""; + delete props[name]; + + return was; + }); + + return style; +} + /** * 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 @@ -109,7 +138,7 @@ export function stubElement(id: string, tagName?: string): any { checked: false, disabled: false, open: false, - style: {}, + style: _stubStyle(), attrs: attrs, kids: [] as any[], listeners: listeners, @@ -190,7 +219,10 @@ export function stubElement(id: string, tagName?: string): any { path.push(node); } - evt.target = evt.target || el; + if (!evt.target) { + // A real Event (as the page fires with `new Event`) has a read-only target until dispatched + Object.defineProperty(evt, "target", { value: el, configurable: true, writable: true }); + } for (let lp = path.length - 1; lp >= 0 && !evt.propagationStopped; lp--) { _fire(path[lp], evt, true); } @@ -284,6 +316,23 @@ export function stubElement(id: string, tagName?: string): any { return el; } +/** A text node: just its text, read the way {@link textOf} reads an element's. */ +export function stubTextNode(text: string): any { + return { nodeType: 3, textContent: String(text), kids: [] as any[] }; +} + +/** + * The text of an element and everything under it that the page shows on screen: without the text + * that is there for a screen reader only (`.sr-only`). + */ +export function shownTextOf(el: any): string { + if (!el || (el.classList && el.classList.contains("sr-only"))) { + return ""; + } + + return (el.textContent || "") + (el.kids || []).map(shownTextOf).join(""); +} + /** The text of an element and everything under it, the way a page would show it. */ export function textOf(el: any): string { if (!el) { @@ -301,11 +350,16 @@ export function textOf(el: any): string { export function stubDocument(registry?: { [id: string]: any }, createMissing?: boolean): any { const ids = registry || {}; const listeners: { [type: string]: IListener[] } = {}; + const root = stubElement("html", "html"); const doc: any = { body: stubElement("body", "body"), + documentElement: root, + // What the results keep pinned to the bottom: they scroll with the page + scrollingElement: root, activeElement: null, listeners: listeners, createElement: (tag: string) => stubElement("", tag), + createTextNode: stubTextNode, createDocumentFragment: () => { const fragment: any = { fragment: true, kids: [] as any[] }; fragment.appendChild = (child: any) => { diff --git a/internal/runner/test/src/ux/ansi.test.ts b/internal/runner/test/src/ux/ansi.test.ts new file mode 100644 index 0000000..085f4d5 --- /dev/null +++ b/internal/runner/test/src/ux/ansi.test.ts @@ -0,0 +1,150 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import { assert } from "@nevware21/tripwire"; +import { appendAnsiSegments, IAnsiSegment, toAnsiSegments } from "../../../src/ux/browser/ui/ansi"; +import { stubElement, textOf, useStubDocument } from "../support/uxDom"; + +/** + * The terminal colour codes the page's log panel draws: which codes become which classes or CSS + * colours, which are dropped, and how the runs between them are put on the page. + */ +describe("ux ansi", () => { + + /** Each run as "classes=text", with any CSS colour after the classes. */ + function runs(segments: IAnsiSegment[]): string[] { + return segments.map((segment) => segment.classes.concat( + segment.color ? [ "color:" + segment.color ] : [], + segment.background ? [ "background:" + segment.background ] : [] + ).join(" ") + "=" + segment.text); + } + + describe("toAnsiSegments", () => { + it("should leave text without codes as one plain run, and nothing as no runs", () => { + assert.deepEqual(toAnsiSegments("plain [text]"), [ { text: "plain [text]", classes: [] } ]); + assert.deepEqual(toAnsiSegments(""), []); + assert.deepEqual(toAnsiSegments("\u001b[0m"), [], "a code with no text around it"); + }); + + it("should colour the text after a foreground code until it is reset", () => { + assert.deepEqual(runs(toAnsiSegments("\u001b[32m✓ passes\u001b[39m \u001b[90m(3ms)\u001b[0m done")), [ + "ansi-fg-32=✓ passes", + "= ", + "ansi-fg-90=(3ms)", + "= done" + ]); + }); + + it("should know the 8 standard and 8 bright colours, for the text and behind it", () => { + assert.deepEqual(runs(toAnsiSegments("\u001b[30ma\u001b[37mb\u001b[97mc\u001b[41md\u001b[107me\u001b[49mf")), [ + "ansi-fg-30=a", + "ansi-fg-37=b", + "ansi-fg-97=c", + "ansi-fg-97 ansi-bg-41=d", + "ansi-fg-97 ansi-bg-107=e", + "ansi-fg-97=f" + ]); + }); + + it("should combine codes in one sequence, and turn each style off on its own", () => { + assert.deepEqual(runs(toAnsiSegments("\u001b[1;3;4;31mall\u001b[22mlight\u001b[2mdim\u001b[23;24mplain\u001b[22mnormal")), [ + "ansi-fg-31 ansi-bold ansi-italic ansi-underline=all", + "ansi-fg-31 ansi-italic ansi-underline=light", + "ansi-fg-31 ansi-dim ansi-italic ansi-underline=dim", + "ansi-fg-31 ansi-dim=plain", + "ansi-fg-31=normal" + ]); + }); + + it("should reset everything for a 0, an empty code, or a sequence with no codes", () => { + assert.deepEqual(runs(toAnsiSegments("\u001b[1;31;42ma\u001b[0mb\u001b[1;31mc\u001b[;mD\u001b[33md\u001b[me")), [ + "ansi-fg-31 ansi-bg-42 ansi-bold=a", + "=b", + "ansi-fg-31 ansi-bold=c", + "=D", + "ansi-fg-33=d", + "=e" + ]); + }); + + it("should use the colour classes for the first 16 of the 256 colours, and CSS for the rest", () => { + assert.deepEqual(runs(toAnsiSegments("\u001b[38;5;1ma\u001b[38;5;9mb\u001b[48;5;4mc\u001b[48;5;12md")), [ + "ansi-fg-31=a", + "ansi-fg-91=b", + "ansi-fg-91 ansi-bg-44=c", + "ansi-fg-91 ansi-bg-104=d" + ]); + assert.deepEqual(runs(toAnsiSegments("\u001b[38;5;208mcube\u001b[48;5;244mgrey\u001b[39;49mplain")), [ + "color:rgb(255,135,0)=cube", + "color:rgb(255,135,0) background:rgb(128,128,128)=grey", + "=plain" + ]); + }); + + it("should use 24-bit colours as they are, carrying on with the codes after one", () => { + assert.deepEqual(runs(toAnsiSegments("\u001b[38;2;10;20;30;1ma\u001b[48;2;255;255;255mb")), [ + "ansi-bold color:rgb(10,20,30)=a", + "ansi-bold color:rgb(10,20,30) background:rgb(255,255,255)=b" + ]); + }); + + it("should ignore an extended colour it cannot read", () => { + assert.deepEqual(runs(toAnsiSegments("\u001b[32m\u001b[38;5ma\u001b[38;2;300;0;0mb\u001b[38;9;1mc\u001b[38;5;256md")), [ + "ansi-fg-32=abcd" + ], "each leaves the colour as it was, so the text is one run"); + }); + + it("should drop a hyperlink's OSC wrapper, keeping its text in the colour around it", () => { + const link = "\u001b]8;;https://example.com/docs\u0007the docs\u001b]8;;\u0007"; + + assert.deepEqual(runs(toAnsiSegments("see " + link + " for more")), [ "=see the docs for more" ]); + assert.deepEqual(runs(toAnsiSegments("\u001b[33m" + link + "\u001b[39m!")), [ "ansi-fg-33=the docs", "=!" ]); + }); + + it("should read the one-byte CSI as it does ESC [", () => { + assert.deepEqual(runs(toAnsiSegments("\u009b31mred\u009b0m plain")), [ "ansi-fg-31=red", "= plain" ]); + }); + + it("should make one run of neighbours drawn the same way", () => { + assert.deepEqual(runs(toAnsiSegments("\u001b[31mone \u001b[31mtwo\u001b[1m three")), [ "ansi-fg-31=one two", "ansi-fg-31 ansi-bold= three" ]); + assert.deepEqual(runs(toAnsiSegments("\u001b[38;5;208ma\u001b[38;5;208mb\u001b[38;5;209mc")), [ "color:rgb(255,135,0)=ab", "color:rgb(255,135,95)=c" ]); + }); + + it("should drop cursor, erase and other sequences that are not about how text looks", () => { + assert.deepEqual(runs(toAnsiSegments("\u001b[2K\u001b[1G\u001b[?25lline\u001b[?25h")), [ "=line" ]); + assert.deepEqual(runs(toAnsiSegments("\u001b[31m\u001b[3Amoved\u001b[0m")), [ "ansi-fg-31=moved" ], "they leave the colour alone"); + }); + }); + + describe("appendAnsiSegments", () => { + let restoreDocument: () => void; + + beforeEach(() => { + restoreDocument = useStubDocument().restore; + }); + + afterEach(() => { + restoreDocument(); + }); + + it("should append plain runs as text and styled ones as spans", () => { + const line = stubElement("", "div"); + line.textContent = "[log] "; + + appendAnsiSegments(line, toAnsiSegments("\u001b[1;32mok\u001b[0m and \u001b[38;5;208;48;2;1;2;3morange")); + + assert.equal(textOf(line), "[log] ok and orange"); + assert.deepEqual(line.kids.map((part: any) => [ part.tagName || "#text", part.className || "", part.textContent ]), [ + [ "SPAN", "ansi-fg-32 ansi-bold", "ok" ], + [ "#text", "", " and " ], + [ "SPAN", "", "orange" ] + ]); + assert.deepEqual(line.kids[2].style, { color: "rgb(255,135,0)", backgroundColor: "rgb(1,2,3)" }); + }); + }); +}); diff --git a/internal/runner/test/src/ux/appearance.test.ts b/internal/runner/test/src/ux/appearance.test.ts new file mode 100644 index 0000000..9c380c0 --- /dev/null +++ b/internal/runner/test/src/ux/appearance.test.ts @@ -0,0 +1,358 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import { assert } from "@nevware21/tripwire"; +import { createAppearancePanel, IAppearancePanel, themeCandidates } from "../../../src/ux/browser/ui/appearance"; +import { THEME_STORAGE_KEY, VISION_STORAGE_KEY, ZOOM_STORAGE_KEY } from "../../../src/ux/browser/ui/theme"; +import { stubElement, stubEvent, useStubDocument } from "../support/uxDom"; + +/** + * The Appearance panel the actions menu opens: a searchable theme combobox, the color-blind toggle and + * the zoom, as stand-in elements arranged as runner.html arranges them. Storage is a plain object. + */ +describe("ux appearance", () => { + const g = globalThis as any; + let restoreDocument: () => void; + let doc: any; + let orgStorage: PropertyDescriptor | undefined; + let saved: { [key: string]: string }; + + interface IPage { + item: any; + button: any; + panel: any; + themeInput: any; + themeList: any; + colorblindToggle: any; + zoomOut: any; + zoomIn: any; + zoomReset: any; + zoomValue: any; + outside: any; + fits: number; + appearance: IAppearancePanel; + } + + function setup(): IPage { + const page: any = { + item: stubElement("appearanceItem", "button"), + button: stubElement("actionsBtn", "button"), + panel: stubElement("appearancePanel", "div"), + themeInput: stubElement("themeInput", "input"), + themeList: stubElement("themeList", "div"), + colorblindToggle: stubElement("colorblindToggle", "input"), + zoomOut: stubElement("zoomOutBtn", "button"), + zoomIn: stubElement("zoomInBtn", "button"), + zoomReset: stubElement("zoomResetBtn", "button"), + zoomValue: stubElement("zoomValue", "output"), + outside: stubElement("results", "div"), + fits: 0 + }; + page.panel.setAttribute("hidden", ""); + [ "themeInput", "themeList", "colorblindToggle", "zoomOut", "zoomValue", "zoomIn", "zoomReset" ].forEach((name) => { + page.panel.appendChild(page[name]); + }); + + page.appearance = createAppearancePanel({ + item: page.item, + button: page.button, + panel: page.panel, + themeInput: page.themeInput, + themeList: page.themeList, + colorblindToggle: page.colorblindToggle, + zoomOut: page.zoomOut, + zoomIn: page.zoomIn, + zoomReset: page.zoomReset, + zoomValue: page.zoomValue, + onZoom: () => { + page.fits++; + } + }); + + return page; + } + + function tick(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); + } + + function key(el: any, name: string): any { + const evt = stubEvent("keydown", { key: name }); + el.dispatchEvent(evt); + + return evt; + } + + function type(input: any, value: string): void { + input.value = value; + input.dispatchEvent(stubEvent("input")); + } + + /** The theme list's rows, with a "*" in front of the highlighted one, or [] while it is closed. */ + function rows(page: IPage): string[] { + if (!page.themeList.classList.contains("open")) { + return []; + } + + return page.themeList.querySelectorAll(".autocomplete-item").map((row: any) => { + const text = (row.kids[0].textContent || "") + row.kids[0].kids.map((part: any) => part.textContent).join(""); + return (row.classList.contains("active") ? "*" : "") + text; + }); + } + + beforeEach(() => { + const installed = useStubDocument(); + doc = installed.document; + restoreDocument = installed.restore; + orgStorage = Object.getOwnPropertyDescriptor(g, "localStorage"); + saved = {}; + Object.defineProperty(g, "localStorage", { + configurable: true, + writable: true, + value: { + getItem: (name: string) => (name in saved ? saved[name] : null), + setItem: (name: string, value: string) => { + saved[name] = String(value); + }, + removeItem: (name: string) => { + delete saved[name]; + } + } + }); + }); + + afterEach(() => { + restoreDocument(); + if (orgStorage) { + Object.defineProperty(g, "localStorage", orgStorage); + } else { + delete g.localStorage; + } + }); + + it("shows each setting as the page has it, and applies it to the page", () => { + saved[THEME_STORAGE_KEY] = "dark"; + saved[VISION_STORAGE_KEY] = "colorblind"; + saved[ZOOM_STORAGE_KEY] = "125"; + + const page = setup(); + + assert.equal(page.themeInput.value, "Dark"); + assert.isTrue(page.colorblindToggle.checked); + assert.equal(page.zoomValue.textContent, "125%"); + assert.equal(doc.body.getAttribute("data-theme"), "dark"); + assert.equal(doc.body.getAttribute("data-vision"), "colorblind"); + assert.equal(doc.body.style.getPropertyValue("--ui-zoom"), "1.25"); + assert.equal(page.zoomReset.getAttribute("aria-disabled"), "false", "there is a zoom to go back from"); + assert.equal(page.zoomReset.getAttribute("title"), "Back to 100%"); + }); + + it("opens from its menu entry once the menu has closed, with focus on the theme and its list still closed", async () => { + const page = setup(); + + page.item.click(); + assert.isTrue(page.panel.hasAttribute("hidden"), "not while the menu is still handling the click"); + + await tick(); + assert.isTrue(page.appearance.isOpen()); + assert.equal(doc.activeElement, page.themeInput); + assert.equal(page.item.getAttribute("aria-expanded"), "true"); + assert.notEqual(page.themeInput.getAttribute("aria-expanded"), "true", "the list waits to be asked for"); + assert.deepEqual(rows(page), []); + }); + + it("lists every theme on ArrowDown with the current one highlighted, and searches them as letters are typed", () => { + const page = setup(); + page.appearance.open(); + + key(page.themeInput, "ArrowDown"); + assert.deepEqual(rows(page), [ "*System", "Light", "Dark", "Low light" ]); + assert.equal(page.themeInput.getAttribute("aria-expanded"), "true"); + + type(page.themeInput, "light"); + assert.deepEqual(rows(page), [ "*Light", "Low light" ]); + + type(page.themeInput, "warm"); + assert.deepEqual(rows(page), [ "*Low light" ], "by a keyword too"); + }); + + it("applies and keeps the theme picked from the list, and Escape closes the list before the panel", () => { + const page = setup(); + page.appearance.open(); + + type(page.themeInput, "lo"); + key(page.themeInput, "Enter"); + + assert.equal(doc.body.getAttribute("data-theme"), "dim"); + assert.equal(saved[THEME_STORAGE_KEY], "dim"); + assert.equal(page.themeInput.value, "Low light"); + assert.deepEqual(rows(page), [], "the list closes"); + assert.isTrue(page.appearance.isOpen(), "the panel stays open"); + + key(page.themeInput, "ArrowDown"); + assert.deepEqual(rows(page), [ "System", "Light", "Dark", "*Low light" ], "reopened on the whole choice, the current one highlighted"); + const escape = key(page.themeInput, "Escape"); + assert.deepEqual(rows(page), []); + assert.isTrue(page.appearance.isOpen(), "the first Escape only closes the list"); + assert.isFalse(escape.defaultPrevented); + + key(page.themeInput, "Escape"); + assert.isFalse(page.appearance.isOpen(), "the second closes the panel"); + assert.equal(doc.activeElement, page.button, "and puts focus back on the menu button"); + assert.equal(page.item.getAttribute("aria-expanded"), "false"); + }); + + it("lists every theme while the box holds the current one's name, and the search's otherwise", () => { + setup(); + + assert.deepEqual(themeCandidates("System").map((row) => row.label), [ "System", "Light", "Dark", "Low light" ]); + assert.deepEqual(themeCandidates("").map((row) => row.value), [ "System", "Light", "Dark", "Low light" ]); + assert.deepEqual(themeCandidates("dar").map((row) => row.value), [ "Dark" ]); + }); + + it("applies a theme typed out in full by name or id, and puts anything else back", () => { + const page = setup(); + + page.themeInput.value = "dark"; + page.themeInput.dispatchEvent(stubEvent("change")); + assert.equal(doc.body.getAttribute("data-theme"), "dark"); + assert.equal(page.themeInput.value, "Dark"); + + page.themeInput.value = " low LIGHT "; + page.themeInput.dispatchEvent(stubEvent("change")); + assert.equal(doc.body.getAttribute("data-theme"), "dim"); + + page.themeInput.value = "neon"; + page.themeInput.dispatchEvent(stubEvent("change")); + assert.equal(doc.body.getAttribute("data-theme"), "dim", "unchanged"); + assert.equal(page.themeInput.value, "Low light", "and the box shows the theme in use again"); + }); + + it("turns the color-blind palette on and off from its checkbox", () => { + const page = setup(); + assert.isFalse(page.colorblindToggle.checked); + + page.colorblindToggle.checked = true; + page.colorblindToggle.dispatchEvent(stubEvent("change")); + assert.equal(doc.body.getAttribute("data-vision"), "colorblind"); + assert.equal(saved[VISION_STORAGE_KEY], "colorblind"); + + page.colorblindToggle.checked = false; + page.colorblindToggle.dispatchEvent(stubEvent("change")); + assert.isFalse(doc.body.hasAttribute("data-vision")); + assert.isFalse(VISION_STORAGE_KEY in saved); + }); + + it("zooms in and out a step at a time, keeps it, and refits the page each time", () => { + const page = setup(); + assert.equal(page.zoomValue.textContent, "100%"); + assert.equal(page.zoomReset.getAttribute("aria-disabled"), "true", "nothing to reset"); + + page.zoomIn.click(); + assert.equal(page.zoomValue.textContent, "110%"); + assert.equal(doc.body.style.getPropertyValue("--ui-zoom"), "1.1"); + assert.equal(saved[ZOOM_STORAGE_KEY], "110"); + assert.equal(page.fits, 1); + assert.equal(page.zoomReset.getAttribute("aria-disabled"), "false"); + + page.zoomOut.click(); + page.zoomOut.click(); + assert.equal(page.zoomValue.textContent, "90%"); + + page.zoomReset.click(); + assert.equal(page.zoomValue.textContent, "100%"); + assert.isFalse(ZOOM_STORAGE_KEY in saved, "back at the run's zoom, nothing is kept"); + assert.equal(page.fits, 4); + }); + + it("stops at either end with the button still focusable, but doing nothing", () => { + saved[ZOOM_STORAGE_KEY] = "50"; + const page = setup(); + assert.equal(page.zoomOut.getAttribute("aria-disabled"), "true"); + assert.isFalse(page.zoomOut.disabled, "never natively disabled, which would drop a keyboard's focus"); + + page.zoomOut.click(); + assert.equal(page.zoomValue.textContent, "50%"); + assert.equal(page.fits, 0); + + page.zoomReset.click(); + saved[ZOOM_STORAGE_KEY] = "250"; + page.zoomIn.click(); + assert.equal(page.zoomValue.textContent, "300%"); + assert.equal(page.zoomIn.getAttribute("aria-disabled"), "true"); + page.zoomIn.click(); + assert.equal(page.zoomValue.textContent, "300%"); + }); + + it("steps on from a zoom the run set between two steps, and resets to the run's", () => { + doc.body.setAttribute("data-default-zoom", "133"); + const page = setup(); + assert.equal(page.zoomValue.textContent, "133%"); + assert.equal(page.zoomReset.getAttribute("aria-disabled"), "true"); + assert.equal(page.zoomReset.getAttribute("title"), "Back to 133%"); + + page.zoomIn.click(); + assert.equal(page.zoomValue.textContent, "150%"); + + page.zoomReset.click(); + assert.equal(page.zoomValue.textContent, "133%"); + assert.isFalse(ZOOM_STORAGE_KEY in saved); + }); + + it("closes on a click outside it, but not on one inside it", () => { + const page = setup(); + page.appearance.open(); + + page.zoomIn.click(); + assert.isTrue(page.appearance.isOpen()); + + page.outside.click(); + assert.isFalse(page.appearance.isOpen()); + assert.equal(doc.activeElement, page.themeInput, "focus is left where the click put it, not moved back"); + }); + + it("closes when focus leaves it, not while focus moves within it", async () => { + const page = setup(); + page.appearance.open(); + + page.zoomIn.focus(); + page.panel.dispatchEvent(stubEvent("focusout")); + await tick(); + assert.isTrue(page.appearance.isOpen()); + + page.outside.focus(); + page.panel.dispatchEvent(stubEvent("focusout")); + await tick(); + assert.isFalse(page.appearance.isOpen()); + }); + + it("closes its list and forgets an unfinished search when it closes", () => { + saved[THEME_STORAGE_KEY] = "light"; + const page = setup(); + page.appearance.open(); + + type(page.themeInput, "da"); + assert.deepEqual(rows(page), [ "*Dark" ]); + + page.appearance.close(false); + assert.deepEqual(rows(page), []); + assert.equal(page.themeInput.value, "Light"); + assert.equal(doc.body.getAttribute("data-theme"), "light"); + }); + + it("copes with a page that has no panel", () => { + const appearance = createAppearancePanel({ + item: null, button: null, panel: null, themeInput: null, themeList: null, colorblindToggle: null, + zoomOut: null, zoomIn: null, zoomReset: null, zoomValue: null + }); + + appearance.open(); + appearance.close(true); + assert.isFalse(appearance.isOpen()); + }); +}); diff --git a/internal/runner/test/src/ux/assertions.test.ts b/internal/runner/test/src/ux/assertions.test.ts index 0bb1787..aa0bbc9 100644 --- a/internal/runner/test/src/ux/assertions.test.ts +++ b/internal/runner/test/src/ux/assertions.test.ts @@ -104,6 +104,19 @@ describe("ux assertions", () => { ]); }); + it("should keep only the values a failure has", () => { + assertions.addAssertion("k", false, "equal", "no", { expected: "2", actual: "1" }); + assertions.addAssertion("k", false, "isTrue", "no", { actual: "false" }); + assertions.addAssertion("k", false, "fail", "no", null); + + assert.deepEqual(assertionDomQueue.map((entry) => [ entry.expected, entry.actual ]), [ + [ "2", "1" ], + [ undefined, "false" ], + [ undefined, undefined ] + ]); + assert.isFalse("expected" in assertionDomQueue[1], "not even as undefined"); + }); + it("should ask for one animation frame per burst and mark it as pending", () => { assertions.addAssertion("k", true, "a", ""); assertions.addAssertion("k", true, "b", ""); @@ -183,6 +196,14 @@ describe("ux assertions", () => { assert.deepEqual(queued, [{ testKey: "outer :: named", passed: true, label: "equal", errorText: "" }]); }); + + it("should keep the values the test frame or worker sent with a failure", () => { + setCurrentExecutingTestKey("outer :: alpha"); + + assertions._handleAssertionEvent({ passed: false, label: "equal", errorText: "no", expected: "2", actual: "1" }); + + assert.deepEqual(assertionDomQueue, [{ testKey: "outer :: alpha", passed: false, label: "equal", errorText: "no", expected: "2", actual: "1" }]); + }); }); describe("handleAssertion", () => { @@ -219,6 +240,22 @@ describe("ux assertions", () => { ]); }); + it("should carry a failure's expected and actual values", () => { + let error: any; + try { + assert.equal(1, 2); + } catch (e) { + error = e; + } + + assertions.handleAssertion({ passed: false, message: "equal", error: error }); + assertions.handleAssertion({ passed: false, message: "thrown", error: new Error("no values") }); + + assert.equal(assertionDomQueue[0].expected, "2"); + assert.equal(assertionDomQueue[0].actual, "1"); + assert.isFalse("expected" in assertionDomQueue[1] || "actual" in assertionDomQueue[1], "an error without values adds none"); + }); + it("should coerce a missing passed flag to a failure", () => { assertions.handleAssertion({ message: "no flag" }); assertions.handleAssertion(null); diff --git a/internal/runner/test/src/ux/autocomplete.test.ts b/internal/runner/test/src/ux/autocomplete.test.ts index 924481e..b5e950d 100644 --- a/internal/runner/test/src/ux/autocomplete.test.ts +++ b/internal/runner/test/src/ux/autocomplete.test.ts @@ -7,7 +7,7 @@ */ import { assert } from "@nevware21/tripwire"; -import { AUTOCOMPLETE_KIND_ATTR, createAutocomplete, IAutocompleteItem } from "../../../src/ux/browser/ui/autocomplete"; +import { _fillHighlighted, AUTOCOMPLETE_KIND_ATTR, createAutocomplete, IAutocompleteItem, IAutocompleteOptions } from "../../../src/ux/browser/ui/autocomplete"; /** * The Scope picker's suggestion dropdown. Which rows match is decided by the caller's @@ -114,7 +114,7 @@ function _matching(query: string): IAutocompleteItem[] { } /** A wired-up input + list, with the gestures a user makes on them. */ -function _setup(getCandidates: (query: string) => IAutocompleteItem[] = _matching) { +function _setup(getCandidates: (query: string) => IAutocompleteItem[] = _matching, options?: IAutocompleteOptions) { const input = _el(); const list = _el(); const queries: string[] = []; @@ -123,25 +123,31 @@ function _setup(getCandidates: (query: string) => IAutocompleteItem[] = _matchin changes.push(evt); }); - createAutocomplete(input, list, (query) => { + const handle = createAutocomplete(input, list, (query) => { queries.push(query); return getCandidates(query); - }); + }, options); const rows = () => list.querySelectorAll(".autocomplete-item"); const texts = (cls: string) => list.kids.filter((kid: any) => kid.className.split(" ").indexOf(cls) !== -1).map((kid: any) => kid.textContent); + // A row's label is split into marked and unmarked pieces once a query matches it + const textOf = (node: any): string => (node.textContent || "") + (node.kids || []).map(textOf).join(""); return { input: input, list: list, + handle: handle, queries: queries, changes: changes, rows: rows, /** Each row as "label [kind]", with a "*" in front of the highlighted one. */ - view: () => rows().map((row: any) => (row.classList.contains("active") ? "*" : "") + row.kids.map((part: any) => part.textContent).join(" [") + (row.kids.length > 1 ? "]" : "")), + view: () => rows().map((row: any) => (row.classList.contains("active") ? "*" : "") + row.kids.map(textOf).join(" [") + (row.kids.length > 1 ? "]" : "")), + /** The marked pieces of each row's label. */ + marks: () => rows().map((row: any) => row.kids[0].kids.filter((part: any) => part.tagName === "MARK").map((part: any) => part.textContent)), notes: (cls: string) => texts(cls), isOpen: () => list.classList.contains("open"), focus: () => input.fire("focus"), + click: () => input.fire("click"), type: (value: string) => { input.value = value; input.fire("input"); @@ -164,7 +170,7 @@ describe("ux autocomplete", () => { beforeEach(() => { documentClicks = []; g.document = { - createElement: () => _el(), + createElement: (tag: string) => Object.assign(_el(), { tagName: String(tag).toUpperCase() }), addEventListener: (type: string, fn: (evt: any) => void) => { assert.equal(type, "click"); documentClicks.push(fn); @@ -351,15 +357,98 @@ describe("ux autocomplete", () => { assert.isFalse(ui.isOpen()); }); - it("should ignore the keyboard while closed", () => { + it("should mark each typed word wherever it occurs in a row's label, ignoring case", () => { + const ui = _setup(() => ITEMS.slice(1, 3)); + ui.focus(); + ui.type("OUT al"); + + assert.deepEqual(ui.view(), [ "*outer [suite]", "outer > alpha [test]" ], "the labels still read the same"); + assert.deepEqual(ui.marks(), [ [ "out" ], [ "out", "al" ] ]); + }); + + it("should leave a label as plain text when there is no query or nothing in it matches", () => { + const ui = _setup(() => ITEMS.slice(1, 3)); + ui.focus(); + assert.deepEqual(ui.marks(), [ [], [] ], "no query"); + assert.equal(ui.rows()[0].kids[0].kids.length, 0); + + ui.type("zzz"); + assert.deepEqual(ui.marks(), [ [], [] ], "no match"); + assert.equal(ui.rows()[1].kids[0].textContent, "outer > alpha"); + }); + + it("should mark overlapping words as one piece, and a repeated one each time", () => { + const target = _el(); + _fillHighlighted(target, "alpha > banana", "ana an"); + + assert.deepEqual(target.kids.map((part: any) => part.tagName + ":" + part.textContent), [ + "SPAN:alpha > b", + "MARK:anana" + ]); + + const repeated = _el(); + _fillHighlighted(repeated, "alpha", "a"); + assert.deepEqual(repeated.kids.map((part: any) => part.tagName + ":" + part.textContent), [ "MARK:a", "SPAN:lph", "MARK:a" ]); + }); + + it("should ignore every key but ArrowDown while closed", () => { const ui = _setup(); ui.input.value = "outer"; - assert.isFalse(ui.key("ArrowDown").defaultPrevented); assert.isFalse(ui.key("Enter").defaultPrevented); + assert.isFalse(ui.key("ArrowUp").defaultPrevented); + assert.isFalse(ui.key("Escape").defaultPrevented); assert.equal(ui.input.value, "outer"); assert.equal(ui.changes.length, 0); assert.deepEqual(ui.queries, [], "never even asked for candidates"); }); + + it("should open again on ArrowDown, or a click in the input, after Escape closed it", () => { + const ui = _setup(); + ui.focus(); + ui.key("Escape"); + assert.isFalse(ui.isOpen()); + + assert.isTrue(ui.key("ArrowDown").defaultPrevented, "the page does not scroll"); + assert.isTrue(ui.isOpen()); + assert.equal(ui.input.getAttribute("aria-expanded"), "true"); + + ui.key("Escape"); + ui.click(); + assert.isTrue(ui.isOpen(), "a click opens it too"); + ui.click(); + assert.isTrue(ui.isOpen(), "and a second leaves it open"); + }); + + it("should wait to be asked with openOnFocus off, for an input that takes focus as its dialog opens", () => { + const ui = _setup(_matching, { openOnFocus: false }); + + ui.focus(); + assert.isFalse(ui.isOpen()); + assert.deepEqual(ui.queries, []); + + ui.key("ArrowDown"); + assert.isTrue(ui.isOpen()); + }); + + it("should hand its owner a close, for when the dialog holding it closes", () => { + const ui = _setup(); + ui.focus(); + + ui.handle.close(); + + assert.isFalse(ui.isOpen()); + assert.equal(ui.input.getAttribute("aria-expanded"), "false"); + assert.isNull(ui.input.getAttribute("aria-activedescendant")); + }); + + it("should mark nothing while the input holds a row's value, which is a choice rather than a search", () => { + const ui = _setup(); + ui.input.value = "outer"; + ui.focus(); + + assert.deepEqual(ui.view(), [ "All tests", "*outer [suite]", "outer > alpha [test]", "other [suite]" ]); + assert.deepEqual(ui.marks(), [ [], [], [], [] ]); + }); }); diff --git a/internal/runner/test/src/ux/client.test.ts b/internal/runner/test/src/ux/client.test.ts index 90fbd1b..35ede36 100644 --- a/internal/runner/test/src/ux/client.test.ts +++ b/internal/runner/test/src/ux/client.test.ts @@ -111,7 +111,7 @@ describe("ux client", function () { 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.location = { href: "http://localhost:4321" + pathname + (search || "") + (opts.hash || ""), host: "localhost:4321", 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)); @@ -281,7 +281,7 @@ describe("ux client", function () { 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.equal(page.status(), "Run complete. Run again, or choose Finish & Stop Server from the ⋮ menu to send the result to the CLI."); assert.deepEqual(page.state.lastResult, { summary: { total: 2 }, coverage: { a: 1 } }); page.registry.finishBtn.click(); @@ -304,7 +304,7 @@ describe("ux client", function () { page.message({ kind: "runDone", summary: {} }); await settle(); - assert.equal(page.status(), "Run stopped. You can run again or finish."); + assert.equal(page.status(), "Run stopped. Run again, or choose Finish & Stop Server from the ⋮ menu to send the result to the CLI."); page.registry.runBtn.click(); assert.deepEqual(page.framePosts[2], { kind: "run", selection: { suiteTitle: "", testTitle: "" } }); @@ -423,16 +423,16 @@ describe("ux client", function () { await settle(); assert.equal(page.frame(), firstFrame); - page.registry.autoReloadToggle.checked = false; - page.registry.autoReloadToggle.dispatchEvent(stubEvent("change")); + page.registry.autoReloadItem.click(); + assert.equal(page.registry.autoReloadItem.getAttribute("aria-checked"), "false", "the menu entry is ticked while it is on"); 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")); + page.registry.autoReloadItem.click(); + assert.equal(page.registry.autoReloadItem.getAttribute("aria-checked"), "true"); poll(); await settle(); assert.notEqual(page.frame(), firstFrame); @@ -465,6 +465,59 @@ describe("ux client", function () { assert.equal(page.registry.assertCount.textContent, "0 assertions"); }); + it("shows the run's totals, the failed count turning Failed only on and off", async () => { + const page = loadPage("/__tripwire__/browser"); + await readyAndRun(page); + const summary = page.registry.runSummary; + const failedChip = () => summary.querySelector(".summary-failed"); + + assert.isFalse(summary.hidden); + assert.deepEqual(summary.kids.map(textOf), [ "1 passed", "1 failed" ]); + assert.equal(failedChip().getAttribute("aria-pressed"), "false"); + + summary.kids[0].click(); + assert.isFalse(page.registry.failedOnlyToggle.checked, "only the failed count is a toggle"); + + failedChip().click(); + assert.isTrue(page.registry.failedOnlyToggle.checked); + assert.equal(failedChip().getAttribute("aria-pressed"), "true"); + assert.equal(page.registry.filterNote.textContent, "1 passing test hidden by Failed only"); + assert.isFalse(page.registry.filterNote.hidden); + + failedChip().click(); + assert.isFalse(page.registry.failedOnlyToggle.checked); + assert.equal(failedChip().getAttribute("aria-pressed"), "false"); + assert.isTrue(page.registry.filterNote.hidden); + + page.registry.failedOnlyToggle.checked = true; + page.registry.failedOnlyToggle.dispatchEvent(stubEvent("change")); + assert.equal(failedChip().getAttribute("aria-pressed"), "true", "the checkbox keeps the chip in step"); + }); + + it("lets Failed only's stand-in in the actions menu change it, as its checkbox would", async () => { + const page = loadPage("/__tripwire__/browser"); + await readyAndRun(page); + assert.isFunction(page.listeners.resize, "the controls are fitted again as the window is resized"); + assert.equal(page.registry.failedOnlyItem.getAttribute("aria-checked"), "false"); + + page.registry.failedOnlyItem.click(); + assert.isTrue(page.registry.failedOnlyToggle.checked); + assert.equal(page.registry.failedOnlyItem.getAttribute("aria-checked"), "true"); + assert.equal(page.registry.filterNote.textContent, "1 passing test hidden by Failed only"); + assert.equal(page.registry.runSummary.querySelector(".summary-failed").getAttribute("aria-pressed"), "true"); + + page.registry.runSummary.querySelector(".summary-failed").click(); + assert.equal(page.registry.failedOnlyItem.getAttribute("aria-checked"), "false", "the failed count keeps the stand-in in step too"); + }); + + it("says under the title which target and server this is, and how many tests it found", () => { + const page = loadPage("/__tripwire__/browser"); + + page.message({ kind: "ready", discovery: DISCOVERY }); + + assert.equal(page.registry.appMeta.textContent, "Browser target · localhost:4321 · 2 tests"); + }); + it("switches to the other target, keeping the rest of the URL", () => { const page = loadPage("/__tripwire__/browser", "?x=1"); @@ -561,8 +614,9 @@ describe("ux client", function () { 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.equal(page.registry.appMeta.textContent, "Worker target · localhost:4321 · 2 tests"); assert.isNull(page.state.activeWorker); - assert.equal(page.status(), "Run complete. You can run again or click Finish to return to CLI."); + assert.equal(page.status(), "Run complete. Run again, or choose Finish & Stop Server from the ⋮ menu to send the result to the CLI."); }); it("warns when discovery fails, and reports a worker that crashes", async () => { diff --git a/internal/runner/test/src/ux/frameMode.test.ts b/internal/runner/test/src/ux/frameMode.test.ts index 2abdc9b..ee866ff 100644 --- a/internal/runner/test/src/ux/frameMode.test.ts +++ b/internal/runner/test/src/ux/frameMode.test.ts @@ -170,7 +170,11 @@ describe("ux frame mode", function () { 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"); + const failed = asserts.filter((evt) => !evt.passed)[0]; + assert.includes(failed.errorText, "expected 1 to equal 2"); + assert.equal(failed.expected, "2", "the values go with the failure, for the page to show"); + assert.equal(failed.actual, "1"); + assert.isFalse("expected" in asserts[0], "a pass has none"); }); it("sends the reporter's console output to the page", async () => { diff --git a/internal/runner/test/src/ux/interactivePage.test.ts b/internal/runner/test/src/ux/interactivePage.test.ts index df024be..372df90 100644 --- a/internal/runner/test/src/ux/interactivePage.test.ts +++ b/internal/runner/test/src/ux/interactivePage.test.ts @@ -66,7 +66,10 @@ const SCOPED_SUITE_TESTS = [ const RERUN_TEST = "Math > multiplication > multiplies by zero"; const PASSING_COUNT = FIXTURE_TESTS.length - 1; -const MENU_ITEMS = [ "Reload Tests", "Clear Results", "Clear Logs", "Finish & Stop Server" ]; +const MENU_ITEMS = [ "Reload Tests", "Clear Logs", "Appearance…", "Finish & Stop Server" ]; + +// The window the page is driven in; WIDE_VIEWPORT holds every control on its row +const WIDE_VIEWPORT = { width: 1280, height: 900 }; // Every browser-driving step gets this rather than the 2000ms default. const STEP_TIMEOUT_MS = 60000; @@ -328,6 +331,39 @@ async function _clickMenuItem(id: string): Promise { await _page.click("#" + id); } +async function _appearanceIsOpen(): Promise { + return _page.$eval("#appearancePanel", (el: Element) => !el.hasAttribute("hidden")); +} + +/** Opens the Appearance panel from the actions menu, which closes behind it, with focus on the theme. */ +async function _openAppearance(): Promise { + if (!(await _appearanceIsOpen())) { + await _clickMenuItem("appearanceItem"); + await _waitFor(() => { + const panel = document.getElementById("appearancePanel"); + return !!panel && !panel.hasAttribute("hidden") && !!document.activeElement && document.activeElement.id === "themeInput"; + }, "the Appearance panel to open on the theme"); + } +} + +/** Searches the Appearance panel's theme box for `search`, and picks the top match with Enter. */ +async function _pickTheme(search: string): Promise { + await _openAppearance(); + await _page.focus("#themeInput"); + await _page.$eval("#themeInput", (el: Element) => (el as HTMLInputElement).select()); + await _page.keyboard.type(search); + await _waitFor(() => { + const list = document.getElementById("themeList"); + return !!list && list.classList.contains("open") && list.querySelectorAll(".autocomplete-item").length > 0; + }, "the theme list to show a match for \"" + search + "\""); + await _page.keyboard.press("Enter"); +} + +/** The theme list's rows, with a "*" in front of the highlighted one. */ +function _themeRows(): Promise { + return _page.$$eval("#themeList.open .autocomplete-item", (els: Element[]) => els.map((el) => (el.classList.contains("active") ? "*" : "") + (el.textContent || ""))); +} + async function _clearScopeIfSet(): Promise { if (!(await _isDisabled("#clearScopeBtn"))) { await _page.click("#clearScopeBtn"); @@ -493,21 +529,34 @@ function _sharedScenarios(target: "browser" | "worker") { suites.forEach((suite) => { byName[suite.name] = suite; }); - assert.equal(byName["Math"].meta, "4/4 tests • 2 child suites"); + assert.equal(byName["Math"].meta, "4 passed · 2 child suites"); assert.equal(byName["Math"].status, "passed"); - assert.equal(byName["Strings"].meta, "3/3 tests • 1 child suite"); + assert.equal(byName["Strings"].meta, "2 passed · 1 failed · 1 child suite"); assert.equal(byName["Strings"].status, "failed"); assert.equal(byName["casing"].status, "failed"); - assert.equal(byName["Arrays"].meta, "2/2 tests"); + assert.equal(byName["Arrays"].meta, "2 passed"); assert.equal(byName["Arrays"].status, "passed"); - assert.equal(byName["Async"].meta, "1/1 tests"); + assert.equal(byName["Async"].meta, "1 passed"); assert.equal(byName["Async"].status, "passed"); + // Each suite's worded counts ("2 passed · 1 failed"), added up across the top-level suites const total = suites .filter((suite) => [ "Math", "Strings", "Arrays", "Async" ].indexOf(suite.name) !== -1) - .reduce((sum, suite) => sum + parseInt(suite.meta.split("/")[1], 10), 0); + .reduce((sum, suite) => sum + suite.meta.split(" · ") + .filter((part) => /^\d+ (passed|failed|skipped)$/.test(part)) + .reduce((count, part) => count + parseInt(part, 10), 0), 0); assert.equal(total, FIXTURE_TESTS.length, "the top-level suite counts add up to the fixture"); + // The failure shows the values it compared, beside its message + const values: string[] = await _page.$$eval("#results .test-node.test-failed .assert-item.assert-fail .assert-values > *", (els: Element[]) => els.map((el) => el.textContent || "")); + assert.deepEqual(values, [ "Expected", "\"Word\"", "Actual", "\"word\"" ]); + + // The run's totals sit beside the status + await _waitFor(() => !!document.querySelector("#runSummary .summary-duration"), "the run summary to show the run's own totals"); + const chips: string[] = await _page.$$eval("#runSummary .summary-chip", (els: Element[]) => els.map((el) => el.textContent || "")); + assert.deepEqual(chips.slice(0, 2), [ PASSING_COUNT + " passed", "1 failed" ]); + assert.match(chips[2], /^\d+(ms|\.\d+s)$/, "and how long it took"); + // Assertions are flushed on an animation frame, so wait for the counter rather than read it. await _waitFor((expected: number) => { const el = document.getElementById("assertCount"); @@ -517,7 +566,7 @@ function _sharedScenarios(target: "browser" | "worker") { assert.notEqual(await _text("#assertCount"), "0 assertions"); assert.equal(await _attr("#finishBtn", "aria-disabled"), "false", "Finish is enabled once a result exists"); - assert.isTrue(await _isDisabled("#clearScopeBtn"), "All tests is disabled when there is no scope to clear"); + assert.isTrue(await _isDisabled("#clearScopeBtn"), "Clear scope is disabled when there is no scope to clear"); }); step("should scope to one suite from the Scope picker, carry it in the URL hash and restore it on reload", async () => { @@ -544,7 +593,7 @@ function _sharedScenarios(target: "browser" | "worker") { assert.equal(await _scopeValue(), SCOPED_SUITE); assert.equal(await _attr("#scopeSelect", "data-autocomplete-kind"), "suite", "the picked row's kind is recorded"); assert.equal(await _hash(), "#suite=" + hashSelectionValue(SCOPED_SUITE), "the scope is mirrored into the hash"); - assert.isFalse(await _isDisabled("#clearScopeBtn"), "All tests is enabled once a scope is set"); + assert.isFalse(await _isDisabled("#clearScopeBtn"), "Clear scope is enabled once a scope is set"); await _clickRunAndWait(); assert.deepEqual(_titles(await _readResults()), SCOPED_SUITE_TESTS, "only the scoped suite ran"); @@ -564,7 +613,7 @@ function _sharedScenarios(target: "browser" | "worker") { assert.deepEqual(_titles(await _readResults()), SCOPED_SUITE_TESTS, "re-running respects the restored scope"); }); - step("should rerun one test from its row, then clear the scope with All tests without running", async () => { + step("should rerun one test from its row, then clear the scope with Clear scope without running", async () => { const selector = "#results .test-node .rerun-btn[data-rerun-test=\"" + RERUN_TEST + "\"]"; await _page.waitForSelector(selector); await _page.click(selector); @@ -582,7 +631,7 @@ function _sharedScenarios(target: "browser" | "worker") { assert.isFalse(await _isDisabled("#clearScopeBtn")); await _page.click("#clearScopeBtn"); - assert.equal(await _scopeValue(), "", "All tests clears the box"); + assert.equal(await _scopeValue(), "", "Clear scope clears the box"); assert.isNull(await _attr("#scopeSelect", "data-autocomplete-kind")); assert.equal(await _hash(), "", "and the hash"); assert.isTrue(await _isDisabled("#clearScopeBtn"), "and disables itself once there is nothing to clear"); @@ -605,6 +654,7 @@ function _sharedScenarios(target: "browser" | "worker") { assert.equal(row.hidden, row.status !== "failed", row.title + " hidden=" + row.hidden + " status=" + row.status); }); assert.equal(results.filter((row) => !row.hidden).length, 1, "only the failing test is visible"); + assert.equal(await _text("#filterNote"), PASSING_COUNT + " passing tests hidden by Failed only", "the page says what it is hiding"); let suites = await _readSuites(); suites.forEach((suite) => { @@ -614,11 +664,22 @@ function _sharedScenarios(target: "browser" | "worker") { await _page.click("#failedOnlyToggle"); assert.isFalse(await _isChecked("#failedOnlyToggle")); + assert.isTrue(await _page.$eval("#filterNote", (el: Element) => (el as HTMLElement).hidden), "nothing hidden, nothing to say"); results = await _readResults(); assert.equal(results.filter((row) => row.hidden).length, 0, "every test row is visible again"); suites = await _readSuites(); assert.equal(suites.filter((suite) => suite.hidden).length, 0, "every suite is visible again"); + + // The failed count beside the status is the same switch, and shows whether it is on + await _page.click("#runSummary .summary-failed"); + assert.isTrue(await _isChecked("#failedOnlyToggle"), "the failed count turns Failed only on"); + assert.equal(await _attr("#runSummary .summary-failed", "aria-pressed"), "true"); + assert.equal((await _readResults()).filter((row) => !row.hidden).length, 1); + + await _page.click("#runSummary .summary-failed"); + assert.isFalse(await _isChecked("#failedOnlyToggle"), "and off again"); + assert.equal(await _attr("#runSummary .summary-failed", "aria-pressed"), "false"); }); step("should expose the actions menu as a keyboard-operable WAI-ARIA menu button", async () => { @@ -647,15 +708,18 @@ function _sharedScenarios(target: "browser" | "worker") { tabindex: el.getAttribute("tabindex") }))); assert.deepEqual(items.map((item) => item.text), MENU_ITEMS, "the items, in order"); - assert.deepEqual(items.map((item) => item.id), [ "reloadBtn", "clearResultsBtn", "clearLogsBtn", "finishBtn" ]); + assert.deepEqual(items.map((item) => item.id), [ "reloadBtn", "clearLogsBtn", "appearanceItem", "finishBtn" ]); assert.deepEqual(items.map((item) => item.tabindex), [ "0", "-1", "-1", "-1" ], "roving tabindex: only the focused item is tabbable"); assert.equal(await _activeElementId(), "reloadBtn", "opening focuses the first item"); assert.equal(await _attr("#actionsMenu .menu-separator", "role"), "separator"); // Arrow keys move and wrap; Home/End jump. await _page.keyboard.press("ArrowDown"); - assert.equal(await _activeElementId(), "clearResultsBtn"); + assert.equal(await _activeElementId(), "autoReloadItem", "the Auto-reload setting is an item too"); await _page.keyboard.press("ArrowDown"); + assert.equal(await _activeElementId(), "clearLogsBtn"); + await _page.keyboard.press("ArrowDown"); + assert.equal(await _activeElementId(), "appearanceItem", "then the entry that opens the Appearance panel"); await _page.keyboard.press("ArrowDown"); assert.equal(await _activeElementId(), "finishBtn"); await _page.keyboard.press("ArrowDown"); @@ -695,23 +759,321 @@ function _sharedScenarios(target: "browser" | "worker") { assert.isFalse(await _isDisabled("#finishBtn"), "a menu item is never natively disabled, so it stays focusable"); }); - step("should clear the results list and the log panel from the actions menu", async () => { + step("should pick the theme by searching for it in the Appearance panel, remembering the choice", async () => { + const theme = () => _page.evaluate(() => ({ + attr: document.body.getAttribute("data-theme"), + saved: localStorage.getItem("tripwire.theme"), + background: getComputedStyle(document.body).backgroundImage, + shown: (document.getElementById("themeInput") as HTMLInputElement).value + })); + + await _page.emulateMediaFeatures([ { name: "prefers-color-scheme", value: "light" } ]); + const system = await theme(); + assert.isNull(system.attr, "System sets no theme of its own"); + assert.isNull(system.saved); + assert.equal(system.shown, "System"); + + // A dialog of its own, as a menu may hold only menu items: the menu closes behind it, and + // focus lands on the theme with its list still closed + await _openAppearance(); + assert.isFalse(await _menuIsOpen(), "the menu closes behind it"); + assert.equal(await _attr("#appearancePanel", "role"), "dialog"); + assert.equal(await _attr("#appearanceItem", "aria-haspopup"), "dialog"); + assert.equal(await _attr("#appearanceItem", "aria-expanded"), "true"); + assert.notEqual(await _attr("#themeInput", "aria-expanded"), "true", "the list waits to be asked for"); + + // Named for a screen reader: the dialog, the combobox and each setting + const tree = await _page.accessibility.snapshot({ interestingOnly: false }); + const names: string[] = []; + const walk = (node: any) => { + if (node) { + names.push(node.role + ":" + (node.name || "")); + (node.children || []).forEach(walk); + } + }; + walk(tree); + [ "dialog:Appearance", "combobox:Theme", "checkbox:Color-blind safe", "group:Zoom", "button:Zoom out", "button:Zoom in", "button:Reset" ].forEach((expected) => { + assert.isTrue(names.indexOf(expected) !== -1, expected + " in the accessibility tree"); + }); + + // ArrowDown lists every theme, the one in use highlighted + await _page.keyboard.press("ArrowDown"); + assert.deepEqual(await _themeRows(), [ "*System", "Light", "Dark", "Low light" ]); + assert.equal(await _attr("#themeInput", "aria-expanded"), "true"); + + // Typing searches, and Enter picks the top match + await _pickTheme("dar"); + const dark = await theme(); + assert.equal(dark.attr, "dark"); + assert.equal(dark.saved, "dark", "kept for the next page load"); + assert.equal(dark.shown, "Dark"); + assert.notEqual(dark.background, system.background, "the page is drawn dark"); + assert.deepEqual(await _themeRows(), [], "picking closes the list"); + assert.isTrue(await _appearanceIsOpen(), "but not the panel"); + + // Escape closes the list first, then the panel, putting focus back on the menu button + await _page.keyboard.press("ArrowDown"); + assert.deepEqual(await _themeRows(), [ "System", "Light", "*Dark", "Low light" ]); + await _page.keyboard.press("Escape"); + assert.deepEqual(await _themeRows(), []); + assert.isTrue(await _appearanceIsOpen(), "the first Escape closes only the list"); + await _page.keyboard.press("Escape"); + assert.isFalse(await _appearanceIsOpen()); + assert.equal(await _activeElementId(), "actionsBtn"); + assert.equal(await _attr("#appearanceItem", "aria-expanded"), "false"); + + // System follows the operating system's setting again + await _pickTheme("system"); + const followed = await theme(); + assert.isNull(followed.attr); + assert.isNull(followed.saved, "nothing kept"); + assert.equal(followed.background, system.background); + + await _page.emulateMediaFeatures([ { name: "prefers-color-scheme", value: "dark" } ]); + assert.equal((await theme()).background, dark.background, "a dark system setting draws it dark"); + + await _pickTheme("light"); + const light = await theme(); + assert.equal(light.attr, "light", "the top match for \"light\", ahead of Low light"); + assert.equal(light.background, system.background, "Light overrides a dark system setting"); + + await _pickTheme("system"); + await _page.emulateMediaFeatures([ { name: "prefers-color-scheme", value: "light" } ]); + + // A click outside closes the panel + await _page.click("#results"); + assert.isFalse(await _appearanceIsOpen()); + }); + + step("should switch to low light, color-blind safe colors and a zoom of the page alone from the Appearance panel", async () => { + const look = () => _page.evaluate(() => { + const passed = document.querySelector("#runSummary .summary-passed") as Element; + return { + theme: document.body.getAttribute("data-theme"), + vision: document.body.getAttribute("data-vision"), + zoom: document.body.style.getPropertyValue("--ui-zoom"), + shellZoom: getComputedStyle(document.querySelector(".app-shell") as Element).getPropertyValue("zoom"), + frameZoom: getComputedStyle(document.getElementById("testFrameContainer") as Element).getPropertyValue("zoom"), + rootSize: getComputedStyle(document.documentElement).fontSize, + shown: (document.getElementById("zoomValue") as Element).textContent, + layout: [ "layout-narrow", "layout-short" ].filter((cls) => document.body.classList.contains(cls)), + overflows: document.documentElement.scrollWidth > window.innerWidth, + background: getComputedStyle(document.body).backgroundImage + " " + getComputedStyle(document.body).backgroundColor, + passedColor: passed ? getComputedStyle(passed).color : "", + saved: [ "tripwire.theme", "tripwire.vision", "tripwire.zoom" ].map((key) => localStorage.getItem(key)) + }; + }); + const before = await look(); + assert.deepEqual([ before.theme, before.vision, before.zoom ], [ null, null, "" ], "the defaults set nothing"); + assert.equal(before.shown, "100%"); + assert.equal(before.rootSize, "16px"); + assert.deepEqual(before.layout, [], "a wide window takes the full layout"); + + await _pickTheme("low"); + const dim = await look(); + assert.equal(dim.theme, "dim"); + assert.notEqual(dim.background, before.background, "low light draws the page differently"); + + await _page.click("#colorblindToggle"); + const colorblind = await look(); + assert.equal(colorblind.vision, "colorblind"); + assert.isTrue(await _isChecked("#colorblindToggle")); + assert.equal(colorblind.passedColor, "rgb(86, 180, 233)", "passed is sky blue on a dark page, not green"); + + // The zoom scales the page's own controls, results and log -- never the browser's zoom, the + // tests' own frame, or the root size a test's content would share + for (let lp = 0; lp < 3; lp++) { + await _page.click("#zoomInBtn"); + } + const zoomed = await look(); + assert.equal(zoomed.shown, "150%"); + assert.deepEqual([ zoomed.zoom, zoomed.shellZoom ], [ "1.5", "1.5" ]); + assert.equal(zoomed.frameZoom, "1", "the tests' frame is not zoomed"); + assert.equal(zoomed.rootSize, "16px", "nor is the root"); + assert.deepEqual(zoomed.saved, [ "dim", "colorblind", "150" ], "each choice is kept for the next page load"); + + // Zoomed far enough, a wide window holds only a narrow page, which takes its compact layout + for (let lp = 0; lp < 3; lp++) { + await _page.click("#zoomInBtn"); + } + const far = await look(); + assert.equal(far.shown, "250%"); + assert.deepEqual(far.layout, [ "layout-narrow", "layout-short" ], "512 by 360 of the page's own pixels"); + assert.isFalse(far.overflows, "and still fits across the window"); + + // A reload opens the page as it was left, before it first paints + await _page.reload({ waitUntil: "load" }); + await _waitForRunComplete(); + const reloaded = await look(); + assert.deepEqual([ reloaded.theme, reloaded.vision, reloaded.zoom, reloaded.shown ], [ "dim", "colorblind", "2.5", "250%" ]); + assert.deepEqual(reloaded.layout, [ "layout-narrow", "layout-short" ]); + + // Back to the defaults, which keeps nothing + await _pickTheme("system"); + await _page.click("#colorblindToggle"); + await _page.click("#zoomResetBtn"); + const after = await look(); + assert.deepEqual([ after.theme, after.vision, after.zoom, after.shown ], [ null, null, "", "100%" ]); + assert.deepEqual(after.saved, [ null, null, null ]); + assert.deepEqual(after.layout, []); + assert.equal(after.passedColor, before.passedColor); + assert.equal(await _attr("#zoomResetBtn", "aria-disabled"), "true", "nothing left to reset"); + + await _page.focus("#themeInput"); + await _page.keyboard.press("Escape"); + assert.isFalse(await _appearanceIsOpen()); + }); + + step("should be usable from the keyboard alone, with every control named for a screen reader", async () => { + // The first Tab stop on the page skips the header and the controls, and leaves the hash alone + const firstStop = await _page.evaluate(() => { + const stop = document.querySelector("a[href], button:not([disabled]), input, select, summary, [tabindex]:not([tabindex='-1'])"); + return stop ? stop.id : ""; + }); + assert.equal(firstStop, "skipLink"); + const hash = await _hash(); + await _page.focus("#skipLink"); + await _page.keyboard.press("Enter"); + assert.equal(await _activeElementId(), "results", "the skip link lands on the results"); + assert.equal(await _hash(), hash, "the hash still holds the scope"); + + // Every control a keyboard can reach has a name a screen reader can say + const unnamed: string[] = await _page.evaluate(() => { + const found: string[] = []; + document.querySelectorAll("a[href], button, input, select, summary, [tabindex]").forEach((el) => { + const html = el as HTMLElement; + if (html.offsetParent === null || html.getAttribute("tabindex") === "-1" && html.id !== "results") { + return; + } + + const labelled = html.getAttribute("aria-label") || html.getAttribute("aria-labelledby") || + (html.id && document.querySelector("label[for='" + html.id + "']")) || html.closest("label") || + (html.textContent || "").trim(); + if (!labelled) { + found.push(html.outerHTML.substring(0, 80)); + } + }); + + return found; + }); + assert.deepEqual(unnamed, [], "unnamed controls"); + + // The accessibility tree Chrome gives a screen reader -- with the log panel open, as a closed + // one is not in it: the regions and the icon buttons by name, and the failure as a failure + await _page.click("#logPanel > summary"); + const tree = await _page.accessibility.snapshot({ interestingOnly: false }); + await _page.click("#logPanel > summary"); + const names: string[] = []; + const inResults: string[] = []; + const walk = (node: any, results: boolean) => { + if (node) { + const name = node.role + ":" + (node.name || ""); + names.push(name); + if (results) { + inResults.push(name); + } + + (node.children || []).forEach((kid: any) => walk(kid, results || name === "main:Test results")); + } + }; + walk(tree, false); + [ "main:Test results", "button:More actions", "button:Clear results", "combobox:Scope", "log:Runner log" ].forEach((expected) => { + assert.isTrue(names.indexOf(expected) !== -1, expected + " in the accessibility tree"); + }); + assert.isTrue(inResults.indexOf("StaticText:Failed:") !== -1, "a failed assertion is announced as failed"); + assert.isFalse(inResults.some((name) => name.indexOf("StaticText:✗") === 0), "and not as its glyph"); + }); + + step("should fold Failed only, then Target into the actions menu as the window narrows", async () => { + // Which controls are folded, whether the toolbar is on one line, and the menu's entries + const layout = () => _page.evaluate(() => { + const folded = (id: string) => getComputedStyle(document.getElementById(id) as Element).display === "none"; + const row = document.getElementById("controlsRow") as HTMLElement; + const shown = Array.prototype.filter.call(row.children, (el: HTMLElement) => el.offsetWidth > 0) as HTMLElement[]; + const last = shown[shown.length - 1]; + const entries = Array.prototype.filter.call(document.querySelectorAll("#actionsMenu [role^='menuitem']"), (el: HTMLElement) => { + for (let node: HTMLElement | null = el; node && node.id !== "actionsMenu"; node = node.parentElement) { + if (node.hasAttribute("hidden")) { + return false; + } + } + + return true; + }) as HTMLElement[]; + + return { + folded: [ "failedOnlyControl", "targetControl" ].filter(folded), + oneLine: last.offsetTop <= shown[0].offsetTop + shown[0].offsetHeight / 2, + menu: entries.map((el) => (el.textContent || "").trim() + (el.getAttribute("aria-checked") === "true" ? " ✓" : "")) + }; + }); + const resize = async (width: number, folded: string[]) => { + await _page.setViewport({ width: width, height: WIDE_VIEWPORT.height }); + // The compact layout follows the window from its resize event, and the folding from the + // frame after that: wait for both, as neither has happened when setViewport returns + await _waitFor((narrow: boolean) => document.body.classList.contains("layout-narrow") === narrow, + "the compact layout to follow the window to " + width + "px", width <= 600); + await _page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + await _waitFor((expected: string) => [ "failedOnlyControl", "targetControl" ] + .filter((id) => getComputedStyle(document.getElementById(id) as Element).display === "none").join() === expected, + "the controls to fold to fit " + width + "px", folded.join()); + + return layout(); + }; + const fixed = [ "Reload Tests", "Auto-reload ✓", "Clear Logs", "Appearance…", "Finish & Stop Server" ]; + const targets = target === "browser" ? [ "Browser ✓", "Worker" ] : [ "Browser", "Worker ✓" ]; + + let now = await layout(); + assert.deepEqual(now.folded, [], "a wide window holds every control"); + assert.isTrue(now.oneLine); + assert.deepEqual(now.menu, fixed, "and the menu holds only its own entries"); + + now = await resize(520, []); + assert.isTrue(now.oneLine, "520px still holds every control"); + assert.deepEqual(now.menu, fixed); + + now = await resize(390, [ "failedOnlyControl", "targetControl" ]); + assert.isTrue(now.oneLine, "a phone folds Failed only to keep Scope and Run on one line"); + assert.deepEqual(now.menu, [ "Failed only" ].concat(targets, fixed), "and Target from the title row, each ticked as its control is"); + + // A stand-in drives the real control + await _clickMenuItem("failedOnlyItem"); + assert.isTrue(await _isChecked("#failedOnlyToggle"), "choosing Failed only in the menu turns the filter on"); + assert.equal((await _readResults()).filter((row) => !row.hidden).length, 1); + assert.isTrue((await layout()).menu.indexOf("Failed only ✓") !== -1, "and ticks its stand-in"); + await _clickMenuItem("failedOnlyItem"); + assert.isFalse(await _isChecked("#failedOnlyToggle")); + + now = await resize(WIDE_VIEWPORT.width, []); + assert.isTrue(now.oneLine, "and widening the window brings them all back"); + assert.deepEqual(now.menu, fixed); + }); + + step("should clear the results from the status line and the log panel from the actions menu", async () => { assert.isTrue((await _readResults()).length > 0, "there are results to clear"); await _waitFor(() => { const logs = document.getElementById("logs"); return !!logs && logs.children.length > 0; }, "the run's log lines to render"); - await _clickMenuItem("clearResultsBtn"); - assert.isFalse(await _menuIsOpen(), "choosing an item closes the menu"); - assert.equal(await _activeElementId(), "actionsBtn", "and returns focus to the button"); + // The reporter's lines are in the colours the CLI's terminal shows them in, with no codes left over + const logs: { coloured: number, codes: boolean } = await _page.$eval("#logs", (el: Element) => ({ + coloured: el.querySelectorAll("span[class*='ansi-fg-']").length, + codes: (el.textContent || "").indexOf("\u001b") !== -1 + })); + assert.isTrue(logs.coloured > 0, "the log draws the reporter's colours"); + assert.isFalse(logs.codes, "and shows none of the codes themselves"); + + assert.equal(await _attr("#clearResultsBtn", "aria-label"), "Clear results", "an icon, named for a screen reader"); + await _page.click("#clearResultsBtn"); assert.equal(await _page.$eval("#results", (el: Element) => el.children.length), 0, "Clear Results empties the results list"); assert.equal(await _text("#assertCount"), "0 assertions", "and resets the assertion counter"); assert.isTrue(await _page.$eval("#logs", (el: Element) => el.children.length > 0), "Clear Results leaves the logs alone"); await _clickMenuItem("clearLogsBtn"); assert.equal(await _page.$eval("#logs", (el: Element) => el.children.length), 0, "Clear Logs empties the log panel"); - assert.isFalse(await _menuIsOpen()); + assert.isFalse(await _menuIsOpen(), "choosing an item closes the menu"); + assert.equal(await _activeElementId(), "actionsBtn", "and returns focus to the button"); }); } @@ -906,6 +1268,8 @@ describe("interactive runner page (puppeteer)", function() { try { await warmed; _page = await _browser.newPage(); + // Wide enough for every control on its row: the narrow-window step folds them on purpose + await _page.setViewport({ width: WIDE_VIEWPORT.width, height: WIDE_VIEWPORT.height }); _page.on("pageerror", (err: any) => { _pageErrors.push(err && err.message ? err.message : String(err)); }); @@ -931,11 +1295,11 @@ describe("interactive runner page (puppeteer)", function() { // The states the page went through on its way here -- see _TRACE_SCRIPT. await _assertFirstRunTrace("browser"); - assert.equal(await _statusText(), "Run complete. You can run again or click Finish to return to CLI."); + assert.equal(await _statusText(), "Run complete. Run again, or choose Finish & Stop Server from the ⋮ menu to send the result to the CLI."); assert.equal(await _text("#runBtn"), "Run Again"); assert.equal(await _pathname(), "/__tripwire__/browser"); assert.equal(await _attr("#finishBtn", "aria-disabled"), "false", "Finish is enabled once a result exists"); - assert.isTrue(await _isChecked("#autoReloadToggle"), "Auto-reload defaults to on"); + assert.equal(await _attr("#autoReloadItem", "aria-checked"), "true", "Auto-reload defaults to on"); assert.equal((await _readResults()).length, FIXTURE_TESTS.length, "the first run rendered every fixture test"); }); @@ -963,7 +1327,7 @@ describe("interactive runner page (puppeteer)", function() { await _assertFirstRunTrace("worker"); assert.equal(await _scopeValue(), SCOPED_SUITE, "the worker page restored the scope"); assert.deepEqual(_titles(await _readResults()), SCOPED_SUITE_TESTS, "the first worker run honoured it"); - assert.equal(await _statusText(), "Run complete. You can run again or click Finish to return to CLI."); + assert.equal(await _statusText(), "Run complete. Run again, or choose Finish & Stop Server from the ⋮ menu to send the result to the CLI."); await _page.click("#clearScopeBtn"); await _clickRunAndWait(); @@ -981,21 +1345,21 @@ describe("interactive runner page (puppeteer)", function() { step("should default on, survive Reload Tests when toggled off, and reset on a full page load", async () => { // Preference lives in memory (state.ts's autoReloadEnabled): not the hash, not storage. await _clearScopeIfSet(); - assert.isTrue(await _isChecked("#autoReloadToggle"), "Auto-reload defaults to on"); + assert.equal(await _attr("#autoReloadItem", "aria-checked"), "true", "Auto-reload defaults to on"); - await _page.click("#autoReloadToggle"); - assert.isFalse(await _isChecked("#autoReloadToggle")); + await _clickMenuItem("autoReloadItem"); + assert.equal(await _attr("#autoReloadItem", "aria-checked"), "false"); // Reload Tests tears down the run state but deliberately keeps this preference. await _clickMenuItem("reloadBtn"); await _waitForRunComplete(); - assert.isFalse(await _isChecked("#autoReloadToggle"), "an in-page reload keeps the toggle off"); + assert.equal(await _attr("#autoReloadItem", "aria-checked"), "false", "an in-page reload keeps the setting off"); assert.equal((await _readResults()).length, FIXTURE_TESTS.length, "Reload Tests re-ran the suite"); // A full page load starts from the default again: nothing persists it. await _page.reload({ waitUntil: "load" }); await _waitForRunComplete(); - assert.isTrue(await _isChecked("#autoReloadToggle"), "a page reload is back to the default"); + assert.equal(await _attr("#autoReloadItem", "aria-checked"), "true", "a page reload is back to the default"); assert.equal(await _hash(), "", "the preference is not carried in the hash"); assert.equal(await _page.evaluate(() => { try { diff --git a/internal/runner/test/src/ux/logging.test.ts b/internal/runner/test/src/ux/logging.test.ts index f00b74f..aecf4ef 100644 --- a/internal/runner/test/src/ux/logging.test.ts +++ b/internal/runner/test/src/ux/logging.test.ts @@ -10,7 +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"; +import { stubElement, stubTextNode, textOf, uxDom } from "../support/uxDom"; /** * The interactive page's log panel and its mirror to the node console: every entry is queued @@ -88,7 +88,8 @@ function _drainFrames(): void { frames = []; g.document = { - createElement: (tag: string) => stubElement(tag), + createElement: (tag: string) => stubElement("", tag), + createTextNode: stubTextNode, createDocumentFragment: () => { const fragment: any = { fragment: true, kids: [] as any[] }; fragment.appendChild = (child: any) => { @@ -114,7 +115,7 @@ function _sleep(ms: number): Promise { } function _lines(): string[] { - return dom.logs.kids.map((line: any) => line.className + " | " + line.textContent); + return dom.logs.kids.map((line: any) => line.className + " | " + textOf(line)); } describe("ux logging", () => { @@ -186,6 +187,22 @@ describe("ux logging", () => { assert.equal(logSendQueue.length, 2, "the server queue is not: it has its own schedule"); }); + it("should show a line in the colours the terminal shows it in, and a blank one as blank", () => { + logging.queueLog("log", "\u001b[32m ✓ passes\u001b[39m \u001b[90m(3ms)\u001b[0m"); + logging.queueLog("log", "\u001b[0m"); + logging.queueLog("log", ""); + _drainFrames(); + + assert.deepEqual(_lines(), [ "log-line log-log | [log] ✓ passes (3ms)", "log-line log-log | ", "log-line log-log | " ]); + assert.deepEqual(dom.logs.kids[0].kids.map((part: any) => (part.className || "text") + "=" + textOf(part)), [ + "ansi-fg-32= ✓ passes", + "text= ", + "ansi-fg-90=(3ms)" + ]); + assert.equal(dom.logs.kids[1].kids.length, 0, "a blank line carries no tag and no parts"); + assert.equal(logSendQueue[0].message, "\u001b[32m ✓ passes\u001b[39m \u001b[90m(3ms)\u001b[0m", "the CLI gets the same colours"); + }); + it("should keep only the newest MAX_LOG_LINES lines", () => { for (let lp = 0; lp < MAX_LOG_LINES + 3; lp++) { logging.queueLog("log", "m" + lp); @@ -193,8 +210,8 @@ describe("ux logging", () => { _drainFrames(); assert.equal(dom.logs.kids.length, MAX_LOG_LINES); - assert.equal(dom.logs.kids[0].textContent, "[log] m3", "the three oldest went"); - assert.equal(dom.logs.kids[MAX_LOG_LINES - 1].textContent, "[log] m" + (MAX_LOG_LINES + 2)); + assert.equal(textOf(dom.logs.kids[0]), "[log] m3", "the three oldest went"); + assert.equal(textOf(dom.logs.kids[MAX_LOG_LINES - 1]), "[log] m" + (MAX_LOG_LINES + 2)); }); it("should keep the panel pinned to the bottom only if it already was", () => { diff --git a/internal/runner/test/src/ux/menu.test.ts b/internal/runner/test/src/ux/menu.test.ts index 093d9a0..895dd06 100644 --- a/internal/runner/test/src/ux/menu.test.ts +++ b/internal/runner/test/src/ux/menu.test.ts @@ -175,6 +175,51 @@ describe("ux menu", () => { await new Promise((resolve) => setTimeout(resolve, 5)); assert.isFalse(isOpen()); }); + + it("counts radio items in a group as items, moving through them and closing when one is chosen", () => { + const group = stubElement("", "div"); + group.setAttribute("role", "group"); + const radio = stubElement("", "button"); + radio.setAttribute("role", "menuitemradio"); + group.appendChild(radio); + menu.insertBefore(group, items[2]); + + button.click(); + key(menu, "End"); + key(menu, "ArrowUp"); + assert.equal(doc.activeElement, radio, "between Finish and Reload"); + assert.equal(radio.getAttribute("tabindex"), "0", "it holds the roving tabindex"); + + radio.click(); + assert.isFalse(isOpen()); + assert.equal(doc.activeElement, button); + }); + + it("passes over hidden items, alone or in a hidden group, and counts checkbox items", () => { + const group = stubElement("", "div"); + const inGroup = stubElement("", "button"); + inGroup.setAttribute("role", "menuitemcheckbox"); + group.appendChild(inGroup); + const alone = stubElement("", "button"); + alone.setAttribute("role", "menuitemcheckbox"); + menu.insertBefore(group, items[0]); + menu.insertBefore(alone, items[0]); + + group.setAttribute("hidden", ""); + alone.setAttribute("hidden", ""); + button.click(); + assert.equal(doc.activeElement, items[0], "opens on the first item showing"); + key(menu, "ArrowUp"); + assert.equal(doc.activeElement, items[2], "and wraps past the hidden ones"); + key(button, "Escape"); + + group.removeAttribute("hidden"); + alone.removeAttribute("hidden"); + button.click(); + assert.equal(doc.activeElement, inGroup, "shown again, they lead the menu"); + key(menu, "ArrowDown"); + assert.equal(doc.activeElement, alone); + }); }); describe("nextMenuIndex", () => { diff --git a/internal/runner/test/src/ux/overflow.test.ts b/internal/runner/test/src/ux/overflow.test.ts new file mode 100644 index 0000000..ef3a619 --- /dev/null +++ b/internal/runner/test/src/ux/overflow.test.ts @@ -0,0 +1,300 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import { assert } from "@nevware21/tripwire"; +import { stubElement, uxDom } from "../support/uxDom"; + +// The module reads the page's elements from ./dom as it loads, which needs the stand-in document first +const dom = uxDom(); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const overflow: typeof import("../../../src/ux/browser/ui/overflow") = require("../../../src/ux/browser/ui/overflow"); +const { _rowWraps, createToolbarOverflow, fitToolbar, foldCheckbox, foldSelect, initToolbarOverflow } = overflow; + +/** + * Folding the page's controls into the actions menu when the window is too narrow for their row. + * The rows here are stand-in elements laid out the way a wrapping flex row is -- each control on the + * line it fits on, a new line when it does not -- from widths the tests choose, so a row's width + * decides what has to fold just as the window's does. + */ +describe("ux overflow", () => { + const GAP = 8; + const HEIGHT = 34; + + /** A row of controls `widths` wide, laid out across `width`, which the test can change. */ + function flexRow(width: number, widths: number[]) { + const row = stubElement("row", "div"); + let rowWidth = width; + const kids = widths.map((_w, idx) => { + const kid = stubElement("control" + idx, "div"); + row.appendChild(kid); + return kid; + }); + + const shown = (idx: number) => !kids[idx].classList.contains("is-folded"); + + /** Each control's line, counting only the ones still on the row. */ + function lines(): number[] { + const found: number[] = []; + let x = 0; + let line = 0; + widths.forEach((w, idx) => { + if (!shown(idx)) { + return; + } + + if (x > 0 && x + w > rowWidth) { + line++; + x = 0; + } + + found[idx] = line; + x += w + GAP; + }); + + return found; + } + + kids.forEach((kid, idx) => { + Object.defineProperty(kid, "offsetWidth", { get: () => (shown(idx) ? widths[idx] : 0) }); + Object.defineProperty(kid, "offsetHeight", { get: () => (shown(idx) ? HEIGHT : 0) }); + Object.defineProperty(kid, "offsetTop", { get: () => (lines()[idx] || 0) * (HEIGHT + GAP) }); + }); + + return { + row: row, + kids: kids, + setWidth: (value: number) => { + rowWidth = value; + } + }; + } + + function entry(control: any) { + const standIn = stubElement("", "button"); + standIn.setAttribute("hidden", ""); + let synced = 0; + + return { + control: control, + standIn: standIn, + sync: () => { + synced++; + }, + synced: () => synced + }; + } + + function folded(...entries: any[]): boolean[] { + return entries.map((item) => item.control.classList.contains("is-folded")); + } + + function standInsShown(...entries: any[]): boolean[] { + return entries.map((item) => !item.standIn.hasAttribute("hidden")); + } + + describe("_rowWraps", () => { + it("should tell a row on one line from one that has wrapped onto another", () => { + const flex = flexRow(500, [ 200, 100, 100 ]); + assert.isFalse(_rowWraps(flex.row)); + + flex.setWidth(400); + assert.isTrue(_rowWraps(flex.row)); + }); + + it("should not count controls that are not showing, and never wrap with fewer than two", () => { + const flex = flexRow(250, [ 200, 100 ]); + assert.isTrue(_rowWraps(flex.row)); + + flex.kids[1].classList.add("is-folded"); + assert.isFalse(_rowWraps(flex.row)); + }); + }); + + describe("createToolbarOverflow", () => { + // A scope box, Failed only, Auto-reload, Run and the menu button: 725 wide on one line + const WIDTHS = [ 320, 118, 127, 94, 34 ]; + + it("should fold the least needed first, only as many as the row needs, and unfold them as it widens", () => { + const flex = flexRow(1000, WIDTHS); + const wrapper = stubElement("folded", "div"); + const failedOnly = entry(flex.kids[1]); + const autoReload = entry(flex.kids[2]); + const overflow = createToolbarOverflow([ { row: flex.row, entries: [ autoReload, failedOnly ] } ], wrapper); + + overflow.fit(); + assert.deepEqual(folded(autoReload, failedOnly), [ false, false ], "room for everything"); + assert.deepEqual(standInsShown(autoReload, failedOnly), [ false, false ]); + assert.isTrue(wrapper.hasAttribute("hidden"), "nothing folded, nothing to show in the menu"); + + flex.setWidth(700); + overflow.fit(); + assert.deepEqual(folded(autoReload, failedOnly), [ true, false ], "one is enough"); + assert.deepEqual(standInsShown(autoReload, failedOnly), [ true, false ]); + assert.isFalse(wrapper.hasAttribute("hidden")); + assert.isFalse(_rowWraps(flex.row)); + + flex.setWidth(560); + overflow.fit(); + assert.deepEqual(folded(autoReload, failedOnly), [ true, true ]); + assert.deepEqual(standInsShown(autoReload, failedOnly), [ true, true ]); + + flex.setWidth(400); + overflow.fit(); + assert.deepEqual(folded(autoReload, failedOnly), [ true, true ], "folding no more than there is, though the row still wraps"); + + flex.setWidth(1000); + overflow.fit(); + assert.deepEqual(folded(autoReload, failedOnly), [ false, false ], "all back as the window widens"); + assert.isTrue(wrapper.hasAttribute("hidden")); + assert.equal(autoReload.synced(), 5, "each fit shows the controls' state on their stand-ins"); + }); + + it("should fit each row on its own, showing the menu's part while any of them is folded", () => { + const header = flexRow(400, [ 250, 140 ]); + const toolbar = flexRow(1000, WIDTHS); + const target = entry(header.kids[1]); + const autoReload = entry(toolbar.kids[2]); + const overflow = createToolbarOverflow([ + { row: header.row, entries: [ target ] }, + { row: toolbar.row, entries: [ autoReload ] } + ], null); + + overflow.fit(); + assert.deepEqual(folded(target, autoReload), [ false, false ]); + + header.setWidth(380); + overflow.fit(); + assert.deepEqual(folded(target, autoReload), [ true, false ], "only the row that is too narrow folds"); + }); + }); + + describe("foldCheckbox", () => { + it("should show the checkbox's state, and toggle it as a click would when chosen", () => { + const control = stubElement("control", "label"); + const input = stubElement("toggle", "input"); + const item = stubElement("item", "button"); + const changes: boolean[] = []; + input.addEventListener("change", () => changes.push(input.checked)); + + const folding = foldCheckbox(control, input, item); + assert.equal(folding.control, control); + assert.equal(folding.standIn, item); + + folding.sync(); + assert.equal(item.getAttribute("aria-checked"), "false"); + + item.click(); + assert.isTrue(input.checked); + assert.deepEqual(changes, [ true ], "the checkbox's own change fires"); + assert.equal(item.getAttribute("aria-checked"), "true"); + + input.checked = false; + input.dispatchEvent({ type: "change" }); + assert.equal(item.getAttribute("aria-checked"), "false", "a change made on the checkbox shows too"); + }); + }); + + describe("foldSelect", () => { + function picker() { + const control = stubElement("control", "div"); + const select = stubElement("select", "select"); + select.value = "browser"; + const group = stubElement("group", "div"); + const label = stubElement("label", "div"); + group.appendChild(label); + const items = [ "browser", "worker" ].map((choice) => { + const item = stubElement("", "button"); + item.setAttribute("data-target-choice", choice); + group.appendChild(item); + return item; + }); + const changes: string[] = []; + select.addEventListener("change", () => changes.push(select.value)); + + return { control: control, select: select, group: group, items: items, changes: changes, folding: foldSelect(control, select, group, "data-target-choice") }; + } + + it("should check the select's value, and set it to another when that is chosen", () => { + const ui = picker(); + assert.equal(ui.folding.standIn, ui.group); + + ui.folding.sync(); + assert.deepEqual(ui.items.map((item: any) => item.getAttribute("aria-checked")), [ "true", "false" ]); + assert.deepEqual(ui.items.map((item: any) => item.getAttribute("aria-disabled")), [ "false", "false" ]); + + ui.items[0].click(); + assert.deepEqual(ui.changes, [], "the current value is no change"); + + ui.items[1].click(); + assert.equal(ui.select.value, "worker"); + assert.deepEqual(ui.changes, [ "worker" ], "the select's own change fires"); + }); + + it("should be disabled while the select is", () => { + const ui = picker(); + ui.select.disabled = true; + + ui.folding.sync(); + ui.items[1].click(); + + assert.deepEqual(ui.items.map((item: any) => item.getAttribute("aria-disabled")), [ "true", "true" ]); + assert.equal(ui.select.value, "browser"); + assert.deepEqual(ui.changes, []); + }); + }); + + describe("initToolbarOverflow", () => { + const g = globalThis as any; + let saved: { [name: string]: any }; + + beforeEach(() => { + saved = { window: g.window, requestAnimationFrame: g.requestAnimationFrame }; + }); + + afterEach(() => { + Object.keys(saved).forEach((name) => { + if (saved[name] === undefined) { + delete g[name]; + } else { + g[name] = saved[name]; + } + }); + }); + + it("should fit the page's rows now and again once per frame as the window is resized", () => { + const listeners: { [type: string]: () => void } = {}; + const frames: Array<() => void> = []; + g.window = { + addEventListener: (type: string, fn: () => void) => { + listeners[type] = fn; + } + }; + g.requestAnimationFrame = (cb: () => void) => { + frames.push(cb); + return frames.length; + }; + dom.failedOnlyToggle.checked = false; + + initToolbarOverflow(); + assert.equal(dom.failedOnlyItem.getAttribute("aria-checked"), "false", "the stand-in starts in step"); + assert.isTrue(dom.foldedControls.hasAttribute("hidden"), "the page's stand-in elements have no layout, so nothing folds"); + + listeners.resize(); + listeners.resize(); + assert.equal(frames.length, 1, "one fit for a burst of resizes"); + + dom.failedOnlyToggle.checked = true; + frames[0](); + assert.equal(dom.failedOnlyItem.getAttribute("aria-checked"), "true", "the fit shows the state again"); + + listeners.resize(); + assert.equal(frames.length, 2, "and the next resize asks for another frame"); + fitToolbar(); + }); + }); +}); diff --git a/internal/runner/test/src/ux/rendering.test.ts b/internal/runner/test/src/ux/rendering.test.ts index e9316d6..c0a8c8e 100644 --- a/internal/runner/test/src/ux/rendering.test.ts +++ b/internal/runner/test/src/ux/rendering.test.ts @@ -11,7 +11,7 @@ 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"; +import { shownTextOf, textOf, useStubDocument, uxDom } from "../support/uxDom"; /** * How the interactive page draws a run: a collapsible node per suite and per test under the results @@ -47,15 +47,17 @@ function find(el: any, cls: string): any { describe("ux rendering", () => { let restoreDocument: () => void; + // The results scroll with the page, so this is what is kept pinned to the bottom + let page: any; beforeEach(() => { - restoreDocument = useStubDocument(dom).restore; + const installed = useStubDocument(dom); + restoreDocument = installed.restore; + page = installed.document.scrollingElement; + dom.failedOnlyToggle.checked = false; rendering.clearResults(dom.assertCount); clearAllState(); setHighlightSelection("", ""); - dom.results.scrollTop = 0; - dom.results.clientHeight = 0; - dom.results.scrollHeight = 0; }); afterEach(() => { @@ -70,7 +72,8 @@ describe("ux rendering", () => { 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.meta.textContent, "", "nothing to count yet"); + assert.equal(suite.meta.style.display, "none"); 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"); @@ -83,10 +86,33 @@ describe("ux rendering", () => { assert.equal(kids(outer.children)[0], inner.details); assert.equal(outer.childSuiteCount, 1); - assert.equal(outer.meta.textContent, "0/0 tests • 1 child suite"); + assert.equal(outer.meta.textContent, "1 child suite"); + assert.equal(outer.meta.style.display, ""); rendering.ensureSuiteNode([ "outer", "second" ]); - assert.equal(outer.meta.textContent, "0/0 tests • 2 child suites"); + assert.equal(outer.meta.textContent, "2 child suites"); + }); + + it("words a suite's counts: how far along while it runs, then what happened", () => { + const suite = rendering.ensureSuiteNode([ "counted" ]); + const meta = (total: number, done: number, failed: number, skipped: number) => { + suite.total = total; + suite.done = done; + suite.failed = failed; + suite.skipped = skipped; + rendering.updateSuiteMeta(suite); + + return suite.meta.textContent; + }; + + assert.equal(meta(3, 1, 0, 0), "1 of 3 done"); + assert.equal(meta(3, 3, 0, 0), "3 passed"); + assert.equal(meta(3, 3, 1, 1), "1 passed · 1 failed · 1 skipped"); + assert.equal(meta(2, 2, 2, 0), "2 failed", "no \"0 passed\""); + assert.equal(meta(2, 2, 0, 2), "2 skipped"); + + rendering.ensureSuiteNode([ "counted", "child" ]); + assert.equal(meta(2, 2, 1, 0), "1 passed · 1 failed · 1 child suite"); }); it("draws the default suite without a rerun button", () => { @@ -100,7 +126,7 @@ describe("ux rendering", () => { const suite = rendering.ensureSuiteNode([ "marked" ]); const marks: any = {}; - [ "passed", "failed", "pending", "running" ].forEach((status) => { + [ "passed", "failed", "pending", "skipped", "running" ].forEach((status) => { rendering.setSuiteMarker(suite, status); marks[status] = [ suite.marker.className, suite.marker.textContent ]; }); @@ -109,6 +135,7 @@ describe("ux rendering", () => { passed: [ "suite-marker marker-passed", "✓" ], failed: [ "suite-marker marker-failed", "✗" ], pending: [ "suite-marker marker-pending", "○" ], + skipped: [ "suite-marker marker-skipped", "○" ], running: [ "suite-marker marker-running", "" ] }); @@ -122,11 +149,14 @@ describe("ux rendering", () => { const test = rendering.ensureTestNode([ "outer", "inner" ], "works"); assert.equal(kids(suiteState["outer > inner"].children)[0], test.details); - assert.equal(test.label.textContent, "… works"); + assert.deepEqual(kids(test.label).map(textOf), [ "…", "works" ], "the marker, then the name"); + assert.equal(test.marker.className, "test-marker marker-running", "its own element, to take the status colour"); + assert.equal(test.marker.getAttribute("aria-hidden"), "true"); assert.equal(test.chip.textContent, "running"); assert.equal(find(test.details, "rerun-btn").getAttribute("data-rerun-test"), "outer > inner > works"); + assert.equal(find(test.details, "test-statuses"), test.chip.parentNode, "the statuses wrap together"); assert.equal(suiteState["outer"].total, 1); - assert.equal(suiteState["outer > inner"].meta.textContent, "0/1 tests"); + assert.equal(suiteState["outer > inner"].meta.textContent, "0 of 1 done"); assert.equal(rendering.ensureTestNode([ "outer", "inner" ], "works"), test, "drawn once"); }); @@ -144,7 +174,9 @@ describe("ux rendering", () => { rendering.setTestStatus(failed, "failed", (node) => filtered.push(node)); assert.isFalse(passed.details.open); - assert.equal(passed.label.textContent, "✓ passes"); + assert.deepEqual(kids(passed.label).map(textOf), [ "✓", "passes" ]); + assert.equal(passed.marker.className, "test-marker marker-passed"); + assert.equal(failed.marker.className, "test-marker marker-failed"); 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")); @@ -155,6 +187,18 @@ describe("ux rendering", () => { assert.deepEqual(filtered, [ passed, failed ]); }); + it("collapses a skipped test, marked as skipped rather than passed", () => { + const skipped = rendering.ensureTestNode([ "s" ], "later"); + + rendering.setTestStatus(skipped, "skipped", noFilter); + + assert.isFalse(skipped.details.open); + assert.deepEqual(kids(skipped.label).map(textOf), [ "○", "later" ]); + assert.equal(skipped.marker.className, "test-marker marker-skipped"); + assert.equal(skipped.chip.className, "status-chip status-skipped"); + assert.isTrue(skipped.details.classList.contains("test-skipped")); + }); + it("keeps the highlighted test open and scrolls to it", () => { setHighlightSelection("s", "s > chosen"); const chosen = rendering.ensureTestNode([ "s" ], "chosen"); @@ -195,6 +239,21 @@ describe("ux rendering", () => { assert.equal(passing.duration.textContent, "5ms"); }); + it("shows a suite whose tests were all skipped as skipped", () => { + const suite = rendering.ensureSuiteNode([ "later" ]); + suite.total = 2; + suite.done = 2; + suite.skipped = 2; + + rendering.updateSuiteStatus("later"); + + assert.equal(suite.chip.className, "status-chip status-skipped"); + assert.equal(suite.chip.textContent, "skipped"); + assert.equal(suite.marker.className, "suite-marker marker-skipped"); + assert.isTrue(suite.details.classList.contains("suite-skipped")); + assert.isFalse(suite.details.open); + }); + 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" ]); @@ -232,7 +291,7 @@ describe("ux rendering", () => { rendering.flushAssertionDomQueue(dom.assertCount); - assert.deepEqual(kids(test.assertions).map((item: any) => [ item.className, item.textContent ]), [ + assert.deepEqual(kids(test.assertions).map((item: any) => [ item.className, shownTextOf(item) ]), [ [ "assert-item assert-pass", "✓ equal(1, 1)" ], [ "assert-item assert-fail", "✗ equal(1, 2) -> expected 1 to equal 2" ] ]); @@ -242,6 +301,74 @@ describe("ux rendering", () => { assert.isFalse(assertionFlushScheduled); }); + it("says passed or failed to a screen reader, in place of the ✓ or ✗", () => { + 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", errorText: "no ::: detail", expected: "1" } + ); + + rendering.flushAssertionDomQueue(null); + + const [ pass, fail ] = kids(test.assertions); + assert.deepEqual(kids(pass).map((part: any) => [ part.className || "text", part.getAttribute ? part.getAttribute("aria-hidden") : null, part.textContent ]), [ + [ "assert-mark", "true", "✓ " ], + [ "sr-only", null, "Passed: " ], + [ "text", null, "equal(1, 1)" ] + ]); + assert.equal(textOf(find(fail, "assert-message")), "✗ Failed: equal -> no"); + assert.equal(find(fail, "assert-details").kids[1].getAttribute("tabindex"), "0", "a long description scrolls, so the keyboard can reach it"); + }); + + it("shows a failure's expected and actual values, and folds the rest of its description away", () => { + const test = rendering.ensureTestNode([ "s" ], "t"); + assertionDomQueue.push({ + testKey: test.key, + passed: false, + label: "equal", + errorText: "expected 1 to equal 2 ::: running \"equal()\" with (1)", + expected: "2", + actual: "1" + }); + + rendering.flushAssertionDomQueue(null); + + const item = kids(test.assertions)[0]; + assert.equal(item.className, "assert-item assert-fail"); + assert.equal(shownTextOf(find(item, "assert-message")), "✗ expected 1 to equal 2", "the message already names the assertion"); + assert.deepEqual(kids(find(item, "assert-values")).map((part: any) => part.className + "=" + part.textContent), [ + "assert-key=Expected", + "assert-value=2", + "assert-key=Actual", + "assert-value=1" + ]); + + const more = find(item, "assert-details"); + assert.equal(more.tagName, "DETAILS"); + assert.isFalse(more.open, "folded away"); + assert.deepEqual(kids(more).map(textOf), [ "Details", "running \"equal()\" with (1)" ]); + }); + + it("shows only the values a failure has, and no Details without a description", () => { + const test = rendering.ensureTestNode([ "s" ], "t"); + assertionDomQueue.push( + { testKey: test.key, passed: false, label: "isOk(value)", errorText: "expected undefined to be truthy", actual: "undefined" }, + { testKey: test.key, passed: false, label: "fail", errorText: " ::: thrown by fail()" } + ); + + rendering.flushAssertionDomQueue(null); + + const withValue = kids(test.assertions)[0]; + assert.equal(shownTextOf(find(withValue, "assert-message")), "✗ isOk(value) -> expected undefined to be truthy"); + assert.deepEqual(kids(find(withValue, "assert-values")).map(textOf), [ "Actual", "undefined" ]); + assert.isNull(find(withValue, "assert-details")); + + const detailOnly = kids(test.assertions)[1]; + assert.equal(shownTextOf(find(detailOnly, "assert-message")), "✗ fail"); + assert.isNull(find(detailOnly, "assert-values")); + assert.equal(textOf(find(detailOnly, "assert-details")), "Detailsthrown by fail()"); + }); + 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++) { @@ -251,7 +378,7 @@ describe("ux rendering", () => { 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(shownTextOf(kids(test.assertions)[0]), "✓ check 1", "the first was dropped"); assert.equal(test.assertionCount, MAX_ASSERTIONS_PER_TEST + 1); }); @@ -268,7 +395,7 @@ describe("ux rendering", () => { rendering.appendSyntheticAssertion(test, false, "", "boom", dom.assertCount); - assert.equal(kids(test.assertions)[0].textContent, "✗ (assertion) -> boom"); + assert.equal(shownTextOf(kids(test.assertions)[0]), "✗ boom", "the error's message, not a placeholder name"); assert.equal(dom.assertCount.textContent, "1 assertion"); rendering.appendSyntheticAssertion(null as any, true, "x", "", dom.assertCount); }); @@ -284,30 +411,101 @@ describe("ux rendering", () => { }); 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; + it("follows new results while the page is scrolled to the bottom, for a whole batch", () => { + page.clientHeight = 100; + page.scrollHeight = 100; rendering.beginRenderBatch(); rendering.ensureSuiteNode([ "a" ]); - dom.results.scrollHeight = 400; + page.scrollHeight = 400; rendering.ensureTestNode([ "a" ], "t"); rendering.endRenderBatch(); - assert.equal(dom.results.scrollTop, 400); + assert.equal(page.scrollTop, 400); + assert.equal(dom.results.scrollTop, 0, "the results panel does not scroll on its own"); }); - it("leaves the panel where it is when it was scrolled up", () => { - dom.results.clientHeight = 100; - dom.results.scrollHeight = 1000; + it("leaves the page where it is when it was scrolled up", () => { + page.clientHeight = 100; + page.scrollHeight = 1000; rendering.ensureSuiteNode([ "a" ]); - assert.equal(dom.results.scrollTop, 0); + assert.equal(page.scrollTop, 0); + rendering.endRenderBatch(); + }); + + it("falls back to the document element without a scrolling element, and to nothing without a document", () => { + const doc = (globalThis as any).document; + const root = doc.documentElement = page; + doc.scrollingElement = null; + root.clientHeight = 100; + root.scrollHeight = 100; + + rendering.beginRenderBatch(); + root.scrollHeight = 300; + rendering.endRenderBatch(); + assert.equal(root.scrollTop, 300); + + restoreDocument(); + restoreDocument = () => undefined; + rendering.beginRenderBatch(); rendering.endRenderBatch(); }); }); + describe("run summary", () => { + function chips(): string[] { + return kids(dom.runSummary).map((chip: any) => chip.tagName + "." + chip.className.replace("summary-chip ", "") + "=" + chip.textContent); + } + + it("is hidden until a test has finished", () => { + rendering.updateRunSummary(); + + assert.isTrue(dom.runSummary.hidden); + assert.deepEqual(chips(), []); + }); + + it("counts the tests as they finish, the failed count a button for Failed only", () => { + execution._handleTestDone([ "s" ], "a", "passed", 1, "", noFilter); + execution._handleTestDone([ "s" ], "b", "failed", 1, "boom", noFilter); + execution._handleTestDone([ "s" ], "c", "skipped", 0, "", noFilter); + + assert.isFalse(dom.runSummary.hidden); + assert.deepEqual(chips(), [ "SPAN.summary-passed=1 passed", "BUTTON.summary-failed=1 failed", "SPAN.summary-skipped=1 skipped" ]); + + const failedChip = find(dom.runSummary, "summary-failed"); + assert.equal(failedChip.getAttribute("type"), "button"); + assert.equal(failedChip.getAttribute("aria-pressed"), "false"); + + dom.failedOnlyToggle.checked = true; + rendering.updateRunSummary(); + assert.equal(find(dom.runSummary, "summary-failed").getAttribute("aria-pressed"), "true"); + assert.isFalse(dom.filterNote.hidden); + assert.equal(dom.filterNote.textContent, "2 passed or skipped tests hidden by Failed only"); + }); + + it("shows the run's own summary once it is over, with how long it took", () => { + execution._handleTestDone([ "s" ], "a", "passed", 1, "", noFilter); + + rendering.updateRunSummary({ passed: 4, failed: 0, skipped: 1, pending: 1, duration: 1534 }); + + assert.deepEqual(chips(), [ "SPAN.summary-passed=4 passed", "SPAN.summary-skipped=2 skipped", "SPAN.summary-duration=1.53s" ]); + + rendering.updateRunSummary({ passed: 1 }); + assert.deepEqual(chips(), [ "SPAN.summary-passed=1 passed" ], "no duration to show"); + }); + + it("is emptied and hidden again when the results are cleared", () => { + execution._handleTestDone([ "s" ], "a", "passed", 1, "", noFilter); + + rendering.clearResults(null); + + assert.isTrue(dom.runSummary.hidden); + assert.deepEqual(chips(), []); + }); + }); + it("clears every drawn result and the counts", () => { rendering.ensureTestNode([ "s" ], "t"); assertionDomQueue.push({ testKey: "x", passed: true, label: "", errorText: "" }); @@ -358,13 +556,16 @@ describe("ux rendering", () => { assert.equal(suiteState["outer > inner"].chip.textContent, "failed"); }); - it("treats skipped and pending tests as passed", () => { + it("shows skipped and pending tests as skipped: not failed, and not claimed to have 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(skipped.status, "skipped"); + assert.equal(pending.status, "skipped"); assert.equal(suiteState["s"].failed, 0); + assert.equal(suiteState["s"].skipped, 2); + assert.equal(suiteState["s"].chip.textContent, "skipped"); + assert.equal(suiteState["s"].meta.textContent, "2 skipped"); }); it("adds a failure of its own to a failed test that reported no assertion", () => { @@ -372,9 +573,9 @@ describe("ux rendering", () => { 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"); + assert.equal(shownTextOf(kids(failed.assertions)[0]), "✗ Test failed -> expected true"); + assert.equal(shownTextOf(kids(timedOut.assertions)[0]), "✗ Test timed out -> Timeout of 2000ms exceeded"); + assert.equal(shownTextOf(kids(silent.assertions)[0]), "✗ Test failed"); }); it("leaves a failed test's own assertions, drawn or still queued, to explain it", () => { diff --git a/internal/runner/test/src/ux/resources.test.ts b/internal/runner/test/src/ux/resources.test.ts new file mode 100644 index 0000000..5d7639b --- /dev/null +++ b/internal/runner/test/src/ux/resources.test.ts @@ -0,0 +1,73 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import "../support/textImports"; +import { assert } from "@nevware21/tripwire"; +import { createBrowserRunnerHtml } from "../../../src/ux/browser/ui/resources"; + +/** + * The interactive page as the server sends it: what it carries of the run's --browser-theme, the + * stylesheets it is built from, and the accessibility basics a page has to declare up front. + */ +describe("ux resources", () => { + describe("createBrowserRunnerHtml", () => { + function bodyTag(html: string): string { + return /]*>/.exec(html)[0]; + } + + it("carries each part of the run's theme to the page as a default it applies before painting", () => { + const html = createBrowserRunnerHtml("browser", "slate", { scheme: "dim", vision: "colorblind", zoom: 125 }); + + assert.equal(bodyTag(html), ""); + assert.includes(html, "choose(\"tripwire.theme\", \"data-default-theme\",", "the inline script reads the default, after a choice kept in the browser"); + assert.includes(html, "choose(\"tripwire.zoom\", \"data-default-zoom\", isZoom)"); + assert.includes(html, "body.style.setProperty(\"--ui-zoom\"", "and applies the zoom to the page's own --ui-zoom"); + }); + + it("carries only the parts the run set, and none without a theme", () => { + assert.equal(bodyTag(createBrowserRunnerHtml("worker", "classic", { zoom: 80 })), ""); + assert.equal(bodyTag(createBrowserRunnerHtml("browser", "classic")), ""); + assert.equal(createBrowserRunnerHtml("browser", "classic").indexOf("__RUNNER_"), -1, "every placeholder filled"); + }); + + it("escapes a theme value, which a custom caller could pass unchecked", () => { + const html = createBrowserRunnerHtml("browser", "classic", { scheme: "dark\" onload=\"x" as any }); + + assert.includes(bodyTag(html), "data-default-theme=\"dark" onload="x\""); + }); + + it("layers the accessibility themes over the style", () => { + const html = createBrowserRunnerHtml("browser", "classic"); + + assert.isTrue(html.indexOf("body.style-classic {") < html.indexOf("body[data-theme=\"dim\"] {"), "after the style, so they win"); + assert.includes(html, "body[data-vision=\"colorblind\"] {"); + assert.equal(html.indexOf("data-font-size"), -1, "the zoom is not a theme: nothing sizes the root, which a test's own content shares"); + }); + + it("zooms only the page's own controls, results and log, and fits its compact layout to the zoom", () => { + const html = createBrowserRunnerHtml("browser", "slate"); + + assert.isTrue(/\.app-shell,\s*\.skip-link \{\s*zoom: var\(--ui-zoom, 1\);/.test(html), "the app shell and the skip link follow --ui-zoom"); + assert.isTrue(/\n
{ + const html = createBrowserRunnerHtml("browser", "classic"); + + assert.includes(html, ""); + assert.includes(html, "Skip to the results"); + assert.includes(html, "
"); + assert.includes(html, "
"); + assert.includes(html, "id=\"logs\" role=\"log\" aria-live=\"off\" aria-label=\"Runner log\" tabindex=\"0\""); + }); + }); +}); diff --git a/internal/runner/test/src/ux/theme.test.ts b/internal/runner/test/src/ux/theme.test.ts new file mode 100644 index 0000000..1dc148f --- /dev/null +++ b/internal/runner/test/src/ux/theme.test.ts @@ -0,0 +1,302 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import { assert } from "@nevware21/tripwire"; +import { BROWSER_ZOOM_MAX, BROWSER_ZOOM_MIN } from "../../../src/shared/browserTheme"; +import { + applyPageSetting, findTheme, fitLayout, readPageSetting, readRunSetting, readThemeChoice, readZoom, searchThemes, stepZoom, THEME_STORAGE_KEY, + THEMES, VISION_STORAGE_KEY, ZOOM_LEVELS, ZOOM_STORAGE_KEY +} from "../../../src/ux/browser/ui/theme"; +import { useStubDocument } from "../support/uxDom"; + +/** + * The page's settings -- its theme, color vision and zoom: what each sets on the page, the choice + * kept in this browser's local storage, and the run's --browser-theme underneath. Storage is a plain + * object here -- or one that throws, as a browser blocking it does -- and `window` only its size. + */ +describe("ux theme", () => { + const g = globalThis as any; + let restoreDocument: () => void; + let doc: any; + let orgStorage: PropertyDescriptor | undefined; + let orgWindow: PropertyDescriptor | undefined; + let saved: { [key: string]: string }; + + function useStorage(storage: any): void { + Object.defineProperty(g, "localStorage", { configurable: true, writable: true, value: storage }); + } + + function plainStorage(): any { + return { + getItem: (key: string) => (key in saved ? saved[key] : null), + setItem: (key: string, value: string) => { + saved[key] = String(value); + }, + removeItem: (key: string) => { + delete saved[key]; + } + }; + } + + function blockedStorage(): any { + const blocked = () => { + throw new Error("The operation is insecure"); + }; + + return { getItem: blocked, setItem: blocked, removeItem: blocked }; + } + + function useWindow(width: number, height: number): void { + Object.defineProperty(g, "window", { configurable: true, writable: true, value: { innerWidth: width, innerHeight: height } }); + } + + function restore(name: string, descriptor: PropertyDescriptor | undefined): void { + if (descriptor) { + Object.defineProperty(g, name, descriptor); + } else { + delete g[name]; + } + } + + function zoomProperty(): string { + return doc.body.style.getPropertyValue("--ui-zoom"); + } + + beforeEach(() => { + const installed = useStubDocument(); + doc = installed.document; + restoreDocument = installed.restore; + orgStorage = Object.getOwnPropertyDescriptor(g, "localStorage"); + orgWindow = Object.getOwnPropertyDescriptor(g, "window"); + saved = {}; + useStorage(plainStorage()); + }); + + afterEach(() => { + restoreDocument(); + restore("localStorage", orgStorage); + restore("window", orgWindow); + }); + + it("shares its storage keys with the page's inline script", () => { + assert.deepEqual([ THEME_STORAGE_KEY, VISION_STORAGE_KEY, ZOOM_STORAGE_KEY ], [ "tripwire.theme", "tripwire.vision", "tripwire.zoom" ]); + }); + + describe("the themes", () => { + it("lists System first, then each theme by a unique id the --browser-theme words share", () => { + assert.deepEqual(THEMES.map((theme) => theme.id), [ "system", "light", "dark", "dim" ]); + assert.deepEqual(THEMES.map((theme) => theme.name), [ "System", "Light", "Dark", "Low light" ]); + assert.equal(findTheme("dim"), THEMES[3]); + assert.isNull(findTheme("sepia")); + assert.isNull(findTheme("constructor"), "not an inherited property"); + }); + + it("finds the ones with every searched word in their name, id or keywords, in any order and ignoring case", () => { + const names = (query: string) => searchThemes(query).map((theme) => theme.name); + + assert.deepEqual(names(""), [ "System", "Light", "Dark", "Low light" ], "nothing searched finds them all"); + assert.deepEqual(names("LIGHT"), [ "Light", "Low light" ]); + assert.deepEqual(names("light low"), [ "Low light" ]); + assert.deepEqual(names("dim"), [ "Low light" ], "by its id"); + assert.deepEqual(names("warm"), [ "Low light" ], "by a keyword"); + assert.deepEqual(names("sepia"), []); + }); + }); + + describe("the theme", () => { + it("reads the saved choice", () => { + saved[THEME_STORAGE_KEY] = "dark"; + assert.equal(readThemeChoice(), "dark"); + }); + + it("follows the system without a choice, with one it does not know, or when storage is blocked", () => { + assert.equal(readThemeChoice(), "system"); + + saved[THEME_STORAGE_KEY] = "sepia"; + assert.equal(readThemeChoice(), "system"); + + useStorage(blockedStorage()); + assert.equal(readThemeChoice(), "system"); + + delete g.localStorage; + assert.equal(readThemeChoice(), "system", "no storage at all"); + }); + + it("sets the body's theme and remembers it", () => { + assert.isTrue(applyPageSetting("theme", "dark", true)); + + assert.equal(doc.body.getAttribute("data-theme"), "dark"); + assert.equal(saved[THEME_STORAGE_KEY], "dark"); + }); + + it("goes back to the system setting by taking the attribute and the saved choice away", () => { + applyPageSetting("theme", "light", true); + + applyPageSetting("theme", "system", true); + + assert.isFalse(doc.body.hasAttribute("data-theme")); + assert.isFalse(THEME_STORAGE_KEY in saved); + }); + + it("only remembers the choice when asked to", () => { + applyPageSetting("theme", "dark", false); + + assert.equal(doc.body.getAttribute("data-theme"), "dark"); + assert.deepEqual(saved, {}); + }); + + it("changes nothing for a theme it does not have", () => { + applyPageSetting("theme", "dark", true); + + assert.isFalse(applyPageSetting("theme", "sepia", true)); + assert.equal(doc.body.getAttribute("data-theme"), "dark"); + assert.equal(saved[THEME_STORAGE_KEY], "dark"); + }); + + it("still applies the choice when it cannot be remembered, or there is no page", () => { + useStorage(blockedStorage()); + applyPageSetting("theme", "light", true); + assert.equal(doc.body.getAttribute("data-theme"), "light"); + + delete g.localStorage; + applyPageSetting("theme", "dark", true); + assert.equal(doc.body.getAttribute("data-theme"), "dark"); + + restoreDocument(); + restoreDocument = () => undefined; + assert.isTrue(applyPageSetting("theme", "dark", false)); + }); + }); + + describe("color vision", () => { + it("turns the color-blind palette on and off, on the body", () => { + applyPageSetting("vision", "colorblind", true); + assert.equal(doc.body.getAttribute("data-vision"), "colorblind"); + assert.equal(saved[VISION_STORAGE_KEY], "colorblind"); + + applyPageSetting("vision", "standard", true); + assert.isFalse(doc.body.hasAttribute("data-vision")); + assert.isFalse(VISION_STORAGE_KEY in saved); + }); + }); + + describe("the zoom", () => { + it("sets the body's --ui-zoom as a scale, which only the page's own controls follow", () => { + applyPageSetting("zoom", "125", true); + assert.equal(zoomProperty(), "1.25"); + assert.equal(saved[ZOOM_STORAGE_KEY], "125"); + assert.equal(readZoom(), 125); + assert.isFalse(doc.documentElement.hasAttribute("data-font-size"), "never the root's size, which a test's content shares"); + + applyPageSetting("zoom", "100", true); + assert.equal(zoomProperty(), "", "100% needs nothing set"); + assert.isFalse(ZOOM_STORAGE_KEY in saved); + assert.equal(readZoom(), 100); + }); + + it("takes a whole percentage from the smallest zoom to the largest, and nothing else", () => { + assert.deepEqual([ ZOOM_LEVELS[0], ZOOM_LEVELS[ZOOM_LEVELS.length - 1] ], [ BROWSER_ZOOM_MIN, BROWSER_ZOOM_MAX ], "the same range as --browser-theme"); + + [ "50", "133", "300" ].forEach((value) => { + assert.isTrue(applyPageSetting("zoom", value, false), value); + }); + [ "49", "301", "12.5", "125%", "", "abc" ].forEach((value) => { + assert.isFalse(applyPageSetting("zoom", value, true), value); + }); + assert.equal(zoomProperty(), "3"); + assert.deepEqual(saved, {}); + + saved[ZOOM_STORAGE_KEY] = "999"; + assert.equal(readZoom(), 100, "a kept value out of range is no choice"); + }); + + it("steps between the zoom levels, from a zoom between two of them to the next, and stops at either end", () => { + assert.equal(stepZoom(100, 1), 110); + assert.equal(stepZoom(100, -1), 90); + assert.equal(stepZoom(133, 1), 150); + assert.equal(stepZoom(133, -1), 125); + assert.equal(stepZoom(300, 1), 300); + assert.equal(stepZoom(50, -1), 50); + }); + }); + + describe("the run's --browser-theme", () => { + it("starts each setting as the run set it, unless this browser kept a choice", () => { + doc.body.setAttribute("data-default-theme", "dark"); + doc.body.setAttribute("data-default-vision", "colorblind"); + doc.body.setAttribute("data-default-zoom", "150"); + assert.deepEqual([ readPageSetting("theme"), readPageSetting("vision"), readPageSetting("zoom") ], [ "dark", "colorblind", "150" ]); + assert.equal(readRunSetting("zoom"), "150"); + + saved[THEME_STORAGE_KEY] = "light"; + saved[ZOOM_STORAGE_KEY] = "80"; + assert.deepEqual([ readPageSetting("theme"), readPageSetting("vision"), readPageSetting("zoom") ], [ "light", "colorblind", "80" ]); + assert.equal(readRunSetting("zoom"), "150", "what the run set, whatever this browser kept"); + + doc.body.setAttribute("data-default-theme", "sepia"); + delete saved[THEME_STORAGE_KEY]; + assert.equal(readThemeChoice(), "system", "a value it does not know is no default"); + }); + + it("keeps a choice only while it differs from the run's, so choosing the run's goes back to following it", () => { + doc.body.setAttribute("data-default-theme", "dark"); + doc.body.setAttribute("data-default-zoom", "150"); + + applyPageSetting("theme", "system", true); + assert.equal(saved[THEME_STORAGE_KEY], "system", "System over the run's Dark has to be kept"); + assert.isFalse(doc.body.hasAttribute("data-theme")); + + applyPageSetting("theme", "dark", true); + assert.isFalse(THEME_STORAGE_KEY in saved); + assert.equal(doc.body.getAttribute("data-theme"), "dark"); + + applyPageSetting("zoom", "100", true); + assert.equal(saved[ZOOM_STORAGE_KEY], "100", "100% over the run's 150% has to be kept"); + applyPageSetting("zoom", "150", true); + assert.isFalse(ZOOM_STORAGE_KEY in saved); + }); + }); + + describe("fitLayout", () => { + function layout(): string[] { + return [ "layout-narrow", "layout-short" ].filter((cls) => doc.body.classList.contains(cls)); + } + + it("takes the compact layout while the window is small for the page at its zoom", () => { + useWindow(1200, 720); + fitLayout(); + assert.deepEqual(layout(), [], "a desktop window at 100%"); + + applyPageSetting("zoom", "125", false); + assert.deepEqual(layout(), [], "960 by 576 of the page's pixels"); + + applyPageSetting("zoom", "200", false); + assert.deepEqual(layout(), [ "layout-narrow", "layout-short" ], "600 by 360: a zoom fits the layout straight away"); + + applyPageSetting("zoom", "100", false); + useWindow(390, 760); + fitLayout(); + assert.deepEqual(layout(), [ "layout-narrow" ], "a phone at 100%"); + + useWindow(1200, 480); + fitLayout(); + assert.deepEqual(layout(), [ "layout-short" ], "a short window"); + }); + + it("does nothing without a window or a page", () => { + delete g.window; + fitLayout(); + assert.deepEqual(layout(), []); + + useWindow(300, 300); + restoreDocument(); + restoreDocument = () => undefined; + fitLayout(); + }); + }); +}); diff --git a/internal/runner/test/src/ux/utils.test.ts b/internal/runner/test/src/ux/utils.test.ts index c9bc654..cd6e7cc 100644 --- a/internal/runner/test/src/ux/utils.test.ts +++ b/internal/runner/test/src/ux/utils.test.ts @@ -9,7 +9,7 @@ import { assert } from "@nevware21/tripwire"; import { _setDurationText, _setNodeHidden, _setStatusClass, _stickToBottom, _wasAtBottom, - createRerunButton, formatCount, getStatusMarker, resolveUrl, safeToString + createRerunButton, formatCount, formatDuration, getStatusMarker, resolveUrl, safeToString } from "../../../src/ux/browser/ui/utils"; /** @@ -135,6 +135,17 @@ describe("ux utils", () => { }); }); + describe("formatDuration", () => { + it("should give under a second in whole ms, and longer in seconds", () => { + assert.equal(formatDuration(0), "0ms"); + assert.equal(formatDuration(12.6), "13ms"); + assert.equal(formatDuration(999), "999ms"); + assert.equal(formatDuration(1000), "1.00s"); + assert.equal(formatDuration(1534), "1.53s"); + assert.equal(formatDuration(12345), "12.3s", "one decimal from ten seconds"); + }); + }); + describe("getStatusMarker", () => { it("should give each status its glyph and a hollow circle to anything else", () => { assert.equal(getStatusMarker("passed"), "✓"); @@ -166,12 +177,15 @@ describe("ux utils", () => { }); describe("_setDurationText", () => { - it("should show a positive duration in ms and hide anything else", () => { + it("should show a positive duration and hide anything else", () => { const el = _element(); _setDurationText(el, 125); assert.equal(el.textContent, "125ms"); assert.equal(el.style.display, "inline-flex"); + _setDurationText(el, 12345); + assert.equal(el.textContent, "12.3s", "a long one in seconds"); + _setDurationText(el, 0); assert.equal(el.textContent, ""); assert.equal(el.style.display, "none"); @@ -191,6 +205,10 @@ describe("ux utils", () => { _setStatusClass(el, "chip", "failed"); assert.deepEqual(el.classes, ["chip", "chip-failed"]); + + _setStatusClass(el, "chip", "skipped"); + _setStatusClass(el, "chip", "passed"); + assert.deepEqual(el.classes, ["chip", "chip-passed"], "skipped is swapped out too"); }); }); diff --git a/package.json b/package.json index a132292..5f3f157 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "cleanBuild": "npm run clean && npm run rebuild && npm run readme-link-check", "docs": "npm run docs-core && npm run docs-chai", "docs-reporter-images": "node docs/scripts/genReporterImages.js", + "docs-tour-images": "node docs/scripts/genTourImages.js", "docs-core": "cd core && npm run docs", "docs-chai": "cd shim/chai && npm run docs", "lint": "grunt lint lint-fix",