diff --git a/README.md b/README.md
index 9512bf2..201875c 100644
--- a/README.md
+++ b/README.md
@@ -169,7 +169,7 @@ HTML-first mode
|
-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.
|
diff --git a/docs/run/headless/README.md b/docs/run/headless/README.md
new file mode 100644
index 0000000..d703d0c
--- /dev/null
+++ b/docs/run/headless/README.md
@@ -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.
diff --git a/src/classes/TestResult.js b/src/classes/TestResult.js
index 73f2d71..11d0f6f 100644
--- a/src/classes/TestResult.js
+++ b/src/classes/TestResult.js
@@ -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.
@@ -466,7 +483,11 @@ ${ this.error.stack }`);
*/
getMessages (o = {}) {
let ret = new String("(Messages)");
- ret.children = this.messages.map(m => `(${ m.method }) ${ 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 `(${ m.method }) ${ args }`;
+ });
return o?.format === "rich" ? ret : stripFormatting(ret);
}
diff --git a/src/cli.js b/src/cli.js
index 1261f8e..b815ad3 100755
--- a/src/cli.js
+++ b/src/cli.js
@@ -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
*/
@@ -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) {
@@ -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]) {
diff --git a/src/env/headless.js b/src/env/headless.js
new file mode 100644
index 0000000..10c2986
--- /dev/null
+++ b/src/env/headless.js
@@ -0,0 +1,301 @@
+import fs from "node:fs";
+import http from "node:http";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { globSync } from "glob";
+import nodeEnv from "./node.js";
+import { deserializeResult, serializeError } from "../headless-util.js";
+
+// Headless browser has a single URL space but we serve two filesystem roots:
+// hTest package source and the user's tests. Use URL prefixes to route.
+const HTEST_BASE = "/__htest__";
+const TESTS_BASE = "/__tests__";
+const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
+// Default hTest source root when running from the installed package.
+const HTEST_ROOT = path.resolve(MODULE_DIR, "..", "..");
+
+const filenamePatterns = {
+ include: /\.js$/,
+ exclude: /^index/,
+};
+
+function resolvePath (filePath, root, base) {
+ let absolute = path.resolve(root, filePath);
+ let relative = path.relative(root, absolute);
+ let normalized = relative.split(path.sep).join("/");
+ base = base.replace(/\/$/, "");
+ return `${ base }/${ normalized }`;
+}
+
+function getTestPaths (location, root) {
+ let resolvedPath = path.resolve(root, location);
+
+ if (fs.existsSync(resolvedPath)) {
+ let stat = fs.statSync(resolvedPath);
+ if (stat.isDirectory()) {
+ return fs.readdirSync(resolvedPath)
+ .filter(name => !filenamePatterns.exclude.test(name) && filenamePatterns.include.test(name))
+ .map(name => path.join(resolvedPath, name));
+ }
+
+ return [resolvedPath];
+ }
+
+ let matches = globSync(location, { nodir: true, cwd: root });
+ return matches.map(match => path.join(root, match));
+}
+
+function resolveTestPaths (test, root, base) {
+ if (typeof test === "string") {
+ return getTestPaths(test, root).map(p => resolvePath(p, root, base));
+ }
+
+ if (Array.isArray(test)) {
+ let flattened = test.flatMap(item => {
+ if (typeof item !== "string") {
+ throw new Error("Headless runner only supports string test locations.");
+ }
+ return getTestPaths(item, root);
+ });
+ return flattened.map(p => resolvePath(p, root, base));
+ }
+
+ throw new Error("Headless runner only supports string test locations.");
+}
+
+function escape (str) {
+ return str.replace(/
+
+
+
+ hTest Headless Runner
+
+
+
+
+
+
+
+`;
+}
+
+async function startServer ({ root, htestRoot, html, htestBase = HTEST_BASE, testsBase = TESTS_BASE } = {}) {
+ return new Promise((resolve, reject) => {
+ let server = http.createServer((req, res) => {
+ let url = new URL(req.url, "http://localhost");
+
+ if (url.pathname === "/index.html") {
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
+ res.end(html);
+ return;
+ }
+
+ let decodedPath = decodeURIComponent(url.pathname);
+ let filePath;
+ // Route hTest imports to the package root, and test imports to the project root.
+ if (decodedPath.startsWith(htestBase + "/")) {
+ let relative = decodedPath.slice(htestBase.length);
+ filePath = path.resolve(htestRoot, "." + relative);
+ }
+ else if (decodedPath.startsWith(testsBase + "/")) {
+ let relative = decodedPath.slice(testsBase.length);
+ filePath = path.resolve(root, "." + relative);
+ }
+ else {
+ res.writeHead(404);
+ res.end("Not found");
+ return;
+ }
+
+ if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
+ res.writeHead(404);
+ res.end("Not found");
+ return;
+ }
+
+ res.writeHead(200, { "Content-Type": "application/javascript", "Cache-Control": "no-store" });
+ fs.createReadStream(filePath).pipe(res);
+ });
+
+ server.on("error", reject);
+ server.listen(0, "127.0.0.1", () => {
+ let address = server.address();
+ resolve({
+ server,
+ baseUrl: `http://127.0.0.1:${address.port}`,
+ });
+ });
+ });
+}
+
+async function loadPlaywright () {
+ try {
+ return await import("playwright");
+ }
+ catch (err) {
+ throw new Error(`Headless runner requires Playwright. Install with "npm i -D playwright".`);
+ }
+}
+
+function getConfig (name = "chromium") {
+ let originalName = name;
+ name = name.toLowerCase();
+
+ switch (name) {
+ case "chromium":
+ case "firefox":
+ case "webkit":
+ return { type: name, name };
+ case "chrome":
+ return { type: "chromium", channel: "chrome", name: "chrome" };
+ case "edge":
+ case "msedge":
+ return { type: "chromium", channel: "msedge", name: "msedge" };
+ default:
+ throw new Error(`Unsupported browser "${originalName}". Use chromium, firefox, webkit, chrome, or edge.`);
+ }
+}
+
+export default {
+ name: "Headless",
+ defaultOptions: {
+ browser: "chromium",
+ get serverRoot () {
+ return process.cwd();
+ },
+ get htestRoot () {
+ return HTEST_ROOT;
+ },
+ },
+ async run (test, options = {}) {
+ let root = path.resolve(options.serverRoot);
+ let htestRoot = path.resolve(options.htestRoot);
+
+ let tests = resolveTestPaths(test, root, TESTS_BASE);
+ if (tests.length === 0) {
+ throw new Error("No tests found for headless run.");
+ }
+
+ let html = getRunnerHtml({ tests, options });
+
+ let { server, baseUrl } = await startServer({ root, htestRoot, html });
+ let browser;
+
+ try {
+ let playwright = await loadPlaywright();
+ let { type, channel } = getConfig(options.browser);
+ browser = playwright[type];
+ if (!browser) {
+ throw new Error(`Unsupported browser "${options.browser}".`);
+ }
+
+ try {
+ console.info(`Launching headless runner...\n`);
+ browser = await browser.launch({ headless: !options.headed, channel });
+ }
+ catch (err) {
+ browser = null;
+ throw err;
+ }
+
+ let page = await browser.newPage();
+
+ let resolveResult;
+ let rejectResult;
+ let resultPromise = new Promise((resolve, reject) => {
+ resolveResult = resolve;
+ rejectResult = reject;
+ });
+
+ let timeout = options.timeout ?? 30000;
+ let timer;
+ let timeoutPromise = new Promise((_, reject) => {
+ timer = setTimeout(() => {
+ reject(new Error(`Headless runner timed out after ${timeout}ms.`));
+ }, timeout);
+ });
+
+ await page.exposeFunction("sendResult", payload => {
+ resolveResult(payload);
+ });
+
+ await page.exposeFunction("sendProgress", payload => {
+ if (payload?.stats?.pending <= 0) {
+ return;
+ }
+ let progress = deserializeResult(payload, options);
+ nodeEnv.done?.(progress, options, null, progress);
+ });
+
+ await page.exposeFunction("sendError", payload => {
+ let err = new Error(payload?.message || "Headless runner failed.");
+ if (payload?.name) {
+ err.name = payload.name;
+ }
+ err.stack = payload?.stack;
+ rejectResult(err);
+ });
+
+ page.on("pageerror", err => {
+ rejectResult(err);
+ });
+
+ await page.goto(`${ baseUrl }/index.html`, { waitUntil: "load" });
+ let payload = await Promise.race([resultPromise, timeoutPromise]);
+ clearTimeout(timer);
+
+ let result = deserializeResult(payload, options);
+ nodeEnv.done?.(result, options, null, result);
+ return result;
+ }
+ catch (err) {
+ err.meta = serializeError(err);
+ throw err;
+ }
+ finally {
+ await browser?.close();
+ server.close();
+ }
+ },
+};
diff --git a/src/env/index.js b/src/env/index.js
index 9d96c31..5a07b24 100644
--- a/src/env/index.js
+++ b/src/env/index.js
@@ -1,3 +1,4 @@
export { default as nodeRun } from "./node.js";
export { default as consoleRun } from "./console.js";
export { default as autoRun } from "./auto.js";
+export { default as headlessRun } from "./headless.js";
diff --git a/src/headless-util.js b/src/headless-util.js
new file mode 100644
index 0000000..e523672
--- /dev/null
+++ b/src/headless-util.js
@@ -0,0 +1,79 @@
+import TestResult from "./classes/TestResult.js";
+import { stringify } from "./util.js";
+
+export function serializeError (error) {
+ if (!error) {
+ return null;
+ }
+
+ return {
+ name: error.name,
+ message: error.message,
+ stack: error.stack,
+ };
+}
+
+export function serializeTest (test) {
+ if (!test) {
+ return null;
+ }
+
+ return {
+ name: test.name,
+ id: test.id,
+ level: test.level,
+ isGroup: test.isGroup,
+ isTest: test.isTest,
+ skip: test.skip,
+ description: test.description,
+ maxTime: test.maxTime,
+ maxTimeAsync: test.maxTimeAsync,
+ throws: test.throws,
+ };
+}
+
+export function serializeResult (result) {
+ return {
+ test: serializeTest(result.test),
+ pass: result.pass,
+ skipped: result.skipped,
+ details: result.details ?? [],
+ error: serializeError(result.error),
+ timeTaken: result.timeTaken ?? 0,
+ timeTakenAsync: result.timeTakenAsync ?? 0,
+ stats: result.stats ?? null,
+ messages: (result.messages ?? []).map(m => ({
+ method: m.method,
+ args: (m.args ?? []).map(arg => stringify(arg)),
+ stringified: true,
+ })),
+ children: (result.tests ?? []).map(child => serializeResult(child)),
+ };
+}
+
+export function deserializeError (payload) {
+ if (!payload) {
+ return null;
+ }
+
+ let err = new Error(payload.message);
+ err.name = payload.name;
+ err.stack = payload.stack;
+ return err;
+}
+
+export function deserializeResult (json, options = {}, parent = null) {
+ let result = new TestResult(json.test ?? {}, parent, options);
+
+ result.pass = json.pass;
+ result.skipped = json.skipped;
+ result.details = json.details ?? [];
+ result.error = deserializeError(json.error);
+ result.timeTaken = json.timeTaken ?? 0;
+ result.timeTakenAsync = json.timeTakenAsync ?? 0;
+ result.stats = json.stats ?? result.stats ?? {};
+ result.messages = json.messages ?? [];
+ result.tests = (json.children ?? []).map(child => deserializeResult(child, options, result));
+
+ return result;
+}
diff --git a/src/run.js b/src/run.js
index 23d434d..10d0e9e 100644
--- a/src/run.js
+++ b/src/run.js
@@ -38,6 +38,14 @@ export default function run (test, options = {}) {
test ??= options.location;
}
+ if (env.setup) {
+ env.setup();
+ }
+
+ if (env.run) {
+ return env.run(test, options);
+ }
+
if (getType(test) == "string") {
if (env.resolveLocation) {
env.resolveLocation(test).then(tests => {
@@ -73,10 +81,6 @@ export default function run (test, options = {}) {
}
}
- if (env.setup) {
- env.setup();
- }
-
if (!(test instanceof Test)) {
test = new Test(test, null, options);
}
diff --git a/src/util.js b/src/util.js
index febfde5..7043d38 100644
--- a/src/util.js
+++ b/src/util.js
@@ -202,8 +202,46 @@ if (IS_NODEJS) {
*/
export async function interceptConsole (fn) {
if (!IS_NODEJS) {
- await fn();
- return [];
+ if (!globalThis?.__HTEST_HEADLESS__) {
+ await fn();
+ return [];
+ }
+
+ let interceptor = globalThis.consoleInterceptor;
+ if (!interceptor) {
+ interceptor = {
+ collectors: [],
+ original: {},
+ };
+
+ // FIXME: This can mix console messages across overlapping async tests.
+ for (let method of ["log", "warn", "error"]) {
+ interceptor.original[method] = console[method].bind(console);
+ console[method] = (...args) => {
+ if (interceptor.collectors.length > 0) {
+ for (let collector of interceptor.collectors) {
+ collector.push({ args, method });
+ }
+ }
+ else {
+ interceptor.original[method](...args);
+ }
+ };
+ }
+
+ globalThis.consoleInterceptor = interceptor;
+ }
+
+ let messages = [];
+ interceptor.collectors.push(messages);
+ try {
+ await fn();
+ }
+ finally {
+ interceptor.collectors = interceptor.collectors.filter(c => c !== messages);
+ }
+
+ return messages;
}
let messages = [];
diff --git a/tests/headless.js b/tests/headless.js
new file mode 100644
index 0000000..7bbec51
--- /dev/null
+++ b/tests/headless.js
@@ -0,0 +1,27 @@
+export default {
+ name: "Headless runner tests",
+ skip: typeof globalThis.document === "undefined",
+ tests: [
+ {
+ name: "Simple math",
+ args: [1, 41],
+ run: (a, b) => a + b,
+ expect: 42,
+ },
+ {
+ name: "DOM APIs",
+ run: () => {
+ let div = document.createElement("div");
+ div.textContent = "Headless runner works!";
+ document.body.append(div);
+ return getComputedStyle(div).display;
+ },
+ expect: "block",
+ },
+ {
+ name: "Hang test",
+ run: () => new Promise(() => {}),
+ skip: true,
+ },
+ ],
+};
|