260 lines
7.7 KiB
TypeScript
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,
|
|
});
|
|
}
|