From 6d34a8a4f86497a111ee8d731a6b2db53e63a9a6 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Tue, 20 Jan 2026 17:23:14 +0100 Subject: [PATCH 01/23] Add headless runner scaffolding and result serialization - [TestResult] Add `toJSON`/`fromJSON` and shared serialization helpers - Add headless env stub and export - Allow `env.run` delegation and CLI flags for `env`/`headless`/`browser` --- src/classes/TestResult.js | 39 ++++++++++++++++++++++++++-- src/cli.js | 18 +++++++++++++ src/env/headless.js | 10 ++++++++ src/env/index.js | 1 + src/run.js | 12 ++++++--- src/util.js | 54 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 128 insertions(+), 6 deletions(-) create mode 100644 src/env/headless.js diff --git a/src/classes/TestResult.js b/src/classes/TestResult.js index 73f2d71..26b8ba7 100644 --- a/src/classes/TestResult.js +++ b/src/classes/TestResult.js @@ -1,7 +1,7 @@ import Test from "./Test.js"; import BubblingEventTarget from "./BubblingEventTarget.js"; import format, { stripFormatting } from "../format-console.js"; -import { delay, formatDuration, interceptConsole, pluralize, stringify, formatDiff } from "../util.js"; +import { delay, formatDuration, interceptConsole, pluralize, stringify, formatDiff, serializeError, deserializeError, serializeTest, serializeMessages } from "../util.js"; import { IS_NODEJS } from "../util.js"; // Make the diff package available both in Node.js and the browser @@ -466,7 +466,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); } @@ -504,6 +508,37 @@ ${ this.error.stack }`); return ret; } + toJSON () { + return { + test: serializeTest(this.test), + pass: this.pass, + skipped: this.skipped, + details: this.details ?? [], + error: serializeError(this.error), + timeTaken: this.timeTaken ?? 0, + timeTakenAsync: this.timeTakenAsync ?? 0, + stats: this.stats ?? null, + messages: serializeMessages(this.messages), + children: (this.tests ?? []).map(t => t.toJSON()), + }; + } + + static fromJSON (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 => TestResult.fromJSON(child, options, result)); + + return result; + } + static warn (...args) { console.warn("[hTest result]", ...args); } diff --git a/src/cli.js b/src/cli.js index 1261f8e..0e713a2 100755 --- a/src/cli.js +++ b/src/cli.js @@ -58,6 +58,24 @@ export default async function cli (options = {}) { } } + let envIndex = argv.indexOf("--env"); + if (envIndex !== -1 && argv[envIndex + 1]) { + options.env = argv[envIndex + 1]; + argv.splice(envIndex, 2); + } + + let headlessIndex = argv.indexOf("--headless"); + if (headlessIndex !== -1) { + options.env = "headless"; + argv.splice(headlessIndex, 1); + } + + let browserIndex = argv.indexOf("--browser"); + if (browserIndex !== -1 && argv[browserIndex + 1]) { + options.browser = argv[browserIndex + 1]; + argv.splice(browserIndex, 2); + } + 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..ce74b71 --- /dev/null +++ b/src/env/headless.js @@ -0,0 +1,10 @@ +export default { + name: "Headless (Chromium)", + defaultOptions: { + browser: "chromium", + headless: true, + }, + async run () { + throw new Error("Headless runner is not implemented yet."); + }, +}; 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/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..083c1b3 100644 --- a/src/util.js +++ b/src/util.js @@ -5,6 +5,60 @@ import * as objects from "./objects.js"; */ export const IS_NODEJS = typeof process === "object" && process?.versions?.node; +export function serializeError (error) { + if (!error) { + return null; + } + + return { + name: error.name, + message: error.message, + stack: error.stack, + }; +} + +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 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 serializeMessages (messages) { + if (!messages?.length) { + return []; + } + + return messages.map(m => ({ + method: m.method, + args: (m.args ?? []).map(arg => stringify(arg)), + stringified: true, + })); +} + /** * Determine the internal JavaScript [[Class]] of an object. * @param {*} value - Value to check From 42ebf811167ecec5c6eb99b4603bddd114e44adc Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Tue, 20 Jan 2026 17:56:41 +0100 Subject: [PATCH 02/23] Add headless runner skeleton with Playwright execution - Implement headless env with local server + HTML harness - Launch Playwright Chromium and collect serialized results - Reuse `TestResult.fromJSON()` and node console output for reporting --- src/env/headless.js | 242 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 240 insertions(+), 2 deletions(-) diff --git a/src/env/headless.js b/src/env/headless.js index ce74b71..503984b 100644 --- a/src/env/headless.js +++ b/src/env/headless.js @@ -1,10 +1,248 @@ +import fs from "node:fs"; +import http from "node:http"; +import path from "node:path"; +import { globSync } from "glob"; +import TestResult from "../classes/TestResult.js"; +import nodeEnv from "./node.js"; +import { getType, serializeError } from "../util.js"; + +const filenamePatterns = { + include: /\.js$/, + exclude: /^index/, +}; + +function toUrlPath (filePath, root) { + let absolute = path.resolve(root, filePath); + let relative = path.relative(root, absolute); + let normalized = relative.split(path.sep).join("/"); + return "/" + normalized; +} + +function collectFilePaths (location, root) { + let absolute = path.resolve(root, location); + + if (fs.existsSync(absolute)) { + let stat = fs.statSync(absolute); + if (stat.isDirectory()) { + let entries = fs.readdirSync(absolute) + .filter(name => !filenamePatterns.exclude.test(name) && filenamePatterns.include.test(name)) + .map(name => path.join(absolute, name)); + return entries; + } + + return [absolute]; + } + + let matches = globSync(location, { nodir: true, cwd: root }); + return matches.map(match => path.join(root, match)); +} + +function resolveTestUrls (test, root) { + let type = getType(test); + + if (type === "string") { + return collectFilePaths(test, root).map(p => toUrlPath(p, root)); + } + + if (Array.isArray(test)) { + let flattened = test.flatMap(item => { + if (getType(item) !== "string") { + throw new Error("Headless runner only supports string test locations."); + } + return collectFilePaths(item, root); + }); + return flattened.map(p => toUrlPath(p, root)); + } + + throw new Error("Headless runner only supports string test locations."); +} + +function escapeJson (value) { + return JSON.stringify(value).replace(/ + + + + hTest Headless Runner + + + + + + +`; +} + +function getContentType (filePath) { + let ext = path.extname(filePath); + switch (ext) { + case ".js": + case ".mjs": + return "application/javascript"; + default: + return "application/octet-stream"; + } +} + +async function startServer ({ root, html }) { + return new Promise((resolve, reject) => { + let server = http.createServer((req, res) => { + let url = new URL(req.url, "http://localhost"); + + if (url.pathname === "/__htest_runner__.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 = path.resolve(root, "." + decodedPath); + + if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) { + res.writeHead(404); + res.end("Not found"); + return; + } + + let contentType = getContentType(filePath); + res.writeHead(200, { "Content-Type": contentType, "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`."); + } +} + export default { name: "Headless (Chromium)", defaultOptions: { browser: "chromium", headless: true, + serverRoot: process.cwd(), }, - async run () { - throw new Error("Headless runner is not implemented yet."); + async run (test, options = {}) { + let root = path.resolve(options.serverRoot ?? process.cwd()); + let testUrls = resolveTestUrls(test, root); + if (testUrls.length === 0) { + throw new Error("No tests found for headless run."); + } + + let browserName = options.browser ?? "chromium"; + let browserOptions = { + only: options.only, + verbose: options.verbose, + path: options.path, + }; + + let html = createRunnerHtml({ + testUrls, + options: browserOptions, + }); + + let { server, baseUrl } = await startServer({ root, html }); + let browser; + + try { + let playwright = await loadPlaywright(); + let browserType = playwright[browserName]; + if (!browserType) { + throw new Error(`Unsupported browser "${browserName}".`); + } + + browser = await browserType.launch({ headless: options.headless !== false }); + let page = await browser.newPage(); + + let resolveResult; + let rejectResult; + let resultPromise = new Promise((resolve, reject) => { + resolveResult = resolve; + rejectResult = reject; + }); + + await page.exposeFunction("__htest_sendResult", payload => { + resolveResult(payload); + }); + await page.exposeFunction("__htest_sendError", payload => { + let err = new Error(payload?.message || "Headless runner failed."); + err.stack = payload?.stack; + rejectResult(err); + }); + + page.on("pageerror", err => { + rejectResult(err); + }); + + console.log(`Tests are running in ${browserName}.`); + + await page.goto(`${ baseUrl }/__htest_runner__.html`, { waitUntil: "load" }); + let payload = await resultPromise; + + let result = TestResult.fromJSON(payload, options); + console.log("Done!"); + nodeEnv.done?.(result, options, null, result); + return result; + } + catch (err) { + err.meta = serializeError(err); + throw err; + } + finally { + if (browser) { + await browser.close(); + } + + server.close(); + } }, }; From cce546439f7c6da86c8dc9753927c867e9e1e6fc Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Tue, 20 Jan 2026 18:12:56 +0100 Subject: [PATCH 03/23] Add simple test --- tests/headless.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 tests/headless.js diff --git a/tests/headless.js b/tests/headless.js new file mode 100644 index 0000000..e7e31b6 --- /dev/null +++ b/tests/headless.js @@ -0,0 +1,21 @@ +export default { + name: "Headless runner tests", + 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", + }, + ], +}; From 432b86de83d668b7f6622bd25b895bf301b06e53 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Tue, 20 Jan 2026 18:34:53 +0100 Subject: [PATCH 04/23] Stream headless progress updates to interactive output - Emit progress snapshots from the browser harness - Render intermediate updates via the Node env renderer - Keep headless output aligned with interactive Node experience --- src/env/headless.js | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/env/headless.js b/src/env/headless.js index 503984b..99ba8a0 100644 --- a/src/env/headless.js +++ b/src/env/headless.js @@ -94,6 +94,13 @@ function createRunnerHtml ({ testUrls, options }) { verbose: options.verbose, env: { name: "Headless Browser" }, }); + + result.addEventListener("done", () => { + if (result.stats?.pending > 0) { + window.__htest_sendProgress(result.toJSON()); + } + }); + await result.finished; window.__htest_sendResult(result.toJSON()); } @@ -213,6 +220,13 @@ export default { await page.exposeFunction("__htest_sendResult", payload => { resolveResult(payload); }); + await page.exposeFunction("__htest_sendProgress", payload => { + if (payload?.stats?.pending <= 0) { + return; + } + let progress = TestResult.fromJSON(payload, options); + nodeEnv.done?.(progress, options, null, progress); + }); await page.exposeFunction("__htest_sendError", payload => { let err = new Error(payload?.message || "Headless runner failed."); err.stack = payload?.stack; @@ -223,13 +237,10 @@ export default { rejectResult(err); }); - console.log(`Tests are running in ${browserName}.`); - await page.goto(`${ baseUrl }/__htest_runner__.html`, { waitUntil: "load" }); let payload = await resultPromise; let result = TestResult.fromJSON(payload, options); - console.log("Done!"); nodeEnv.done?.(result, options, null, result); return result; } From 94914407f58fdce506c85e120697c9ebe7d68428 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Tue, 20 Jan 2026 19:31:35 +0100 Subject: [PATCH 05/23] Simplify and prifity code --- src/env/headless.js | 38 +++++++++++++------------------------- 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/src/env/headless.js b/src/env/headless.js index 99ba8a0..a671500 100644 --- a/src/env/headless.js +++ b/src/env/headless.js @@ -72,15 +72,15 @@ function createRunnerHtml ({ testUrls, options }) { hTest Headless Runner - - + + @@ -249,11 +247,14 @@ export default { if (payload?.stats?.pending <= 0) { return; } - let progress = TestResult.fromJSON(payload, options); + 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); }); @@ -265,7 +266,7 @@ export default { await page.goto(`${ baseUrl }/index.html`, { waitUntil: "load" }); let payload = await resultPromise; - let result = TestResult.fromJSON(payload, options); + let result = deserializeResult(payload, options); nodeEnv.done?.(result, options, null, result); return result; } diff --git a/src/util.js b/src/util.js index 59ff233..7043d38 100644 --- a/src/util.js +++ b/src/util.js @@ -5,60 +5,6 @@ import * as objects from "./objects.js"; */ export const IS_NODEJS = typeof process === "object" && process?.versions?.node; -export function serializeError (error) { - if (!error) { - return null; - } - - return { - name: error.name, - message: error.message, - stack: error.stack, - }; -} - -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 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 serializeMessages (messages) { - if (!messages?.length) { - return []; - } - - return messages.map(m => ({ - method: m.method, - args: (m.args ?? []).map(arg => stringify(arg)), - stringified: true, - })); -} - /** * Determine the internal JavaScript [[Class]] of an object. * @param {*} value - Value to check From 07e1521c2677ea3127bf9ec76028c99aa8168468 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Tue, 20 Jan 2026 22:12:32 +0100 Subject: [PATCH 09/23] Fix headless harness JSON parsing - Use `application/json` script tags for payloads - Read JSON via `document.getElementById` to avoid implicit globals --- src/env/headless.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/env/headless.js b/src/env/headless.js index a509647..6ed938e 100644 --- a/src/env/headless.js +++ b/src/env/headless.js @@ -72,8 +72,8 @@ function createRunnerHtml ({ testUrls, options }) { hTest Headless Runner - - + + - + +