@microsoft/fast-test-harness 0.1.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.
Files changed (63) hide show
  1. package/README.md +267 -0
  2. package/dist/dts/build/dom-shim.d.ts +10 -0
  3. package/dist/dts/build/dom-shim.d.ts.map +1 -0
  4. package/dist/dts/build/dom-shim.test.d.ts +2 -0
  5. package/dist/dts/build/dom-shim.test.d.ts.map +1 -0
  6. package/dist/dts/build/generate-stylesheets.d.ts +62 -0
  7. package/dist/dts/build/generate-stylesheets.d.ts.map +1 -0
  8. package/dist/dts/build/generate-stylesheets.test.d.ts +2 -0
  9. package/dist/dts/build/generate-stylesheets.test.d.ts.map +1 -0
  10. package/dist/dts/build/generate-templates.d.ts +69 -0
  11. package/dist/dts/build/generate-templates.d.ts.map +1 -0
  12. package/dist/dts/build/generate-templates.test.d.ts +2 -0
  13. package/dist/dts/build/generate-templates.test.d.ts.map +1 -0
  14. package/dist/dts/build/generate-webui-templates.d.ts +54 -0
  15. package/dist/dts/build/generate-webui-templates.d.ts.map +1 -0
  16. package/dist/dts/build/generate-webui-templates.test.d.ts +2 -0
  17. package/dist/dts/build/generate-webui-templates.test.d.ts.map +1 -0
  18. package/dist/dts/fixtures/assertions.d.ts +19 -0
  19. package/dist/dts/fixtures/assertions.d.ts.map +1 -0
  20. package/dist/dts/fixtures/csr-fixture.d.ts +114 -0
  21. package/dist/dts/fixtures/csr-fixture.d.ts.map +1 -0
  22. package/dist/dts/fixtures/csr-fixture.pw.spec.d.ts +2 -0
  23. package/dist/dts/fixtures/csr-fixture.pw.spec.d.ts.map +1 -0
  24. package/dist/dts/fixtures/index.d.ts +30 -0
  25. package/dist/dts/fixtures/index.d.ts.map +1 -0
  26. package/dist/dts/fixtures/ssr-fixture.d.ts +42 -0
  27. package/dist/dts/fixtures/ssr-fixture.d.ts.map +1 -0
  28. package/dist/dts/fixtures/ssr-fixture.pw.spec.d.ts +2 -0
  29. package/dist/dts/fixtures/ssr-fixture.pw.spec.d.ts.map +1 -0
  30. package/dist/dts/index.d.ts +7 -0
  31. package/dist/dts/index.d.ts.map +1 -0
  32. package/dist/dts/ssr/entry-client.d.ts +2 -0
  33. package/dist/dts/ssr/entry-client.d.ts.map +1 -0
  34. package/dist/dts/ssr/render.d.ts +127 -0
  35. package/dist/dts/ssr/render.d.ts.map +1 -0
  36. package/dist/dts/ssr/render.test.d.ts +2 -0
  37. package/dist/dts/ssr/render.test.d.ts.map +1 -0
  38. package/dist/esm/build/dom-shim.js +142 -0
  39. package/dist/esm/build/dom-shim.test.js +202 -0
  40. package/dist/esm/build/generate-stylesheets.js +70 -0
  41. package/dist/esm/build/generate-stylesheets.test.js +74 -0
  42. package/dist/esm/build/generate-templates.js +243 -0
  43. package/dist/esm/build/generate-templates.test.js +231 -0
  44. package/dist/esm/build/generate-webui-templates.js +121 -0
  45. package/dist/esm/build/generate-webui-templates.test.js +179 -0
  46. package/dist/esm/fixtures/assertions.js +49 -0
  47. package/dist/esm/fixtures/csr-fixture.js +153 -0
  48. package/dist/esm/fixtures/csr-fixture.pw.spec.js +137 -0
  49. package/dist/esm/fixtures/index.js +48 -0
  50. package/dist/esm/fixtures/ssr-fixture.js +113 -0
  51. package/dist/esm/fixtures/ssr-fixture.pw.spec.js +189 -0
  52. package/dist/esm/index.js +6 -0
  53. package/dist/esm/ssr/entry-client.js +2 -0
  54. package/dist/esm/ssr/render.js +381 -0
  55. package/dist/esm/ssr/render.test.js +236 -0
  56. package/package.json +88 -0
  57. package/playwright.config.d.ts +4 -0
  58. package/playwright.config.mjs +38 -0
  59. package/public/styles.css +15 -0
  60. package/server.mjs +317 -0
  61. package/start.mjs +244 -0
  62. package/vite.config.d.ts +4 -0
  63. package/vite.config.mjs +35 -0
@@ -0,0 +1,179 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { test } from "node:test";
6
+ import { generateWebuiTemplates } from "@microsoft/fast-test-harness/build/generate-webui-templates.js";
7
+ test.describe("generateWebuiTemplates", () => {
8
+ let tempDir;
9
+ test.beforeEach(async () => {
10
+ tempDir = await mkdtemp(join(tmpdir(), "fast-webui-templ-"));
11
+ });
12
+ test.afterEach(async () => {
13
+ await rm(tempDir, { recursive: true, force: true });
14
+ });
15
+ test("should generate a webui template without f-template wrapper", async () => {
16
+ const distDir = join(tempDir, "dist");
17
+ await mkdir(distDir, { recursive: true });
18
+ await writeFile(join(distDir, "badge.template.js"), `export const template = {
19
+ html: "<template><slot></slot></template>",
20
+ factories: {}
21
+ };`);
22
+ await generateWebuiTemplates({ cwd: tempDir, tagPrefix: "mai" });
23
+ const html = await readFile(join(distDir, "badge.template-webui.html"), "utf8");
24
+ assert.ok(html.includes('<template shadowrootmode="open">'));
25
+ assert.ok(html.includes("<slot></slot>"));
26
+ assert.ok(!html.includes("<f-template"), "should not have f-template wrapper");
27
+ assert.ok(!html.includes("{{styles}}"), "should not have styles marker");
28
+ });
29
+ test("should write to outDir when specified", async () => {
30
+ const distDir = join(tempDir, "dist");
31
+ await mkdir(distDir, { recursive: true });
32
+ await writeFile(join(distDir, "card.template.js"), `export const template = {
33
+ html: "<template><div>card</div></template>",
34
+ factories: {}
35
+ };`);
36
+ await generateWebuiTemplates({
37
+ cwd: tempDir,
38
+ outDir: "out",
39
+ tagPrefix: "fast",
40
+ });
41
+ const html = await readFile(join(tempDir, "out", "card.template-webui.html"), "utf8");
42
+ assert.ok(html.includes('<template shadowrootmode="open">'));
43
+ });
44
+ test("should skip modules without a template export", async () => {
45
+ const distDir = join(tempDir, "dist");
46
+ await mkdir(distDir, { recursive: true });
47
+ await writeFile(join(distDir, "empty.template.js"), `export const styles = ":host {}";`);
48
+ await generateWebuiTemplates({ cwd: tempDir, tagPrefix: "fast" });
49
+ try {
50
+ await readFile(join(distDir, "empty.template-webui.html"), "utf8");
51
+ assert.fail("Should not have created an HTML file");
52
+ }
53
+ catch (err) {
54
+ assert.strictEqual(err.code, "ENOENT");
55
+ }
56
+ });
57
+ test("should apply a format function", async () => {
58
+ const distDir = join(tempDir, "dist");
59
+ await mkdir(distDir, { recursive: true });
60
+ await writeFile(join(distDir, "text.template.js"), `export const template = {
61
+ html: "<template><span>text</span></template>",
62
+ factories: {}
63
+ };`);
64
+ await generateWebuiTemplates({
65
+ cwd: tempDir,
66
+ tagPrefix: "fast",
67
+ format: html => `<!-- webui-formatted -->\n${html}`,
68
+ });
69
+ const html = await readFile(join(distDir, "text.template-webui.html"), "utf8");
70
+ assert.ok(html.startsWith("<!-- webui-formatted -->"));
71
+ });
72
+ test("should add shadowrootdelegatesfocus from definition-async", async () => {
73
+ const distDir = join(tempDir, "dist");
74
+ await mkdir(distDir, { recursive: true });
75
+ await writeFile(join(distDir, "input.template.js"), `export const template = {
76
+ html: "<template><input /></template>",
77
+ factories: {}
78
+ };`);
79
+ await writeFile(join(distDir, "input.definition-async.js"), `export const definition = {
80
+ name: "fast-input",
81
+ shadowOptions: { delegatesFocus: true },
82
+ };`);
83
+ await generateWebuiTemplates({ cwd: tempDir, tagPrefix: "fast" });
84
+ const html = await readFile(join(distDir, "input.template-webui.html"), "utf8");
85
+ assert.ok(html.includes("shadowrootdelegatesfocus"), `should include delegatesFocus, got: ${html}`);
86
+ });
87
+ test("should not add shadowrootdelegatesfocus when absent", async () => {
88
+ const distDir = join(tempDir, "dist");
89
+ await mkdir(distDir, { recursive: true });
90
+ await writeFile(join(distDir, "div.template.js"), `export const template = {
91
+ html: "<template><div>hello</div></template>",
92
+ factories: {}
93
+ };`);
94
+ await generateWebuiTemplates({ cwd: tempDir, tagPrefix: "fast" });
95
+ const html = await readFile(join(distDir, "div.template-webui.html"), "utf8");
96
+ assert.ok(!html.includes("shadowrootdelegatesfocus"));
97
+ });
98
+ test("should strip the {{styles}} marker from output", async () => {
99
+ const distDir = join(tempDir, "dist");
100
+ await mkdir(distDir, { recursive: true });
101
+ // The template contains nested content that would produce a styles marker
102
+ // during f-template generation (convertTemplate injects it).
103
+ await writeFile(join(distDir, "label.template.js"), `export const template = {
104
+ html: "<template><label>Name</label></template>",
105
+ factories: {}
106
+ };`);
107
+ await generateWebuiTemplates({ cwd: tempDir, tagPrefix: "mai" });
108
+ const html = await readFile(join(distDir, "label.template-webui.html"), "utf8");
109
+ assert.ok(!html.includes("{{styles}}"), `should not contain styles marker: ${html}`);
110
+ assert.ok(!html.includes("{%styles%}"), `should not contain alt styles marker: ${html}`);
111
+ });
112
+ test("should handle modules with a default export", async () => {
113
+ const distDir = join(tempDir, "dist");
114
+ await mkdir(distDir, { recursive: true });
115
+ await writeFile(join(distDir, "icon.template.js"), `const template = {
116
+ html: "<template><svg></svg></template>",
117
+ factories: {}
118
+ };
119
+ export default template;`);
120
+ await generateWebuiTemplates({ cwd: tempDir, tagPrefix: "fast" });
121
+ const html = await readFile(join(distDir, "icon.template-webui.html"), "utf8");
122
+ assert.ok(html.includes('<template shadowrootmode="open">'));
123
+ assert.ok(html.includes("<svg></svg>"));
124
+ });
125
+ test("should handle multiple template modules in one pass", async () => {
126
+ const distDir = join(tempDir, "dist");
127
+ await mkdir(distDir, { recursive: true });
128
+ await writeFile(join(distDir, "button.template.js"), `export const template = {
129
+ html: "<template><button><slot></slot></button></template>",
130
+ factories: {}
131
+ };`);
132
+ await writeFile(join(distDir, "badge.template.js"), `export const template = {
133
+ html: "<template><span><slot></slot></span></template>",
134
+ factories: {}
135
+ };`);
136
+ await generateWebuiTemplates({ cwd: tempDir, tagPrefix: "mai" });
137
+ const buttonHtml = await readFile(join(distDir, "button.template-webui.html"), "utf8");
138
+ const badgeHtml = await readFile(join(distDir, "badge.template-webui.html"), "utf8");
139
+ assert.ok(buttonHtml.includes("<button>"));
140
+ assert.ok(badgeHtml.includes("<span>"));
141
+ });
142
+ test("should gracefully handle a format function that throws", async () => {
143
+ const distDir = join(tempDir, "dist");
144
+ await mkdir(distDir, { recursive: true });
145
+ await writeFile(join(distDir, "broken.template.js"), `export const template = {
146
+ html: "<template><div>ok</div></template>",
147
+ factories: {}
148
+ };`);
149
+ // Should not throw — just warn and skip formatting.
150
+ await generateWebuiTemplates({
151
+ cwd: tempDir,
152
+ tagPrefix: "fast",
153
+ format: () => {
154
+ throw new Error("format boom");
155
+ },
156
+ });
157
+ const html = await readFile(join(distDir, "broken.template-webui.html"), "utf8");
158
+ // The file should still be written (unformatted).
159
+ assert.ok(html.includes("<div>ok</div>"));
160
+ });
161
+ test("should use a custom glob pattern", async () => {
162
+ const distDir = join(tempDir, "dist");
163
+ const sub = join(distDir, "components");
164
+ await mkdir(sub, { recursive: true });
165
+ // Use the standard .template.js suffix so basename extraction works
166
+ await writeFile(join(sub, "alert.template.js"), `export const template = {
167
+ html: "<template><div role='alert'></div></template>",
168
+ factories: {}
169
+ };`);
170
+ await generateWebuiTemplates({
171
+ cwd: tempDir,
172
+ tagPrefix: "fast",
173
+ pattern: "components/*.template.js",
174
+ });
175
+ const html = await readFile(join(sub, "alert.template-webui.html"), "utf8");
176
+ assert.ok(html.includes("role="), `got: ${html}`);
177
+ assert.ok(html.includes("alert"), `got: ${html}`);
178
+ });
179
+ });
@@ -0,0 +1,49 @@
1
+ import { expect as baseExpect, } from "@playwright/test";
2
+ /**
3
+ * Evaluate whether an element has the given state on its `elementInternals`
4
+ * property using the `:state()` pseudo-class.
5
+ *
6
+ * @param locator - The Playwright locator for the element.
7
+ * @param state - The name of the state.
8
+ * @param options - Optional timeout configuration.
9
+ */
10
+ export async function toHaveCustomState(locator, state, options) {
11
+ const assertionName = "toHaveCustomState";
12
+ let pass;
13
+ let matcherResult;
14
+ const expected = !this.isNot;
15
+ try {
16
+ baseExpect(await locator.evaluate((el, state) => el.matches(`:state(${state})`), state, options)).toEqual(true);
17
+ pass = true;
18
+ }
19
+ catch (err) {
20
+ matcherResult = err.matcherResult;
21
+ pass = false;
22
+ }
23
+ const message = pass
24
+ ? () => this.utils.matcherHint(assertionName, undefined, undefined, {
25
+ isNot: this.isNot,
26
+ }) +
27
+ "\n\n" +
28
+ `Locator: ${locator}\n` +
29
+ `Expected: ${this.isNot ? "not" : ""}${this.utils.printExpected(expected)}\n` +
30
+ (matcherResult
31
+ ? `Received: ${this.utils.printReceived(matcherResult.actual)}`
32
+ : "")
33
+ : () => this.utils.matcherHint(assertionName, undefined, undefined, {
34
+ isNot: this.isNot,
35
+ }) +
36
+ "\n\n" +
37
+ `Locator: ${locator}\n` +
38
+ `Expected: ${this.utils.printExpected(expected)}\n` +
39
+ (matcherResult
40
+ ? `Received: ${this.utils.printReceived(matcherResult.actual)}`
41
+ : "");
42
+ return {
43
+ name: assertionName,
44
+ message,
45
+ pass,
46
+ expected,
47
+ actual: matcherResult?.actual,
48
+ };
49
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * A fixture for testing FAST components with Playwright.
3
+ */
4
+ export class CSRFixture {
5
+ /**
6
+ * Creates an instance of the CSRFixture.
7
+ *
8
+ * @param page - The Playwright page object.
9
+ * @param tagName - The tag name of the custom element.
10
+ * @param innerHTML - The inner HTML of the custom element.
11
+ * @param waitFor - Additional custom elements to wait for.
12
+ */
13
+ constructor(page, tagName, innerHTML, waitFor = []) {
14
+ this.page = page;
15
+ this.tagName = tagName;
16
+ this.innerHTML = innerHTML;
17
+ this.element = this.page.locator(this.tagName);
18
+ this.waitFor = waitFor;
19
+ }
20
+ /**
21
+ * Adds a style tag to the page.
22
+ *
23
+ * @param options - The options for the style tag.
24
+ * @see {@link Page.addStyleTag}
25
+ */
26
+ async addStyleTag(options) {
27
+ await this.page.addStyleTag(options);
28
+ }
29
+ /**
30
+ * Navigates to the specified URL.
31
+ *
32
+ * @param url - The URL to navigate to. Defaults to "/".
33
+ */
34
+ async goto(url = "/") {
35
+ await this.page.goto(url);
36
+ }
37
+ /**
38
+ * Applies a set of design tokens as CSS custom properties on the body.
39
+ *
40
+ * @param tokens - A record mapping token names to values. Each key will
41
+ * be prefixed with `--` and set as a CSS custom property.
42
+ */
43
+ async applyTokens(tokens) {
44
+ await this.page.evaluate(async (theme) => {
45
+ Object.entries(theme).forEach(([key, value]) => {
46
+ document.body.style.setProperty(`--${key}`, typeof value === "string" ? value : `${value}`);
47
+ });
48
+ }, tokens);
49
+ }
50
+ /**
51
+ * Generates the default template for the fixture.
52
+ */
53
+ defaultTemplate(tagName = this.tagName, attributes = {}, innerHTML = this.innerHTML) {
54
+ const attributesString = Object.entries(attributes)
55
+ .map(([key, value]) => {
56
+ if (value === true) {
57
+ return key;
58
+ }
59
+ return `${key}="${value.replace(/"/g, "")}"`;
60
+ })
61
+ .join(" ");
62
+ return `<${tagName} ${attributesString}>${innerHTML}</${tagName}>`;
63
+ }
64
+ /**
65
+ * Sets the template for the fixture's page.
66
+ *
67
+ * When `templateOrOptions` is an object, the method merges specific
68
+ * template options with values configured via the Playwright `test.use`
69
+ * configuration for the current test suite.
70
+ *
71
+ * If `templateOrOptions` is a string, it is treated as the complete HTML
72
+ * body for the fixture.
73
+ *
74
+ * If `templateOrOptions` is not provided, the method uses the default
75
+ * template based on the fixture's `tagName` and `innerHTML` properties.
76
+ *
77
+ * @param templateOrOptions - The template configuration.
78
+ */
79
+ async setTemplate(templateOrOptions) {
80
+ const template = typeof templateOrOptions === "string"
81
+ ? templateOrOptions
82
+ : this.defaultTemplate(this.tagName, templateOrOptions?.attributes, templateOrOptions?.innerHTML);
83
+ await this.page.locator("body").evaluate((node, template) => {
84
+ const fragment = document.createRange().createContextualFragment(template);
85
+ node.innerHTML = "";
86
+ node.append(fragment);
87
+ }, template);
88
+ if (this.tagName) {
89
+ await this.waitForStability();
90
+ }
91
+ }
92
+ /**
93
+ * Waits for the fixture to reach a stable state.
94
+ *
95
+ * This includes waiting for the custom element and any additional
96
+ * specified elements to be defined and for the body to become stable.
97
+ */
98
+ async waitForStability() {
99
+ if ((await this.element.count()) > 0) {
100
+ const elements = await this.page
101
+ .locator([this.tagName, ...this.waitFor].join(","))
102
+ .all();
103
+ await Promise.allSettled(elements.map(element => element.waitFor({
104
+ state: "attached",
105
+ timeout: 1000,
106
+ })));
107
+ }
108
+ await this.waitForCustomElement(this.tagName, ...this.waitFor);
109
+ await (await this.page.locator("body").elementHandle())?.waitForElementState("stable");
110
+ }
111
+ /**
112
+ * Updates the content of the fixture by modifying the specified
113
+ * element's attributes and/or inner HTML.
114
+ *
115
+ * @param locator - The locator or selector for the element to update.
116
+ * @param options - The options for updating the element.
117
+ */
118
+ async updateTemplate(locator, options) {
119
+ const element = typeof locator === "string" ? this.page.locator(locator) : locator;
120
+ await element.evaluate((node, options) => {
121
+ if (options.innerHTML !== undefined) {
122
+ node.innerHTML = options.innerHTML;
123
+ }
124
+ if (options.attributes) {
125
+ const attributesAsJSON = options.attributes;
126
+ Object.entries(attributesAsJSON).forEach(([key, value]) => {
127
+ if (value === true) {
128
+ node.setAttribute(key, "");
129
+ }
130
+ else if (value === false) {
131
+ node.removeAttribute(key);
132
+ }
133
+ else if (typeof value === "string") {
134
+ node.setAttribute(key, value);
135
+ }
136
+ });
137
+ }
138
+ }, options);
139
+ }
140
+ /**
141
+ * Waits for the specified custom elements to be defined in the
142
+ * browser's CustomElementRegistry.
143
+ *
144
+ * @param tagName - The primary tag name to wait for.
145
+ * @param tagNames - Additional tag names to wait for.
146
+ */
147
+ async waitForCustomElement(tagName = this.tagName, ...tagNames) {
148
+ if (!tagName && !tagNames.length) {
149
+ return;
150
+ }
151
+ await this.page.waitForFunction((tagNames) => Promise.all(tagNames.map(t => customElements.whenDefined(t))), [tagName, ...tagNames]);
152
+ }
153
+ }
@@ -0,0 +1,137 @@
1
+ import { expect, test } from "@microsoft/fast-test-harness";
2
+ test.describe("CSR: setTemplate", () => {
3
+ test.use({ tagName: "test-widget" });
4
+ test("should render the element with default options", async ({ fastPage }) => {
5
+ await fastPage.setTemplate();
6
+ await expect(fastPage.element).toHaveCount(1);
7
+ });
8
+ test("should render with innerHTML", async ({ fastPage }) => {
9
+ await fastPage.setTemplate({ innerHTML: "<span>child</span>" });
10
+ await expect(fastPage.element).toHaveCount(1);
11
+ await expect(fastPage.element).toContainText("child");
12
+ });
13
+ test("should render with attributes", async ({ fastPage }) => {
14
+ await fastPage.setTemplate({
15
+ attributes: { label: "Hello", size: "large" },
16
+ });
17
+ await expect(fastPage.element).toHaveAttribute("label", "Hello");
18
+ await expect(fastPage.element).toHaveAttribute("size", "large");
19
+ });
20
+ test("should render with boolean attribute", async ({ fastPage }) => {
21
+ await fastPage.setTemplate({ attributes: { disabled: true } });
22
+ await expect(fastPage.element).toHaveAttribute("disabled");
23
+ });
24
+ test("should render with a raw HTML string", async ({ fastPage }) => {
25
+ await fastPage.setTemplate(`<test-widget label="raw">raw content</test-widget>`);
26
+ await expect(fastPage.element).toHaveAttribute("label", "raw");
27
+ await expect(fastPage.element).toContainText("raw content");
28
+ });
29
+ test("should replace the previous template on subsequent calls", async ({ fastPage, }) => {
30
+ await fastPage.setTemplate({ attributes: { label: "first" } });
31
+ await expect(fastPage.element).toHaveAttribute("label", "first");
32
+ await fastPage.setTemplate({ attributes: { label: "second" } });
33
+ await expect(fastPage.element).toHaveAttribute("label", "second");
34
+ await expect(fastPage.element).toHaveCount(1);
35
+ });
36
+ });
37
+ test.describe("CSR: updateTemplate", () => {
38
+ test.use({ tagName: "test-widget" });
39
+ test("should update attributes on an existing element", async ({ fastPage }) => {
40
+ await fastPage.setTemplate({ attributes: { label: "before" } });
41
+ await expect(fastPage.element).toHaveAttribute("label", "before");
42
+ await fastPage.updateTemplate("test-widget", {
43
+ attributes: { label: "after" },
44
+ });
45
+ await expect(fastPage.element).toHaveAttribute("label", "after");
46
+ });
47
+ test("should remove an attribute when set to false", async ({ fastPage }) => {
48
+ await fastPage.setTemplate({ attributes: { disabled: true } });
49
+ await expect(fastPage.element).toHaveAttribute("disabled");
50
+ await fastPage.updateTemplate("test-widget", {
51
+ attributes: { disabled: false },
52
+ });
53
+ await expect(fastPage.element).not.toHaveAttribute("disabled");
54
+ });
55
+ test("should update innerHTML", async ({ fastPage }) => {
56
+ await fastPage.setTemplate({ innerHTML: "original" });
57
+ await fastPage.updateTemplate("test-widget", {
58
+ innerHTML: "updated",
59
+ });
60
+ await expect(fastPage.element).toContainText("updated");
61
+ });
62
+ test("should accept a Locator", async ({ fastPage }) => {
63
+ await fastPage.setTemplate({ attributes: { label: "loc" } });
64
+ await fastPage.updateTemplate(fastPage.element, {
65
+ attributes: { label: "via-locator" },
66
+ });
67
+ await expect(fastPage.element).toHaveAttribute("label", "via-locator");
68
+ });
69
+ });
70
+ test.describe("CSR: applyTokens", () => {
71
+ test.use({ tagName: "test-widget" });
72
+ test("should set CSS custom properties on the body", async ({ fastPage }) => {
73
+ await fastPage.setTemplate();
74
+ await fastPage.applyTokens({
75
+ "color-primary": "#0078d4",
76
+ "spacing-m": "8px",
77
+ });
78
+ const value = await fastPage.page.evaluate(() => document.body.style.getPropertyValue("--color-primary"));
79
+ expect(value).toBe("#0078d4");
80
+ });
81
+ });
82
+ test.describe("CSR: waitForCustomElement", () => {
83
+ test.use({ tagName: "test-widget" });
84
+ test("should resolve for a registered element", async ({ fastPage }) => {
85
+ await fastPage.setTemplate();
86
+ await fastPage.waitForCustomElement("test-widget");
87
+ });
88
+ });
89
+ test.describe("CSR: toHaveCustomState", () => {
90
+ test.use({ tagName: "test-widget" });
91
+ test("should pass when element has the custom state", async ({ fastPage }) => {
92
+ await fastPage.setTemplate({ attributes: { disabled: true } });
93
+ await expect(fastPage.element).toHaveCustomState("disabled");
94
+ });
95
+ test("should fail (negated) when element does not have the state", async ({ fastPage, }) => {
96
+ await fastPage.setTemplate();
97
+ await expect(fastPage.element).not.toHaveCustomState("disabled");
98
+ });
99
+ });
100
+ test.describe("CSR: element locator", () => {
101
+ test.use({ tagName: "test-widget" });
102
+ test("should locate the element by tag name", async ({ fastPage }) => {
103
+ await fastPage.setTemplate();
104
+ await expect(fastPage.element).toHaveCount(1);
105
+ });
106
+ });
107
+ test.describe("CSR: addStyleTag", () => {
108
+ test.use({ tagName: "test-widget" });
109
+ test("should add a style tag with content", async ({ fastPage }) => {
110
+ await fastPage.setTemplate();
111
+ await fastPage.addStyleTag({
112
+ content: "test-widget { border: 1px solid red; }",
113
+ });
114
+ const hasBorder = await fastPage.page.evaluate(() => {
115
+ const styles = document.querySelectorAll("style");
116
+ return Array.from(styles).some(s => s.textContent?.includes("border: 1px solid red"));
117
+ });
118
+ expect(hasBorder).toBe(true);
119
+ });
120
+ });
121
+ test.describe("CSR: multiple elements", () => {
122
+ test.use({ tagName: "test-widget" });
123
+ test("should handle templates with multiple instances", async ({ fastPage }) => {
124
+ await fastPage.setTemplate(`
125
+ <test-widget label="first">one</test-widget>
126
+ <test-widget label="second">two</test-widget>
127
+ `);
128
+ await expect(fastPage.element).toHaveCount(2);
129
+ });
130
+ });
131
+ test.describe("CSR: waitFor", () => {
132
+ test.use({ tagName: "test-widget", waitFor: ["test-widget"] });
133
+ test("should wait for additional elements specified in waitFor", async ({ fastPage, }) => {
134
+ await fastPage.setTemplate();
135
+ await expect(fastPage.element).toHaveCount(1);
136
+ });
137
+ });
@@ -0,0 +1,48 @@
1
+ import { expect as baseExpect, test as baseTest } from "@playwright/test";
2
+ import { toHaveCustomState } from "./assertions.js";
3
+ import { CSRFixture } from "./csr-fixture.js";
4
+ import { SSRFixture } from "./ssr-fixture.js";
5
+ const isSSR = process.env.PLAYWRIGHT_TEST_SSR === "true";
6
+ export const test = baseTest.extend({
7
+ /**
8
+ * The inner HTML to set on the fixture's custom element. This can be used
9
+ * to provide slotted content or otherwise customize the fixture's template.
10
+ */
11
+ innerHTML: ["", { option: true }],
12
+ /**
13
+ * The tag name of the custom element to test. This is used to construct the
14
+ * fixture's template and to determine when the page has finished loading.
15
+ */
16
+ tagName: ["", { option: true }],
17
+ /**
18
+ * Additional custom elements to wait for before running the test.
19
+ */
20
+ waitFor: [[], { option: true }],
21
+ /**
22
+ * Indicates if the test is running in SSR mode. When `true`, the fixture
23
+ * uses the `SSRFixture` class, which generates server-rendered fixtures.
24
+ *
25
+ * This can be set directly in a fixture via `test.use({ ssr: true })`, or
26
+ * indirectly with the environment variable `PLAYWRIGHT_TEST_SSR=true`.
27
+ */
28
+ ssr: [!!isSSR, { option: true }],
29
+ async fastPage({ page, innerHTML, ssr, tagName, waitFor }, use, testInfo) {
30
+ const testId = testInfo.titlePath
31
+ .join("-")
32
+ .replace(/[^a-z0-9-]/gi, "_")
33
+ .toLowerCase();
34
+ const testTitle = ssr ? `${testInfo.titlePath.join(" › ")}` : undefined;
35
+ const fastPage = ssr
36
+ ? new SSRFixture(page, tagName, innerHTML, waitFor, testId, testTitle)
37
+ : new CSRFixture(page, tagName, innerHTML, waitFor);
38
+ if (!ssr) {
39
+ await fastPage.goto();
40
+ await page.emulateMedia({ reducedMotion: "reduce" });
41
+ await fastPage.waitForCustomElement(tagName, ...waitFor);
42
+ }
43
+ await use(fastPage);
44
+ },
45
+ });
46
+ export const expect = baseExpect.extend({
47
+ toHaveCustomState,
48
+ });
@@ -0,0 +1,113 @@
1
+ import { CSRFixture } from "./csr-fixture.js";
2
+ export class SSRFixture extends CSRFixture {
3
+ constructor(page, tagName, innerHTML = "", waitFor = [], testId, testTitle) {
4
+ super(page, tagName, innerHTML, waitFor);
5
+ this.testId = testId;
6
+ this.testTitle = testTitle;
7
+ /**
8
+ * Styles buffered before {@link setTemplate} is called.
9
+ */
10
+ this.pendingStyles = [];
11
+ /**
12
+ * Whether the template has been rendered.
13
+ */
14
+ this.templateRendered = false;
15
+ }
16
+ /**
17
+ * Buffers style tags added before {@link setTemplate} so they can be
18
+ * included in the generated SSR page. After the template has been
19
+ * rendered, calls pass through to the page directly.
20
+ *
21
+ * @param options - The options for the style tag.
22
+ * @see {@link Page.addStyleTag}
23
+ */
24
+ async addStyleTag(options) {
25
+ if (this.templateRendered) {
26
+ await this.page.addStyleTag(options);
27
+ return;
28
+ }
29
+ this.pendingStyles.push(options);
30
+ }
31
+ /**
32
+ * Sets up the test fixture by posting the template or configuration
33
+ * to the SSR generation endpoint.
34
+ *
35
+ * This method constructs a request body based on the provided
36
+ * `templateOrOptions` and the current test context (like `testId`
37
+ * and `testTitle`). It sends this data to the `/generate-fixture`
38
+ * endpoint to create a server-side rendered fixture, navigates the
39
+ * page to the resulting URL, and waits for the page to stabilize.
40
+ *
41
+ * @param templateOrOptions - The template configuration.
42
+ */
43
+ async setTemplate(templateOrOptions) {
44
+ const body = {};
45
+ if (this.testId) {
46
+ body.testId = this.testId;
47
+ body.testTitle = this.testTitle || this.formatTestTitle(this.testId);
48
+ }
49
+ if (typeof templateOrOptions === "string") {
50
+ body.html = templateOrOptions.trim();
51
+ }
52
+ if (typeof templateOrOptions === "object") {
53
+ if (typeof templateOrOptions.innerHTML === "string") {
54
+ body.innerHTML = templateOrOptions.innerHTML;
55
+ }
56
+ if (templateOrOptions.attributes) {
57
+ const cleanedAttributes = {};
58
+ Object.entries(templateOrOptions.attributes).forEach(([key, value]) => {
59
+ cleanedAttributes[key.trim()] =
60
+ typeof value === "string" ? value.trim() : value;
61
+ });
62
+ body.attributes = JSON.stringify(cleanedAttributes);
63
+ }
64
+ }
65
+ if (!body.html && typeof templateOrOptions !== "string") {
66
+ body.tagName = this.tagName;
67
+ if (!body.innerHTML && typeof templateOrOptions?.innerHTML !== "string") {
68
+ body.innerHTML = this.innerHTML;
69
+ }
70
+ }
71
+ Object.entries(body).forEach(([key, value]) => {
72
+ if (typeof value === "string") {
73
+ body[key] = value.replace(/\s+/g, " ").trim();
74
+ }
75
+ });
76
+ if (this.pendingStyles.length) {
77
+ body.styles = JSON.stringify(this.pendingStyles.map(s => s?.content).filter((c) => !!c));
78
+ }
79
+ const response = await this.page.request.post("/generate-fixture", {
80
+ data: body,
81
+ });
82
+ if (!response.ok()) {
83
+ throw new Error(`Failed to generate fixture: ${response.status()} ${response.statusText()}`);
84
+ }
85
+ const result = await response.json();
86
+ if (!result.url) {
87
+ throw new Error(`Invalid response from server: ${JSON.stringify(result)}`);
88
+ }
89
+ await this.page.goto(result.url);
90
+ await this.waitForStability();
91
+ this.templateRendered = true;
92
+ this.pendingStyles.length = 0;
93
+ }
94
+ /**
95
+ * Formats the test title based on the provided test ID.
96
+ */
97
+ formatTestTitle(testId) {
98
+ const match = testId.match(/^(.*?\.(ts|js))-(.+)$/);
99
+ if (!match) {
100
+ return testId
101
+ .split("_")
102
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
103
+ .join(" ");
104
+ }
105
+ const filePath = match[1];
106
+ const testParts = match[3];
107
+ const sections = testParts.split("-").map(section => section
108
+ .split("_")
109
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
110
+ .join(" "));
111
+ return `${sections.join(" › ")} (${filePath})`;
112
+ }
113
+ }