From 80bd74afcf1fbf072955c164c2f63e111589c1ec Mon Sep 17 00:00:00 2001 From: Dylan Piercey Date: Wed, 14 Feb 2024 16:10:51 -0700 Subject: [PATCH] feat: expose normalize api (#24) --- README.md | 15 ++++++ src/__tests__/render.browser.ts | 26 ++++++++++- src/__tests__/render.server.ts | 18 +++++++- src/index-browser.ts | 2 +- src/index.ts | 4 +- src/shared.ts | 81 +++++++++++++++++++++++++++++++++ src/typings.d.ts | 2 +- 7 files changed, 142 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b604bc1..0367eb6 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,21 @@ With mocha you can use `mocha -r @marko/testing-library/dont-cleanup-after-each` If you are using Jest, you can include `setupFilesAfterEnv: ["@marko/testing-library/dont-cleanup-after-each"]` in your Jest config to avoid doing this in each file. +### `normalize()` + +Returns a clone of the passed DOM container with Marko's internal markers removed (data-marko, etc.), id's and whitespace are also normalized. + +```javascript +import { render, normalize } from "@marko/testing-library"; +import HelloTemplate from "./src/__test__/fixtures/hello-name.marko"; + +test("snapshot", async () => { + const { container } = await render(HelloTemplate, { name: "World" }); + + expect(normalize(container)).toMatchSnapshot(); +}); +``` + ## Setup Marko testing library is not dependent on any test runner, however it is dependent on the test environment. These utilities work for testing both server side, and client side Marko templates and provide a slightly different implementation for each. This is done using a [browser shim](https://github.com/defunctzombie/package-browser-field-spec), just like in Marko. diff --git a/src/__tests__/render.browser.ts b/src/__tests__/render.browser.ts index 1550df4..3328456 100644 --- a/src/__tests__/render.browser.ts +++ b/src/__tests__/render.browser.ts @@ -1,4 +1,11 @@ -import { render, fireEvent, screen, cleanup, act } from "../index-browser"; +import { + render, + fireEvent, + screen, + cleanup, + act, + normalize, +} from "../index-browser"; import Counter from "./fixtures/counter.marko"; import SplitCounter from "./fixtures/split-counter.marko"; import LegacyCounter from "./fixtures/legacy-counter"; @@ -18,6 +25,23 @@ test("renders interactive content in the document", async () => { expect(getByText("Value: 1")).toBeInTheDocument(); }); +test("normalizes a rendered containers content", async () => { + const { container } = await render(Counter); + + expect(normalize(container)).toMatchInlineSnapshot(` +
+
+ Value: 0 + +
+
+ `); +}); + test("renders interactive split component in the document", async () => { const { getByText } = await render(SplitCounter, { message: "Count" }); expect(getByText(/0/)).toBeInTheDocument(); diff --git a/src/__tests__/render.server.ts b/src/__tests__/render.server.ts index ce1e987..a22298a 100644 --- a/src/__tests__/render.server.ts +++ b/src/__tests__/render.server.ts @@ -1,4 +1,4 @@ -import { render, screen, fireEvent, cleanup, act } from ".."; +import { render, screen, fireEvent, cleanup, act, normalize } from ".."; import Counter from "./fixtures/counter.marko"; import SplitCounter from "./fixtures/split-counter.marko"; import LegacyCounter from "./fixtures/legacy-counter"; @@ -17,6 +17,22 @@ test("renders static content in a document with a browser context", async () => expect(container.firstElementChild).toHaveAttribute("class", "counter"); }); +test("normalizes a rendered containers content", async () => { + const { container } = await render(Counter); + expect(normalize(container)).toMatchInlineSnapshot(` + +
+ Value: 0 + +
+
+ `); +}); + test("renders split component in the document", async () => { const { getByText } = await render(SplitCounter, { message: "Count" }); expect(getByText(/0/)).toBeDefined(); diff --git a/src/index-browser.ts b/src/index-browser.ts index 622bae8..02535e9 100644 --- a/src/index-browser.ts +++ b/src/index-browser.ts @@ -14,7 +14,7 @@ interface MountedComponent { } const mountedComponents = new Set(); -export { FireFunction, FireObject, fireEvent, act } from "./shared"; +export { FireFunction, FireObject, fireEvent, act, normalize } from "./shared"; export type RenderResult = AsyncReturnValue; diff --git a/src/index.ts b/src/index.ts index f49313c..bb0a881 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,7 @@ import { screen as testingLibraryScreen, } from "@testing-library/dom"; -export { FireFunction, FireObject, fireEvent, act } from "./shared"; +export { FireFunction, FireObject, fireEvent, act, normalize } from "./shared"; export type RenderResult = AsyncReturnValue; @@ -21,7 +21,7 @@ export const screen: typeof testingLibraryScreen = {} as any; let activeContainer: DocumentFragment | undefined; -export async function render( +export async function render>( template: T | { default: T }, input: Marko.TemplateInput> = {} as any, // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/src/shared.ts b/src/shared.ts index e149ead..97bf845 100644 --- a/src/shared.ts +++ b/src/shared.ts @@ -35,6 +35,83 @@ export type FireObject = { ) => Promise>; }; +const SHOW_ELEMENT = 1; +const SHOW_COMMENT = 128; +const COMMENT_NODE = 8; + +export function normalize(container: T) { + const idMap: Map = new Map(); + const clone = container.cloneNode(true) as T; + const document = container.ownerDocument!; + const commentAndElementWalker = document.createTreeWalker( + clone, + SHOW_ELEMENT | SHOW_COMMENT + ); + + let node: Comment | Element; + let nextNode = commentAndElementWalker.nextNode(); + while ((node = nextNode as Comment | Element)) { + nextNode = commentAndElementWalker.nextNode(); + if (isComment(node)) { + node.remove(); + } else { + const { id, attributes } = node; + if (/\d/.test(id)) { + let idIndex = idMap.get(id); + + if (idIndex === undefined) { + idIndex = idMap.size; + idMap.set(id, idIndex); + } + + node.id = `GENERATED-${idIndex}`; + } + + for (let i = attributes.length; i--; ) { + const attr = attributes[i]; + + if (/^data-(w-|widget$|marko(-|$))/.test(attr.name)) { + node.removeAttributeNode(attr); + } + } + } + } + + if (idMap.size) { + const elementWalker = document.createTreeWalker(clone, SHOW_ELEMENT); + + nextNode = elementWalker.nextNode(); + while ((node = nextNode as Element)) { + nextNode = elementWalker.nextNode(); + const { attributes } = node; + + for (let i = attributes.length; i--; ) { + const attr = attributes[i]; + const { value } = attr; + const updated = value + .split(" ") + .map((part) => { + const idIndex = idMap.get(part); + if (idIndex === undefined) { + return part; + } + + return `GENERATED-${idIndex}`; + }) + .join(" "); + + if (value !== updated) { + attr.value = updated; + } + } + } + } + + clone.normalize(); + + return clone; +} + export async function act unknown>(fn: T) { type Return = ReturnType; if (typeof window === "undefined") { @@ -94,3 +171,7 @@ const tick = function waitForBatchedUpdates() { return new Promise(tick); } + +function isComment(node: Node): node is Comment { + return node.nodeType === COMMENT_NODE; +} diff --git a/src/typings.d.ts b/src/typings.d.ts index 0c5ae11..4773cde 100644 --- a/src/typings.d.ts +++ b/src/typings.d.ts @@ -9,7 +9,7 @@ declare namespace Marko { // against the v3 compat layer in Marko 4. // eslint-disable-next-line @typescript-eslint/no-empty-interface - export interface Template {} + export interface Template {} // eslint-disable-next-line @typescript-eslint/no-unused-vars export type TemplateInput = any; // eslint-disable-next-line @typescript-eslint/no-unused-vars