diff --git a/src/classes/RefTest.js b/src/classes/RefTest.js new file mode 100644 index 0000000..ff46335 --- /dev/null +++ b/src/classes/RefTest.js @@ -0,0 +1,315 @@ +import { doClick, create, $$, bind, ready } from "../dom.js"; +import { formatDuration } from "../util.js"; +import hooks from "../hooks.js"; +import * as compare from "../compare.js"; + +export default class RefTest { + constructor (table) { + this.table = table; + table.reftest = this; + this.columns = + +this.table.getAttribute("data-columns") || + Math.max.apply( + Math, + [...this.table.rows].map(row => row.cells.length), + ); + this.manual = this.table.matches(".manual"); + this.init(); + } + + async init () { + this.compare = this.manual + ? null + : await RefTest.getTest(this.table.getAttribute("data-test")); + this.setup(); + + if (!this.manual) { + this.startup = performance.now(); + this.test(); + } + } + + setup () { + if (this.table.rows.length === 0) { + console.warn("Empty reftest:", this.table); + return; + } + + // Add table header if not present + if (!this.table.querySelector("thead") && this.columns > 1) { + var header = [ + ...Array(Math.max(0, this.columns - 2)).fill("Test"), + "Actual", + "Expected", + ].slice(-this.columns); + + create("thead", { + contents: [ + { + tag: "tr", + contents: header.map(text => { + return { tag: "th", textContent: text }; + }), + }, + ], + start: this.table, + }); + } + + // Observe class changes on s and update the results + this.resultObserver = new MutationObserver(mutation => { + for (let { target } of mutation) { + if (target.matches("tr")) { + RefTest.updateResults(); + } + } + }); + + this.resultObserver.observe(this.table, { + subtree: true, + attributes: true, + attributeFilter: ["class"], + }); + + if (!this.manual) { + let test = x => { + requestAnimationFrame(() => this.test()); + }; + + this.observer = new MutationObserver(test); + this.observe(); + + bind(this.table, "input change click", test); + + $$("[data-click]", this.table) + .concat(this.table.matches("[data-click]") ? [this.table] : []) + .forEach(target => { + target + .getAttribute("data-click") + .trim() + .split(/\s*,\s*/) + .forEach(click => doClick(click, target)); + }); + } + + ready().then(() => RefTest.updateResults()); + } + + observe () { + this.observerRunning = true; + + this.observer.observe(this.table, { + subtree: true, + childList: true, + attributes: true, + characterData: true, + }); + } + + unobserve () { + this.observer.disconnect(); + this.observerRunning = false; + } + + // Run code past observer + sneak (callback) { + this.unobserve(); + var ret = callback.call(this); + this.observe(); + return ret; + } + + test () { + hooks.run("reftest-test", this); + + for (let tr of this.table.rows) { + if (!this.table.tHead || tr !== this.table.tHead.rows[0]) { + this.testRow(tr); + } + } + } + + async testRow (tr) { + let env = { context: this, tr, cells: [...tr.cells] }; + hooks.run("reftest-testrow", env); + + if (!env.tr.compare) { + env.tr.compare = await RefTest.getTest(env.tr.getAttribute("data-test"), this.compare); + } + + let resultCell = env.tr.cells[env.tr.cells.length - 1]; + + if (env.cells.length) { + if (this.columns == 3) { + // Test, actual, expected + if (env.cells.length == 1) { + // expected is the same as test + resultCell = create("td", { after: env.cells[0] }); + env.cells.push(resultCell); + } + + if (env.cells.length == 2) { + // missing actual + resultCell = create("td", { after: env.cells[0] }); + env.cells.splice(1, 0, resultCell); + } + + if (!env.cells[2].textContent) { + env.cells[2].textContent = env.cells[0].textContent; + } + } + else if (this.columns == 2 && !env.cells[0].innerHTML) { + // Empty cell, takes the test from above + let previous = env.tr; + while ((previous = previous.previousElementSibling)) { + if (previous.cells[0].innerHTML) { + env.cells[0] = previous.cells[0]; + break; + } + } + } + + try { + var ret = this.sneak(() => tr.compare(...env.cells)); + resultCell.onclick = null; + } + catch (e) { + ret = e; + var error = true; + resultCell.textContent = e + ""; + resultCell.onclick = evt => console.error(e); + } + + var error = ret instanceof Error; + + var previousClass = tr.classList.contains("pass") ? "pass" : "fail"; + tr.classList.remove("pass", "fail"); + let pass = ret; + if (error) { + pass = tr.hasAttribute("data-error"); + } + + var className = pass ? "pass" : "fail"; + tr.classList.add(className); + + if ( + className == "pass" && + className != previousClass && + !tr.classList.contains("interactive") + ) { + // Display how long it took + let time = performance.now() - this.startup; + tr.setAttribute("data-time", formatDuration(time)); + } + } + } + + static hooks = hooks; + + // Retrieve the comparator function based on a data-test string + static async getTest (test, fallback) { + if (test) { + if (test in this.compare) { + return this.compare[test]; + } + else { + if (test in globalThis) { + return globalThis[test]; + } + else { + // return new Function("td", "ref", test); + // Try again in a bit + await new Promise(resolve => + window.addEventListener("load", resolve, { once: true })); + return globalThis[test]; + } + } + } + + return fallback || RefTest.compare.contents; + } + + // Default comparison functions + static compare = compare; + + // Prettify code for presentation + // TODO just use Prism whitespace plugin + static presentCode (code) { + // Remove blank line in the beginning and end + code = code.replace(/^\s*\n|\n\s*$/g, ""); + + // Remove extra indentation + var indent = (code.match(/^\s*/) || [""])[0]; + code = code.replace(RegExp("^" + indent, "gm"), ""); + + code = code.replace(/document.write/g, "print"); + + return code; + } + + static updateResults () { + this.results = { + pass: $$("table.reftest:not(.skipped) tr.pass:not(.skipped)"), + fail: $$("table.reftest:not(.skipped) tr.fail:not(.skipped)"), + skipped: $$("table.reftest tr.skipped"), + current: { + pass: -1, + fail: -1, + skipped: -1, + }, + // interactive: $$("table.reftest tr.interactive") + }; + + let detail = { + pass: this.results.pass.length, + fail: this.results.fail.length, + skipped: this.results.skipped.length, + }; + + document.body.style.setProperty("--pass", detail.pass); + document.body.style.setProperty("--fail", detail.fail); + document.body.style.setProperty("--skipped", detail.skipped); + + document.body.classList.toggle("no-passed", detail.pass === 0); + document.body.classList.toggle("no-failed", detail.fail === 0); + document.body.classList.toggle("no-skipped", detail.skipped === 0); + // $(".count-interactive", RefTest.nav).textContent = RefTest.results.interactive.length; + + document.dispatchEvent(new CustomEvent("testresultsupdate", { detail })); + } + + // Navigate tests + static #navigateTests (type = "fail", offset) { + let elements = this.results[type]; + let i = this.results.current[type] + offset; + + if (!elements.length) { + return; + } + + if (i >= elements.length) { + i = 0; + } + else if (i < 0) { + i = elements.length - 1; + } + + if (elements.length > 1) { + let countElement = RefTest.nav.querySelector(".count-" + type); + countElement.querySelector(".nav").hidden = false; + countElement.querySelector(".current").textContent = i + 1; + } + + elements[i].scrollIntoView({ behavior: "smooth" }); + + this.results.current[type] = i; + } + + static next (type = "fail") { + this.#navigateTests(type, 1); + } + + static previous (type = "fail") { + this.#navigateTests(type, -1); + } +} diff --git a/src/dom.js b/src/dom.js new file mode 100644 index 0000000..329441d --- /dev/null +++ b/src/dom.js @@ -0,0 +1,91 @@ +import { delay, getType, stringify } from "./util.js"; +export { default as create } from "https://v2.blissfuljs.com/src/dom/create.js"; +export { default as bind } from "https://v2.blissfuljs.com/src/events/bind.js"; +export { default as include } from "https://v2.blissfuljs.com/src/async/include.js"; + +/** + * Parse data-click text into a meaningful structure. + * @example + * .foo .bar wait 5s after load + * @example + * .foo .bar wait 3s (no event, DOMContentLoaded assumed) + * @example + * wait 1s after load (no selector, element assumed) + */ +export function parseClick (click) { + var ret = { times: 1 }; + + click = click.replace(/wait ([\d.]+)s/i, ($0, $1) => { + ret.delay = $1 * 1000; + return ""; + }); + + click = click.replace(/after ([\w:-]+)\s*$/i, ($0, $1) => { + ret.event = $1; + return ""; + }); + + click = click.replace(/(\d+) times/i, ($0, $1) => { + ret.times = $1; + return ""; + }); + + ret.selector = click.trim(); + + return ret; +} + +export async function doClick (click, target) { + click = parseClick(click); + + if (click.event) { + await new Promise(resolve => target.addEventListener(click.event, resolve, { once: true })); + } + + if (click.delay) { + await delay(click.delay); + } + + let targets = click.selector ? $$(click.selector, target) : [target]; + + for (let target of targets) { + for (let i = 0; i < click.times; i++) { + target.click(); + } + } +} + +export function $$ (selector, context = document) { + return [...context.querySelectorAll(selector)]; +} + +export function ready (doc = document) { + return new Promise(resolve => { + if (doc.readyState !== "loading") { + resolve(); + } + else { + doc.addEventListener("DOMContentLoaded", resolve, { once: true }); + } + }); +} + +export function output (obj) { + return stringify(obj, { + custom: obj => { + if (Array.isArray(obj)) { + return obj.map(output).join(", "); + } + + if (typeof obj === "string") { + return obj; + } + + // Handle nested objects + if (obj && typeof obj === "object") { + let entries = Object.entries(obj).map(([key, value]) => `${key}: ${output(value)}`); + return "{ " + entries.join(", ") + " }"; + } + }, + }); +} diff --git a/src/render.js b/src/render.js index f44be94..302a291 100644 --- a/src/render.js +++ b/src/render.js @@ -4,8 +4,8 @@ import Test from "./classes/Test.js"; import TestResult from "./classes/TestResult.js"; -import RefTest from "https://html.htest.dev/src/classes/RefTest.js"; -import { create, output } from "https://html.htest.dev/src/util.js"; +import RefTest from "./classes/RefTest.js"; +import { create, output } from "./dom.js"; import { formatDuration } from "./util.js"; import format from "./format-console.js";