muddle/views/testing.ts
2026-10-23 09:12:49 -04:00

260 lines
7.7 KiB
TypeScript

import { existsSync } from "node:fs";
import { readdir, readFile } from "node:fs/promises";
import { join, relative, resolve } from "node:path";
import { TemplateEngine } from "./engine/runtime.ts";
import { lex } from "./engine/lexer.ts";
import { parse } from "./engine/parser.ts";
import type { Node } from "./engine/types.ts";
import type { ComponentRegistry } from "./components.ts";
/** The helpers that are available inside a `$$$` block. */
export const ASSERTION_HELPERS = `
window.muddle = window.muddle || {};
window.muddle.assert = function (condition, message) {
if (!condition) {
throw new Error(message || "assertion failed");
}
};
window.muddle.assertText = function (selector, expected, message) {
const element = document.querySelector(selector);
const actual = element ? element.textContent.trim() : null;
if (actual !== expected) {
throw new Error(
(message || selector) + ": expected " + JSON.stringify(expected) + ", got " + JSON.stringify(actual),
);
}
};
window.muddle.assertHtml = function (selector, expected, message) {
const element = document.querySelector(selector);
const actual = element ? element.innerHTML.trim() : null;
if (actual !== expected) {
throw new Error(
(message || selector) + ": expected " + JSON.stringify(expected) + ", got " + JSON.stringify(actual),
);
}
};
window.muddle.assertCount = function (selector, expected, message) {
const actual = document.querySelectorAll(selector).length;
if (actual !== expected) {
throw new Error(
(message || selector) + ": expected " + expected + " elements, got " + actual,
);
}
};
window.muddle.assertExists = function (selector, message) {
if (!document.querySelector(selector)) {
throw new Error((message || selector) + ": the element does not exist");
}
};
window.muddle.assertMissing = function (selector, message) {
if (document.querySelector(selector)) {
throw new Error((message || selector) + ": the element should not exist");
}
};
window.muddle.assertAttribute = function (selector, name, expected, message) {
const element = document.querySelector(selector);
const actual = element ? element.getAttribute(name) : null;
if (actual !== expected) {
throw new Error(
(message || selector) + ": expected the attribute " + name + " to be " + JSON.stringify(expected) + ", got " + JSON.stringify(actual),
);
}
};
window.muddle.assertState = function (selector, state, message) {
const element = document.querySelector(selector);
const actual = element ? element.getAttribute("data-state") : null;
if (actual !== state) {
throw new Error(
(message || selector) + ": expected the state " + JSON.stringify(state) + ", got " + JSON.stringify(actual),
);
}
};
const assert = window.muddle.assert;
const assertText = window.muddle.assertText;
const assertHtml = window.muddle.assertHtml;
const assertCount = window.muddle.assertCount;
const assertExists = window.muddle.assertExists;
const assertMissing = window.muddle.assertMissing;
const assertAttribute = window.muddle.assertAttribute;
const assertState = window.muddle.assertState;
`;
export interface TemplateTestOptions {
/** Root directory of the project. */
root: string;
/** Directories scanned for templates. */
viewRoots: string[];
/** Locals handed to the templates. */
locals?: Record<string, unknown>;
/** Directory of the components. */
componentsDir?: string;
/** Server components available to the templates. */
components?: ComponentRegistry;
}
export interface TemplateTest {
/** Absolute path of the template. */
path: string;
/** Path of the template, relative to the root. */
key: string;
/** The assertions of the template. */
assertions: string;
}
/** Reads the assertions of a template. */
export function readAssertions(source: string): string {
const parsed = parse(lex(source));
const blocks: string[] = [];
collect(parsed.nodes, blocks);
return blocks.join("\n");
}
function collect(nodes: Node[], blocks: string[]): void {
for (const node of nodes) {
if (node.kind === "test") {
if (node.code.trim().length > 0) blocks.push(node.code);
continue;
}
const children = childrenOf(node);
if (children) collect(children, blocks);
}
}
function childrenOf(node: Node): Node[] | undefined {
switch (node.kind) {
case "element":
case "each":
case "while":
case "mixin":
case "mixin-call":
case "include":
case "block":
case "slot":
case "comment":
return node.children;
case "if":
return node.branches.flatMap((branch) => branch.children);
default:
return undefined;
}
}
/** Lists the templates of the project. */
export async function listTemplates(dirs: string[]): Promise<string[]> {
const files: string[] = [];
for (const dir of dirs) {
if (!existsSync(dir)) continue;
for (const entry of await readdir(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...(await listTemplates([path])));
continue;
}
if (entry.name.endsWith(".spug")) files.push(path);
}
}
return files;
}
/** Collects the templates that carry assertions. */
export async function collectTemplateTests(
options: TemplateTestOptions,
): Promise<TemplateTest[]> {
const tests: TemplateTest[] = [];
for (const file of await listTemplates(options.viewRoots)) {
const source = await readFile(file, "utf8");
const assertions = readAssertions(source);
if (assertions.trim().length === 0) continue;
tests.push({
path: file,
key: relative(options.root, file).replace(/\\/g, "/"),
assertions,
});
}
return tests;
}
export interface TemplateTestFailure {
/** Path of the template. */
key: string;
/** The failure of the template. */
error: Error;
}
export interface TemplateTestRun {
/** The templates that were tested. */
tested: TemplateTest[];
/** The failures of the templates. */
failures: TemplateTestFailure[];
/** `true` when the browser could not be started. */
skipped: boolean;
/** The reason of the skip. */
reason?: string;
}
/**
* Runs the tests of the templates.
*
* The templates are rendered on the server, the assertions are evaluated in a
* browser. When playwright is not installed the run is skipped, the tests of
* the templates do not fail the build.
*/
export async function runTemplateTests(
options: TemplateTestOptions,
): Promise<TemplateTestRun> {
const tests = await collectTemplateTests(options);
const failures: TemplateTestFailure[] = [];
if (tests.length === 0) return { tested: [], failures, skipped: false };
let chromium: typeof import("playwright").chromium;
try {
({ chromium } = await import("playwright"));
} catch (error) {
return {
tested: tests,
failures,
skipped: true,
reason: `playwright is not installed: ${(error as Error).message}`,
};
}
const engine = new TemplateEngine({
viewsDir: options.viewRoots[0] ?? options.root,
componentsDir: options.componentsDir,
root: options.root,
components: options.components,
});
const browser = await chromium.launch();
try {
const page = await browser.newPage();
for (const test of tests) {
const html = await engine.render(test.path, options.locals ?? {});
await page.setContent(html, { waitUntil: "load" });
try {
await page.evaluate(
`(async () => {\n${ASSERTION_HELPERS}\n${test.assertions}\n})()`,
);
} catch (error) {
failures.push({
key: test.key,
error: error as Error,
});
}
}
} finally {
await browser.close();
}
return { tested: tests, failures, skipped: false };
}
/** The engine of the project, used by the runner. */
export function createTestEngine(options: TemplateTestOptions): TemplateEngine {
return new TemplateEngine({
viewsDir: options.viewRoots[0] ?? resolve(options.root),
componentsDir: options.componentsDir,
root: options.root,
components: options.components,
});
}