diff --git a/.github/workflows/ci-validate-platforms.yml b/.github/workflows/ci-validate-platforms.yml
index 68615481900..b76f539ef1f 100644
--- a/.github/workflows/ci-validate-platforms.yml
+++ b/.github/workflows/ci-validate-platforms.yml
@@ -74,11 +74,11 @@ jobs:
run: cargo install wasm-pack
- name: Build workspaces
- run: npx lage build ${{ github.event_name == 'pull_request' && '--since origin/main' || '' }} --allow-no-target-runs
+ run: npx lage build ${{ github.event_name == 'pull_request' && '--since origin/main' || '' }} --allow-no-target-runs --verbose
- name: Install playwright dependencies and browsers
run: |
npx playwright install --with-deps
- name: Run tests in all Packages
- run: npx lage test:node test:playwright ${{ github.event_name == 'pull_request' && '--since origin/main' || '' }} --allow-no-target-runs
+ run: npx lage test:node test:playwright ${{ github.event_name == 'pull_request' && '--since origin/main' || '' }} --allow-no-target-runs --verbose
diff --git a/.github/workflows/ci-validate-pr.yml b/.github/workflows/ci-validate-pr.yml
index 3e12f8927f9..80612cbecd5 100644
--- a/.github/workflows/ci-validate-pr.yml
+++ b/.github/workflows/ci-validate-pr.yml
@@ -58,14 +58,14 @@ jobs:
run: cargo install wasm-pack
- name: Build workspaces
- run: npx lage build ${{ github.event_name == 'pull_request' && format('--since origin/{0}', github.event.pull_request.base.ref) || '' }} --allow-no-target-runs
+ run: npx lage build ${{ github.event_name == 'pull_request' && format('--since origin/{0}', github.event.pull_request.base.ref) || '' }} --allow-no-target-runs --verbose
- name: Install playwright dependencies and browsers
run: |
npx playwright install --with-deps
- name: Testing unit tests
- run: npx lage test:node test:chromium ${{ github.event_name == 'pull_request' && format('--since origin/{0}', github.event.pull_request.base.ref) || '' }} --allow-no-target-runs
+ run: npx lage test:node test:chromium ${{ github.event_name == 'pull_request' && format('--since origin/{0}', github.event.pull_request.base.ref) || '' }} --allow-no-target-runs --verbose
- name: Testing final validation
run: npm run test:validation
diff --git a/.github/workflows/ci-webui-integration.yml b/.github/workflows/ci-webui-integration.yml
index 25ec5a641cc..aa3ebe609b0 100644
--- a/.github/workflows/ci-webui-integration.yml
+++ b/.github/workflows/ci-webui-integration.yml
@@ -41,7 +41,7 @@ jobs:
run: cargo install wasm-pack
- name: Build workspaces
- run: npm run build
+ run: npm run build -- --verbose
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
diff --git a/change/@microsoft-fast-html-3b1a0ebf-21d7-47fb-96ab-cd709b0ee939.json b/change/@microsoft-fast-html-3b1a0ebf-21d7-47fb-96ab-cd709b0ee939.json
new file mode 100644
index 00000000000..33eec87327b
--- /dev/null
+++ b/change/@microsoft-fast-html-3b1a0ebf-21d7-47fb-96ab-cd709b0ee939.json
@@ -0,0 +1,7 @@
+{
+ "type": "prerelease",
+ "comment": "update file paths and add syntax export",
+ "packageName": "@microsoft/fast-html",
+ "email": "863023+radium-v@users.noreply.github.com",
+ "dependentChangeType": "none"
+}
diff --git a/change/@microsoft-fast-test-harness-c2765407-ced6-420a-b45d-af7f9a31c057.json b/change/@microsoft-fast-test-harness-c2765407-ced6-420a-b45d-af7f9a31c057.json
new file mode 100644
index 00000000000..03024291dfc
--- /dev/null
+++ b/change/@microsoft-fast-test-harness-c2765407-ced6-420a-b45d-af7f9a31c057.json
@@ -0,0 +1,7 @@
+{
+ "type": "minor",
+ "comment": "feat: enhance SSR renderer and CLI with new template generation capabilities",
+ "packageName": "@microsoft/fast-test-harness",
+ "email": "863023+radium-v@users.noreply.github.com",
+ "dependentChangeType": "none"
+}
diff --git a/package-lock.json b/package-lock.json
index b5f817f91da..05d89773bb7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -32,7 +32,7 @@
"lage": "2.15.8",
"lefthook": "2.1.4",
"rollup": "4.59.0",
- "typescript": "5.3.3",
+ "typescript": "5.5.4",
"vite": "7.3.2",
"yargs": "17.7.2"
},
@@ -2865,7 +2865,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
- "dev": true,
"license": "ISC"
},
"node_modules/brace-expansion": {
@@ -2904,6 +2903,7 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -2913,6 +2913,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -2926,6 +2927,7 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -2978,6 +2980,91 @@
"node": ">=8"
}
},
+ "node_modules/cheerio": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
+ "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
+ "license": "MIT",
+ "dependencies": {
+ "cheerio-select": "^2.1.0",
+ "dom-serializer": "^2.0.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
+ "encoding-sniffer": "^0.2.1",
+ "htmlparser2": "^10.1.0",
+ "parse5": "^7.3.0",
+ "parse5-htmlparser2-tree-adapter": "^7.1.0",
+ "parse5-parser-stream": "^7.1.2",
+ "undici": "^7.19.0",
+ "whatwg-mimetype": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=20.18.1"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/cheerio?sponsor=1"
+ }
+ },
+ "node_modules/cheerio-select": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
+ "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^1.0.0",
+ "css-select": "^5.1.0",
+ "css-what": "^6.1.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/cheerio/node_modules/htmlparser2": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
+ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
+ "entities": "^7.0.1"
+ }
+ },
+ "node_modules/cheerio/node_modules/htmlparser2/node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/cheerio/node_modules/parse5": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -3181,6 +3268,7 @@
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -3190,6 +3278,7 @@
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -3248,7 +3337,6 @@
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
- "dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
@@ -3265,7 +3353,6 @@
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
- "dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
@@ -3373,7 +3460,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
@@ -3388,7 +3474,6 @@
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
- "dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
@@ -3413,7 +3498,6 @@
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
- "dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
@@ -3429,7 +3513,6 @@
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
- "dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
@@ -3444,6 +3527,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
@@ -3483,6 +3567,31 @@
"node": ">= 0.8"
}
},
+ "node_modules/encoding-sniffer": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
+ "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "^0.6.3",
+ "whatwg-encoding": "^3.1.1"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
+ }
+ },
+ "node_modules/encoding-sniffer/node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
@@ -3531,6 +3640,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -3556,6 +3666,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -3926,6 +4037,7 @@
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -3992,6 +4104,7 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -4016,6 +4129,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
@@ -4266,6 +4380,7 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -4321,6 +4436,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -4532,6 +4648,7 @@
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.10"
@@ -5108,6 +5225,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -5374,7 +5492,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
- "dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
@@ -5421,6 +5538,7 @@
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -5549,6 +5667,55 @@
"node": ">=14.13.0"
}
},
+ "node_modules/parse5-htmlparser2-tree-adapter": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
+ "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
+ "license": "MIT",
+ "dependencies": {
+ "domhandler": "^5.0.3",
+ "parse5": "^7.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/parse5-parser-stream": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
+ "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
+ "license": "MIT",
+ "dependencies": {
+ "parse5": "^7.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/parse5-parser-stream/node_modules/parse5": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -5790,6 +5957,7 @@
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
@@ -5818,6 +5986,7 @@
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
+ "dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
@@ -6254,6 +6423,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -6273,6 +6443,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -6289,6 +6460,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@@ -6307,6 +6479,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@@ -6675,9 +6848,9 @@
}
},
"node_modules/typescript": {
- "version": "5.3.3",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz",
- "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==",
+ "version": "5.5.4",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
+ "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -6711,6 +6884,15 @@
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
+ "node_modules/undici": {
+ "version": "7.25.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
+ "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
"node_modules/undici-types": {
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
@@ -6756,6 +6938,7 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -6835,6 +7018,54 @@
}
}
},
+ "node_modules/vite/node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/whatwg-encoding": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+ "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-encoding/node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -7128,13 +7359,12 @@
"version": "0.0.1",
"license": "MIT",
"dependencies": {
- "express": "5.2.1"
+ "cheerio": "1.2.0"
},
"bin": {
"fast-test-harness": "start.mjs"
},
"devDependencies": {
- "@microsoft/fast-build": "*",
"@microsoft/fast-html": "*"
},
"engines": {
@@ -7147,276 +7377,11 @@
"vite": ">=7.0.0"
},
"peerDependenciesMeta": {
- "@microsoft/fast-build": {
- "optional": true
- },
"@microsoft/fast-html": {
"optional": true
}
}
},
- "packages/fast-test-harness/node_modules/accepts": {
- "version": "2.0.0",
- "license": "MIT",
- "dependencies": {
- "mime-types": "^3.0.0",
- "negotiator": "^1.0.0"
- },
- "engines": {
- "node": ">= 0.6"
- }
- },
- "packages/fast-test-harness/node_modules/body-parser": {
- "version": "2.2.2",
- "license": "MIT",
- "dependencies": {
- "bytes": "^3.1.2",
- "content-type": "^1.0.5",
- "debug": "^4.4.3",
- "http-errors": "^2.0.0",
- "iconv-lite": "^0.7.0",
- "on-finished": "^2.4.1",
- "qs": "^6.14.1",
- "raw-body": "^3.0.1",
- "type-is": "^2.0.1"
- },
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/express"
- }
- },
- "packages/fast-test-harness/node_modules/content-disposition": {
- "version": "1.1.0",
- "license": "MIT",
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/express"
- }
- },
- "packages/fast-test-harness/node_modules/cookie-signature": {
- "version": "1.2.2",
- "license": "MIT",
- "engines": {
- "node": ">=6.6.0"
- }
- },
- "packages/fast-test-harness/node_modules/debug": {
- "version": "4.4.3",
- "license": "MIT",
- "dependencies": {
- "ms": "^2.1.3"
- },
- "engines": {
- "node": ">=6.0"
- },
- "peerDependenciesMeta": {
- "supports-color": {
- "optional": true
- }
- }
- },
- "packages/fast-test-harness/node_modules/express": {
- "version": "5.2.1",
- "license": "MIT",
- "dependencies": {
- "accepts": "^2.0.0",
- "body-parser": "^2.2.1",
- "content-disposition": "^1.0.0",
- "content-type": "^1.0.5",
- "cookie": "^0.7.1",
- "cookie-signature": "^1.2.1",
- "debug": "^4.4.0",
- "depd": "^2.0.0",
- "encodeurl": "^2.0.0",
- "escape-html": "^1.0.3",
- "etag": "^1.8.1",
- "finalhandler": "^2.1.0",
- "fresh": "^2.0.0",
- "http-errors": "^2.0.0",
- "merge-descriptors": "^2.0.0",
- "mime-types": "^3.0.0",
- "on-finished": "^2.4.1",
- "once": "^1.4.0",
- "parseurl": "^1.3.3",
- "proxy-addr": "^2.0.7",
- "qs": "^6.14.0",
- "range-parser": "^1.2.1",
- "router": "^2.2.0",
- "send": "^1.1.0",
- "serve-static": "^2.2.0",
- "statuses": "^2.0.1",
- "type-is": "^2.0.1",
- "vary": "^1.1.2"
- },
- "engines": {
- "node": ">= 18"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/express"
- }
- },
- "packages/fast-test-harness/node_modules/finalhandler": {
- "version": "2.1.1",
- "license": "MIT",
- "dependencies": {
- "debug": "^4.4.0",
- "encodeurl": "^2.0.0",
- "escape-html": "^1.0.3",
- "on-finished": "^2.4.1",
- "parseurl": "^1.3.3",
- "statuses": "^2.0.1"
- },
- "engines": {
- "node": ">= 18.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/express"
- }
- },
- "packages/fast-test-harness/node_modules/fresh": {
- "version": "2.0.0",
- "license": "MIT",
- "engines": {
- "node": ">= 0.8"
- }
- },
- "packages/fast-test-harness/node_modules/iconv-lite": {
- "version": "0.7.2",
- "license": "MIT",
- "dependencies": {
- "safer-buffer": ">= 2.1.2 < 3.0.0"
- },
- "engines": {
- "node": ">=0.10.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/express"
- }
- },
- "packages/fast-test-harness/node_modules/media-typer": {
- "version": "1.1.0",
- "license": "MIT",
- "engines": {
- "node": ">= 0.8"
- }
- },
- "packages/fast-test-harness/node_modules/merge-descriptors": {
- "version": "2.0.0",
- "license": "MIT",
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "packages/fast-test-harness/node_modules/mime-db": {
- "version": "1.54.0",
- "license": "MIT",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "packages/fast-test-harness/node_modules/mime-types": {
- "version": "3.0.2",
- "license": "MIT",
- "dependencies": {
- "mime-db": "^1.54.0"
- },
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/express"
- }
- },
- "packages/fast-test-harness/node_modules/ms": {
- "version": "2.1.3",
- "license": "MIT"
- },
- "packages/fast-test-harness/node_modules/negotiator": {
- "version": "1.0.0",
- "license": "MIT",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "packages/fast-test-harness/node_modules/raw-body": {
- "version": "3.0.2",
- "license": "MIT",
- "dependencies": {
- "bytes": "~3.1.2",
- "http-errors": "~2.0.1",
- "iconv-lite": "~0.7.0",
- "unpipe": "~1.0.0"
- },
- "engines": {
- "node": ">= 0.10"
- }
- },
- "packages/fast-test-harness/node_modules/send": {
- "version": "1.2.1",
- "license": "MIT",
- "dependencies": {
- "debug": "^4.4.3",
- "encodeurl": "^2.0.0",
- "escape-html": "^1.0.3",
- "etag": "^1.8.1",
- "fresh": "^2.0.0",
- "http-errors": "^2.0.1",
- "mime-types": "^3.0.2",
- "ms": "^2.1.3",
- "on-finished": "^2.4.1",
- "range-parser": "^1.2.1",
- "statuses": "^2.0.2"
- },
- "engines": {
- "node": ">= 18"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/express"
- }
- },
- "packages/fast-test-harness/node_modules/serve-static": {
- "version": "2.2.1",
- "license": "MIT",
- "dependencies": {
- "encodeurl": "^2.0.0",
- "escape-html": "^1.0.3",
- "parseurl": "^1.3.3",
- "send": "^1.2.0"
- },
- "engines": {
- "node": ">= 18"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/express"
- }
- },
- "packages/fast-test-harness/node_modules/type-is": {
- "version": "2.0.1",
- "license": "MIT",
- "dependencies": {
- "content-type": "^1.0.5",
- "media-typer": "^1.1.0",
- "mime-types": "^3.0.0"
- },
- "engines": {
- "node": ">= 0.6"
- }
- },
"sites/benchmarks": {
"name": "@microsoft/fast-bench",
"version": "0.0.0",
diff --git a/package.json b/package.json
index 97b8239df5f..774e44cdfe6 100644
--- a/package.json
+++ b/package.json
@@ -67,7 +67,7 @@
"lage": "2.15.8",
"lefthook": "2.1.4",
"rollup": "4.59.0",
- "typescript": "5.3.3",
+ "typescript": "5.5.4",
"vite": "7.3.2",
"yargs": "17.7.2"
},
diff --git a/packages/fast-html/package.json b/packages/fast-html/package.json
index 6ec9cd09c3f..91cc51f8e76 100644
--- a/packages/fast-html/package.json
+++ b/packages/fast-html/package.json
@@ -17,8 +17,8 @@
"url": "https://github.com/Microsoft/fast/issues/new/choose"
},
"files": [
- "./dist",
- "./rules/*.yml"
+ "dist",
+ "rules/*.yml"
],
"scripts": {
"build:tsc": "tsgo -p ./tsconfig.json",
@@ -54,6 +54,10 @@
"types": "./dist/dts/components/utilities.d.ts",
"default": "./dist/esm/components/utilities.js"
},
+ "./syntax.js": {
+ "types": "./dist/dts/components/syntax.d.ts",
+ "default": "./dist/esm/components/syntax.js"
+ },
"./rules/*.yml": "./rules/*.yml",
"./package.json": "./package.json"
},
diff --git a/packages/fast-test-harness/DESIGN.md b/packages/fast-test-harness/DESIGN.md
index 1828daa97f3..443078fa239 100644
--- a/packages/fast-test-harness/DESIGN.md
+++ b/packages/fast-test-harness/DESIGN.md
@@ -17,17 +17,11 @@ This document describes the internal architecture of the `@microsoft/fast-test-h
- [CSR Request Flow](#csr-request-flow)
- [SSR Request Flow](#ssr-request-flow)
- [Caching and Deduplication](#caching-and-deduplication)
-6. [SSR Rendering Utilities](#ssr-rendering-utilities)
- - [renderFixture](#renderfixture)
- - [renderTemplate](#rendertemplate)
- - [processDsdTemplate](#processdstemplate)
- - [injectChildTemplates](#injectchildtemplates)
- - [renderPreloadLinks](#renderpreloadlinks)
-7. [Asset Resolution](#asset-resolution)
-8. [Configuration Files](#configuration-files)
+6. [SSR Rendering](#ssr-rendering)
+7. [Configuration Files](#configuration-files)
- [Playwright Configuration](#playwright-configuration)
- [Vite Configuration](#vite-configuration)
-9. [Exports](#exports)
+8. [Exports](#exports)
---
@@ -40,24 +34,28 @@ This document describes the internal architecture of the `@microsoft/fast-test-h
The mode is selected per test via `test.use({ ssr: true })` or globally via the `PLAYWRIGHT_TEST_SSR=true` environment variable.
-```
+```ts
test("renders element", async ({ fastPage }) => {
await fastPage.setTemplate({ attributes: { label: "Hello" } });
await expect(fastPage.element).toBeVisible();
});
- │
- ├── CSR path ──────────────────────────────────────────┐
- │ page.goto("/") │
- │ page.evaluate() → inject HTML into
│
- │ waitForCustomElement() + waitForStability() │
- │ │
- └── SSR path ──────────────────────────────────────────┐
- POST /generate-fixture { tagName, attributes, … } │
- server: vite.ssrLoadModule("entry-server.js") │
- server: render(queryObj) → { template, fixture } │
- server: inject into ssr.html → write to temp/ │
- page.goto("/ssr-.html") │
- waitForStability() │
+```
+
+Both modes use the same test API. The fixture handles routing internally:
+
+```mermaid
+flowchart TD
+ A["setTemplate(options)"] --> B{ssr?}
+ B -- CSR --> C["page.goto('/')"]
+ C --> D["page.evaluate() — inject HTML into body"]
+ D --> E["waitForCustomElement()"]
+ E --> F["waitForStability()"]
+ B -- SSR --> G["POST /generate-fixture"]
+ G --> H["Server: vite.ssrLoadModule('entry-server.js')"]
+ H --> I["Server: render(queryObj)"]
+ I --> J["Server: assemble ssr.html, cache in memory"]
+ J --> K["page.goto('/ssr-testId.html')"]
+ K --> F
```
---
@@ -66,17 +64,20 @@ test("renders element", async ({ fastPage }) => {
| File | Role |
|------|------|
-| `src/index.ts` | Package barrel — re-exports fixtures, assertions, and SSR utilities |
+| `src/index.ts` | Package barrel — re-exports fixtures, assertions, build utilities, and SSR renderer |
| `src/fixtures/index.ts` | Extends Playwright's `test` with `fastPage` fixture and `expect` with custom assertions |
| `src/fixtures/csr-fixture.ts` | `CSRFixture` class — client-side rendering fixture |
| `src/fixtures/ssr-fixture.ts` | `SSRFixture` class — server-side rendering fixture (extends `CSRFixture`) |
| `src/fixtures/assertions.ts` | Custom Playwright assertion `toHaveCustomState` |
-| `src/ssr/render.ts` | SSR rendering helpers: `renderFixture`, `renderTemplate`, `renderPreloadLinks` |
-| `src/ssr/assets.ts` | Asset resolution helpers: `readAsset`, `resolveAssetUrl` |
+| `src/build/dom-shim.ts` | Minimal DOM shim for running FAST Element's `css` and `html` tagged templates in Node.js |
+| `src/build/generate-stylesheets.ts` | Extracts compiled FAST `ElementStyles` JS modules into plain `.css` files |
+| `src/build/generate-templates.ts` | Converts compiled FAST `ViewTemplate` JS modules into declarative `` HTML files |
+| `src/build/generate-webui-templates.ts` | Converts compiled FAST `ViewTemplate` JS modules into WebUI-compatible declarative shadow DOM `` HTML files |
+| `src/ssr/render.ts` | `createSSRRenderer` factory — scans for component build artifacts and uses the `@microsoft/fast-build` WASM module to produce SSR output |
| `src/ssr/entry-client.ts` | SSR hydration entry point — defines `` for the browser |
-| `server.mjs` | Express + Vite dev server — serves CSR pages and handles SSR fixture generation |
-| `start.mjs` | CLI entry point (`fast-test-harness` bin) — calls `startServer()` |
-| `playwright.config.ts` | Shared Playwright configuration (browsers, web server, test matching) |
+| `server.mjs` | Node.js HTTP server with Vite middleware — serves CSR pages and handles SSR fixture generation |
+| `start.mjs` | CLI entry point (`fast-test-harness` bin) — supports subcommands (`serve`, `generate-templates`, `generate-stylesheets`, `generate-webui-templates`) and flags via `node:util` `parseArgs` |
+| `playwright.config.mjs` | Shared Playwright configuration (browsers, web server, test matching) |
| `vite.config.mjs` | Shared Vite configuration (port, resolve conditions, build settings) |
| `public/styles.css` | Base CSS reset served as a Vite public asset |
@@ -163,7 +164,7 @@ The assertion evaluates `el.matches(`:state(${state})`)` in the browser via `loc
## Server
-The server (`server.mjs`) is an Express application with Vite running in middleware mode. It handles both CSR page serving and SSR fixture generation.
+The server (`server.mjs`) is a plain Node.js HTTP server (using `node:http`) with Vite running in middleware mode. It handles both CSR page serving and SSR fixture generation.
### Consumer-Owned SSR Contract
@@ -183,21 +184,21 @@ The `startServer(cwd, root, configFile)` function accepts overrides for each pat
|-----------|---------|-------------|
| `cwd` | `process.cwd()` | Static file serving root |
| `root` | `/test` | Vite root (contains `index.html`, `ssr.html`) |
-| `configFile` | `/vite.config.ts` | Vite config path |
+| `configFile` | Vite auto-discovery | Vite config path |
### Startup
-`startServer(cwd, root, configFile)` initializes:
+`startServer(cwd, root, configFile, options)` accepts an `options` object with `port`, `base`, and `debug` properties (falling back to `PORT`, `BASE`, and `FAST_DEBUG` environment variables, then defaults). It initializes:
-1. A `temp/` directory under `root` for writing SSR fixture files (cleaned on startup)
-2. A Vite dev server in middleware mode, configured to ignore the temp directory
-3. Express middleware stack: Vite middleware → static file serving from `cwd` → route handlers
+1. A Vite dev server in middleware mode, configured to ignore the temp directory and with a `fast-test-harness:resolve-css-links` plugin that resolves bare package CSS specifiers in `` tags to `/@fs/` URLs
+2. A Node.js HTTP server with this request handling order: route handlers (`/generate-fixture`, `/ssr-*.html`) → static file serving from `cwd` → Vite middleware → HTML catch-all
+3. When `debug` is enabled: a `temp/` directory under `root` for writing SSR fixture HTML files (cleaned on startup, useful for post-failure inspection)
-The server listens on port `5273` by default (configurable via `PORT` environment variable).
+The server listens on `port` (default `3278`).
### CSR Request Flow
-The catch-all `*all` handler serves the Vite-transformed `index.html` for navigation requests (those with `Accept: text/html`). The transformed HTML is cached after the first request. Non-HTML requests (module imports, assets) fall through to Vite's middleware.
+After Vite's middleware processes module transforms and HMR, a fallback handler serves the Vite-transformed `index.html` for navigation requests (those with `Accept: text/html`). The transformed HTML is cached after the first request. Non-HTML requests that Vite doesn't handle receive a 404.
```
Browser GET /
@@ -211,26 +212,31 @@ Browser GET /
The `/generate-fixture` POST endpoint handles SSR fixture generation:
-```
-Browser POST /generate-fixture { testId, tagName, attributes, … }
- → validate testId (required, alphanumeric + hyphens/underscores only)
- → parse attributes and styles from JSON strings
- → check pendingGenerations deduplication map
- → read ssr.html template
- → vite.transformIndexHtml() for module injection
- → vite.ssrLoadModule("/src/entry-server.js")
- → call render(body) → { template, fixture, preloadLinks }
- → replace placeholders in ssr.html:
- → test title
- → f-template HTML
- → rendered element HTML
- → preload links + inline styles
- → cache in fixtureCache Map
- → write to temp/ssr-.html (for debugging)
- → respond with { url: "/ssr-.html" }
+```mermaid
+sequenceDiagram
+ participant B as Browser
+ participant S as Server
+ participant V as Vite
+ participant E as entry-server.ts
+
+ B->>S: POST /generate-fixture { testId, tagName, … }
+ S->>S: Validate testId, parse attributes & styles
+ S->>S: Check pendingGenerations (dedup)
+ S->>S: Read ssr.html template
+ S->>V: ssrLoadModule("/src/entry-server.js")
+ V-->>S: entry-server module
+ S->>E: render(body)
+ E-->>S: { template, fixture, preloadLinks }
+ S->>S: Replace placeholders in ssr.html
+ S->>V: transformIndexHtml(url, assembled)
+ V-->>S: Transformed HTML
+ S->>S: Cache in fixtureCache
+ S-->>B: { url: "/ssr-testId.html" }
+ B->>S: GET /ssr-testId.html
+ S-->>B: Cached fixture HTML
```
-A dedicated `GET /ssr-:id.html` handler serves cached fixtures directly from memory, bypassing the filesystem.
+When `debug` is enabled, the server also writes fixtures to `temp/ssr-.html` for post-failure inspection.
### Caching and Deduplication
@@ -238,61 +244,50 @@ A dedicated `GET /ssr-:id.html` handler serves cached fixtures directly from mem
|-------|---------|
| `cachedIndexHtml` | Caches the Vite-transformed `index.html` for CSR — avoids re-reading and re-transforming on every navigation |
| `fixtureCache` | Maps SSR fixture URLs to their rendered HTML — serves fixtures from memory without filesystem reads |
-| `pendingGenerations` | Deduplicates concurrent SSR generation requests for the same `testId` — if a generation is already in progress, subsequent requests await the same promise |
+| `pendingGenerations` | Deduplicates concurrent SSR generation requests for the same `testId` — if a generation is already in progress, subsequent requests await the same promise. This guards against retries of the same test dispatching overlapping requests before the first completes. |
---
-## SSR Rendering Utilities
-
-These functions in `src/ssr/render.ts` are used by consumer `entry-server.ts` modules to build SSR output.
-
-### renderFixture
-
-`renderFixture(queryObj, dsdTemplate?, styles?, templateData?, childTemplates?)` assembles the fixture element HTML.
-
-- If `queryObj.html` is present, uses it as the raw fixture HTML.
-- Otherwise, builds the element from `queryObj.tagName`, `queryObj.attributes` (parsed from JSON), and `queryObj.innerHTML`.
-- When a `dsdTemplate` is provided, it is processed through `processDsdTemplate` to resolve `{{placeholder}}` bindings from the element's attributes and `templateData`.
-- The processed DSD is injected inside the opening tag before the innerHTML.
-- If `childTemplates` is provided, DSD templates are injected into nested custom elements via `injectChildTemplates`.
+## SSR Rendering
-### renderTemplate
+The `src/ssr/render.ts` module exports `createSSRRenderer`, a factory that scans for component build artifacts (f-templates, stylesheets) and returns a `{ render }` object compatible with the server's `entry-server.ts` contract. It uses the `@microsoft/fast-build` WASM module to render f-templates into declarative shadow DOM on each request, with full expression evaluation and nested component support.
-`renderTemplate(rawTemplate, styles)` replaces `{{styles}}` in an f-template HTML string with a `` tag. If no `{{styles}}` placeholder is found and a styles URL is provided, it inserts the link tag after the opening `` tag.
+### createSSRRenderer
-### processDsdTemplate
+`createSSRRenderer(options: SSRRendererOptions)` returns `{ render(queryObj) => RenderResult }`.
-`processDsdTemplate(dsd, templateValues)` resolves `{{varName}}` placeholders in a DSD template string. It uses a character-scanning approach (not regex) to avoid O(n²) backtracking on untrusted input.
+**Options:**
-The function handles two contexts:
-
-| Context | Behavior |
-|---------|----------|
-| **Attribute binding** (`attr="{{varName}}"` or `?attr="{{varName}}"`) | Scans backwards from `{{` to find the attribute name and optional `?` boolean prefix. If the value is `undefined`, the entire attribute is removed. For boolean attributes (`?`), a truthy value emits the bare attribute name; falsy removes it. |
-| **Text content** (`{{varName}}` in element body) | Replaces with the stringified value, or empty string if `undefined`. |
-
-Attribute names are also normalized by stripping hyphens (e.g., `my-attr` also checks `myattr`) to support camelCase state properties bound to hyphenated HTML attributes.
-
-### injectChildTemplates
-
-`injectChildTemplates(html, childTemplates)` injects DSD templates into nested custom elements found in an HTML string. It uses a regex with a lookahead (`(?=[\s>/])`) to avoid partial matches on hyphenated names (e.g., `my-radio` does not match `my-radio-group`). Each child element's own attributes are extracted and passed to `processDsdTemplate`.
-
-### renderPreloadLinks
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `tagPrefix` | `string` | — | Tag name prefix for custom elements (e.g., `"fluent"`, `"mai"`) |
+| `packageName` | `string?` | — | Monolithic package name — scans subdirectories for component artifacts. Mutually exclusive with `components`. |
+| `components` | `ComponentRegistration[]?` | — | Explicit list of per-component packages. Mutually exclusive with `packageName`. |
+| `distDir` | `string?` | `"dist/esm"` | Artifact directory relative to the package root. Only used with `packageName`. |
+| `themeStylesheet` | `string?` | — | Stylesheet URL or server-relative path for a global theme stylesheet included in every SSR fixture. Used directly as the `` tag's `href`. |
-`renderPreloadLinks(styles, tokensThemeUrl?)` generates `` tags: a `stylesheet` link for the theme URL (if provided) and `preload` links for each style URL.
+**Supported layouts:**
----
+- **Monolithic package** (`packageName`): Scans `/` for `**/*.template.html` files, treating each parent directory as a component name. Resolves `//template.html` and `//styles.css` via the package's exports map.
+- **Per-component packages** (`components`): Uses an explicit `ComponentRegistration[]` array. Each entry maps a component name to an npm package, and resolves `/template.html` and `/styles.css`.
-## Asset Resolution
+**Initialization flow:**
-The `src/ssr/assets.ts` module provides two helpers for locating files in SSR `entry-server.ts` modules:
+1. Validates that `packageName` and `components` are not both provided (throws if so).
+2. Loads the `@microsoft/fast-build` WASM module (throws if not installed).
+3. Collects f-template and stylesheet artifacts for all components.
+4. Injects stylesheet `` tags into f-templates (replaces `{{styles}}` placeholder or strips the marker when styles URL is empty).
+5. Parses f-templates into the WASM templates map (`tagName → inner template content`).
+6. Loads CEM default state per-package, keyed by tag name.
+7. Concatenates all styled f-templates for client hydration.
-| Function | Purpose |
-|----------|---------|
-| `readAsset(specifier)` | Reads a file as a UTF-8 string. Accepts bare package specifiers (resolved via `import.meta.resolve` through `package.json` exports maps) or filesystem paths (resolved relative to `process.cwd()`). |
-| `resolveAssetUrl(specifier, root?)` | Resolves a specifier to a server-relative URL path (starting with `/`) for use in `` tags. The path is made relative to `root` (defaults to `process.cwd()`). |
+**Per-request `render(queryObj)` flow:**
-Both delegate to `resolveSpecifier`, which distinguishes bare specifiers from filesystem paths by checking whether the specifier starts with `.` or `/`.
+1. Builds entry HTML from `queryObj` — either raw HTML (`queryObj.html`) or a constructed element (`queryObj.tagName`, `queryObj.attributes`, `queryObj.innerHTML`). Attributes with `false`, `null`, or `undefined` values are omitted; other values are HTML-escaped.
+2. Builds state JSON from attributes (including hyphen-stripped variants for camelCase bindings).
+3. Calls the WASM `render_entry_with_templates()` to produce full HTML with declarative shadow DOM.
+4. Extracts `` content from the rendered document.
+5. Returns `{ template, fixture, preloadLinks }`.
---
@@ -304,13 +299,14 @@ Both delegate to `resolveSpecifier`, which distinguishes bare specifiers from fi
| Setting | Value |
|---------|-------|
-| `retries` | `3` |
-| `fullyParallel` | `true` |
-| `reducedMotion` | `"reduce"` |
-| `testMatch` | `src/**/*.pw.spec.ts` |
+| `retries` | `3` in CI, `1` locally |
+| `timeout` | `10_000` in CI, `5_000` locally |
+| `fullyParallel` | `true` locally, `false` in CI |
+| `use.contextOptions.reducedMotion` | `"reduce"` |
+| `testMatch` | `**/*.pw.spec.ts` |
| `reporter` | `"list"` |
-| `webServer.command` | `node start.mjs` |
-| `webServer.port` | `5273` |
+| `webServer.command` | `fast-test-harness` |
+| `webServer.port` | `3278` (configurable via `PORT` env var) |
| `projects` | Chromium, Firefox, WebKit (Safari with `deviceScaleFactor: 1`) |
### Vite Configuration
@@ -321,7 +317,7 @@ Both delegate to `resolveSpecifier`, which distinguishes bare specifiers from fi
|---------|-------|
| `clearScreen` | `false` |
| `resolve.conditions` | `["test"]` — enables the `test` condition in `package.json` exports maps, allowing packages to expose source files (`.ts`) directly to Vite instead of compiled output |
-| `server.port` | `5273` (from `PORT` env var) |
+| `server.port` | `3278` (from `PORT` env var) |
| `server.strictPort` | `true` |
| `build.minify` | `false` |
| `build.sourcemap` | `true` |
@@ -332,10 +328,10 @@ Both delegate to `resolveSpecifier`, which distinguishes bare specifiers from fi
| Specifier | Contents |
|-----------|----------|
-| `@microsoft/fast-test-harness` | `test`, `expect`, `CSRFixture`, `SSRFixture`, `readAsset`, `resolveAssetUrl`, `renderFixture`, `renderTemplate` |
-| `@microsoft/fast-test-harness/server.mjs` | `startServer`, `app` |
-| `@microsoft/fast-test-harness/ssr/render.js` | `renderFixture`, `renderTemplate`, `renderPreloadLinks` |
-| `@microsoft/fast-test-harness/ssr/assets.js` | `readAsset`, `resolveAssetUrl` |
-| `@microsoft/fast-test-harness/playwright.config.ts` | Shared Playwright configuration |
+| `@microsoft/fast-test-harness` | `test`, `expect`, `CSRFixture`, `SSRFixture`, `createSSRRenderer`, `ComponentRegistration`, `RenderResult`, `SSRRendererOptions`, build utilities (`installDomShim`, `generateStylesheets`, `generateFTemplates`, `generateWebuiTemplates`) |
+| `@microsoft/fast-test-harness/server.mjs` | `startServer` |
+| `@microsoft/fast-test-harness/ssr/render.js` | `createSSRRenderer`, `ComponentRegistration`, `RenderResult`, `SSRRendererOptions`, `renderTemplate`, `buildEntryHtml`, `buildState`, `parseDefaultValue` |
+| `@microsoft/fast-test-harness/build/*.js` | Build utilities: `installDomShim`, `generateStylesheets`, `generateFTemplates`, `generateWebuiTemplates` |
+| `@microsoft/fast-test-harness/playwright.config.mjs` | Shared Playwright configuration |
| `@microsoft/fast-test-harness/vite.config.mjs` | Shared Vite configuration |
| `@microsoft/fast-test-harness/public/*` | Static assets (base CSS reset) |
diff --git a/packages/fast-test-harness/README.md b/packages/fast-test-harness/README.md
index 6135bad6979..d3b0127bb05 100644
--- a/packages/fast-test-harness/README.md
+++ b/packages/fast-test-harness/README.md
@@ -123,15 +123,18 @@ setTheme(lightTheme);
```
-**`entry-client.ts`** registers components for DSD hydration using `defineAsync`:
+**`entry-client.ts`** imports the harness SSR entry (which defines the `` element) and registers components for DSD hydration using `defineAsync`:
```ts
-import { TemplateElement } from "@microsoft/fast-html";
-TemplateElement.define({ name: "f-template" });
+import "@microsoft/fast-test-harness/ssr/entry-client.js";
-// Load all define-async modules
-const modules = import.meta.glob("../../src/*/define-async.{ts,js}");
-Promise.all(Object.values(modules).map(m => m()));
+import { RenderableFASTElement } from "@microsoft/fast-html";
+import { MyButton, definition } from "../../src/button/index.js";
+
+RenderableFASTElement(MyButton).defineAsync({
+ name: definition.name,
+ templateOptions: "defer-and-hydrate",
+});
```
**`entry-server.ts`** exports a `render()` function that the server calls for each `setTemplate()` request. It returns three strings that get injected into `ssr.html`:
@@ -144,35 +147,47 @@ export function render(queryObj: Record): {
};
```
-Each component needs three build artifacts for SSR: an `` (`.template.html`), a DSD template (`.template-dsd.html`), and optionally a stylesheet (`.styles.css`). Use `renderFixture` and `renderTemplate` from the harness to assemble the output:
+Use `createSSRRenderer` from the harness to build the `render()` function. It scans for component build artifacts (f-templates, stylesheets) and uses the `@microsoft/fast-build` WASM renderer to produce declarative shadow DOM on each request.
+
+**Multi-component package** (all components in one package):
```ts
-import { readAsset, resolveAssetUrl } from "@microsoft/fast-test-harness/ssr/assets.js";
-import { renderFixture, renderTemplate } from "@microsoft/fast-test-harness/ssr/render.js";
+import { createSSRRenderer } from "@microsoft/fast-test-harness/ssr/render.js";
+
+const { render } = createSSRRenderer({
+ packageName: "@my-scope/web-components",
+ tagPrefix: "my",
+});
-const fTemplate = readAsset("@my-scope/button/template.html");
-const dsd = readAsset("@my-scope/button/template-dsd.html");
-const styles = resolveAssetUrl("@my-scope/button/styles.css");
+export { render };
+```
+
+**Per-component packages** (each component is a separate npm package):
+
+```ts
+import { createSSRRenderer } from "@microsoft/fast-test-harness/ssr/render.js";
+
+const { render } = createSSRRenderer({
+ tagPrefix: "my",
+ components: [
+ { name: "button", packageName: "@my-scope/button" },
+ { name: "checkbox", packageName: "@my-scope/checkbox" },
+ ],
+});
-export function render(queryObj: Record = {}) {
- return {
- template: renderTemplate(fTemplate, styles),
- fixture: renderFixture(queryObj, dsd, styles),
- preloadLinks: "",
- };
-}
+export { render };
```
## Server
-The package includes an Express + Vite server that handles both CSR page serving and SSR fixture generation. Run it directly or let Playwright manage it via `webServer`:
+The package includes a Node.js HTTP server with Vite middleware that handles both CSR page serving and SSR fixture generation. Run it directly or let Playwright manage it via `webServer`:
```ts
// playwright.config.ts
export default defineConfig({
webServer: {
command: "fast-test-harness",
- port: 5173,
+ port: 3278,
reuseExistingServer: true,
},
});
@@ -182,37 +197,71 @@ For custom setup, import `startServer`:
```ts
import { startServer } from "@microsoft/fast-test-harness/server.mjs";
-await startServer(process.cwd(), "./test", "./test/vite.config.ts");
+await startServer(process.cwd(), "./test", "./test/vite.config.ts", {
+ port: 4000,
+ debug: true,
+});
```
| Parameter | Default | Description |
-|-----------|---------|-------------|
+| --------- | ------- | ----------- |
| `cwd` | `process.cwd()` | Static file serving root |
| `root` | `/test` | Vite root (contains `index.html`, `ssr.html`) |
-| `configFile` | `/vite.config.ts` | Vite config path |
+| `configFile` | Vite auto-discovery | Vite config path |
+| `options.port` | `3278` | Server port |
+| `options.base` | `/` | Base URL path |
+| `options.debug` | `false` | Write SSR fixtures to `temp/` for inspection |
-| Environment variable | Default | Description |
-|---------------------|---------|-------------|
-| `PORT` | `5173` | Server port |
-| `BASE` | `/` | Base URL path |
-| `PLAYWRIGHT_TEST_SSR` | — | Set `"true"` for SSR mode |
+### CLI flags
-## Rendering utilities
+```
+fast-test-harness [command] [options]
+
+Commands:
+ serve Start the test harness dev server (default)
+ generate-templates Generate HTML files from compiled templates
+ generate-stylesheets Generate CSS files from compiled ElementStyles
+ generate-webui-templates Generate WebUI-compatible DSD templates
+
+Serve options:
+ -p, --port Server port (default: 3278)
+ -b, --base Base URL path (default: /)
+ -r, --root Vite root directory (default: /test)
+ -c, --config Vite config file path (default: Vite auto-discovery)
+ -d, --debug Write SSR fixtures to temp/ for inspection
+ -v, --version Show version number
+ -h, --help Show help message
+```
+
+CLI flags take precedence over environment variables.
-**`renderFixture(queryObj, dsdTemplate?, styles?, templateData?, childTemplates?)`** builds the fixture element HTML. Injects the DSD template inside the element when provided. `childTemplates` is a `Record` that injects DSD into nested custom elements found in the innerHTML or raw HTML.
+| Environment variable | Default | Description |
+| -------------------- | ------- | ----------- |
+| `PORT` | `3278` | Server port (overridden by `--port`) |
+| `BASE` | `/` | Base URL path (overridden by `--base`) |
+| `FAST_DEBUG` | — | Set `"true"` to enable debug mode (overridden by `--debug`) |
+| `PLAYWRIGHT_TEST_SSR` | — | Set `"true"` for SSR mode |
-**`renderTemplate(rawTemplate, styles)`** replaces `{{styles}}` in an f-template HTML string with a `` tag for the given stylesheet URL.
+## SSR renderer
-**`readAsset(specifier)`** reads a file as UTF-8 from a package export path or filesystem path using `import.meta.resolve`.
+**`createSSRRenderer(options)`** scans for component build artifacts and returns a `{ render }` object compatible with the server's `entry-server.ts` contract. It uses the `@microsoft/fast-build` WASM module to render f-templates into declarative shadow DOM.
-**`resolveAssetUrl(specifier, root?)`** resolves a specifier to a server-relative URL path for use in `` tags.
+| Option | Type | Description |
+|--------|------|-------------|
+| `tagPrefix` | `string` | Tag name prefix for custom elements (e.g., `"fluent"`, `"mai"`) |
+| `packageName` | `string?` | Monolithic package name — scans subdirectories for component artifacts. Mutually exclusive with `components`. |
+| `components` | `ComponentRegistration[]?` | Explicit list of per-component packages. Mutually exclusive with `packageName`. |
+| `distDir` | `string?` | Artifact directory relative to the package root (default: `"dist/esm"`). Only used with `packageName`. |
+| `themeStylesheet` | `string?` | URL or package specifier for a global theme stylesheet. |
## Exports
| Specifier | Contents |
|-----------|----------|
-| `@microsoft/fast-test-harness` | `test`, `expect`, `CSRFixture`, `SSRFixture`, `readAsset`, `resolveAssetUrl`, `renderFixture`, `renderTemplate` |
-| `@microsoft/fast-test-harness/server.mjs` | `startServer`, `app` |
-| `@microsoft/fast-test-harness/ssr/render.js` | `renderFixture`, `renderTemplate`, `renderPreloadLinks` |
-| `@microsoft/fast-test-harness/ssr/assets.js` | `readAsset`, `resolveAssetUrl` |
+| `@microsoft/fast-test-harness` | `test`, `expect`, `CSRFixture`, `SSRFixture`, `createSSRRenderer`, build utilities |
+| `@microsoft/fast-test-harness/server.mjs` | `startServer` |
+| `@microsoft/fast-test-harness/ssr/render.js` | `createSSRRenderer`, `ComponentRegistration`, `RenderResult`, `SSRRendererOptions` |
+| `@microsoft/fast-test-harness/build/*.js` | `installDomShim`, `generateStylesheets`, `generateFTemplates`, `generateWebuiTemplates` |
+| `@microsoft/fast-test-harness/playwright.config.mjs` | Shared Playwright configuration |
+| `@microsoft/fast-test-harness/vite.config.mjs` | Shared Vite configuration |
| `@microsoft/fast-test-harness/public/*` | Static assets (base CSS) |
diff --git a/packages/fast-test-harness/package.json b/packages/fast-test-harness/package.json
index 8e23feac13d..e39c322eaa0 100644
--- a/packages/fast-test-harness/package.json
+++ b/packages/fast-test-harness/package.json
@@ -1,6 +1,5 @@
{
"name": "@microsoft/fast-test-harness",
- "private": true,
"version": "0.0.1",
"author": {
"name": "Microsoft",
@@ -23,33 +22,54 @@
"exports": {
".": {
"types": "./dist/dts/index.d.ts",
- "test": "./src/index.ts",
"default": "./dist/esm/index.js"
},
- "./vite.config.mjs": {
- "default": "./vite.config.mjs"
+ "./build/*.js": {
+ "types": "./dist/dts/build/*.d.ts",
+ "default": "./dist/esm/build/*.js"
},
"./ssr/*.js": {
"types": "./dist/dts/ssr/*.d.ts",
- "test": "./src/ssr/*.ts",
"default": "./dist/esm/ssr/*.js"
},
- "./playwright.config.ts": "./playwright.config.ts",
+ "./playwright.config.mjs": {
+ "types": "./playwright.config.d.ts",
+ "default": "./playwright.config.mjs"
+ },
"./public/*": "./public/*",
+ "./template.html": "./test/src/test-widget/test-widget.template.html",
+ "./styles.css": "./test/src/test-widget/test-widget.styles.css",
"./server.mjs": "./server.mjs",
"./start.mjs": "./start.mjs",
+ "./vite.config.mjs": {
+ "types": "./vite.config.d.ts",
+ "default": "./vite.config.mjs"
+ },
"./package.json": "./package.json"
},
"scripts": {
+ "clean": "clean dist temp test-results",
"build": "npm run build:tsc",
- "build:tsc": "tsgo -p tsconfig.build.json"
+ "build:tsc": "tsgo -p tsconfig.build.json",
+ "test": "npm run test:node && npm run test:playwright",
+ "test:node": "node --test --experimental-test-isolation=none \"**/*.test.ts\"",
+ "test:playwright": "playwright test"
},
+ "files": [
+ "dist",
+ "playwright.config.mjs",
+ "playwright.config.d.ts",
+ "public",
+ "server.mjs",
+ "start.mjs",
+ "vite.config.mjs",
+ "vite.config.d.ts"
+ ],
"dependencies": {
- "express": "5.2.1"
+ "cheerio": "1.2.0"
},
"devDependencies": {
- "@microsoft/fast-html": "*",
- "@microsoft/fast-build": "*"
+ "@microsoft/fast-html": "*"
},
"peerDependencies": {
"@microsoft/fast-build": ">=0.5.0 <1.0.0",
@@ -58,9 +78,6 @@
"vite": ">=7.0.0"
},
"peerDependenciesMeta": {
- "@microsoft/fast-build": {
- "optional": true
- },
"@microsoft/fast-html": {
"optional": true
}
diff --git a/packages/fast-test-harness/playwright.config.d.ts b/packages/fast-test-harness/playwright.config.d.ts
new file mode 100644
index 00000000000..4810f2a0886
--- /dev/null
+++ b/packages/fast-test-harness/playwright.config.d.ts
@@ -0,0 +1,4 @@
+declare module "@microsoft/fast-test-harness/playwright.config.mjs" {
+ const config: import("@playwright/test").PlaywrightTestConfig;
+ export default config;
+}
diff --git a/packages/fast-test-harness/playwright.config.ts b/packages/fast-test-harness/playwright.config.mjs
similarity index 58%
rename from packages/fast-test-harness/playwright.config.ts
rename to packages/fast-test-harness/playwright.config.mjs
index 9e678c9d5eb..81f665c5440 100644
--- a/packages/fast-test-harness/playwright.config.ts
+++ b/packages/fast-test-harness/playwright.config.mjs
@@ -1,9 +1,14 @@
import { defineConfig, devices } from "@playwright/test";
+const CI = process.env.CI === "true";
+const PORT = process.env.PORT ? Number(process.env.PORT) : 3278;
+
export default defineConfig({
- retries: 3,
- fullyParallel: true,
+ retries: CI ? 3 : 1,
+ timeout: CI ? 10_000 : 5_000,
+ fullyParallel: !CI,
use: {
+ baseURL: `http://localhost:${PORT}`,
contextOptions: {
reducedMotion: "reduce",
},
@@ -20,12 +25,14 @@ export default defineConfig({
},
],
reporter: "list",
- testMatch: "src/**/*.pw.spec.ts",
+ testMatch: "**/*.pw.spec.ts",
webServer: {
- command: "node start.mjs",
- port: 5273,
+ command: "fast-test-harness",
+ port: PORT,
+ env: {
+ ...process.env,
+ PORT: PORT.toString(),
+ },
reuseExistingServer: true,
- stdout: "pipe",
- stderr: "pipe",
},
});
diff --git a/packages/fast-test-harness/server.mjs b/packages/fast-test-harness/server.mjs
index 2dec893b9a5..64e34467f22 100644
--- a/packages/fast-test-harness/server.mjs
+++ b/packages/fast-test-harness/server.mjs
@@ -1,24 +1,132 @@
import fs from "node:fs/promises";
-import { dirname, resolve } from "node:path";
-import { fileURLToPath } from "node:url";
-import express from "express";
+import { createServer as createHttpServer } from "node:http";
+import { extname, isAbsolute, relative, resolve } from "node:path";
+import { load } from "cheerio";
+
+const MIME_TYPES = {
+ ".html": "text/html",
+ ".js": "application/javascript",
+ ".mjs": "application/javascript",
+ ".css": "text/css",
+ ".json": "application/json",
+ ".wasm": "application/wasm",
+ ".svg": "image/svg+xml",
+ ".png": "image/png",
+ ".jpg": "image/jpeg",
+ ".gif": "image/gif",
+ ".ico": "image/x-icon",
+ ".woff": "font/woff",
+ ".woff2": "font/woff2",
+ ".ttf": "font/ttf",
+};
+
+/**
+ * Read the full request body as a string.
+ */
+function readBody(req) {
+ return new Promise((resolve, reject) => {
+ const chunks = [];
+ req.on("data", chunk => chunks.push(chunk));
+ req.on("end", () => resolve(Buffer.concat(chunks).toString()));
+ req.on("error", reject);
+ });
+}
-const __dirname = fileURLToPath(dirname(import.meta.url));
+/**
+ * Send a JSON response.
+ */
+function jsonResponse(res, statusCode, data) {
+ const body = JSON.stringify(data);
+ res.writeHead(statusCode, {
+ "Content-Type": "application/json",
+ "Content-Length": Buffer.byteLength(body),
+ });
+ res.end(body);
+}
-const PORT = process.env.PORT || 5273;
-const base = process.env.BASE || "/";
+/**
+ * Send an HTML response.
+ */
+function htmlResponse(res, statusCode, html) {
+ res.writeHead(statusCode, {
+ "Content-Type": "text/html",
+ "Content-Length": Buffer.byteLength(html),
+ });
+ res.end(html);
+}
+
+/**
+ * Try to serve a static file from `root`. Returns true if served.
+ */
+async function tryServeStatic(req, res, root) {
+ const urlPath = new URL(req.url, "http://localhost").pathname;
+ const filePath = resolve(root, `.${urlPath}`);
+
+ // Prevent path traversal — reject if the resolved path escapes root.
+ const rel = relative(root, filePath);
+ if (rel.startsWith("..") || isAbsolute(rel)) {
+ return false;
+ }
+
+ try {
+ const stat = await fs.stat(filePath);
+ if (!stat.isFile()) {
+ return false;
+ }
+ const ext = extname(filePath);
+ const mime = MIME_TYPES[ext] || "application/octet-stream";
+ const content = await fs.readFile(filePath);
+ res.writeHead(200, {
+ "Content-Type": mime,
+ "Content-Length": content.length,
+ });
+ res.end(content);
+ return true;
+ } catch {
+ return false;
+ }
+}
-export const app = express();
+export async function startServer(cwd = process.cwd(), root, configFile, options = {}) {
+ const {
+ port = process.env.PORT ? Number(process.env.PORT) : 3278,
+ base = process.env.BASE || "/",
+ debug = process.env.FAST_DEBUG === "true",
+ } = options;
-export async function startServer(cwd = process.cwd(), root, configFile) {
root = root ?? resolve(cwd, "./test");
- configFile = configFile ?? resolve(root, "vite.config.ts");
+
+ try {
+ await fs.access(root);
+ } catch {
+ console.error(
+ `Error: Vite root directory does not exist: ${root}\n` +
+ ` Use --root to specify a different directory, or run from a package with a test/ folder.`,
+ );
+ process.exit(1);
+ }
+
+ if (configFile) {
+ try {
+ await fs.access(configFile);
+ } catch {
+ console.error(
+ `Error: Vite config file not found: ${configFile}\n` +
+ ` Use --config to specify a different config file.`,
+ );
+ process.exit(1);
+ }
+ }
+
const indexPath = resolve(root, "./index.html");
- const tempDir = resolve(root, "temp");
- await fs.rm(tempDir, { recursive: true, force: true });
- await fs.mkdir(tempDir, { recursive: true });
- const realTempDir = await fs.realpath(tempDir);
+ let realTempDir;
+ if (debug) {
+ const tempDir = resolve(root, "temp");
+ await fs.rm(tempDir, { recursive: true, force: true });
+ await fs.mkdir(tempDir, { recursive: true });
+ realTempDir = await fs.realpath(tempDir);
+ }
const pendingGenerations = new Map();
let cachedIndexHtml = null;
@@ -28,7 +136,7 @@ export async function startServer(cwd = process.cwd(), root, configFile) {
const vite = await createServer({
root,
- configFile,
+ ...(configFile && { configFile }),
server: {
middlewareMode: true,
watch: {
@@ -36,128 +144,174 @@ export async function startServer(cwd = process.cwd(), root, configFile) {
},
},
appType: "custom",
- publicDir: resolve(__dirname, "./public"),
+ plugins: [
+ {
+ name: "fast-test-harness:resolve-css-links",
+ transformIndexHtml: {
+ order: "pre",
+ async handler(html) {
+ const $ = load(html, {
+ xmlMode: false,
+ decodeEntities: false,
+ });
+ let changed = false;
+
+ for (const el of $("link[href$='.css']").toArray()) {
+ const href = $(el).attr("href");
+ if (
+ !href ||
+ href.startsWith("/") ||
+ href.startsWith(".") ||
+ href.startsWith("http")
+ ) {
+ continue;
+ }
+ const resolved = await vite.pluginContainer.resolveId(
+ href,
+ root,
+ );
+ if (resolved?.id) {
+ $(el).attr("href", `/@fs/${resolved.id}`);
+ changed = true;
+ }
+ }
+
+ return changed ? $.html() : html;
+ },
+ },
+ },
+ ],
});
- app.use(vite.middlewares);
+ const server = createHttpServer(async (req, res) => {
+ const url = new URL(req.url, "http://localhost");
+ const pathname = url.pathname;
- app.use(express.static(cwd));
+ // POST /generate-fixture — SSR fixture generation.
+ if (req.method === "POST" && pathname === "/generate-fixture") {
+ try {
+ const body = JSON.parse(await readBody(req));
- app.post("/generate-fixture", express.json(), async (req, res) => {
- try {
- if (!req.body.testId) {
- throw new Error("testId is required");
- }
+ if (!body.testId) {
+ throw new Error("testId is required");
+ }
- if (!/^[a-z0-9_-]+$/i.test(req.body.testId)) {
- throw new Error("testId contains invalid characters");
- }
+ if (!/^[a-z0-9_-]+$/i.test(body.testId)) {
+ throw new Error("testId contains invalid characters");
+ }
- if (req.body.attributes) {
- req.body.attributes = JSON.parse(req.body.attributes);
- }
+ if (body.attributes) {
+ body.attributes = JSON.parse(body.attributes);
+ }
- if (req.body.styles) {
- req.body.styles = JSON.parse(req.body.styles);
- }
+ if (body.styles) {
+ body.styles = JSON.parse(body.styles);
+ }
- const testId = req.body.testId;
- const filename = `ssr-${testId}.html`;
- const filePath = resolve(realTempDir, filename);
+ const testId = body.testId;
+ const filename = `ssr-${testId}.html`;
- const url = `/${filename}`;
+ const fixtureUrl = `/${filename}`;
- if (pendingGenerations.has(filename)) {
- await pendingGenerations.get(filename);
- return res.status(200).json({ url });
- }
+ if (pendingGenerations.has(filename)) {
+ await pendingGenerations.get(filename);
+ return jsonResponse(res, 200, { url: fixtureUrl });
+ }
- const generateTask = (async () => {
- const templateFile = await fs.readFile(
- resolve(root, "./ssr.html"),
- "utf-8",
- );
- const page = await vite.transformIndexHtml(url, templateFile);
-
- const { render } = await vite.ssrLoadModule("/src/entry-server.js");
-
- const { template, fixture, preloadLinks } = render(req.body);
-
- const styleTags = (req.body.styles || [])
- .map(s => ``)
- .join("\n");
-
- const html = page
- .replace(
- "",
- () => req.body.testTitle || "FAST Test Harness (SSR)",
- )
- .replace("", () => template ?? "")
- .replace("", () => fixture ?? "")
- .replace(
- "",
- () => `${preloadLinks ?? ""}${styleTags}`,
+ const generateTask = (async () => {
+ const templateFile = await fs.readFile(
+ resolve(root, "./ssr.html"),
+ "utf-8",
);
- fixtureCache.set(url, html);
-
- // Write to disk for debugging; served from cache above.
- await fs.writeFile(filePath, html, "utf-8");
- })();
-
- pendingGenerations.set(filename, generateTask);
-
- try {
- await generateTask;
- res.status(200).json({ url });
- } finally {
- pendingGenerations.delete(filename);
+ const { render } = await vite.ssrLoadModule("/src/entry-server.js");
+
+ const { template, fixture, preloadLinks } = render(body);
+
+ const styleTags = (body.styles || [])
+ .map(s => ``)
+ .join("\n");
+
+ const assembled = templateFile
+ .replace(
+ "",
+ () => body.testTitle || "FAST Test Harness (SSR)",
+ )
+ .replace("", () => template ?? "")
+ .replace("", () => fixture ?? "")
+ .replace(
+ "",
+ () => `${preloadLinks ?? ""}${styleTags}`,
+ );
+
+ const html = await vite.transformIndexHtml(fixtureUrl, assembled);
+
+ fixtureCache.set(fixtureUrl, html);
+
+ if (debug) {
+ const filePath = resolve(realTempDir, filename);
+ await fs.writeFile(filePath, html, "utf-8");
+ }
+ })();
+
+ pendingGenerations.set(filename, generateTask);
+
+ try {
+ await generateTask;
+ jsonResponse(res, 200, { url: fixtureUrl });
+ } finally {
+ pendingGenerations.delete(filename);
+ }
+ } catch (e) {
+ vite?.ssrFixStacktrace?.(e);
+ console.log(e.stack);
+ res.writeHead(500).end("Internal Server Error");
}
- } catch (e) {
- vite?.ssrFixStacktrace?.(e);
- console.log(e.stack);
- res.status(500).end("Internal Server Error");
+ return;
}
- });
- // Serve SSR fixtures from cache without hitting the filesystem.
- app.get("/ssr-:id.html", (req, res, next) => {
- const url = req.path;
- const cached = fixtureCache.get(url);
- if (cached) {
- return res.status(200).set({ "Content-Type": "text/html" }).send(cached);
+ // GET /ssr-*.html — serve cached SSR fixtures.
+ if (req.method === "GET" && /^\/ssr-[^/]+\.html$/.test(pathname)) {
+ const cached = fixtureCache.get(pathname);
+ if (cached) {
+ return htmlResponse(res, 200, cached);
+ }
}
- next();
- });
- // This server is a Playwright test harness, not a production service.
- // It only serves localhost during test runs (local and CI). Rate limiting
- // is unnecessary.
- app.use("*all", async (req, res, next) => {
- // Only serve the HTML shell for navigation requests, not for
- // module/asset requests that Vite's middleware didn't handle.
- const accept = req.headers.accept || "";
- if (!accept.includes("text/html")) {
- return next();
+ // Try static files from cwd.
+ if (req.method === "GET" && (await tryServeStatic(req, res, cwd))) {
+ return;
}
- try {
- const url = req.originalUrl.replace(base, "");
-
- if (!cachedIndexHtml) {
- const indexFile = await fs.readFile(indexPath, "utf-8");
- cachedIndexHtml = await vite.transformIndexHtml(url, indexFile);
+ // Delegate to Vite's middleware (module transforms, HMR, etc.).
+ // Vite handles its own routes; for anything left over, serve
+ // the HTML shell for navigation requests.
+ vite.middlewares(req, res, async () => {
+ const accept = req.headers.accept || "";
+ if (!accept.includes("text/html")) {
+ res.writeHead(404).end();
+ return;
}
- res.status(200).set({ "Content-Type": "text/html" }).send(cachedIndexHtml);
- } catch (e) {
- vite?.ssrFixStacktrace?.(e);
- console.log(e.stack);
- res.status(500).end("Internal Server Error");
- }
+ try {
+ if (!cachedIndexHtml) {
+ const indexFile = await fs.readFile(indexPath, "utf-8");
+ cachedIndexHtml = await vite.transformIndexHtml(
+ req.url || "/",
+ indexFile,
+ );
+ }
+
+ htmlResponse(res, 200, cachedIndexHtml);
+ } catch (e) {
+ vite?.ssrFixStacktrace?.(e);
+ console.log(e.stack);
+ res.writeHead(500).end("Internal Server Error");
+ }
+ });
});
- app.listen(PORT, () => {
- console.log(`Server started at http://localhost:${PORT}`);
+ server.listen(port, () => {
+ console.log(`Server started at http://localhost:${port}`);
});
}
diff --git a/packages/fast-test-harness/src/build/dom-shim.test.ts b/packages/fast-test-harness/src/build/dom-shim.test.ts
new file mode 100644
index 00000000000..a40baf593c5
--- /dev/null
+++ b/packages/fast-test-harness/src/build/dom-shim.test.ts
@@ -0,0 +1,263 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+// Each test needs a clean globalThis, so we tear down the shim between tests.
+function teardownShim() {
+ for (const key of [
+ "Node",
+ "Element",
+ "HTMLElement",
+ "Document",
+ "CustomEvent",
+ "CSSStyleSheet",
+ "ShadowRoot",
+ "CustomElementRegistry",
+ "MutationObserver",
+ "MediaQueryList",
+ "matchMedia",
+ "document",
+ "customElements",
+ "window",
+ "CSS",
+ ]) {
+ delete (globalThis as any)[key];
+ }
+}
+
+test.describe("installDomShim", () => {
+ test.beforeEach(() => {
+ teardownShim();
+ });
+
+ async function loadShim() {
+ // Dynamic import so each test gets a fresh evaluation context
+ // after globalThis is cleaned.
+ const { installDomShim } = await import(
+ "@microsoft/fast-test-harness/build/dom-shim.js"
+ );
+ installDomShim();
+ }
+
+ test("should assign globals on first call", async () => {
+ await loadShim();
+
+ assert.ok((globalThis as any).window !== undefined);
+ assert.ok((globalThis as any).document !== undefined);
+ assert.ok((globalThis as any).customElements !== undefined);
+ assert.ok((globalThis as any).Node !== undefined);
+ assert.ok((globalThis as any).Element !== undefined);
+ assert.ok((globalThis as any).HTMLElement !== undefined);
+ assert.ok((globalThis as any).CSSStyleSheet !== undefined);
+ assert.ok((globalThis as any).MutationObserver !== undefined);
+ assert.ok((globalThis as any).matchMedia !== undefined);
+ });
+
+ test("should be idempotent when window is already defined", async () => {
+ const sentinel = { __sentinel: true };
+ (globalThis as any).window = sentinel;
+
+ await loadShim();
+
+ assert.strictEqual((globalThis as any).window, sentinel);
+ assert.strictEqual((globalThis as any).document, undefined);
+ });
+
+ test("should set window to globalThis", async () => {
+ await loadShim();
+
+ assert.strictEqual((globalThis as any).window, globalThis);
+ });
+
+ test("should provide CSS.supports that returns true", async () => {
+ await loadShim();
+
+ assert.strictEqual((globalThis as any).CSS.supports("display", "flex"), true);
+ });
+
+ test("should not overwrite an existing CSS global", async () => {
+ const existing = { supports: () => false };
+ (globalThis as any).CSS = existing;
+
+ await loadShim();
+
+ assert.strictEqual((globalThis as any).CSS, existing);
+ });
+});
+
+test.describe("ShimHTMLElement", () => {
+ test.beforeEach(async () => {
+ teardownShim();
+ const { installDomShim } = await import(
+ "@microsoft/fast-test-harness/build/dom-shim.js"
+ );
+ installDomShim();
+ });
+
+ test("should support setAttribute / getAttribute / hasAttribute", () => {
+ const el = new (globalThis as any).HTMLElement();
+
+ assert.strictEqual(el.hasAttribute("id"), false);
+ assert.strictEqual(el.getAttribute("id"), null);
+
+ el.setAttribute("id", "test");
+ assert.strictEqual(el.hasAttribute("id"), true);
+ assert.strictEqual(el.getAttribute("id"), "test");
+ });
+
+ test("should support removeAttribute", () => {
+ const el = new (globalThis as any).HTMLElement();
+ el.setAttribute("class", "foo");
+ assert.strictEqual(el.hasAttribute("class"), true);
+
+ el.removeAttribute("class");
+ assert.strictEqual(el.hasAttribute("class"), false);
+ assert.strictEqual(el.getAttribute("class"), null);
+ });
+
+ test("should return attributes as an array of {name, value}", () => {
+ const el = new (globalThis as any).HTMLElement();
+ el.setAttribute("role", "button");
+ el.setAttribute("aria-label", "Close");
+
+ const attrs = el.attributes;
+ assert.strictEqual(attrs.length, 2);
+ assert.deepStrictEqual(attrs.map((a: any) => a.name).sort(), [
+ "aria-label",
+ "role",
+ ]);
+ });
+
+ test("should support attachShadow with open mode", () => {
+ const el = new (globalThis as any).HTMLElement();
+ const sr = el.attachShadow({ mode: "open" });
+
+ assert.ok(sr);
+ assert.strictEqual(sr.host, el);
+ assert.strictEqual(el.shadowRoot, sr);
+ });
+
+ test("should not expose shadowRoot for closed mode", () => {
+ const el = new (globalThis as any).HTMLElement();
+ el.attachShadow({ mode: "closed" });
+
+ assert.strictEqual(el.shadowRoot, null);
+ });
+
+ test("should provide a classList stub", () => {
+ const el = new (globalThis as any).HTMLElement();
+ const cl = el.classList;
+
+ assert.strictEqual(cl.contains("foo"), false);
+ // Should not throw
+ cl.add("foo");
+ cl.remove("foo");
+ cl.toggle("foo");
+ });
+});
+
+test.describe("ShimCSSStyleSheet", () => {
+ test.beforeEach(async () => {
+ teardownShim();
+ const { installDomShim } = await import(
+ "@microsoft/fast-test-harness/build/dom-shim.js"
+ );
+ installDomShim();
+ });
+
+ test("should support insertRule", () => {
+ const sheet = new (globalThis as any).CSSStyleSheet();
+
+ const idx = sheet.insertRule(".foo { color: red }", 0);
+ assert.strictEqual(idx, 0);
+ assert.strictEqual(sheet.cssRules.length, 1);
+ assert.strictEqual(sheet.cssRules[0].selectorText, ".foo ");
+ });
+});
+
+test.describe("ShimCustomElementRegistry", () => {
+ test.beforeEach(async () => {
+ teardownShim();
+ const { installDomShim } = await import(
+ "@microsoft/fast-test-harness/build/dom-shim.js"
+ );
+ installDomShim();
+ });
+
+ test("should support define and get", () => {
+ class MyEl {}
+ (globalThis as any).customElements.define("my-el", MyEl);
+
+ assert.strictEqual((globalThis as any).customElements.get("my-el"), MyEl);
+ });
+
+ test("should return undefined for unknown elements", () => {
+ assert.strictEqual(
+ (globalThis as any).customElements.get("unknown-el"),
+ undefined,
+ );
+ });
+
+ test("should resolve whenDefined immediately", async () => {
+ const result = await (globalThis as any).customElements.whenDefined("any-el");
+ assert.strictEqual(result, undefined);
+ });
+});
+
+test.describe("ShimDocument", () => {
+ test.beforeEach(async () => {
+ teardownShim();
+ const { installDomShim } = await import(
+ "@microsoft/fast-test-harness/build/dom-shim.js"
+ );
+ installDomShim();
+ });
+
+ test("should create elements via createElement", () => {
+ const el = (globalThis as any).document.createElement("div");
+ assert.ok(el);
+ assert.strictEqual(typeof el.setAttribute, "function");
+ });
+
+ test("should support adoptedStyleSheets", () => {
+ assert.ok(Array.isArray((globalThis as any).document.adoptedStyleSheets));
+ });
+});
+
+test.describe("ShimCustomEvent", () => {
+ test.beforeEach(async () => {
+ teardownShim();
+ const { installDomShim } = await import(
+ "@microsoft/fast-test-harness/build/dom-shim.js"
+ );
+ installDomShim();
+ });
+
+ test("should carry detail", () => {
+ const evt = new (globalThis as any).CustomEvent("test", { detail: 42 });
+ assert.strictEqual(evt.detail, 42);
+ assert.strictEqual(evt.type, "test");
+ });
+
+ test("should default detail to null", () => {
+ const evt = new (globalThis as any).CustomEvent("test");
+ assert.strictEqual(evt.detail, null);
+ });
+});
+
+test.describe("matchMedia", () => {
+ test.beforeEach(async () => {
+ teardownShim();
+ const { installDomShim } = await import(
+ "@microsoft/fast-test-harness/build/dom-shim.js"
+ );
+ installDomShim();
+ });
+
+ test("should return a MediaQueryList with matches = false", () => {
+ const mql = (globalThis as any).matchMedia("(prefers-color-scheme: dark)");
+ assert.strictEqual(mql.matches, false);
+ // Should not throw
+ mql.addEventListener("change", () => {});
+ mql.removeEventListener("change", () => {});
+ });
+});
diff --git a/packages/fast-test-harness/src/build/dom-shim.ts b/packages/fast-test-harness/src/build/dom-shim.ts
new file mode 100644
index 00000000000..7dae60a73e0
--- /dev/null
+++ b/packages/fast-test-harness/src/build/dom-shim.ts
@@ -0,0 +1,157 @@
+/**
+ * Minimal DOM shim for running FAST Element's `css` and `html` tagged
+ * templates in Node.js. Provides just enough of the DOM API to resolve
+ * `ElementStyles.toString()` and compile `html` templates.
+ *
+ * This module is idempotent — if `globalThis.window` is already defined,
+ * no shims are applied.
+ */
+
+class ShimNode extends EventTarget {}
+class ShimElement extends ShimNode {}
+
+class ShimHTMLElement extends ShimElement {
+ static elementAttributes = new WeakMap>();
+ _shadowRoot: object | null = null;
+
+ get attributes(): Array<{ name: string; value: string }> {
+ return Array.from(ShimHTMLElement.elementAttributes.get(this) ?? []).map(
+ ([name, value]) => ({ name, value }),
+ );
+ }
+
+ get shadowRoot() {
+ return this._shadowRoot;
+ }
+
+ setAttribute(name: string, value: string) {
+ let attrs = ShimHTMLElement.elementAttributes.get(this);
+ if (!attrs) {
+ attrs = new Map();
+ ShimHTMLElement.elementAttributes.set(this, attrs);
+ }
+ attrs.set(name, value);
+ }
+
+ removeAttribute(name: string) {
+ ShimHTMLElement.elementAttributes.get(this)?.delete(name);
+ }
+
+ hasAttribute(name: string) {
+ return ShimHTMLElement.elementAttributes.get(this)?.has(name) ?? false;
+ }
+
+ getAttribute(name: string) {
+ const v = ShimHTMLElement.elementAttributes.get(this)?.get(name);
+ return v === undefined ? null : v;
+ }
+
+ attachShadow(init?: { mode?: string }) {
+ const sr = { host: this };
+ if (init?.mode === "open") {
+ this._shadowRoot = sr;
+ }
+ return sr;
+ }
+
+ get classList() {
+ return {
+ add() {},
+ remove() {},
+ contains() {
+ return false;
+ },
+ toggle() {},
+ };
+ }
+
+ get part() {
+ return this.classList;
+ }
+}
+
+class ShimCSSStyleSheet {
+ cssRules: Array<{ selectorText: string }> = [];
+
+ replace() {}
+
+ insertRule(rule: string, index = 0) {
+ this.cssRules.splice(index, 0, { selectorText: rule.split("{")[0] });
+ return index;
+ }
+}
+
+class ShimCustomElementRegistry {
+ __definitions = new Map();
+
+ define(name: string, ctor: any) {
+ this.__definitions.set(name, {
+ ctor,
+ observedAttributes: ctor.observedAttributes ?? [],
+ });
+ }
+
+ get(name: string) {
+ return this.__definitions.get(name)?.ctor;
+ }
+
+ whenDefined() {
+ return Promise.resolve();
+ }
+}
+
+class ShimDocument extends ShimNode {
+ adoptedStyleSheets: any[] = [];
+
+ createTreeWalker() {
+ return {};
+ }
+
+ createTextNode() {
+ return {};
+ }
+
+ createElement() {
+ return new ShimHTMLElement();
+ }
+}
+
+class ShimMutationObserver {
+ observe() {}
+ disconnect() {}
+}
+
+class ShimMediaQueryList {
+ matches = false;
+ addEventListener() {}
+ removeEventListener() {}
+}
+
+export function installDomShim(): void {
+ if ((globalThis as any).window !== undefined) {
+ return;
+ }
+
+ (globalThis as any).Node = ShimNode;
+ (globalThis as any).Element = ShimElement;
+ (globalThis as any).HTMLElement = ShimHTMLElement;
+ (globalThis as any).Document = ShimDocument;
+ (globalThis as any).CustomEvent = class extends Event {
+ detail: any;
+ constructor(type: string, init?: CustomEventInit) {
+ super(type, init);
+ this.detail = init?.detail ?? null;
+ }
+ };
+ (globalThis as any).CSSStyleSheet = ShimCSSStyleSheet;
+ (globalThis as any).ShadowRoot = class {};
+ (globalThis as any).CustomElementRegistry = ShimCustomElementRegistry;
+ (globalThis as any).MutationObserver = ShimMutationObserver;
+ (globalThis as any).MediaQueryList = ShimMediaQueryList;
+ (globalThis as any).matchMedia = () => new ShimMediaQueryList();
+ (globalThis as any).document = new ShimDocument();
+ (globalThis as any).customElements = new ShimCustomElementRegistry();
+ (globalThis as any).window = globalThis;
+
+ (globalThis as any).CSS ??= { supports: () => true };
+}
diff --git a/packages/fast-test-harness/src/build/generate-stylesheets.test.ts b/packages/fast-test-harness/src/build/generate-stylesheets.test.ts
new file mode 100644
index 00000000000..ad0b52c9bdd
--- /dev/null
+++ b/packages/fast-test-harness/src/build/generate-stylesheets.test.ts
@@ -0,0 +1,111 @@
+import assert from "node:assert/strict";
+import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { test } from "node:test";
+import { generateStylesheets } from "@microsoft/fast-test-harness/build/generate-stylesheets.js";
+
+test.describe("generateStylesheets", () => {
+ let tempDir: string;
+
+ test.beforeEach(async () => {
+ tempDir = await mkdtemp(join(tmpdir(), "fast-styles-"));
+ });
+
+ test.afterEach(async () => {
+ await rm(tempDir, { recursive: true, force: true });
+ });
+
+ test("should extract CSS from a styles module", async () => {
+ const distDir = join(tempDir, "dist");
+ await mkdir(distDir, { recursive: true });
+
+ // Write a fake styles module that exports an ElementStyles-like object.
+ await writeFile(
+ join(distDir, "button.styles.js"),
+ `export const styles = { styles: [":host { display: block; }", "span { color: red; }"] };`,
+ );
+
+ await generateStylesheets({ cwd: tempDir });
+
+ const css = await readFile(join(distDir, "button.styles.css"), "utf8");
+ assert.ok(css.includes(":host { display: block; }"));
+ assert.ok(css.includes("span { color: red; }"));
+ });
+
+ test("should write to outDir when specified", async () => {
+ const distDir = join(tempDir, "dist");
+ const outDir = join(tempDir, "out");
+ await mkdir(distDir, { recursive: true });
+
+ await writeFile(
+ join(distDir, "card.styles.js"),
+ `export const styles = { styles: [".card { padding: 8px; }"] };`,
+ );
+
+ await generateStylesheets({ cwd: tempDir, outDir: "out" });
+
+ const css = await readFile(join(outDir, "card.styles.css"), "utf8");
+ assert.ok(css.includes(".card { padding: 8px; }"));
+ });
+
+ test("should apply a format function", async () => {
+ const distDir = join(tempDir, "dist");
+ await mkdir(distDir, { recursive: true });
+
+ await writeFile(
+ join(distDir, "link.styles.js"),
+ `export const styles = { styles: ["a { color: blue; }"] };`,
+ );
+
+ await generateStylesheets({
+ cwd: tempDir,
+ format: css => `/* formatted */\n${css}`,
+ });
+
+ const css = await readFile(join(distDir, "link.styles.css"), "utf8");
+ assert.ok(css.startsWith("/* formatted */"));
+ });
+
+ test("should flatten nested styles arrays", async () => {
+ const distDir = join(tempDir, "dist");
+ await mkdir(distDir, { recursive: true });
+
+ await writeFile(
+ join(distDir, "nested.styles.js"),
+ `export const styles = {
+ styles: [
+ { styles: [":host { display: flex; }", "div { margin: 0; }"] },
+ "span { font-size: 14px; }"
+ ]
+ };`,
+ );
+
+ await generateStylesheets({ cwd: tempDir });
+
+ const css = await readFile(join(distDir, "nested.styles.css"), "utf8");
+ assert.ok(css.includes(":host { display: flex; }"));
+ assert.ok(css.includes("div { margin: 0; }"));
+ assert.ok(css.includes("span { font-size: 14px; }"));
+ });
+
+ test("should skip modules without a styles export", async () => {
+ const distDir = join(tempDir, "dist");
+ await mkdir(distDir, { recursive: true });
+
+ await writeFile(
+ join(distDir, "empty.styles.js"),
+ `export const template = "";`,
+ );
+
+ await generateStylesheets({ cwd: tempDir });
+
+ // Should not create a CSS file
+ try {
+ await readFile(join(distDir, "empty.styles.css"), "utf8");
+ assert.fail("Should not have created a CSS file");
+ } catch (err: any) {
+ assert.strictEqual(err.code, "ENOENT");
+ }
+ });
+});
diff --git a/packages/fast-test-harness/src/build/generate-stylesheets.ts b/packages/fast-test-harness/src/build/generate-stylesheets.ts
new file mode 100644
index 00000000000..e7f65c08142
--- /dev/null
+++ b/packages/fast-test-harness/src/build/generate-stylesheets.ts
@@ -0,0 +1,145 @@
+/**
+ * Style extraction — converts compiled FAST ElementStyles JS modules
+ * into plain CSS files.
+ *
+ * Usage as a module:
+ * ```ts
+ * import { generateStylesheets } from "@microsoft/fast-test-harness/build/generate-stylesheets.js";
+ *
+ * await generateStylesheets({ cwd: process.cwd() });
+ * ```
+ *
+ * Usage as a Lage worker:
+ * ```ts
+ * import { generateStylesheets } from "@microsoft/fast-test-harness/build/generate-stylesheets.js";
+ *
+ * export default async function init({ target }) {
+ * await generateStylesheets({ cwd: target.cwd });
+ * }
+ * ```
+ */
+
+import { glob, mkdir, writeFile } from "node:fs/promises";
+import path from "node:path";
+import { pathToFileURL } from "node:url";
+import { styleText } from "node:util";
+import { installDomShim } from "./dom-shim.js";
+
+export interface GenerateStylesheetsOptions {
+ /**
+ * Root directory of the package. Defaults to `process.cwd()`.
+ */
+ cwd?: string;
+
+ /**
+ * Directory containing compiled JS style modules, relative to `cwd`.
+ * @default "dist"
+ */
+ distDir?: string;
+
+ /**
+ * Glob pattern for style modules, relative to `distDir`.
+ * @default `**/*.styles.js`
+ */
+ pattern?: string;
+
+ /**
+ * Output directory for generated CSS files, relative to `cwd`.
+ * When set, output files are written here instead of next to the
+ * source JS modules.
+ * @default distDir
+ */
+ outDir?: string;
+
+ /**
+ * Optional formatter function applied to extracted CSS before writing.
+ * Receives the CSS string and output file path, returns formatted CSS.
+ *
+ * Example with Prettier:
+ * ```ts
+ * import prettier from "prettier";
+ *
+ * await generateStylesheets({
+ * format: async (css, filePath) => {
+ * const options = await prettier.resolveConfig(filePath);
+ * return prettier.format(css, { ...options, filepath: filePath });
+ * },
+ * });
+ * ```
+ */
+ format?: (css: string, filePath: string) => string | Promise;
+}
+
+interface StyleSheet {
+ styles: Array;
+ toString?: () => string;
+}
+
+function flattenStyles(style: string | StyleSheet): string[] {
+ if (typeof style === "string") {
+ return [style];
+ }
+ if (Array.isArray(style.styles)) {
+ return style.styles.flatMap(flattenStyles);
+ }
+ return [style.toString?.() ?? ""];
+}
+
+export async function generateStylesheets(
+ options: GenerateStylesheetsOptions = {},
+): Promise {
+ installDomShim();
+
+ const cwd = options.cwd ?? process.cwd();
+ const distDir = path.resolve(cwd, options.distDir ?? "dist");
+ const outDir = options.outDir ? path.resolve(cwd, options.outDir) : null;
+ const pattern = options.pattern ?? "**/*.styles.js";
+
+ for await (const jsFile of glob(pattern, { cwd: distDir })) {
+ const jsFilePath = path.resolve(distDir, jsFile);
+ const baseName = path.basename(jsFile, ".js") + ".css";
+ const cssFilePath = outDir
+ ? path.resolve(outDir, baseName)
+ : path.resolve(path.dirname(jsFilePath), baseName);
+
+ try {
+ const mod = await import(pathToFileURL(jsFilePath).href);
+ const stylesheet: StyleSheet | undefined = mod.styles ?? mod.default;
+
+ if (!stylesheet?.styles) {
+ continue;
+ }
+
+ let css = stylesheet.styles.flatMap(flattenStyles).join("\n");
+
+ if (options.format) {
+ try {
+ css = await options.format(css, cssFilePath);
+ } catch (formatError: any) {
+ console.warn(
+ styleText(["yellow", "bold"], "⚠"),
+ `Format failed for ${path.relative(cwd, cssFilePath)}:`,
+ formatError.message,
+ );
+ }
+ }
+
+ await mkdir(path.dirname(cssFilePath), { recursive: true });
+ await writeFile(cssFilePath, css, "utf8");
+
+ console.log(
+ styleText(["green", "bold"], "✔"),
+ "Style:",
+ styleText("dim", path.relative(cwd, jsFilePath)),
+ "→",
+ styleText("bold", path.relative(cwd, cssFilePath)),
+ );
+ } catch (error: any) {
+ console.error(
+ styleText(["red", "bold"], "✘"),
+ `Failed: ${path.relative(cwd, jsFilePath)}`,
+ error.message,
+ );
+ }
+ }
+}
diff --git a/packages/fast-test-harness/src/build/generate-templates.test.ts b/packages/fast-test-harness/src/build/generate-templates.test.ts
new file mode 100644
index 00000000000..21d590f612d
--- /dev/null
+++ b/packages/fast-test-harness/src/build/generate-templates.test.ts
@@ -0,0 +1,332 @@
+import assert from "node:assert/strict";
+import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { test } from "node:test";
+import { installDomShim } from "@microsoft/fast-test-harness/build/dom-shim.js";
+import {
+ convertTemplate,
+ generateFTemplates,
+} from "@microsoft/fast-test-harness/build/generate-templates.js";
+import { generateWebuiTemplates } from "@microsoft/fast-test-harness/build/generate-webui-templates.js";
+
+test.describe("convertTemplate", async () => {
+ // Install the DOM shim before any tests — convertTemplate needs fast-html
+ // syntax constants which require a DOM environment, and FAST Element needs
+ // basic DOM globals to initialize.
+ installDomShim();
+
+ // Dynamic import after the DOM shim is installed so FAST Element can
+ // access `document`, `CSSStyleSheet`, etc.
+ const { html, ref, slotted, children } = await import("@microsoft/fast-element");
+ test("should wrap a static template in f-template tags", () => {
+ const template = html`hello
`;
+ const result = convertTemplate(template, "fast-test");
+
+ assert.ok(result);
+ assert.ok(result.includes('hello"));
+ assert.ok(result.includes("{{styles}}"));
+ });
+
+ test("should return null-safe for empty factories", () => {
+ const template = html``;
+ const result = convertTemplate(template, "fast-empty");
+
+ assert.ok(result);
+ assert.ok(result.includes(""));
+ });
+
+ test("should inject {{styles}} after the opening template tag", () => {
+ const template = html`content
`;
+ const result = convertTemplate(template, "fast-styles");
+
+ assert.ok(result);
+ const templateIdx = result.indexOf("");
+ const stylesIdx = result.indexOf("{{styles}}");
+ assert.ok(stylesIdx > templateIdx, "{{styles}} should appear after ");
+ });
+
+ test("should convert RefDirective factories to f-ref attributes", () => {
+ const template = html``;
+ const result = convertTemplate(template, "fast-ref");
+
+ assert.ok(result);
+ assert.ok(result.includes('f-ref="{myRef}"'), `got: ${result}`);
+ });
+
+ test("should convert SlottedDirective factories to f-slotted attributes", () => {
+ const template = html``;
+ const result = convertTemplate(template, "fast-slotted");
+
+ assert.ok(result);
+ assert.ok(result.includes("f-slotted="), `got: ${result}`);
+ assert.ok(result.includes("slottedItems"), `got: ${result}`);
+ });
+
+ test("should convert value bindings to {{expression}}", () => {
+ const template = html`${x => x.label}`;
+ const result = convertTemplate(template, "fast-binding");
+
+ assert.ok(result);
+ assert.ok(result.includes("{{label}}"), `got: ${result}`);
+ });
+
+ test("should convert boolean bindings to ?attr expressions", () => {
+ const template = html``;
+ const result = convertTemplate(template, "fast-bool");
+
+ assert.ok(result);
+ assert.ok(result.includes('?disabled="{{disabled}}"'), `got: ${result}`);
+ });
+
+ test("should inline static sub-templates", () => {
+ const template = html`${() => ""}
`;
+ const result = convertTemplate(template, "fast-inline");
+
+ assert.ok(result);
+ assert.ok(result.includes(""), `got: ${result}`);
+ });
+
+ test("should convert ChildrenDirective factories to f-children attributes", () => {
+ const template = html``;
+ const result = convertTemplate(template, "fast-children");
+
+ assert.ok(result);
+ assert.ok(result.includes("f-children="), `got: ${result}`);
+ assert.ok(result.includes("childItems"), `got: ${result}`);
+ });
+
+ test("should convert event bindings to @event expressions", () => {
+ const template = html``;
+ const result = convertTemplate(template, "fast-event");
+
+ assert.ok(result);
+ assert.ok(result.includes("@click="), `got: ${result}`);
+ assert.ok(result.includes("handleClick"), `got: ${result}`);
+ });
+
+ test("should convert property bindings to :prop expressions", () => {
+ const template = html``;
+ const result = convertTemplate(template, "fast-prop");
+
+ assert.ok(result);
+ assert.ok(result.includes(":value="), `got: ${result}`);
+ assert.ok(result.includes("currentValue"), `got: ${result}`);
+ });
+
+ test("should handle multiple factories in a single template", () => {
+ const template = html`${x => x.label}`;
+ const result = convertTemplate(template, "fast-multi");
+
+ assert.ok(result);
+ assert.ok(result.includes("{{label}}"), `got: ${result}`);
+ assert.ok(result.includes('?disabled="{{disabled}}"'), `got: ${result}`);
+ });
+
+ test("should inline a static sub-template ViewTemplate", () => {
+ const icon = html``;
+ const template = html`${() => icon}
`;
+ const result = convertTemplate(template, "fast-sub");
+
+ assert.ok(result);
+ assert.ok(result.includes(""), `got: ${result}`);
+ });
+});
+
+test.describe("generateFTemplates", () => {
+ let tempDir: string;
+
+ test.beforeEach(async () => {
+ tempDir = await mkdtemp(join(tmpdir(), "fast-ftemplates-"));
+ });
+
+ test.afterEach(async () => {
+ await rm(tempDir, { recursive: true, force: true });
+ });
+
+ test("should generate an f-template HTML file from a template module", async () => {
+ const distDir = join(tempDir, "dist");
+ await mkdir(distDir, { recursive: true });
+
+ await writeFile(
+ join(distDir, "badge.template.js"),
+ `export const template = {
+ html: "",
+ factories: {}
+ };`,
+ );
+
+ await generateFTemplates({ cwd: tempDir, tagPrefix: "mai" });
+
+ const html = await readFile(join(distDir, "badge.template.html"), "utf8");
+ assert.ok(html.includes('"));
+ assert.ok(html.includes("{{styles}}"));
+ });
+
+ test("should write to outDir when specified", async () => {
+ const distDir = join(tempDir, "dist");
+ await mkdir(distDir, { recursive: true });
+
+ await writeFile(
+ join(distDir, "card.template.js"),
+ `export const template = {
+ html: "card
",
+ factories: {}
+ };`,
+ );
+
+ await generateFTemplates({ cwd: tempDir, outDir: "out", tagPrefix: "fast" });
+
+ const html = await readFile(join(tempDir, "out", "card.template.html"), "utf8");
+ assert.ok(html.includes(' {
+ const distDir = join(tempDir, "dist");
+ await mkdir(distDir, { recursive: true });
+
+ await writeFile(
+ join(distDir, "empty.template.js"),
+ `export const styles = ":host {}";`,
+ );
+
+ await generateFTemplates({ cwd: tempDir, tagPrefix: "fast" });
+
+ try {
+ await readFile(join(distDir, "empty.template.html"), "utf8");
+ assert.fail("Should not have created an HTML file");
+ } catch (err: any) {
+ assert.strictEqual(err.code, "ENOENT");
+ }
+ });
+
+ test("should apply a format function", async () => {
+ const distDir = join(tempDir, "dist");
+ await mkdir(distDir, { recursive: true });
+
+ await writeFile(
+ join(distDir, "text.template.js"),
+ `export const template = {
+ html: "text",
+ factories: {}
+ };`,
+ );
+
+ await generateFTemplates({
+ cwd: tempDir,
+ tagPrefix: "fast",
+ format: html => `\n${html}`,
+ });
+
+ const html = await readFile(join(distDir, "text.template.html"), "utf8");
+ assert.ok(html.startsWith(""));
+ });
+});
+
+test.describe("generateWebuiTemplates", () => {
+ let tempDir: string;
+
+ test.beforeEach(async () => {
+ tempDir = await mkdtemp(join(tmpdir(), "fast-webui-"));
+ });
+
+ test.afterEach(async () => {
+ await rm(tempDir, { recursive: true, force: true });
+ });
+
+ test("should generate a webui template without f-template wrapper", async () => {
+ const distDir = join(tempDir, "dist");
+ await mkdir(distDir, { recursive: true });
+
+ await writeFile(
+ join(distDir, "badge.template.js"),
+ `export const template = {
+ html: "",
+ factories: {}
+ };`,
+ );
+
+ await generateWebuiTemplates({ cwd: tempDir, tagPrefix: "mai" });
+
+ const html = await readFile(join(distDir, "badge.template-webui.html"), "utf8");
+ assert.ok(html.includes(''));
+ assert.ok(html.includes(""));
+ assert.ok(!html.includes(" {
+ const distDir = join(tempDir, "dist");
+ await mkdir(distDir, { recursive: true });
+
+ await writeFile(
+ join(distDir, "card.template.js"),
+ `export const template = {
+ html: "card
",
+ factories: {}
+ };`,
+ );
+
+ await generateWebuiTemplates({
+ cwd: tempDir,
+ outDir: "out",
+ tagPrefix: "fast",
+ });
+
+ const html = await readFile(
+ join(tempDir, "out", "card.template-webui.html"),
+ "utf8",
+ );
+ assert.ok(html.includes(''));
+ });
+
+ test("should add shadowrootdelegatesfocus from definition-async", async () => {
+ const distDir = join(tempDir, "dist");
+ await mkdir(distDir, { recursive: true });
+
+ await writeFile(
+ join(distDir, "input.template.js"),
+ `export const template = {
+ html: "",
+ factories: {}
+ };`,
+ );
+
+ await writeFile(
+ join(distDir, "input.definition-async.js"),
+ `export const definition = {
+ name: "fast-input",
+ shadowOptions: { delegatesFocus: true },
+ };`,
+ );
+
+ await generateWebuiTemplates({ cwd: tempDir, tagPrefix: "fast" });
+
+ const html = await readFile(join(distDir, "input.template-webui.html"), "utf8");
+ assert.ok(
+ html.includes("shadowrootdelegatesfocus"),
+ `should include delegatesFocus, got: ${html}`,
+ );
+ });
+
+ test("should not add shadowrootdelegatesfocus when absent", async () => {
+ const distDir = join(tempDir, "dist");
+ await mkdir(distDir, { recursive: true });
+
+ await writeFile(
+ join(distDir, "div.template.js"),
+ `export const template = {
+ html: "hello
",
+ factories: {}
+ };`,
+ );
+
+ await generateWebuiTemplates({ cwd: tempDir, tagPrefix: "fast" });
+
+ const html = await readFile(join(distDir, "div.template-webui.html"), "utf8");
+ assert.ok(!html.includes("shadowrootdelegatesfocus"));
+ });
+});
diff --git a/packages/fast-test-harness/src/build/generate-templates.ts b/packages/fast-test-harness/src/build/generate-templates.ts
new file mode 100644
index 00000000000..ed50eb8a8e7
--- /dev/null
+++ b/packages/fast-test-harness/src/build/generate-templates.ts
@@ -0,0 +1,379 @@
+/**
+ * F-template generation — converts compiled FAST Element ViewTemplate
+ * JS modules into declarative `` HTML files.
+ *
+ * This reverse-engineers FAST Element's internal binding marker format
+ * back into human-readable f-template syntax (`f-ref`, `f-slotted`,
+ * `@event`, `?bool`, `:prop`, `{{expr}}`).
+ *
+ * Usage as a module:
+ * ```ts
+ * import { generateFTemplates } from "@microsoft/fast-test-harness/build/generate-templates.js";
+ *
+ * await generateFTemplates({ cwd: process.cwd(), tagPrefix: "fluent" });
+ * ```
+ */
+
+import { glob, mkdir, writeFile } from "node:fs/promises";
+import path from "node:path";
+import { pathToFileURL } from "node:url";
+import { styleText } from "node:util";
+import {
+ attributeDirectivePrefix,
+ clientSideCloseExpression,
+ clientSideOpenExpression,
+ closeExpression,
+ eventArgAccessor,
+ openExpression,
+} from "@microsoft/fast-html/syntax.js";
+import { installDomShim } from "./dom-shim.js";
+
+const stylesMarker = `${openExpression}styles${closeExpression}`;
+
+function wrapClientExpression(expression: string): string {
+ return `${clientSideOpenExpression}${expression}${clientSideCloseExpression}`;
+}
+
+function wrapDefaultExpression(expression: string): string {
+ return `${openExpression}${expression}${closeExpression}`;
+}
+
+function attributeDirective(name: string, value: string): string {
+ return `${attributeDirectivePrefix}${name}="${wrapClientExpression(value)}"`;
+}
+
+export interface GenerateFTemplatesOptions {
+ /**
+ * Root directory of the package. Defaults to `process.cwd()`.
+ */
+ cwd?: string;
+
+ /**
+ * Directory containing compiled JS template modules, relative to `cwd`.
+ * @default "dist"
+ */
+ distDir?: string;
+
+ /**
+ * Glob pattern for template modules, relative to `distDir`.
+ * @default `**/*.template.js`
+ */
+ pattern?: string;
+
+ /**
+ * Output directory for generated HTML files, relative to `cwd`.
+ * When set, output files are written here instead of next to the
+ * source JS modules.
+ * @default distDir
+ */
+ outDir?: string;
+
+ /**
+ * Tag name prefix for generated component names.
+ * Combined with the component directory name: `${tagPrefix}-${componentName}`.
+ * @default "fast"
+ */
+ tagPrefix?: string;
+
+ /**
+ * Optional formatter function applied to generated HTML before writing.
+ */
+ format?: (html: string, filePath: string) => string | Promise;
+}
+
+export interface ViewTemplate {
+ html: string | HTMLTemplateElement;
+ factories: Record;
+}
+
+interface Factory {
+ constructor: { name: string };
+ options?: any;
+ dataBinding?: {
+ evaluate: (...args: any[]) => any;
+ };
+}
+
+/**
+ * Extract a readable binding expression from a factory's evaluate function.
+ */
+function extractBindingExpression(factory: Factory): string {
+ if (!factory?.dataBinding?.evaluate) {
+ return "";
+ }
+
+ const fnStr = factory.dataBinding.evaluate.toString();
+ const arrowMatch = fnStr.match(/=>\s*(.+)$/s);
+ if (arrowMatch) {
+ let expr = arrowMatch[1].trim();
+ expr = expr.replace(/\bx\./g, "").replace(/c\.event\b/g, eventArgAccessor);
+ return expr;
+ }
+
+ return fnStr;
+}
+
+/**
+ * Extract the `filter elements(...)` suffix for a slotted or children
+ * directive.
+ */
+function extractSlottedFilter(factory: Factory): string {
+ const filter = factory.options?.filter;
+ if (!filter) {
+ return "";
+ }
+
+ if (filter.name === "selectElements") {
+ return " filter elements()";
+ }
+
+ let selector: string | null = null;
+ try {
+ filter({
+ nodeType: 1,
+ matches(s: string) {
+ selector = s;
+ return true;
+ },
+ });
+ } catch {
+ // If extraction fails, fall back to no-arg elements().
+ }
+
+ if (selector) {
+ return ` filter elements(${selector})`;
+ }
+
+ return " filter elements()";
+}
+
+/**
+ * Check if a factory is a static sub-template interpolation. If so,
+ * evaluate it and return the inlined HTML.
+ */
+function tryInlineStaticTemplate(factory: Factory): string | null {
+ if (!factory?.dataBinding?.evaluate) {
+ return null;
+ }
+
+ const fnStr = factory.dataBinding.evaluate.toString();
+
+ if (/^\(\)\s*=>/.test(fnStr)) {
+ try {
+ const result = factory.dataBinding.evaluate({}, {});
+ if (result && typeof result === "object" && typeof result.html === "string") {
+ return result.html;
+ }
+ if (typeof result === "string") {
+ return result;
+ }
+ } catch {
+ // Fall through to normal binding handling.
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Convert a ViewTemplate's html string and factories into an
+ * f-template HTML string.
+ */
+export function convertTemplate(
+ viewTemplate: ViewTemplate,
+ componentName: string,
+): string | null {
+ const { factories } = viewTemplate;
+ const html =
+ typeof viewTemplate.html === "string"
+ ? viewTemplate.html
+ : viewTemplate.html.innerHTML;
+
+ const factoryEntries = Object.entries(factories);
+ if (factoryEntries.length === 0) {
+ // No factories — pure static template.
+ const content = html.replace(/<\/?template[^>]*>/g, "").trim();
+ return `${stylesMarker}${content}\n`;
+ }
+
+ // Derive the binding marker prefix from the first factory key.
+ // Factory IDs follow the pattern `${marker}-${counter}` (e.g. "fast-a1b2c3-1"),
+ // where `marker` is generated once per session in fast-element's markup.ts.
+ const prefix = factoryEntries[0][0].replace(/-\d+$/, "");
+
+ const factoryMap = new Map();
+ for (const [id, factory] of factoryEntries) {
+ factoryMap.set(id, factory);
+ }
+
+ let fContent = html;
+
+ // Attribute-position markers → f-ref / f-slotted / f-children
+ const attrBindingRe = new RegExp(
+ `${prefix}-\\d+="${prefix}\\{(${prefix}-\\d+)\\}${prefix}"`,
+ "g",
+ );
+ fContent = fContent.replace(attrBindingRe, (match: string, factoryId: string) => {
+ const factory = factoryMap.get(factoryId);
+ if (!factory) {
+ return match;
+ }
+ if (factory.constructor.name === "RefDirective") {
+ const prop =
+ typeof factory.options === "string"
+ ? factory.options
+ : factory.options?.property;
+ return attributeDirective("ref", prop);
+ }
+ if (factory.constructor.name === "SlottedDirective") {
+ const prop =
+ typeof factory.options === "string"
+ ? factory.options
+ : factory.options?.property;
+ const filterStr = extractSlottedFilter(factory);
+ return attributeDirective("slotted", `${prop}${filterStr}`);
+ }
+ if (factory.constructor.name === "ChildrenDirective") {
+ const prop =
+ typeof factory.options === "string"
+ ? factory.options
+ : factory.options?.property;
+ const filterStr = extractSlottedFilter(factory);
+ return attributeDirective("children", `${prop}${filterStr}`);
+ }
+ return match;
+ });
+
+ // Event bindings → @event="{handler(e)}"
+ const eventBindingRe = new RegExp(
+ `(@[a-z]+)="${prefix}\\{(${prefix}-\\d+)\\}${prefix}"`,
+ "g",
+ );
+ fContent = fContent.replace(
+ eventBindingRe,
+ (match: string, aspect: string, factoryId: string) => {
+ const factory = factoryMap.get(factoryId);
+ if (!factory) {
+ return match;
+ }
+ const evalStr = extractBindingExpression(factory);
+ return `${aspect}="${wrapClientExpression(evalStr)}"`;
+ },
+ );
+
+ // Boolean/property bindings → ?attr="{{expr}}"
+ const boolAttrBindingRe = new RegExp(
+ `([?:][a-zA-Z-]+)="${prefix}\\{(${prefix}-\\d+)\\}${prefix}"`,
+ "g",
+ );
+ fContent = fContent.replace(
+ boolAttrBindingRe,
+ (match: string, aspect: string, factoryId: string) => {
+ const factory = factoryMap.get(factoryId);
+ if (!factory) {
+ return match;
+ }
+ const evalStr = extractBindingExpression(factory);
+ return `${aspect}="${wrapDefaultExpression(evalStr)}"`;
+ },
+ );
+
+ // Attribute-value and content bindings → attr="{{propName}}" or inline HTML
+ const valBindingRe = new RegExp(`${prefix}\\{(${prefix}-\\d+)\\}${prefix}`, "g");
+ fContent = fContent.replace(valBindingRe, (match: string, factoryId: string) => {
+ const factory = factoryMap.get(factoryId);
+ if (!factory) {
+ return match;
+ }
+
+ const inlined = tryInlineStaticTemplate(factory);
+ if (inlined !== null) {
+ return inlined;
+ }
+
+ const evalStr = extractBindingExpression(factory);
+ return wrapDefaultExpression(evalStr);
+ });
+
+ let fInner = fContent.trim();
+ if (!/]*>/.test(fInner)) {
+ fInner = `${fInner}`;
+ }
+ // Inject the {{styles}} marker immediately after the opening tag
+ // so the test harness can substitute it with a at
+ // render time. Harness fallback auto-injects if the marker is missing, but
+ // emitting it explicitly keeps generated output consistent with hand-authored
+ // f-templates (see MAI core components).
+ fInner = fInner.replace(/(]*>)/, `$1${stylesMarker}`);
+ return `\n${fInner}\n\n`;
+}
+
+export async function generateFTemplates(
+ options: GenerateFTemplatesOptions = {},
+): Promise {
+ installDomShim();
+
+ const cwd = options.cwd ?? process.cwd();
+ const distDir = path.resolve(cwd, options.distDir ?? "dist");
+ const outDir = options.outDir ? path.resolve(cwd, options.outDir) : null;
+ const pattern = options.pattern ?? "**/*.template.js";
+ const tagPrefix = options.tagPrefix ?? "fast";
+
+ for await (const jsFile of glob(pattern, { cwd: distDir })) {
+ const jsFilePath = path.resolve(distDir, jsFile);
+ const componentBaseName = path.basename(jsFile, ".template.js");
+ const componentName = `${tagPrefix}-${componentBaseName}`;
+
+ try {
+ const mod = await import(pathToFileURL(jsFilePath).href);
+ const template: ViewTemplate | undefined = mod.template ?? mod.default;
+
+ if (!template?.html) {
+ continue;
+ }
+
+ const fTemplateHtml = convertTemplate(template, componentName);
+ if (!fTemplateHtml) {
+ continue;
+ }
+
+ let html = fTemplateHtml;
+
+ if (options.format) {
+ try {
+ html = await options.format(html, jsFilePath);
+ } catch (formatError: any) {
+ console.warn(
+ styleText(["yellow", "bold"], "⚠"),
+ `Format failed for ${componentName}:`,
+ formatError.message,
+ );
+ }
+ }
+
+ const fTemplatePath = outDir
+ ? path.resolve(outDir, `${componentBaseName}.template.html`)
+ : path.resolve(
+ path.dirname(jsFilePath),
+ `${componentBaseName}.template.html`,
+ );
+
+ await mkdir(path.dirname(fTemplatePath), { recursive: true });
+ await writeFile(fTemplatePath, html, "utf8");
+
+ console.log(
+ styleText(["green", "bold"], "✔"),
+ "f-template:",
+ styleText("dim", path.relative(cwd, jsFilePath)),
+ "→",
+ styleText("bold", path.relative(cwd, fTemplatePath)),
+ );
+ } catch (error: any) {
+ console.error(
+ styleText(["red", "bold"], "✘"),
+ `Failed: ${path.relative(cwd, jsFilePath)}`,
+ error.message,
+ );
+ }
+ }
+}
diff --git a/packages/fast-test-harness/src/build/generate-webui-templates.test.ts b/packages/fast-test-harness/src/build/generate-webui-templates.test.ts
new file mode 100644
index 00000000000..6b662da7497
--- /dev/null
+++ b/packages/fast-test-harness/src/build/generate-webui-templates.test.ts
@@ -0,0 +1,285 @@
+import assert from "node:assert/strict";
+import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { test } from "node:test";
+import { generateWebuiTemplates } from "@microsoft/fast-test-harness/build/generate-webui-templates.js";
+
+test.describe("generateWebuiTemplates", () => {
+ let tempDir: string;
+
+ test.beforeEach(async () => {
+ tempDir = await mkdtemp(join(tmpdir(), "fast-webui-templ-"));
+ });
+
+ test.afterEach(async () => {
+ await rm(tempDir, { recursive: true, force: true });
+ });
+
+ test("should generate a webui template without f-template wrapper", async () => {
+ const distDir = join(tempDir, "dist");
+ await mkdir(distDir, { recursive: true });
+
+ await writeFile(
+ join(distDir, "badge.template.js"),
+ `export const template = {
+ html: "",
+ factories: {}
+ };`,
+ );
+
+ await generateWebuiTemplates({ cwd: tempDir, tagPrefix: "mai" });
+
+ const html = await readFile(join(distDir, "badge.template-webui.html"), "utf8");
+ assert.ok(html.includes(''));
+ assert.ok(html.includes(""));
+ assert.ok(!html.includes(" {
+ const distDir = join(tempDir, "dist");
+ await mkdir(distDir, { recursive: true });
+
+ await writeFile(
+ join(distDir, "card.template.js"),
+ `export const template = {
+ html: "card
",
+ factories: {}
+ };`,
+ );
+
+ await generateWebuiTemplates({
+ cwd: tempDir,
+ outDir: "out",
+ tagPrefix: "fast",
+ });
+
+ const html = await readFile(
+ join(tempDir, "out", "card.template-webui.html"),
+ "utf8",
+ );
+ assert.ok(html.includes(''));
+ });
+
+ test("should skip modules without a template export", async () => {
+ const distDir = join(tempDir, "dist");
+ await mkdir(distDir, { recursive: true });
+
+ await writeFile(
+ join(distDir, "empty.template.js"),
+ `export const styles = ":host {}";`,
+ );
+
+ await generateWebuiTemplates({ cwd: tempDir, tagPrefix: "fast" });
+
+ try {
+ await readFile(join(distDir, "empty.template-webui.html"), "utf8");
+ assert.fail("Should not have created an HTML file");
+ } catch (err: any) {
+ assert.strictEqual(err.code, "ENOENT");
+ }
+ });
+
+ test("should apply a format function", async () => {
+ const distDir = join(tempDir, "dist");
+ await mkdir(distDir, { recursive: true });
+
+ await writeFile(
+ join(distDir, "text.template.js"),
+ `export const template = {
+ html: "text",
+ factories: {}
+ };`,
+ );
+
+ await generateWebuiTemplates({
+ cwd: tempDir,
+ tagPrefix: "fast",
+ format: html => `\n${html}`,
+ });
+
+ const html = await readFile(join(distDir, "text.template-webui.html"), "utf8");
+ assert.ok(html.startsWith(""));
+ });
+
+ test("should add shadowrootdelegatesfocus from definition-async", async () => {
+ const distDir = join(tempDir, "dist");
+ await mkdir(distDir, { recursive: true });
+
+ await writeFile(
+ join(distDir, "input.template.js"),
+ `export const template = {
+ html: "",
+ factories: {}
+ };`,
+ );
+
+ await writeFile(
+ join(distDir, "input.definition-async.js"),
+ `export const definition = {
+ name: "fast-input",
+ shadowOptions: { delegatesFocus: true },
+ };`,
+ );
+
+ await generateWebuiTemplates({ cwd: tempDir, tagPrefix: "fast" });
+
+ const html = await readFile(join(distDir, "input.template-webui.html"), "utf8");
+ assert.ok(
+ html.includes("shadowrootdelegatesfocus"),
+ `should include delegatesFocus, got: ${html}`,
+ );
+ });
+
+ test("should not add shadowrootdelegatesfocus when absent", async () => {
+ const distDir = join(tempDir, "dist");
+ await mkdir(distDir, { recursive: true });
+
+ await writeFile(
+ join(distDir, "div.template.js"),
+ `export const template = {
+ html: "hello
",
+ factories: {}
+ };`,
+ );
+
+ await generateWebuiTemplates({ cwd: tempDir, tagPrefix: "fast" });
+
+ const html = await readFile(join(distDir, "div.template-webui.html"), "utf8");
+ assert.ok(!html.includes("shadowrootdelegatesfocus"));
+ });
+
+ test("should strip the {{styles}} marker from output", async () => {
+ const distDir = join(tempDir, "dist");
+ await mkdir(distDir, { recursive: true });
+
+ // The template contains nested content that would produce a styles marker
+ // during f-template generation (convertTemplate injects it).
+ await writeFile(
+ join(distDir, "label.template.js"),
+ `export const template = {
+ html: "",
+ factories: {}
+ };`,
+ );
+
+ await generateWebuiTemplates({ cwd: tempDir, tagPrefix: "mai" });
+
+ const html = await readFile(join(distDir, "label.template-webui.html"), "utf8");
+ assert.ok(
+ !html.includes("{{styles}}"),
+ `should not contain styles marker: ${html}`,
+ );
+ assert.ok(
+ !html.includes("{%styles%}"),
+ `should not contain alt styles marker: ${html}`,
+ );
+ });
+
+ test("should handle modules with a default export", async () => {
+ const distDir = join(tempDir, "dist");
+ await mkdir(distDir, { recursive: true });
+
+ await writeFile(
+ join(distDir, "icon.template.js"),
+ `const template = {
+ html: "",
+ factories: {}
+ };
+ export default template;`,
+ );
+
+ await generateWebuiTemplates({ cwd: tempDir, tagPrefix: "fast" });
+
+ const html = await readFile(join(distDir, "icon.template-webui.html"), "utf8");
+ assert.ok(html.includes(''));
+ assert.ok(html.includes(""));
+ });
+
+ test("should handle multiple template modules in one pass", async () => {
+ const distDir = join(tempDir, "dist");
+ await mkdir(distDir, { recursive: true });
+
+ await writeFile(
+ join(distDir, "button.template.js"),
+ `export const template = {
+ html: "",
+ factories: {}
+ };`,
+ );
+
+ await writeFile(
+ join(distDir, "badge.template.js"),
+ `export const template = {
+ html: "",
+ factories: {}
+ };`,
+ );
+
+ await generateWebuiTemplates({ cwd: tempDir, tagPrefix: "mai" });
+
+ const buttonHtml = await readFile(
+ join(distDir, "button.template-webui.html"),
+ "utf8",
+ );
+ const badgeHtml = await readFile(
+ join(distDir, "badge.template-webui.html"),
+ "utf8",
+ );
+
+ assert.ok(buttonHtml.includes("