design-embed 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -2
- package/dist/cli.mjs +28 -224
- package/dist/core-BLV62TaX.mjs +907 -0
- package/dist/index.d.mts +185 -312
- package/dist/index.mjs +2 -2
- package/package.json +2 -10
- package/src/cli.ts +7 -32
- package/src/commands/compile.ts +20 -88
- package/src/commands/generateTests.ts +9 -91
- package/src/commands/init.ts +5 -8
- package/src/commands/plugin.ts +3 -14
- package/src/config/index.ts +302 -0
- package/src/core/diagnostics/diagnostic.ts +18 -0
- package/src/core/diagnostics/jsonDiagnostic.ts +51 -0
- package/src/core/index.ts +579 -0
- package/src/core/nodes.ts +74 -0
- package/src/core/pipeline/checkMode.ts +46 -0
- package/src/core/plugins/pluginApi.ts +44 -0
- package/src/core/types.ts +120 -0
- package/src/index.ts +48 -2
- package/src/targets/html.ts +565 -12
- package/dist/src-D3fnqGCq.mjs +0 -511
package/src/targets/html.ts
CHANGED
|
@@ -1,39 +1,150 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
DesignNode,
|
|
3
|
+
Diagnostic,
|
|
4
|
+
GeneratedFile,
|
|
5
|
+
PropValue,
|
|
3
6
|
TargetEmitInput,
|
|
4
7
|
TargetEmitResult,
|
|
5
8
|
TargetEmitter,
|
|
6
|
-
|
|
9
|
+
TargetTestGenerateInput,
|
|
10
|
+
TargetTestGenerateResult,
|
|
11
|
+
TargetTestGenerator,
|
|
12
|
+
} from "../core/index.ts";
|
|
7
13
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Public API
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
export interface HtmlTargetOptions {
|
|
19
|
+
domModel?: "light" | "shadow";
|
|
14
20
|
}
|
|
15
21
|
|
|
16
|
-
export
|
|
22
|
+
export class HtmlTarget implements TargetEmitter, TargetTestGenerator {
|
|
23
|
+
private readonly domModel: "light" | "shadow";
|
|
24
|
+
|
|
25
|
+
constructor(options: HtmlTargetOptions = {}) {
|
|
26
|
+
this.domModel = options.domModel ?? "light";
|
|
27
|
+
}
|
|
28
|
+
|
|
17
29
|
emit({ nodes, css, config }: TargetEmitInput): TargetEmitResult {
|
|
18
|
-
const viewsDir = config?.output?.viewsDir ?? "src/generated/views";
|
|
30
|
+
const viewsDir = String(config?.output?.viewsDir ?? "src/generated/views");
|
|
31
|
+
const viewName = config?.output?.viewName ?? "index";
|
|
32
|
+
|
|
33
|
+
const components = collectComponents(nodes);
|
|
34
|
+
const scriptTag =
|
|
35
|
+
components.length > 0
|
|
36
|
+
? `<script defer src="./${viewName}.js"></script>\n`
|
|
37
|
+
: "";
|
|
38
|
+
|
|
39
|
+
const files: GeneratedFile[] = [
|
|
40
|
+
{
|
|
41
|
+
path: `${viewsDir}/${viewName}.html`,
|
|
42
|
+
contents: emitHtml(nodes, css) + scriptTag,
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
if (components.length > 0) {
|
|
47
|
+
files.push({
|
|
48
|
+
path: `${viewsDir}/${viewName}.ts`,
|
|
49
|
+
contents: emitWebComponentFile(components, this.domModel),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { files };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
generateTests(input: TargetTestGenerateInput): TargetTestGenerateResult {
|
|
57
|
+
return htmlTestGenerator.generateTests(input);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const htmlTarget: TargetEmitter & TargetTestGenerator = new HtmlTarget();
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Test generator (unchanged)
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
const htmlTestGenerator: TargetTestGenerator = {
|
|
68
|
+
generateTests({
|
|
69
|
+
html,
|
|
70
|
+
css,
|
|
71
|
+
config,
|
|
72
|
+
}: TargetTestGenerateInput): TargetTestGenerateResult {
|
|
73
|
+
const diagnostics: Diagnostic[] = [];
|
|
74
|
+
const tests = config.tests;
|
|
75
|
+
|
|
76
|
+
if (tests?.runner && tests.runner !== "playwright") {
|
|
77
|
+
diagnostics.push({
|
|
78
|
+
code: "TEST_RUNNER_UNSUPPORTED",
|
|
79
|
+
message: `Unsupported test runner: ${tests.runner}`,
|
|
80
|
+
severity: "error",
|
|
81
|
+
});
|
|
82
|
+
return { files: [], diagnostics };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const viewsDir = config.output?.viewsDir ?? "src/generated/views";
|
|
86
|
+
const viewName = config.output?.viewName ?? "index";
|
|
87
|
+
const outputDir = tests?.outputDir ?? `${viewsDir}/tests`;
|
|
88
|
+
const fixturePath = `${outputDir}/${viewName}.reference.html`;
|
|
89
|
+
const specPath = `${outputDir}/${viewName}.spec.ts`;
|
|
90
|
+
const outputHtmlPath = `${viewsDir}/${viewName}.html`;
|
|
91
|
+
const referenceHtml = `${css?.trim() ? `<style>\n${css}\n</style>\n` : ""}${html}`;
|
|
92
|
+
|
|
19
93
|
return {
|
|
94
|
+
diagnostics,
|
|
20
95
|
files: [
|
|
21
96
|
{
|
|
22
|
-
path:
|
|
23
|
-
contents:
|
|
97
|
+
path: fixturePath,
|
|
98
|
+
contents: referenceHtml.endsWith("\n")
|
|
99
|
+
? referenceHtml
|
|
100
|
+
: `${referenceHtml}\n`,
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
path: specPath,
|
|
104
|
+
contents: emitHtmlVisualSpec({
|
|
105
|
+
viewName,
|
|
106
|
+
relativeOutputPath: toRelativeFilePath(specPath, outputHtmlPath),
|
|
107
|
+
fixtureFileName: `${viewName}.reference.html`,
|
|
108
|
+
viewports: tests?.viewports ?? [
|
|
109
|
+
{ name: "default", width: 1440, height: 900 },
|
|
110
|
+
],
|
|
111
|
+
states: tests?.states ?? [{ name: "default" }],
|
|
112
|
+
assertions: {
|
|
113
|
+
screenshot: tests?.assertions?.screenshot ?? true,
|
|
114
|
+
layout: tests?.assertions?.layout ?? true,
|
|
115
|
+
layoutTolerance: tests?.assertions?.layoutTolerance ?? 0,
|
|
116
|
+
selectors: tests?.assertions?.selectors ?? [":scope", ":scope *"],
|
|
117
|
+
screenshotThreshold:
|
|
118
|
+
tests?.assertions?.screenshotThreshold ?? 0.1,
|
|
119
|
+
screenshotMaxDiffPixels:
|
|
120
|
+
tests?.assertions?.screenshotMaxDiffPixels ?? 0,
|
|
121
|
+
},
|
|
122
|
+
}),
|
|
24
123
|
},
|
|
25
124
|
],
|
|
26
125
|
};
|
|
27
126
|
},
|
|
28
127
|
};
|
|
29
128
|
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// HTML emit
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
function emitHtml(nodes: DesignNode[], css?: string): string {
|
|
134
|
+
const body = nodes.map((node) => emitNode(node, 0)).join("");
|
|
135
|
+
if (!css?.trim()) {
|
|
136
|
+
return body;
|
|
137
|
+
}
|
|
138
|
+
return `<style>\n${css.trim()}\n</style>\n${body}\n`;
|
|
139
|
+
}
|
|
140
|
+
|
|
30
141
|
function emitNode(node: DesignNode, depth: number): string {
|
|
31
142
|
const indent = "\t".repeat(depth);
|
|
32
143
|
if (node.kind === "text") {
|
|
33
144
|
return `${indent}${escapeHtml(node.text ?? "")}\n`;
|
|
34
145
|
}
|
|
35
146
|
if (node.kind === "component") {
|
|
36
|
-
return
|
|
147
|
+
return emitComponentHtml(node, depth);
|
|
37
148
|
}
|
|
38
149
|
|
|
39
150
|
const attributes = Object.entries(node.attributes ?? {})
|
|
@@ -56,6 +167,433 @@ function emitNode(node: DesignNode, depth: number): string {
|
|
|
56
167
|
.join("")}${indent}</${node.tagName}>\n`;
|
|
57
168
|
}
|
|
58
169
|
|
|
170
|
+
function emitComponentHtml(node: DesignNode, depth: number): string {
|
|
171
|
+
const indent = "\t".repeat(depth);
|
|
172
|
+
const tag = toCustomElementTag(node.component ?? "component");
|
|
173
|
+
|
|
174
|
+
const attrParts = Object.entries(node.props ?? {})
|
|
175
|
+
.filter(([name, prop]) => name !== "children" && prop.kind !== "children")
|
|
176
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
177
|
+
.flatMap(([name, prop]) => {
|
|
178
|
+
const part = formatPropAsAttribute(name, prop);
|
|
179
|
+
return part !== null ? [part] : [];
|
|
180
|
+
})
|
|
181
|
+
.join(" ");
|
|
182
|
+
const openTag = attrParts ? `<${tag} ${attrParts}>` : `<${tag}>`;
|
|
183
|
+
|
|
184
|
+
const childrenProp = node.props?.children;
|
|
185
|
+
if (childrenProp?.kind === "text") {
|
|
186
|
+
return `${indent}${openTag}${escapeHtml(childrenProp.value)}</${tag}>\n`;
|
|
187
|
+
}
|
|
188
|
+
if (childrenProp?.kind === "children") {
|
|
189
|
+
const kids = childrenProp.value;
|
|
190
|
+
if (kids.length === 0) {
|
|
191
|
+
return `${indent}${openTag}</${tag}>\n`;
|
|
192
|
+
}
|
|
193
|
+
return `${indent}${openTag}\n${kids
|
|
194
|
+
.map((child) => emitNode(child, depth + 1))
|
|
195
|
+
.join("")}${indent}</${tag}>\n`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const children = node.children ?? [];
|
|
199
|
+
if (children.length === 0) {
|
|
200
|
+
return `${indent}${openTag}</${tag}>\n`;
|
|
201
|
+
}
|
|
202
|
+
return `${indent}${openTag}\n${children
|
|
203
|
+
.map((child) => emitNode(child, depth + 1))
|
|
204
|
+
.join("")}${indent}</${tag}>\n`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function formatPropAsAttribute(name: string, prop: PropValue): string | null {
|
|
208
|
+
if (prop.kind === "children") {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
if (prop.value === false) {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
if (prop.value === true) {
|
|
215
|
+
return name;
|
|
216
|
+
}
|
|
217
|
+
const value = String(prop.value);
|
|
218
|
+
return value === "" ? name : `${name}="${escapeAttribute(value)}"`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// Web component emit
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
const VOID_ELEMENTS = new Set([
|
|
226
|
+
"area",
|
|
227
|
+
"base",
|
|
228
|
+
"br",
|
|
229
|
+
"col",
|
|
230
|
+
"embed",
|
|
231
|
+
"hr",
|
|
232
|
+
"img",
|
|
233
|
+
"input",
|
|
234
|
+
"link",
|
|
235
|
+
"meta",
|
|
236
|
+
"param",
|
|
237
|
+
"source",
|
|
238
|
+
"track",
|
|
239
|
+
"wbr",
|
|
240
|
+
]);
|
|
241
|
+
|
|
242
|
+
interface PropBinding {
|
|
243
|
+
propName: string;
|
|
244
|
+
attrName: string;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
interface ComponentInfo {
|
|
248
|
+
tagName: string;
|
|
249
|
+
className: string;
|
|
250
|
+
observedAttributes: string[];
|
|
251
|
+
sourceTagName?: string;
|
|
252
|
+
staticAttrEntries: [string, string][];
|
|
253
|
+
propBindings: PropBinding[];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function collectComponents(nodes: DesignNode[]): ComponentInfo[] {
|
|
257
|
+
const seen = new Map<string, ComponentInfo>();
|
|
258
|
+
|
|
259
|
+
function visit(node: DesignNode): void {
|
|
260
|
+
if (node.kind === "component") {
|
|
261
|
+
const key = `${node.importPath ?? ""}::${node.importName ?? ""}`;
|
|
262
|
+
if (!seen.has(key) && node.importPath && node.importName) {
|
|
263
|
+
seen.set(key, buildComponentInfo(node));
|
|
264
|
+
}
|
|
265
|
+
for (const child of node.children ?? []) {
|
|
266
|
+
visit(child);
|
|
267
|
+
}
|
|
268
|
+
for (const prop of Object.values(node.props ?? {})) {
|
|
269
|
+
if (prop.kind === "children") {
|
|
270
|
+
for (const child of prop.value) {
|
|
271
|
+
visit(child);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
} else if (node.kind === "element") {
|
|
276
|
+
for (const child of node.children ?? []) {
|
|
277
|
+
visit(child);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
for (const node of nodes) {
|
|
283
|
+
visit(node);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return Array.from(seen.values());
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function buildComponentInfo(node: DesignNode): ComponentInfo {
|
|
290
|
+
const importName = node.importName ?? node.component ?? "Component";
|
|
291
|
+
const tagName = toCustomElementTag(importName);
|
|
292
|
+
const className = toPascalCase(tagName);
|
|
293
|
+
|
|
294
|
+
const observedAttributes: string[] = [];
|
|
295
|
+
const propBindings: PropBinding[] = [];
|
|
296
|
+
const mappedOriginalAttrs = new Set<string>();
|
|
297
|
+
|
|
298
|
+
for (const [name, prop] of Object.entries(node.props ?? {})) {
|
|
299
|
+
if (prop.kind === "literal") {
|
|
300
|
+
observedAttributes.push(name);
|
|
301
|
+
if (prop.attribute) {
|
|
302
|
+
propBindings.push({ propName: name, attrName: prop.attribute });
|
|
303
|
+
mappedOriginalAttrs.add(prop.attribute);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
observedAttributes.sort();
|
|
308
|
+
propBindings.sort((a, b) => a.attrName.localeCompare(b.attrName));
|
|
309
|
+
|
|
310
|
+
const sourceEl = node.sourceElement;
|
|
311
|
+
let sourceTagName: string | undefined;
|
|
312
|
+
let staticAttrEntries: [string, string][] = [];
|
|
313
|
+
|
|
314
|
+
if (sourceEl?.kind === "element" && sourceEl.tagName) {
|
|
315
|
+
sourceTagName = sourceEl.tagName;
|
|
316
|
+
staticAttrEntries = Object.entries(sourceEl.attributes ?? {})
|
|
317
|
+
.filter(([attr]) => !mappedOriginalAttrs.has(attr))
|
|
318
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
tagName,
|
|
323
|
+
className,
|
|
324
|
+
observedAttributes,
|
|
325
|
+
sourceTagName,
|
|
326
|
+
staticAttrEntries,
|
|
327
|
+
propBindings,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function emitWebComponentFile(
|
|
332
|
+
components: ComponentInfo[],
|
|
333
|
+
domModel: "light" | "shadow",
|
|
334
|
+
): string {
|
|
335
|
+
const classes = components
|
|
336
|
+
.map((c) => emitWebComponentClass(c, domModel))
|
|
337
|
+
.join("\n\n");
|
|
338
|
+
|
|
339
|
+
const registrations = components
|
|
340
|
+
.map((c) => `customElements.define("${c.tagName}", ${c.className});`)
|
|
341
|
+
.join("\n");
|
|
342
|
+
|
|
343
|
+
return `${classes}\n\n${registrations}\n`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function emitWebComponentClass(
|
|
347
|
+
info: ComponentInfo,
|
|
348
|
+
domModel: "light" | "shadow",
|
|
349
|
+
): string {
|
|
350
|
+
const hasShadow = domModel === "shadow";
|
|
351
|
+
const {
|
|
352
|
+
className,
|
|
353
|
+
observedAttributes,
|
|
354
|
+
sourceTagName,
|
|
355
|
+
staticAttrEntries,
|
|
356
|
+
propBindings,
|
|
357
|
+
} = info;
|
|
358
|
+
|
|
359
|
+
const attrArray =
|
|
360
|
+
observedAttributes.length === 0
|
|
361
|
+
? "[]"
|
|
362
|
+
: `[${observedAttributes.map((a) => JSON.stringify(a)).join(", ")}]`;
|
|
363
|
+
|
|
364
|
+
const shadowSetup = hasShadow
|
|
365
|
+
? `\tprivate shadow: ShadowRoot;\n\n\tconstructor() {\n\t\tsuper();\n\t\tthis.shadow = this.attachShadow({ mode: "open" });\n\t}\n\n`
|
|
366
|
+
: "";
|
|
367
|
+
|
|
368
|
+
let renderBody: string;
|
|
369
|
+
|
|
370
|
+
if (sourceTagName && !hasShadow) {
|
|
371
|
+
const isVoid = VOID_ELEMENTS.has(sourceTagName);
|
|
372
|
+
const lines: string[] = ["\t\tif (!this.parentNode) return;"];
|
|
373
|
+
|
|
374
|
+
if (observedAttributes.length > 0) {
|
|
375
|
+
for (const a of observedAttributes) {
|
|
376
|
+
lines.push(`\t\tconst ${a} = this.getAttribute(${JSON.stringify(a)});`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
lines.push(
|
|
381
|
+
`\t\tconst el = document.createElement(${JSON.stringify(sourceTagName)});`,
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
for (const [attr, value] of staticAttrEntries) {
|
|
385
|
+
lines.push(
|
|
386
|
+
`\t\tel.setAttribute(${JSON.stringify(attr)}, ${JSON.stringify(value)});`,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
for (const { propName, attrName } of propBindings) {
|
|
390
|
+
lines.push(
|
|
391
|
+
`\t\tif (${propName} !== null) el.setAttribute(${JSON.stringify(attrName)}, ${propName});`,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (!isVoid) {
|
|
396
|
+
lines.push("\t\tel.innerHTML = this.innerHTML;");
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
lines.push("\t\tthis.replaceWith(el);");
|
|
400
|
+
renderBody = lines.join("\n");
|
|
401
|
+
} else {
|
|
402
|
+
const attrVars = observedAttributes
|
|
403
|
+
.map((a) => `\t\tconst ${a} = this.getAttribute("${a}");`)
|
|
404
|
+
.join("\n");
|
|
405
|
+
const fallbackLines: string[] = [];
|
|
406
|
+
if (attrVars) fallbackLines.push(attrVars);
|
|
407
|
+
if (hasShadow)
|
|
408
|
+
fallbackLines.push(`\t\tthis.shadow.innerHTML = \`<slot></slot>\`;`);
|
|
409
|
+
renderBody = fallbackLines.join("\n");
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const renderMethod = renderBody
|
|
413
|
+
? `\tprivate render(): void {\n${renderBody}\n\t}`
|
|
414
|
+
: `\tprivate render(): void {\n\t}`;
|
|
415
|
+
|
|
416
|
+
return `class ${className} extends HTMLElement {
|
|
417
|
+
${shadowSetup}\tstatic get observedAttributes(): string[] {
|
|
418
|
+
\t\treturn ${attrArray};
|
|
419
|
+
\t}
|
|
420
|
+
|
|
421
|
+
\tconnectedCallback(): void {
|
|
422
|
+
\t\tthis.render();
|
|
423
|
+
\t}
|
|
424
|
+
|
|
425
|
+
\tattributeChangedCallback(): void {
|
|
426
|
+
\t\tthis.render();
|
|
427
|
+
\t}
|
|
428
|
+
|
|
429
|
+
${renderMethod}
|
|
430
|
+
}`;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
// Playwright test generator
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
interface HtmlVisualSpecInput {
|
|
438
|
+
viewName: string;
|
|
439
|
+
relativeOutputPath: string;
|
|
440
|
+
fixtureFileName: string;
|
|
441
|
+
viewports: Array<{ name?: string; width: number; height: number }>;
|
|
442
|
+
states: Array<{
|
|
443
|
+
name: string;
|
|
444
|
+
hover?: string;
|
|
445
|
+
focus?: string;
|
|
446
|
+
click?: string;
|
|
447
|
+
waitFor?: string;
|
|
448
|
+
}>;
|
|
449
|
+
assertions: {
|
|
450
|
+
screenshot: boolean;
|
|
451
|
+
layout: boolean;
|
|
452
|
+
layoutTolerance: number;
|
|
453
|
+
selectors: string[];
|
|
454
|
+
screenshotThreshold: number;
|
|
455
|
+
screenshotMaxDiffPixels: number;
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function emitHtmlVisualSpec(input: HtmlVisualSpecInput): string {
|
|
460
|
+
const viewports = JSON.stringify(input.viewports, null, 2);
|
|
461
|
+
const states = JSON.stringify(input.states, null, 2);
|
|
462
|
+
const selectors = JSON.stringify(input.assertions.selectors, null, 2);
|
|
463
|
+
const screenshotEnabled = JSON.stringify(input.assertions.screenshot);
|
|
464
|
+
const layoutEnabled = JSON.stringify(input.assertions.layout);
|
|
465
|
+
const layoutTolerance = JSON.stringify(input.assertions.layoutTolerance);
|
|
466
|
+
const screenshotThreshold = JSON.stringify(
|
|
467
|
+
input.assertions.screenshotThreshold,
|
|
468
|
+
);
|
|
469
|
+
const screenshotMaxDiffPixels = JSON.stringify(
|
|
470
|
+
input.assertions.screenshotMaxDiffPixels,
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
return `import { readFileSync } from "node:fs";
|
|
474
|
+
import { dirname, resolve } from "node:path";
|
|
475
|
+
import { fileURLToPath } from "node:url";
|
|
476
|
+
import { expect, test } from "@playwright/test";
|
|
477
|
+
import pixelmatch from "pixelmatch";
|
|
478
|
+
import { PNG } from "pngjs";
|
|
479
|
+
|
|
480
|
+
const currentDir = dirname(fileURLToPath(import.meta.url));
|
|
481
|
+
const referenceHtml = readFileSync(resolve(currentDir, "./${input.fixtureFileName}"), "utf-8");
|
|
482
|
+
const outputHtmlPath = resolve(currentDir, "${input.relativeOutputPath}");
|
|
483
|
+
const viewports = ${viewports};
|
|
484
|
+
const states = ${states};
|
|
485
|
+
const selectors = ${selectors};
|
|
486
|
+
const screenshotEnabled = ${screenshotEnabled};
|
|
487
|
+
const layoutEnabled = ${layoutEnabled};
|
|
488
|
+
const layoutTolerance = ${layoutTolerance};
|
|
489
|
+
const screenshotThreshold = ${screenshotThreshold};
|
|
490
|
+
const screenshotMaxDiffPixels = ${screenshotMaxDiffPixels};
|
|
491
|
+
|
|
492
|
+
for (const viewport of viewports) {
|
|
493
|
+
\tfor (const state of states) {
|
|
494
|
+
\t\tconst viewportName = viewport.name ?? String(viewport.width) + "x" + String(viewport.height);
|
|
495
|
+
\t\ttest("${input.viewName} matches source at " + viewportName + " / " + state.name, async ({ page }) => {
|
|
496
|
+
\t\t\tawait page.setViewportSize({ width: viewport.width, height: viewport.height });
|
|
497
|
+
|
|
498
|
+
\t\t\tawait page.setContent(referenceHtml);
|
|
499
|
+
\t\t\tawait applyState(page, state);
|
|
500
|
+
\t\t\tconst expectedScreenshot = screenshotEnabled ? await page.screenshot({ fullPage: true }) : undefined;
|
|
501
|
+
\t\t\tconst expectedLayout = layoutEnabled ? await readLayout(page.locator("body > *").first(), selectors) : [];
|
|
502
|
+
|
|
503
|
+
\t\t\tawait page.goto("file://" + outputHtmlPath);
|
|
504
|
+
\t\t\tawait page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))));
|
|
505
|
+
\t\t\tawait applyState(page, state);
|
|
506
|
+
\t\t\tconst actualScreenshot = screenshotEnabled ? await page.screenshot({ fullPage: true }) : undefined;
|
|
507
|
+
\t\t\tconst actualLayout = layoutEnabled ? await readLayout(page.locator("body > *").first(), selectors) : [];
|
|
508
|
+
|
|
509
|
+
\t\t\tif (screenshotEnabled) {
|
|
510
|
+
\t\t\t\tconst expectedPng = PNG.sync.read(expectedScreenshot);
|
|
511
|
+
\t\t\t\tconst actualPng = PNG.sync.read(actualScreenshot);
|
|
512
|
+
\t\t\t\texpect(actualPng.width, "screenshot width").toBe(expectedPng.width);
|
|
513
|
+
\t\t\t\texpect(actualPng.height, "screenshot height").toBe(expectedPng.height);
|
|
514
|
+
\t\t\t\tconst diff = new PNG({ width: expectedPng.width, height: expectedPng.height });
|
|
515
|
+
\t\t\t\tconst diffPixelCount = pixelmatch(expectedPng.data, actualPng.data, diff.data, expectedPng.width, expectedPng.height, { threshold: screenshotThreshold });
|
|
516
|
+
\t\t\t\texpect(diffPixelCount, "screenshot diff pixels").toBeLessThanOrEqual(screenshotMaxDiffPixels);
|
|
517
|
+
\t\t\t}
|
|
518
|
+
\t\t\tif (layoutEnabled) {
|
|
519
|
+
\t\t\t\texpectLayoutToMatch(actualLayout, expectedLayout, layoutTolerance);
|
|
520
|
+
\t\t\t}
|
|
521
|
+
\t\t});
|
|
522
|
+
\t}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function applyState(page, state) {
|
|
526
|
+
\tif (state.waitFor) {
|
|
527
|
+
\t\tawait page.waitForSelector(state.waitFor);
|
|
528
|
+
\t}
|
|
529
|
+
\tif (state.hover) {
|
|
530
|
+
\t\tawait page.hover(state.hover);
|
|
531
|
+
\t}
|
|
532
|
+
\tif (state.focus) {
|
|
533
|
+
\t\tawait page.focus(state.focus);
|
|
534
|
+
\t}
|
|
535
|
+
\tif (state.click) {
|
|
536
|
+
\t\tawait page.click(state.click);
|
|
537
|
+
\t}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async function readLayout(root, selectorsToRead) {
|
|
541
|
+
\treturn root.evaluate((element, values) => {
|
|
542
|
+
\t\treturn values.flatMap((selector) => {
|
|
543
|
+
\t\t\tconst matches = selector === ":scope" ? [element] : Array.from(element.querySelectorAll(selector));
|
|
544
|
+
\t\t\treturn matches.map((matchedElement, index) => {
|
|
545
|
+
\t\t\t\tconst rect = matchedElement.getBoundingClientRect();
|
|
546
|
+
\t\t\t\treturn {
|
|
547
|
+
\t\t\t\t\tselector,
|
|
548
|
+
\t\t\t\t\tindex,
|
|
549
|
+
\t\t\t\t\ttagName: matchedElement.tagName.toLowerCase(),
|
|
550
|
+
\t\t\t\t\tx: rect.x,
|
|
551
|
+
\t\t\t\t\ty: rect.y,
|
|
552
|
+
\t\t\t\t\twidth: rect.width,
|
|
553
|
+
\t\t\t\t\theight: rect.height,
|
|
554
|
+
\t\t\t\t};
|
|
555
|
+
\t\t\t});
|
|
556
|
+
\t\t});
|
|
557
|
+
\t}, selectorsToRead);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function expectLayoutToMatch(actual, expected, tolerance) {
|
|
561
|
+
\texpect(actual.length).toBe(expected.length);
|
|
562
|
+
\tfor (let index = 0; index < expected.length; index += 1) {
|
|
563
|
+
\t\tconst actualRect = actual[index];
|
|
564
|
+
\t\tconst expectedRect = expected[index];
|
|
565
|
+
\t\texpect(actualRect.selector).toBe(expectedRect.selector);
|
|
566
|
+
\t\texpect(actualRect.index).toBe(expectedRect.index);
|
|
567
|
+
\t\texpect(actualRect.tagName).toBe(expectedRect.tagName);
|
|
568
|
+
\t\tfor (const key of ["x", "y", "width", "height"]) {
|
|
569
|
+
\t\t\tconst drift = Math.abs(actualRect[key] - expectedRect[key]);
|
|
570
|
+
\t\t\texpect(drift, \`\${expectedRect.selector}[\${expectedRect.index}] \${key} drift\`).toBeLessThanOrEqual(tolerance);
|
|
571
|
+
\t\t}
|
|
572
|
+
\t}
|
|
573
|
+
}
|
|
574
|
+
`;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// ---------------------------------------------------------------------------
|
|
578
|
+
// Utilities
|
|
579
|
+
// ---------------------------------------------------------------------------
|
|
580
|
+
|
|
581
|
+
function toRelativeFilePath(fromFile: string, toFile: string): string {
|
|
582
|
+
const fromParts = fromFile.split("/").slice(0, -1);
|
|
583
|
+
const toParts = toFile.split("/");
|
|
584
|
+
while (
|
|
585
|
+
fromParts.length > 0 &&
|
|
586
|
+
toParts.length > 0 &&
|
|
587
|
+
fromParts[0] === toParts[0]
|
|
588
|
+
) {
|
|
589
|
+
fromParts.shift();
|
|
590
|
+
toParts.shift();
|
|
591
|
+
}
|
|
592
|
+
const prefix = fromParts.map(() => "..");
|
|
593
|
+
const relative = [...prefix, ...toParts].join("/");
|
|
594
|
+
return relative.startsWith(".") ? relative : `./${relative}`;
|
|
595
|
+
}
|
|
596
|
+
|
|
59
597
|
function escapeHtml(value: string): string {
|
|
60
598
|
return value
|
|
61
599
|
.replace(/&/g, "&")
|
|
@@ -66,3 +604,18 @@ function escapeHtml(value: string): string {
|
|
|
66
604
|
function escapeAttribute(value: string): string {
|
|
67
605
|
return escapeHtml(value).replace(/"/g, """);
|
|
68
606
|
}
|
|
607
|
+
|
|
608
|
+
function toCustomElementTag(name: string): string {
|
|
609
|
+
const kebab = name
|
|
610
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
611
|
+
.toLowerCase()
|
|
612
|
+
.replace(/^-/, "");
|
|
613
|
+
return kebab.includes("-") ? kebab : `${kebab}-el`;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function toPascalCase(kebab: string): string {
|
|
617
|
+
return kebab
|
|
618
|
+
.split("-")
|
|
619
|
+
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
|
620
|
+
.join("");
|
|
621
|
+
}
|