From 82d1b847c218d9a7e3c50a03ac283fb3f534c6c7 Mon Sep 17 00:00:00 2001 From: Nev Date: Tue, 27 Oct 2026 20:21:29 -0700 Subject: [PATCH 1/2] feat(runner): redesign the interactive page, with an Appearance panel, accessibility themes and a tour of tripwire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Layout: the title row holds the Target picker and the actions menu at the top right; the Scope row (Scope, Failed only, Run) and the status line stay pinned while the results scroll with the page, so there is one scrollbar and the background no longer repeats with a seam. - Narrow windows: instead of wrapping a row, Failed only and then Target fold into the actions menu as stand-ins that drive the real controls (ui/overflow.ts); they unfold as the window widens, and on a phone Scope and Run share one line. - Actions menu: Reload Tests, Auto-reload (now a ticked menu setting with no toolbar checkbox), Clear Logs, Appearance… and Finish & Stop Server; it supports checkbox and radio entries, skips hidden entries with the arrow keys and scrolls on a short window. - Clear Results moves out of the menu to an icon at the end of the status line, Clear scope becomes an ✕ inside the Scope box that shows while a scope is set, and the toolbar Finish button is removed in favour of the menu's Finish & Stop Server. - Status line: a run summary (passed, failed, skipped and the run's duration) whose failed count toggles Failed only, kept right-aligned with the assertion count and Clear results even when the line wraps; the status text points at Finish & Stop Server, and the header shows the target, host and test count. - Results: suites read "2 passed · 1 failed · 1 child suite", each row's left edge shows its state, skipped and pending tests show as skipped rather than passed, the test glyphs take their status colour, durations over a second read in seconds, and Failed only says how many tests it is hiding. - Failed assertions show their message once, then the Expected and Actual values (sent from the test frame and the worker via shared/assertionValues.ts), with the rest of the description folded under Details; the values are written without running anything of the test's own (no getters, toJSON or toString; accessors read [Getter]) and never throw, so the assertion always fails with its own error. - Runner Log: draws the reporters' terminal colours (ui/ansi.ts, tokenized by @nevware21/chromacon's parseAnsi: SGR styles and 16, 256 and 24-bit colours, with other sequences such as OSC 8 hyperlinks dropped) in the GitHub dark palette the docs' reporter images use, and keeps blank lines blank. - Appearance panel (ui/appearance.ts): a dialog opened from the actions menu, as a menu may hold only menu items; it holds the theme as a searchable combobox (any part of a name, in any order, or ArrowDown for the full list), Color-blind safe as a checkbox, and the zoom; Escape closes the theme list first and then the panel, returning focus to the menu button. - Themes come from one list (THEMES in ui/theme.ts) so that user-defined themes can be added later: System, Light and Dark for both styles, Low light (dim), and color-blind safe (Okabe-Ito blue and orange in light and dark, the log included). - Zoom: − and + step through 50% to 300% and Reset returns to the run's zoom; it is CSS zoom on the page's own container (--ui-zoom on .app-shell), so it never uses the browser's zoom and never scales a test's frame, content or the root font size; viewport units inside it are divided by the zoom. - The compact layout follows the zoom: body classes layout-narrow and layout-short, set before first paint and on resize, replace the two window-size media queries, so a zoomed page takes its narrow layout as a smaller window would. - --browser-theme and browserTheme in a config file (a string or an array) set the theme, color vision and zoom (eg. dim,colorblind,125%); the flag replaces only the parts it names, and an unknown word or a zoom outside 50% to 300% is an error. A choice made in the panel is kept in that browser while it differs from the run's setting, and is applied inline before the page first paints. - Autocomplete: a closed list reopens on ArrowDown or a click, a new openOnFocus option keeps it closed when its input takes focus as a dialog opens, it returns a close() for its owner, and a value already picked from the list is no longer highlighted as if it were a search. - Accessibility: page language; a skip link to the results that leaves the hash alone; labelled regions; named icon buttons and plain-word names where the small-capital labels leaked into accessible names; aria-activedescendant for the suggestion lists; assertions announced as passed or failed instead of by glyph; focusable scrolling regions; focus-visible outlines; reduced-motion support. - Build: the browser and worker bundles keep their iife wrapper (uglify's toplevel now applies only to the CommonJS CLI), since unwrapped into a bare block their vars were globals and the test frame's own `it` replaced ts-utils' "call", stopping every run in the page. - Build: uglify no longer runs with unsafe, which rewrote every String(x) as ""+x in all three bundles; that throws for a Symbol and failed each symbol assertion in debug:browser and debug:worker. - Removes the unused resources/runner.css and resources/runner.html. - Docs: a new tour (docs/tour.md) shows tripwire's own suite running unchanged on node, in Chrome and in a web worker, coverage from the command line, the interactive page and the measured speed, with images made from real runs by docs/scripts/genTourImages.js (npm run docs-tour-images, or -- terminal / -- browser for one set); it is linked from the README, the docs index, why-tripwire.md and llms.txt. - Docs: test-runner.md documents the reworked interactive page and adds a Themes and accessibility section covering the Appearance panel and zoom, with --browser-theme and browserTheme in both tables; --help, llms.txt and the Mocha guide's configPrecedence block are updated. - Tests: new ansi, overflow, theme, appearance, resources, assertionValues, browserTheme and genTourImages suites; the uxDom stand-ins gain comma selectors, scrollingElement and documentElement, text nodes, real Event dispatch, style.setProperty and shownTextOf. - Tests: binGuard adds BIN-SCOPE (nothing but expressions at the top level of the browser and worker bundles) and BIN-STRING (String(x) survives minification). - The puppeteer page test now covers the run summary, Expected/Actual values, log colours, folding at 520px and 390px, theme search and the Appearance panel, color-blind colours, zoom with the compact layout across a reload, keyboard use and Chrome's accessibility tree. --- README.md | 4 +- docs/README.md | 3 + docs/images/tour/browser-dark.png | Bin 0 -> 96239 bytes docs/images/tour/browser-failure.png | Bin 0 -> 9895 bytes docs/images/tour/browser-phone.png | Bin 0 -> 49084 bytes docs/images/tour/browser-runner.png | Bin 0 -> 101129 bytes docs/images/tour/browser-scope.png | Bin 0 -> 51331 bytes docs/images/tour/coverage.svg | 33 + docs/images/tour/one-file-node.svg | 30 + docs/images/tour/one-file-three-targets.svg | 37 ++ docs/images/tour/whole-suite.svg | 24 + docs/llms.txt | 3 +- docs/migration/migrating-from-mocha.md | 1 + docs/scripts/genTourImages.js | 330 ++++++++++ docs/test-runner.md | 65 +- docs/tour.md | 179 ++++++ docs/why-tripwire.md | 4 + internal/runner/rollup.config.js | 12 +- internal/runner/src/cli/parseArgs.ts | 37 ++ internal/runner/src/client/workerRunner.ts | 5 +- internal/runner/src/execute/browserRunner.ts | 2 +- internal/runner/src/index.ts | 1 + internal/runner/src/interfaces/IParsedArgs.ts | 7 + .../src/interfaces/IRunnerUxProvider.ts | 4 +- .../runner/src/interfaces/ITripwireConfig.ts | 9 + internal/runner/src/shared/assertionValues.ts | 186 ++++++ internal/runner/src/shared/browserTheme.ts | 100 +++ internal/runner/src/tripwire.ts | 1 + .../src/ux/browser/resources/runner.css | 39 -- .../src/ux/browser/resources/runner.html | 40 -- .../src/ux/browser/resources/styles/base.css | 572 ++++++++++++++++-- .../ux/browser/resources/styles/classic.css | 81 ++- .../src/ux/browser/resources/styles/slate.css | 81 ++- .../ux/browser/resources/styles/themes.css | 109 ++++ .../browser/resources/templates/runner.html | 171 ++++-- internal/runner/src/ux/browser/ui/ansi.ts | 242 ++++++++ .../runner/src/ux/browser/ui/appearance.ts | 271 +++++++++ .../runner/src/ux/browser/ui/assertions.ts | 27 +- .../runner/src/ux/browser/ui/autocomplete.ts | 97 ++- internal/runner/src/ux/browser/ui/client.ts | 106 +++- internal/runner/src/ux/browser/ui/dom.ts | 26 +- .../runner/src/ux/browser/ui/filtering.ts | 29 +- .../runner/src/ux/browser/ui/frame-mode.ts | 5 +- internal/runner/src/ux/browser/ui/logging.ts | 13 +- internal/runner/src/ux/browser/ui/menu.ts | 31 +- internal/runner/src/ux/browser/ui/overflow.ts | 197 ++++++ .../runner/src/ux/browser/ui/resources.ts | 28 +- internal/runner/src/ux/browser/ui/shared.ts | 4 + internal/runner/src/ux/browser/ui/state.ts | 11 + .../src/ux/browser/ui/test-execution.ts | 15 +- internal/runner/src/ux/browser/ui/theme.ts | 275 +++++++++ .../runner/src/ux/browser/ui/ui-control.ts | 4 + .../runner/src/ux/browser/ui/ui-rendering.ts | 333 +++++++--- internal/runner/src/ux/browser/ui/utils.ts | 14 +- internal/runner/src/ux/index.ts | 1 + .../runner/src/ux/types/BrowserUiTheme.ts | 35 ++ .../test/src/artifacts/binGuard.test.ts | 41 +- .../runner/test/src/cli/parseArgs.test.ts | 33 +- .../test/src/client/workerRunner.test.ts | 2 + .../test/src/docs/genTourImages.test.ts | 110 ++++ .../runner/test/src/execute/assets.test.ts | 4 +- .../test/src/execute/browserRunner.test.ts | 8 + .../test/src/shared/assertionValues.test.ts | 203 +++++++ .../test/src/shared/browserTheme.test.ts | 68 +++ internal/runner/test/src/support/uxDom.ts | 62 +- internal/runner/test/src/ux/ansi.test.ts | 150 +++++ .../runner/test/src/ux/appearance.test.ts | 358 +++++++++++ .../runner/test/src/ux/assertions.test.ts | 37 ++ .../runner/test/src/ux/autocomplete.test.ts | 105 +++- internal/runner/test/src/ux/client.test.ts | 70 ++- internal/runner/test/src/ux/frameMode.test.ts | 6 +- .../test/src/ux/interactivePage.test.ts | 414 ++++++++++++- internal/runner/test/src/ux/logging.test.ts | 27 +- internal/runner/test/src/ux/menu.test.ts | 45 ++ internal/runner/test/src/ux/overflow.test.ts | 300 +++++++++ internal/runner/test/src/ux/rendering.test.ts | 261 +++++++- internal/runner/test/src/ux/resources.test.ts | 73 +++ internal/runner/test/src/ux/theme.test.ts | 302 +++++++++ internal/runner/test/src/ux/utils.test.ts | 22 +- package.json | 1 + 80 files changed, 6217 insertions(+), 419 deletions(-) create mode 100644 docs/images/tour/browser-dark.png create mode 100644 docs/images/tour/browser-failure.png create mode 100644 docs/images/tour/browser-phone.png create mode 100644 docs/images/tour/browser-runner.png create mode 100644 docs/images/tour/browser-scope.png create mode 100644 docs/images/tour/coverage.svg create mode 100644 docs/images/tour/one-file-node.svg create mode 100644 docs/images/tour/one-file-three-targets.svg create mode 100644 docs/images/tour/whole-suite.svg create mode 100644 docs/scripts/genTourImages.js create mode 100644 docs/tour.md create mode 100644 internal/runner/src/shared/assertionValues.ts create mode 100644 internal/runner/src/shared/browserTheme.ts delete mode 100644 internal/runner/src/ux/browser/resources/runner.css delete mode 100644 internal/runner/src/ux/browser/resources/runner.html create mode 100644 internal/runner/src/ux/browser/resources/styles/themes.css create mode 100644 internal/runner/src/ux/browser/ui/ansi.ts create mode 100644 internal/runner/src/ux/browser/ui/appearance.ts create mode 100644 internal/runner/src/ux/browser/ui/overflow.ts create mode 100644 internal/runner/src/ux/browser/ui/theme.ts create mode 100644 internal/runner/src/ux/types/BrowserUiTheme.ts create mode 100644 internal/runner/test/src/docs/genTourImages.test.ts create mode 100644 internal/runner/test/src/shared/assertionValues.test.ts create mode 100644 internal/runner/test/src/shared/browserTheme.test.ts create mode 100644 internal/runner/test/src/ux/ansi.test.ts create mode 100644 internal/runner/test/src/ux/appearance.test.ts create mode 100644 internal/runner/test/src/ux/overflow.test.ts create mode 100644 internal/runner/test/src/ux/resources.test.ts create mode 100644 internal/runner/test/src/ux/theme.test.ts 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/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 0000000000000000000000000000000000000000..c0e4ff4d9b29d6aed2b9bc0123f167be6f6bfeb9 GIT binary patch literal 96239 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

oLJ|qhaKuW>KUH5_b7HG*%zO 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 Gz5ahVmHztx literal 0 HcmV?d00001 diff --git a/docs/images/tour/browser-failure.png b/docs/images/tour/browser-failure.png new file mode 100644 index 0000000000000000000000000000000000000000..4cd83a6956416c84689a2d11cdcbe9f5f0ece9b5 GIT binary patch literal 9895 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 E06(WVhyVZp literal 0 HcmV?d00001 diff --git a/docs/images/tour/browser-phone.png b/docs/images/tour/browser-phone.png new file mode 100644 index 0000000000000000000000000000000000000000..ebb79e099c273b7892dc5535d815e44405e447eb GIT binary patch literal 49084 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

qTXK{Jmj8_6%CZCI3KUtPV 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 Qz%x*?l1dWQVuk_#2Ugbfm;e9( literal 0 HcmV?d00001 diff --git a/docs/images/tour/browser-runner.png b/docs/images/tour/browser-runner.png new file mode 100644 index 0000000000000000000000000000000000000000..14694b9f1d5e547d037e95da01b436fc13677dc8 GIT binary patch literal 101129 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 CDKv=y literal 0 HcmV?d00001 diff --git a/docs/images/tour/browser-scope.png b/docs/images/tour/browser-scope.png new file mode 100644 index 0000000000000000000000000000000000000000..e475e5baf6c072e50a97c6d4b80f3f7f02d6963a GIT binary patch literal 51331 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 z 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 + 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", From b35b1f4009710dca59708cbb216872a4403124b7 Mon Sep 17 00:00:00 2001 From: nevware21-bot <252503968+nevware21-bot@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:24:46 -0700 Subject: [PATCH 2/2] chore: syncing versions and shrinkwrap --- common/config/rush/npm-shrinkwrap.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) 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"