chore: dom translator and tests (#65)

* chore: dom translator and tests

* fix: proper skipping of tests
This commit is contained in:
Ryan Carniato 2021-02-18 14:14:42 -08:00 committed by GitHub
parent 8dee9dd838
commit d5ab10232d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
63 changed files with 879 additions and 31 deletions

6
package-lock.json generated
View File

@ -5985,6 +5985,12 @@
"integrity": "sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ==",
"dev": true
},
"esm": {
"version": "3.2.25",
"resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz",
"integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==",
"dev": true
},
"espree": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/espree/-/espree-7.3.1.tgz",

View File

@ -17,6 +17,7 @@
"cross-env": "^7.0.2",
"eslint": "^7.14.0",
"eslint-config-prettier": "^6.15.0",
"esm": "^3.2.25",
"fixpack": "^3.0.6",
"husky": "^4.3.0",
"jsdom": "^16.4.0",
@ -56,7 +57,7 @@
"size": "cross-env SIZE=1 rollup -c ./rollup.config.js && node ./utilities/sizes.js",
"size:check": "cross-env CHECK=1 npm run size",
"size:write": "cross-env WRITE=1 npm run size && git add .sizes.json",
"test": "cross-env NODE_ENV=test MARKO_SOURCE_RUNTIME=1 mocha -r ts-node/register -r source-map-support/register packages/*/test/{*.test.ts,*/*.test.ts}",
"test": "cross-env NODE_ENV=test MARKO_SOURCE_RUNTIME=1 TS_NODE_IGNORE='/node_modules/(?!@marko/)/' mocha -r esm -r ts-node/register -r source-map-support/register packages/*/test/{*.test.ts,*/*.test.ts}",
"test:coverage": "nyc --reporter=text-summary npm run test",
"test:watch": "npm run test -- --watch --watch-files '**/*.ts'"
}

View File

@ -6,12 +6,13 @@ import {
assertNoVar
} from "@marko/babel-utils";
import { writeHTML } from "../util/html-write";
import { writeTemplate } from "../util/dom-writer";
import { isOutputHTML } from "../util/marko-config";
export function enter(tag: NodePath<t.MarkoTag>) {
if (isOutputHTML(tag)) {
writeHTML(tag)`<!--`;
}
} else writeTemplate(tag, `<!--`);
}
export function exit(tag: NodePath<t.MarkoTag>) {
@ -22,7 +23,7 @@ export function exit(tag: NodePath<t.MarkoTag>) {
if (isOutputHTML(tag)) {
writeHTML(tag)`-->`;
}
} else writeTemplate(tag, `-->`);
tag.remove();
}

View File

@ -1,6 +1,41 @@
import { types as t, NodePath } from "@marko/babel-types";
import { callRuntime } from "../util/runtime";
import {
needsPlaceholderMarker,
isOnlyChild,
Walks,
writeHydrate,
writeTemplate,
writeWalks,
checkNextMarker
} from "../util/dom-writer";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export default function (placeholder: NodePath<t.MarkoPlaceholder>) {
// TODO.
if (needsPlaceholderMarker(placeholder)) {
console.log("REPLACE");
writeWalks(placeholder, Walks.REPLACE);
writeTemplate(placeholder, "<!>");
} else if (isOnlyChild(placeholder)) {
console.log("GET");
writeWalks(placeholder, Walks.GET);
writeTemplate(placeholder, " ");
} else if (!checkNextMarker(placeholder)) {
console.log("AFTER");
writeWalks(placeholder, Walks.AFTER);
} else {
console.log("BEFORE");
writeWalks(placeholder, Walks.BEFORE);
}
// writeHydrate(
// placeholder,
// t.expressionStatement(callRuntime(placeholder, "walk"))
// );
writeHydrate(
placeholder,
t.expressionStatement(
callRuntime(placeholder, "text", placeholder.get("value").node)
)
);
placeholder.remove();
}

View File

@ -1,7 +1,14 @@
import { types as t, NodePath } from "@marko/babel-types";
import { writeExports } from "../util/dom-export";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function enter(program: NodePath<t.Program>) {}
export function enter(program: NodePath<t.Program>) {
program.state.template = "";
program.state.walks = [];
program.state.hydrate = [];
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function exit(program: NodePath<t.Program>) {}
export function exit(program: NodePath<t.Program>) {
writeExports(program);
}

View File

@ -1,10 +1,79 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { types as t, NodePath } from "@marko/babel-types";
import { getTagDef } from "@marko/babel-utils";
import {
writeHydrate,
writeTemplate,
writeWalks,
Walks,
setOnlyChild,
clearOnlyChild
} from "../../util/dom-writer";
import { callRuntime, getHTMLRuntime } from "../../util/runtime";
export function enter(tag: NodePath<t.MarkoTag>) {
// TODO
const attrs = tag.get("attributes");
const tagDef = getTagDef(tag);
const hasSpread = attrs.some(attr => attr.isMarkoSpreadAttribute());
let ofInterest = false;
if (tagDef) {
writeTemplate(tag, `<${tagDef.name}`);
for (const attr of attrs as NodePath<t.MarkoAttribute>[]) {
const name = attr.node.name;
const value = attr.get("value");
const { confident, value: computed } = value.evaluate();
// special handling of class/style??
if (confident) {
writeTemplate(tag, getHTMLRuntime(tag).attr(name, computed));
} else {
if (!ofInterest) {
ofInterest = true;
writeWalks(tag, Walks.GET);
writeHydrate(tag, t.expressionStatement(callRuntime(tag, "walk")));
}
writeHydrate(
tag,
t.expressionStatement(
callRuntime(tag, "attr", t.stringLiteral(name), attr.node.value!)
)
);
}
}
let emptyBody = false;
if (tagDef && tagDef.parseOptions?.openTagOnly) {
switch (tagDef.htmlType) {
case "svg":
case "math":
writeTemplate(tag, `/>`);
break;
default:
writeTemplate(tag, `>`);
break;
}
emptyBody = true;
} else if (tag.node.body.body.length) {
writeTemplate(tag, `>`);
if (tag.node.body.body.length === 1) setOnlyChild(tag);
} else {
writeTemplate(tag, `></${tagDef.name}>`);
emptyBody = true;
}
if (emptyBody) {
writeWalks(tag, Walks.NEXT);
tag.remove();
} else writeWalks(tag, Walks.ENTER);
}
}
export function exit(tag: NodePath<t.MarkoTag>) {
// TODO
const tagDef = getTagDef(tag);
if (tagDef && tagDef.name) writeTemplate(tag, `</${tagDef.name}>`);
writeWalks(tag, Walks.EXIT);
clearOnlyChild(tag);
tag.remove();
}

View File

@ -1,12 +1,23 @@
import { types as t, NodePath } from "@marko/babel-types";
import { writeHTML } from "./util/html-write";
import {
Walks,
writeTemplate,
writeWalks,
markTextSiblings,
checkLastStatic
} from "./util/dom-writer";
import { isOutputHTML } from "./util/marko-config";
export default function (text: NodePath<t.MarkoText>) {
if (isOutputHTML(text)) {
writeHTML(text)`${text.node.value}`;
} else {
// TODO
writeTemplate(text, text.node.value);
if (checkLastStatic(text)) {
writeWalks(text, Walks.NEXT);
}
markTextSiblings(text);
}
text.remove();

View File

@ -0,0 +1,54 @@
import { NodePath, Program, types as t } from "@marko/babel-types";
import { callRuntime } from "./runtime";
import { encodeWalks } from "./walks";
export function writeExports(path: NodePath<Program>) {
const template = t.identifier("template");
const walks = t.identifier("walks");
const hydrate = t.identifier("hydrate");
// template
path.node.body.push(
t.exportNamedDeclaration(
t.variableDeclaration("const", [
t.variableDeclarator(
template,
t.stringLiteral(path.state.template || "")
)
])
),
t.exportNamedDeclaration(
t.variableDeclaration("const", [
t.variableDeclarator(
walks,
t.stringLiteral(encodeWalks(path.state.walks))
)
])
),
t.exportNamedDeclaration(
t.variableDeclaration("const", [
t.variableDeclarator(
hydrate,
callRuntime(
path,
"register",
t.stringLiteral(path.hub.file.metadata.marko.id),
t.arrowFunctionExpression(
[t.identifier("input")],
t.blockStatement(path.state.hydrate)
)
)
)
])
),
t.exportDefaultDeclaration(
callRuntime(
path,
"createRenderFn",
template,
walks,
t.arrayExpression(),
hydrate
)
)
);
}

View File

@ -0,0 +1,67 @@
import {
NodePath,
Node,
MarkoTag,
MarkoText,
types as t,
MarkoPlaceholder
} from "@marko/babel-types";
import { Walks } from "./walks";
export { Walks } from "./walks";
export function writeTemplate(path: NodePath<any>, s: string) {
path.state.template += s;
}
export function writeHydrate(path: NodePath<any>, code: Node) {
path.state.hydrate.push(code);
}
export function writeWalks(path: NodePath<any>, code: Walks) {
path.state.walks.push(code);
}
export function checkLastStatic(path: NodePath<any>) {
let i = +path.key;
let temp: NodePath<any>;
while ((temp = path.getSibling(++i)).node) {
if (t.isMarkoPlaceholder(temp)) return false;
}
return true;
}
export function checkNextMarker(path: NodePath<any>) {
let i = +path.key;
let temp: NodePath<any>;
while ((temp = path.getSibling(++i)).node) {
if (!t.isMarkoPlaceholder(temp)) return true;
}
return false;
}
export function markTextSiblings(path: NodePath<MarkoText>) {
const sibling = path.getSibling(+path.key + 1);
if (sibling && t.isMarkoPlaceholder(sibling.node))
path.state.precedingText = true;
}
export function needsPlaceholderMarker(path: NodePath<MarkoPlaceholder>) {
if (!path.state.precedingText) return false;
const sibling = path.getSibling(+path.key + 1);
if (sibling && t.isMarkoText(sibling.node)) return true;
path.state.precedingText = false;
return false;
}
export function setOnlyChild(path: NodePath<MarkoTag>) {
path.state.onlyChild = true;
}
export function clearOnlyChild(path: NodePath<MarkoTag>) {
path.state.onlyChild = false;
}
export function isOnlyChild(path: NodePath<any>) {
return path.state.onlyChild;
}

View File

@ -8,7 +8,8 @@ export function importRuntime<T extends t.Node>(
path: NodePath<T>,
name: string
) {
return importNamed(path.hub.file, getRuntimePath(path), name);
const { output } = getMarkoOpts(path);
return importNamed(path.hub.file, getRuntimePath(path, output), name);
}
export function callRuntime<
@ -25,19 +26,28 @@ export function callRuntime<
}
export function getHTMLRuntime<T extends t.Node>(path: NodePath<T>) {
return getRuntime(path) as typeof import("@marko/runtime-fluurt/src/html");
return getRuntime(
path,
"html"
) as typeof import("@marko/runtime-fluurt/src/html");
}
export function getDOMRuntime<T extends t.Node>(path: NodePath<T>) {
return getRuntime(path) as typeof import("@marko/runtime-fluurt/src/dom");
return getRuntime(
path,
"dom"
) as typeof import("@marko/runtime-fluurt/src/dom");
}
function getRuntime<T extends t.Node>(path: NodePath<T>): unknown {
return require(getRuntimePath(path));
function getRuntime<T extends t.Node>(
path: NodePath<T>,
output: string
): unknown {
return require(getRuntimePath(path, output));
}
function getRuntimePath<T extends t.Node>(path: NodePath<T>) {
const { output, optimize } = getMarkoOpts(path);
function getRuntimePath<T extends t.Node>(path: NodePath<T>, output: string) {
const { optimize } = getMarkoOpts(path);
return `@marko/runtime-fluurt/${
USE_SOURCE_RUNTIME ? "src" : optimize ? "dist" : "debug"
}/${output}`;

View File

@ -0,0 +1,140 @@
const enum WalkCodes {
Get = 33, // !
Before = 35, // #
After = 36, // $
Inside = 37, // %
Replace = 38, // &
Out = 39,
OutEnd = 49,
Over = 58,
OverEnd = 91,
Next = 93,
NextEnd = 126
}
const get = String.fromCharCode(WalkCodes.Get);
const before = String.fromCharCode(WalkCodes.Before);
const after = String.fromCharCode(WalkCodes.After);
const replace = String.fromCharCode(WalkCodes.Replace);
const inside = String.fromCharCode(WalkCodes.Inside);
function next(number: number) {
return toCharString(number, WalkCodes.Next, WalkCodes.NextEnd);
}
function over(number: number) {
return toCharString(number, WalkCodes.Over, WalkCodes.OverEnd);
}
function out(number: number) {
return toCharString(number, WalkCodes.Out, WalkCodes.OutEnd);
}
function toCharString(
number: number,
startCharCode: number,
endCharCode: number
) {
const total = endCharCode - startCharCode + 1;
let value = "";
while (number > total) {
value += String.fromCharCode(endCharCode);
number -= total;
}
return value + String.fromCharCode(startCharCode + number - 1);
}
export const enum Walks {
ENTER,
EXIT,
NEXT,
OVER,
GET,
BEFORE,
AFTER,
INSIDE,
REPLACE
}
type WalkInfo = {
hasAction: boolean;
sequence: Walks[];
earlyExits: number;
};
const lookup = {
[Walks.EXIT]: out,
[Walks.NEXT]: next,
[Walks.OVER]: over,
[Walks.GET]: get,
[Walks.BEFORE]: before,
[Walks.AFTER]: after,
[Walks.INSIDE]: inside,
[Walks.REPLACE]: replace
};
function resolveSequence(walks: Walks[]) {
let current: Walks;
let count = 0;
let results = "";
for (let i = 0, len = walks.length; i < len; i++) {
const w = walks[i];
if (w !== current!) {
if (current!) {
results += (lookup[current] as any)(count);
}
current = w;
count = 0;
}
count++;
}
if (current!) results += (lookup[current] as any)(count);
return results;
}
export function encodeWalks(walks: Walks[]): string {
let results = "";
let inner: WalkInfo;
const current: WalkInfo[] = [
{
hasAction: false,
sequence: [],
earlyExits: 0
}
];
for (let i = 0, len = walks.length; i < len; i++) {
switch (walks[i]) {
case Walks.NEXT:
current[0].sequence.push(Walks.NEXT);
break;
case Walks.ENTER:
current.unshift({
hasAction: false,
sequence: [...current[0].sequence, Walks.NEXT],
earlyExits: 0
});
break;
case Walks.EXIT:
inner = current.shift()!;
if (!inner.hasAction) {
current[0].sequence.push(Walks.OVER);
} else {
current[0].hasAction = true;
current[0].earlyExits = inner.earlyExits;
for (let j = 0, len = ++current[0].earlyExits; j < len; j++)
current[0].sequence.push(Walks.EXIT);
}
break;
default:
current[0].hasAction = true;
if (current[0].sequence.length) {
results += resolveSequence(current[0].sequence);
current[0].sequence = [];
current[0].earlyExits = 0;
}
results += lookup[walks[i]];
}
}
results += resolveSequence(current[0].sequence);
console.log(results, next(1) + after + out(1));
return results;
}

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
(intermediate value)(intermediate value)(intermediate value) is not a function

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
child is not a function

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
_child is not a function

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -0,0 +1 @@
export const skip = ["dom-compiled", "dom-rendered"];

View File

@ -6,6 +6,8 @@ import stripAnsi from "strip-ansi";
import { compileFile } from "@marko/compiler";
import { install } from "marko/node-require";
import * as translator from "../src";
import snapshot from "./utils/snapshot";
import renderAndTrackMutations from "./utils/render-and-track-mutations";
const baseConfig = {
translator,
@ -29,10 +31,10 @@ install({
describe("translator", () => {
autotest("fixtures", {
"html-compiled": runCompileTest({ output: "html" }),
"html-rendered": runHTMLRenderTest,
"dom-compiled": () => {},
"dom-rendered": () => {}
"html-compiled": runTestWithConfig(runCompileTest({ output: "html" })),
"html-rendered": runTestWithConfig(runHTMLRenderTest),
"dom-compiled": runTestWithConfig(runCompileTest({ output: "dom" })),
"dom-rendered": runTestWithConfig(runDOMRenderTest)
});
});
@ -53,6 +55,7 @@ function runCompileTest(config: { output: string }) {
try {
output = (await compileFile(templateFile, compilerConfig)).code;
// .replace(/"\.\//g, '"../')
} catch (compileSnapshotErr) {
try {
snapshot(stripCwd(stripAnsi(compileSnapshotErr.message)), {
@ -78,9 +81,9 @@ function runCompileTest(config: { output: string }) {
};
}
function runHTMLRenderTest({ mode, test, resolve, snapshot }) {
function runHTMLRenderTest({ mode, test, resolve, snapshot }, { inputHTML }) {
// const templateFile = resolve("./snapshots/html-compiled-expected.js");
const templateFile = resolve("template.marko");
const inputFile = resolve("input.ts");
const snapshotsDir = resolve("snapshots");
const name = `snapshots${path.sep + mode}`;
@ -88,18 +91,11 @@ function runHTMLRenderTest({ mode, test, resolve, snapshot }) {
await ensureDir(snapshotsDir);
const { render } = await import(templateFile);
let input: Record<string, unknown>;
let html = "";
try {
input = await import(inputFile);
} catch {
input = {};
}
try {
await render(
input,
inputHTML || {},
new Writable({
write(chunk: string) {
html += chunk;
@ -121,6 +117,39 @@ function runHTMLRenderTest({ mode, test, resolve, snapshot }) {
});
}
function runDOMRenderTest({ mode, test, resolve }, { inputDOM }) {
const templateFile = resolve("snapshots/dom-compiled-expected.js");
const snapshotsDir = resolve("snapshots");
test(async () => {
await ensureDir(snapshotsDir);
snapshot(
snapshotsDir,
`${mode}.md`,
await renderAndTrackMutations(templateFile, inputDOM)
);
});
}
function runTestWithConfig(fn) {
return opts => {
let config;
try {
config = require(opts.resolve("config.ts"));
} catch {
config = {};
}
if (config.skip && config.skip.includes(opts.mode)) {
opts.skip("Not Implemented");
return;
}
return fn(opts, config);
};
}
async function ensureDir(dir: string) {
try {
await fs.promises.access(dir);

View File

@ -0,0 +1,24 @@
import { DOMWindow } from "jsdom";
import createBrowser from "jsdom-context-require";
export default function (options: Parameters<typeof createBrowser>[0]) {
// something up with extensions
const browser = createBrowser({ ...options, extensions: require.extensions });
const window = browser.window as DOMWindow & { MessageChannel: any };
window.queueMicrotask = queueMicrotask;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
window.MessageChannel = (window as any).MessageChannel = class MessageChannel {
port1: any;
port2: any;
constructor() {
this.port1 = { onmessage() {} };
this.port2 = {
postMessage: () => {
setImmediate(this.port1.onmessage);
}
};
}
};
window.requestAnimationFrame = fn => setTimeout(fn);
return browser;
}

View File

@ -0,0 +1,29 @@
export function getNodePath(node: Node) {
const parts: string[] = [];
let cur: Node | null = node;
while (cur) {
const { parentNode } = cur;
if (!parentNode || (cur as any).TEST_ROOT) {
break;
}
let name = getTypeName(cur);
const index = parentNode
? (Array.from(parentNode.childNodes) as Node[]).indexOf(cur)
: -1;
if (index !== -1) {
name += `${index}`;
}
parts.unshift(name);
cur = parentNode;
}
return parts.join("/");
}
export function getTypeName(node: Node) {
return node.nodeName.toLowerCase();
}

View File

@ -0,0 +1,114 @@
import createBrowser from "./create-browser";
import createMutationTracker from "./track-mutations";
import { wait, isWait } from "./resolve";
const browser = createBrowser({
dir: __dirname,
html: ""
});
const window = browser.window;
const document = window.document;
const { createRenderFn, runInBatch } = browser.require(
"@marko/runtime-fluurt/src/dom/index"
) as typeof import("@marko/runtime-fluurt/src/dom/index");
interface Test {
wait?: number;
inputs: [
Record<string, unknown>,
...Array<
| Record<string, unknown>
| ((container: Element) => void)
| ReturnType<typeof wait>
>
];
default: ReturnType<typeof createRenderFn>;
html: string;
FAILS_HYDRATE?: boolean;
}
export default async function renderAndGetMutations(
test: string,
inputs = []
): Promise<string> {
if (!Array.isArray(inputs)) inputs = [inputs];
const { default: render } = browser.require(test) as Test;
const [firstInput] = inputs;
const container = Object.assign(document.createElement("div"), {
TEST_ROOT: true
});
const tracker = createMutationTracker(window, container);
document.body.appendChild(container);
try {
tracker.beginUpdate();
const instance = render(firstInput);
container.appendChild(instance);
// const initialHTML = container.innerHTML;
tracker.logUpdate(firstInput);
for (const update of inputs.slice(1)) {
if (isWait(update)) {
await update();
} else {
tracker.beginUpdate();
if (typeof update === "function") {
runInBatch(() => update(container));
} else {
instance.rerender(update);
}
tracker.logUpdate(update);
}
}
// if (!FAILS_HYDRATE) {
// const inputSignal = source(firstInput);
// (window as any).M$c = [[0, id, dynamicKeys(inputSignal, renderer.input)]];
// container.innerHTML = `<!M$0>${initialHTML}<!M$0/>`;
// container.insertBefore(document.createTextNode(""), container.firstChild);
// tracker.dropUpdate();
// init();
// tracker.logUpdate(firstInput);
// const logs = tracker.getRawLogs();
// logs[logs.length - 1] = "--- Hydrate ---\n" + logs[logs.length - 1];
// // Hydrate should end up with the same html as client side render.
// assert.equal(container.innerHTML, initialHTML);
// // Run the same updates after hydrate and ensure the same mutations.
// let resultIndex = 0;
// for (const update of inputs.slice(1)) {
// if (wait) {
// await resolveAfter(null, wait);
// }
// if (typeof update === "function") {
// update(container);
// } else {
// const batch = beginBatch();
// set(inputSignal, update);
// endBatch(batch);
// }
// assert.equal(
// tracker.getUpdate(update),
// tracker.getRawLogs()[++resultIndex]
// );
// }
// if (wait) {
// await resolveAfter(null, wait);
// }
// }
return tracker.getLogs();
} finally {
tracker.cleanup();
document.body.removeChild(container);
}
}

View File

@ -0,0 +1,27 @@
const TIMEOUT_MULTIPLIER = 16;
export function wait(timeout: number) {
return Object.assign(() => resolveAfter(`wait:${timeout}`, timeout), {
wait: true
});
}
export function isWait(value: any): value is ReturnType<typeof wait> {
return value.wait;
}
export function resolveAfter<T>(value: T, timeout: number) {
const p = new Promise(resolve =>
setTimeout(() => resolve(value), timeout * TIMEOUT_MULTIPLIER)
) as Promise<T>;
return Object.assign(p, { value });
}
export function rejectAfter<T extends Error>(value: T, timeout: number) {
const p = new Promise((_, reject) =>
setTimeout(() => reject(value), timeout * TIMEOUT_MULTIPLIER)
) as Promise<never>;
return Object.assign(p, { value });
}

View File

@ -0,0 +1,48 @@
import fs from "fs";
import path from "path";
import assert from "assert";
export default function snapshot(
dir: string,
file: string,
data: string,
originalError?: Error
) {
const parsed = path.parse(file);
const ext = parsed.ext;
let name = parsed.name;
if (name) {
name += ".";
}
try {
fs.accessSync(dir);
} catch {
fs.mkdirSync(dir);
}
const expectedFile = path.join(dir, `${name}expected${ext}`);
const actualFile = path.join(dir, `${name}actual${ext}`);
fs.writeFileSync(actualFile, data, "utf-8");
if (process.env.UPDATE_EXPECTATIONS) {
fs.writeFileSync(expectedFile, data, "utf-8");
} else {
const expected = fs.existsSync(expectedFile)
? fs.readFileSync(expectedFile, "utf-8")
: "";
try {
assert.equal(data, expected);
} catch (err) {
err.snapshot = true;
err.name = err.name.replace(" [ERR_ASSERTION]", "");
err.stack = "";
err.message = path.relative(process.cwd(), actualFile);
throw originalError || err;
}
}
}

View File

@ -0,0 +1,131 @@
import format from "pretty-format";
import { getNodePath, getTypeName } from "./get-node-info";
const { DOMElement, DOMCollection } = format.plugins;
export default function createMutationTracker(window, container) {
const result: string[] = [];
let currentRecords: unknown[] | null = null;
const observer = new window.MutationObserver(records => {
if (currentRecords) {
currentRecords = currentRecords.concat(records);
} else {
result.push(getStatusString(container, records, "ASYNC"));
}
});
observer.observe(container, {
attributes: true,
attributeOldValue: true,
characterData: true,
characterDataOldValue: true,
childList: true,
subtree: true
});
return {
beginUpdate() {
currentRecords = [];
},
dropUpdate() {
observer.takeRecords();
currentRecords = null;
},
getUpdate(update) {
if (currentRecords) {
currentRecords = currentRecords.concat(observer.takeRecords());
} else {
currentRecords = observer.takeRecords();
}
const updateString = getStatusString(container, currentRecords, update);
currentRecords = null;
return updateString;
},
log(message) {
result.push(message);
},
logUpdate(update) {
result.push(this.getUpdate(update));
},
getRawLogs() {
return result;
},
getLogs() {
return result.join("\n\n\n");
},
cleanup() {
observer.disconnect();
}
};
}
function getStatusString(container: HTMLDivElement, changes, update) {
const clone = container.cloneNode(true);
clone.normalize();
return `# Render ${
typeof update === "function"
? `\n${update
.toString()
.replace(/^.*?{\s*([\s\S]*?)\s*}.*?$/, "$1")
.replace(/^ {4}/gm, "")}\n`
: JSON.stringify(update)
}\n\`\`\`html\n${Array.from(clone.childNodes)
.map(child =>
format(child, {
plugins: [DOMElement, DOMCollection]
}).trim()
)
.filter(Boolean)
.join("\n")
.trim()}\n\`\`\`\n\n# Mutations\n\`\`\`\n${changes
.map(formatMutationRecord)
.join("\n")}\n\`\`\``;
}
function formatMutationRecord(record: MutationRecord) {
const { target, oldValue } = record;
switch (record.type) {
case "attributes": {
const { attributeName } = record;
const newValue = (target as HTMLElement).getAttribute(
attributeName as string
);
return `${getNodePath(target)}: attr(${attributeName}) ${JSON.stringify(
oldValue
)} => ${JSON.stringify(newValue)}`;
}
case "characterData": {
return `${getNodePath(target)}: ${JSON.stringify(
oldValue
)} => ${JSON.stringify(target.nodeValue)}`;
}
case "childList": {
const { removedNodes, addedNodes, previousSibling, nextSibling } = record;
const details: string[] = [];
if (removedNodes.length) {
const relativeNode = previousSibling || nextSibling || target;
const position =
relativeNode === previousSibling
? "after"
: relativeNode === nextSibling
? "before"
: "in";
details.push(
`removed ${Array.from(removedNodes)
.map(getTypeName)
.join(", ")} ${position} ${getNodePath(relativeNode)}`
);
}
if (addedNodes.length) {
details.push(
`inserted ${Array.from(addedNodes).map(getNodePath).join(", ")}`
);
}
return details.join("\n");
}
}
}