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; /** 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 { 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 { 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 { 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, }); }