Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6d34a8a
Add headless runner scaffolding and result serialization
DmitrySharabin Jan 20, 2026
42ebf81
Add headless runner skeleton with Playwright execution
DmitrySharabin Jan 20, 2026
cce5464
Add simple test
DmitrySharabin Jan 20, 2026
432b86d
Stream headless progress updates to interactive output
DmitrySharabin Jan 20, 2026
9491440
Simplify and prifity code
DmitrySharabin Jan 20, 2026
fed0ef5
Add Playwright browser options to headless runner
DmitrySharabin Jan 20, 2026
d741b03
First pass on implementing the console interceptor
DmitrySharabin Jan 20, 2026
51d7292
Refactor headless serialization
DmitrySharabin Jan 20, 2026
07e1521
Fix headless harness JSON parsing
DmitrySharabin Jan 20, 2026
d6751db
Add headless timeout and hang test
DmitrySharabin Jan 20, 2026
d45deba
Add a FIXME comment
DmitrySharabin Jan 20, 2026
26a0c4f
Move files around
DmitrySharabin Jan 20, 2026
7980f57
Add some docs
DmitrySharabin Jan 20, 2026
5ed125b
Improve options handling
DmitrySharabin Jan 20, 2026
c98a276
Add inline docs
DmitrySharabin Jan 20, 2026
e7564cd
Some code improvements
DmitrySharabin Jan 20, 2026
17eef7a
Skip headless tests if not run in the browser
DmitrySharabin Jan 21, 2026
f67e0fe
Prevent errors + show some useful hints
DmitrySharabin Jan 21, 2026
72c47fc
Some code improvements
DmitrySharabin Jan 22, 2026
31d8f76
Set headless flag before imports and load diff eagerly outside headless
DmitrySharabin Jan 22, 2026
67f5703
Fix headless imports when hTest is installed as a dependency
DmitrySharabin Jan 22, 2026
2b7d0dd
Improve code readability
DmitrySharabin Jan 22, 2026
bd53fea
Correctly handle failed tests returned from the headless runner
DmitrySharabin Jan 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ HTML-first mode
<tr>
<td>

Write your tests in nested object literals, and you can [run them either in Node](docs/run/node) or [in the browser](docs/run/html).
Write your tests in nested object literals, and you can [run them either in Node](docs/run/node), [headless in a real browser](docs/run/headless), or [in the browser](docs/run/html).
Tests inherit values they don’t specify from their parents, so you never have to repeat yourself.
</td>
<td>
Expand Down
48 changes: 48 additions & 0 deletions docs/run/headless/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Headless (Playwright)

Headless mode runs your existing JS-first tests inside a real browser engine (Chromium, Firefox, WebKit) while keeping hTest's Node output and reporting. This is useful when tests rely on browser APIs that don't exist in Node, or when you want parity with real rendering/JS engines without changing how results are displayed.

## Why use it

- Run browser-only tests (DOM APIs, layout, canvas) without rewriting your suite.
- Keep the same CLI output and interactive UI you get in Node.
- Choose the browser engine explicitly to match your target environment.
- CI/CD-friendly.

## Install

Headless runs are opt-in and require Playwright:

```bash
npm i -D playwright
npx playwright install chromium
```

## Usage

Run tests in the default headless browser (Chromium):

```bash
npx htest --headless path/to/tests
```

Choose a browser engine:

```bash
npx htest --headless --browser firefox path/to/tests
```

Supported values: `chromium`, `firefox`, `webkit`, `chrome`, `edge`.

## CI/CD

Use `--ci` to disable interactive mode:

```bash
npx htest --headless --ci path/to/tests
```

## Notes

- This runner executes JS-first tests only.
- Results are still rendered by the Node CLI UI.
27 changes: 24 additions & 3 deletions src/classes/TestResult.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,25 @@ import format, { stripFormatting } from "../format-console.js";
import { delay, formatDuration, interceptConsole, pluralize, stringify, formatDiff } from "../util.js";
import { IS_NODEJS } from "../util.js";

// Make the diff package available both in Node.js and the browser
const { diffChars } = await import(IS_NODEJS ? "diff" : "https://cdn.jsdelivr.net/npm/diff@7.0.0/lib/index.es6.js");
let diffModule;
// FIXME: Replace this dummy diff with a proper headless-safe diff import.
let diffChars = (actual, expected) => [
{ value: actual, removed: true },
{ value: expected, added: true },
]; // Dummy function for headless environments

if (!globalThis?.__HTEST_HEADLESS__) {
// Load eagerly in non-headless environments (Node.js and the browser) so diffs are ready when needed.
let mod = await import(
IS_NODEJS
? "diff"
: "https://cdn.jsdelivr.net/npm/diff@8.0.3/dist/diff.min.js"
);
diffModule = mod?.default ?? mod;
if (typeof diffModule?.diffChars === "function") {
diffChars = diffModule.diffChars;
}
}

/**
* Represents the result of a test or group of tests.
Expand Down Expand Up @@ -466,7 +483,11 @@ ${ this.error.stack }`);
*/
getMessages (o = {}) {
let ret = new String("<c yellow><b><i>(Messages)</i></b></c>");
ret.children = this.messages.map(m => `<dim>(${ m.method })</dim> ${ m.args.map(a => stringify(a)).join(" ") }`);
ret.children = (this.messages ?? []).map(m => {
let args = m.args ?? [];
args = m.stringified ? args.join(" ") : args.map(a => stringify(a)).join(" ");
return `<dim>(${ m.method })</dim> ${ args }`;
});

return o?.format === "rich" ? ret : stripFormatting(ret);
}
Expand Down
14 changes: 13 additions & 1 deletion src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export async function getConfig (glob = CONFIG_GLOB) {
* Supported flags:
* --ci Run in continuous integration mode (disables interactive features)
* --verbose Verbose output (show all tests, not just failed, skipped, or tests with intercepted console messages)
* --headless Run in headless mode (implies --browser chromium)
* --browser Browser to use for headless mode (chromium, firefox, webkit, chrome, edge)
*
* @param {object} [options] Same as `run()` options, but command line arguments take precedence
*/
Expand All @@ -49,7 +51,7 @@ export default async function cli (options = {}) {

let argv = process.argv.slice(2);

const flags = ["ci", "verbose"];
const flags = ["ci", "verbose", "headless"];
for (let flag of flags) {
let flagIndex = argv.indexOf("--" + flag);
if (flagIndex !== -1) {
Expand All @@ -58,6 +60,16 @@ export default async function cli (options = {}) {
}
}

let browserIndex = argv.indexOf("--browser");
if (browserIndex !== -1 && argv[browserIndex + 1]) {
options.browser = argv[browserIndex + 1];
argv.splice(browserIndex, 2);
}

if (options.headless) {
options.env = "headless";
}

let location = argv[0];

if (argv[1]) {
Expand Down
Loading