diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ea43c2c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,58 @@ +name: CI + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +jobs: + test: + name: Unit tests + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install root dependencies + run: npm ci --ignore-scripts + + - name: Run unit tests + run: npm test + + build: + name: Playground build + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install root dependencies + run: npm ci --ignore-scripts + + - name: Install playground dependencies + run: npm ci --ignore-scripts --prefix playground + + - name: Type-check playground + run: npm run playground:check + + - name: Build playground + run: npm run playground:build diff --git a/package-lock.json b/package-lock.json index 49d5336..d9792dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,8 @@ "astro": "^6.1.6", "igniteui-theming": "^26.0.1", "sass": "^1.83.0", - "typescript": "^5.6.0" + "typescript": "^5.6.0", + "vitest": "^4.1.7" }, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24.0.0" @@ -2367,52 +2368,6 @@ "win32" ] }, - "node_modules/@shikijs/core": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.23.0.tgz", - "integrity": "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.23.0", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4", - "hast-util-to-html": "^9.0.5" - } - }, - "node_modules/@shikijs/engine-javascript": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.23.0.tgz", - "integrity": "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.23.0", - "@shikijs/vscode-textmate": "^10.0.2", - "oniguruma-to-es": "^4.3.4" - } - }, - "node_modules/@shikijs/engine-oniguruma": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", - "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.23.0", - "@shikijs/vscode-textmate": "^10.0.2" - } - }, - "node_modules/@shikijs/langs": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", - "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.23.0" - } - }, "node_modules/@shikijs/primitive": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.1.0.tgz", @@ -2440,33 +2395,30 @@ "node": ">=20" } }, - "node_modules/@shikijs/themes": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", - "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.23.0" - } - }, - "node_modules/@shikijs/types": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", - "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - } - }, "node_modules/@shikijs/vscode-textmate": { "version": "10.0.2", "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -2476,6 +2428,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -2577,6 +2536,129 @@ "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", "license": "ISC" }, + "node_modules/@vitest/expect": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.7", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -2697,6 +2779,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/astring": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", @@ -3000,6 +3092,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -3149,6 +3251,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", @@ -3814,6 +3923,16 @@ "node": ">=18.0.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -6456,6 +6575,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/piccolore": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", @@ -7631,23 +7757,6 @@ "node": ">=8" } }, - "node_modules/shiki": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.23.0.tgz", - "integrity": "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "@shikijs/core": "3.23.0", - "@shikijs/engine-javascript": "3.23.0", - "@shikijs/engine-oniguruma": "3.23.0", - "@shikijs/langs": "3.23.0", - "@shikijs/themes": "3.23.0", - "@shikijs/types": "3.23.0", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - } - }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -7724,6 +7833,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -7770,6 +7886,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -7780,6 +7903,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -7888,6 +8018,13 @@ "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyclip": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.12.tgz", @@ -7922,6 +8059,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "7.0.30", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", @@ -8533,6 +8680,96 @@ } } }, + "node_modules/vitest": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -8612,6 +8849,23 @@ "node": ">=4" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index 8708396..b13fdc8 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,8 @@ "astro": "^6.1.6", "igniteui-theming": "^26.0.1", "sass": "^1.83.0", - "typescript": "^5.6.0" + "typescript": "^5.6.0", + "vitest": "^4.1.7" }, "workspaces": [ "playground" @@ -104,6 +105,8 @@ "access": "public" }, "scripts": { + "test": "vitest run", + "test:watch": "vitest", "playground": "npm --prefix playground run dev", "playground:dev": "npm --prefix playground run dev", "playground:build": "npm --prefix playground run build", diff --git a/tests/components/ApiLink.test.ts b/tests/components/ApiLink.test.ts new file mode 100644 index 0000000..0b43d55 --- /dev/null +++ b/tests/components/ApiLink.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'vitest'; +import { experimental_AstroContainer as AstroContainer } from 'astro/container'; +import ApiLink from '../../src/components/mdx/ApiLink/ApiLink.astro'; +import { MOCK_PLATFORM } from '../setup.ts'; + +const locals = { platformContext: MOCK_PLATFORM }; + +describe('ApiLink', () => { + it('renders an anchor element', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(ApiLink, { + props: { type: 'Toast' }, + locals, + }); + + expect(html).toContain(''); + }); + + it('renders type name inside ', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(ApiLink, { + props: { type: 'Toast' }, + locals, + }); + + expect(html).toContain(' { + const container = await AstroContainer.create(); + const html = await container.renderToString(ApiLink, { + props: { type: 'Calendar' }, + locals, + }); + + expect(html).toContain('IgrCalendar'); + }); + + it('skips the prefix when prefixed=false', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(ApiLink, { + props: { type: 'configureTheme', kind: 'function', prefixed: false }, + locals, + }); + + expect(html).toContain('configureTheme'); + expect(html).not.toContain('IgrconfigureTheme'); + }); + + it('uses custom label when provided', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(ApiLink, { + props: { type: 'Toast', label: 'My Toast' }, + locals, + }); + + expect(html).toContain('My Toast'); + }); + + it('appends member anchor to the URL', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(ApiLink, { + props: { type: 'Toast', member: 'show' }, + locals, + }); + + expect(html).toContain('#show'); + }); + + it('builds URL from the correct docRoot', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(ApiLink, { + props: { type: 'Toast' }, + locals, + }); + + expect(html).toContain('https://example.com/react/igniteui-react/latest'); + }); + + it('renders kind="sass" link using sassApiUrl', async () => { + const container = await AstroContainer.create(); + const localsWithSass = { + platformContext: { + ...MOCK_PLATFORM, + sassApiUrl: 'https://example.com/sass', + }, + }; + const html = await container.renderToString(ApiLink, { + props: { kind: 'sass', module: 'animations', type: 'mixin-slide-in-left' }, + locals: localsWithSass, + }); + + expect(html).toContain('https://example.com/sass/animations#mixin-slide-in-left'); + }); +}); diff --git a/tests/components/ApiRef.test.ts b/tests/components/ApiRef.test.ts new file mode 100644 index 0000000..b67bdf5 --- /dev/null +++ b/tests/components/ApiRef.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; +import { experimental_AstroContainer as AstroContainer } from 'astro/container'; +import ApiRef from '../../src/components/mdx/ApiRef/ApiRef.astro'; +import { MOCK_PLATFORM } from '../setup.ts'; + +const locals = { platformContext: MOCK_PLATFORM }; + +describe('ApiRef', () => { + it('renders a list', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(ApiRef, { + props: { types: ['Toast'] }, + locals, + }); + + expect(html).toContain(' { + const container = await AstroContainer.create(); + const html = await container.renderToString(ApiRef, { + props: { types: ['Toast', 'Calendar', 'Combo'] }, + locals, + }); + + const liCount = (html.match(/]/g) ?? []).length; + expect(liCount).toBe(3); + }); + + it('renders platform-prefixed names', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(ApiRef, { + props: { types: ['Toast'] }, + locals, + }); + + expect(html).toContain('IgrToast'); + }); + + it('skips prefix when prefixed=false', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(ApiRef, { + props: { types: ['SortingStrategy'], prefixed: false }, + locals, + }); + + expect(html).toContain('SortingStrategy'); + expect(html).not.toContain('IgrSortingStrategy'); + }); + + it('renders links pointing to the correct docRoot', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(ApiRef, { + props: { types: ['Toast'] }, + locals, + }); + + expect(html).toContain('https://example.com/react/igniteui-react/latest'); + }); + + it('renders enum links with the correct URL segment', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(ApiRef, { + props: { types: ['CalendarSelection'], kind: 'enum' }, + locals, + }); + + expect(html).toContain('/enums/'); + }); + + it('wraps label in ', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(ApiRef, { + props: { types: ['Toast'] }, + locals, + }); + + expect(html).toContain(' { + it('renders with default note type', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsAside); + + expect(html).toContain('igd-aside--note'); + expect(html).toContain('role="note"'); + }); + + it('renders with type="info"', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsAside, { + props: { type: 'info' }, + }); + + expect(html).toContain('igd-aside--info'); + expect(html).toContain('aria-label="Info"'); + }); + + it('renders with type="warning"', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsAside, { + props: { type: 'warning' }, + }); + + expect(html).toContain('igd-aside--warning'); + expect(html).toContain('aria-label="Warning"'); + }); + + it('renders custom title', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsAside, { + props: { type: 'note', title: 'Custom Title' }, + }); + + expect(html).toContain('aria-label="Custom Title"'); + expect(html).toContain('Custom Title'); + }); + + it('suppresses icon when icon prop is empty string', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsAside, { + props: { type: 'info', icon: '' }, + }); + + expect(html).not.toContain('igd-aside__icon'); + }); + + it('renders custom icon name', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsAside, { + props: { type: 'note', icon: 'custom-icon' }, + }); + + expect(html).toContain('name="custom-icon"'); + }); +}); diff --git a/tests/components/DocsBreadcrumb.test.ts b/tests/components/DocsBreadcrumb.test.ts new file mode 100644 index 0000000..cec44d9 --- /dev/null +++ b/tests/components/DocsBreadcrumb.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest'; +import { experimental_AstroContainer as AstroContainer } from 'astro/container'; +import DocsBreadcrumb from '../../src/components/DocsBreadcrumb/DocsBreadcrumb.astro'; +import type { SidebarEntry } from '../../src/lib/sidebar/types.ts'; + +const SIDEBAR: SidebarEntry[] = [ + { + label: 'Components', + items: [ + { label: 'Button', slug: 'components/button' }, + ], + }, +]; + +describe('DocsBreadcrumb', () => { + it('renders site title', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsBreadcrumb, { + props: { siteTitle: 'IgniteUI' }, + }); + + expect(html).toContain('IgniteUI'); + expect(html).toContain('docs-breadcrumb-home'); + }); + + it('renders breadcrumb from sidebarItems when slug matches', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsBreadcrumb, { + props: { + siteTitle: 'IgniteUI', + sidebarItems: SIDEBAR, + currentSlug: 'components/button', + }, + }); + + expect(html).toContain('Button'); + }); + + it('renders pageTitle as fallback crumb when slug is not in sidebar', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsBreadcrumb, { + props: { + siteTitle: 'IgniteUI', + sidebarItems: SIDEBAR, + currentSlug: 'components/unknown', + pageTitle: 'My Page', + }, + }); + + expect(html).toContain('My Page'); + }); + + it('renders nothing when siteTitle and pageTitle are both empty', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsBreadcrumb, { + props: { siteTitle: '', pageTitle: '', sidebarItems: [], currentSlug: '' }, + }); + + expect(html).not.toContain('docs-breadcrumb'); + }); + + it('has aria-label="Breadcrumb" for accessibility', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsBreadcrumb, { + props: { siteTitle: 'IgniteUI' }, + }); + + expect(html).toContain('aria-label="Breadcrumb"'); + }); +}); diff --git a/tests/components/DocsSidebar.test.ts b/tests/components/DocsSidebar.test.ts new file mode 100644 index 0000000..951a11e --- /dev/null +++ b/tests/components/DocsSidebar.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { experimental_AstroContainer as AstroContainer } from 'astro/container'; +import DocsSidebar from '../../src/components/DocsSidebar/DocsSidebar.astro'; +import type { SidebarEntry } from '../../src/lib/sidebar/types.ts'; + +const ITEMS: SidebarEntry[] = [ + { + label: 'Getting Started', + items: [{ label: 'Introduction', slug: 'intro' }], + }, + { label: 'Button', slug: 'components/button' }, +]; + +describe('DocsSidebar', () => { + it('renders the sidebar container', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsSidebar, { + props: { items: ITEMS }, + }); + + expect(html).toContain('docs-sidebar'); + expect(html).toContain('class="docs-sidebar"'); + }); + + it('renders all sidebar items', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsSidebar, { + props: { items: ITEMS }, + }); + + expect(html).toContain('Getting Started'); + expect(html).toContain('Introduction'); + expect(html).toContain('Button'); + }); + + it('marks current page item as active', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsSidebar, { + props: { items: ITEMS, currentSlug: 'components/button' }, + }); + + expect(html).toContain('aria-current="page"'); + }); + + it('renders the filter input by default', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsSidebar, { + props: { items: ITEMS }, + }); + + expect(html).toContain('sidebar-filter-input'); + }); + + it('hides the filter input when showFilter=false', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsSidebar, { + props: { items: ITEMS, showFilter: false }, + }); + + expect(html).not.toContain('sidebar-filter-input'); + }); +}); diff --git a/tests/components/DocsSubHeader.test.ts b/tests/components/DocsSubHeader.test.ts new file mode 100644 index 0000000..7b96c69 --- /dev/null +++ b/tests/components/DocsSubHeader.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; +import { experimental_AstroContainer as AstroContainer } from 'astro/container'; +import DocsSubHeader from '../../src/components/DocsSubHeader/DocsSubHeader.astro'; + +describe('DocsSubHeader', () => { + it('renders the subheader container', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsSubHeader, { + props: { siteTitle: 'IgniteUI' }, + }); + + expect(html).toContain('igd-docs-subheader'); + expect(html).toContain('docs-subheader-menu'); + }); + + it('renders the logo text', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsSubHeader, { + props: { siteTitle: 'IgniteUI', logoText: 'IgniteUI' }, + }); + + expect(html).toContain('IgniteUI'); + }); + + it('renders theme toggle when showThemeToggle=true', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsSubHeader, { + props: { showThemeToggle: true }, + }); + + expect(html).toContain('data-theme-toggle'); + expect(html).toContain('Toggle light/dark theme'); + }); + + it('does not render theme toggle by default', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsSubHeader, { + props: {}, + }); + + expect(html).not.toContain('data-theme-toggle'); + }); + + it('renders package selector when packages are provided', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsSubHeader, { + props: { + packages: ['@igniteui/angular', '@igniteui/react'], + selectedPackage: '@igniteui/angular', + }, + }); + + expect(html).toContain('@igniteui/angular'); + expect(html).toContain('@igniteui/react'); + expect(html).toContain('package-select'); + }); + + it('renders version selector when versions are provided', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsSubHeader, { + props: { + versions: ['18.x', '17.x'], + selectedVersion: '18.x', + }, + }); + + expect(html).toContain('18.x'); + expect(html).toContain('17.x'); + expect(html).toContain('version-select'); + }); + + it('renders sidebar toggle button', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsSubHeader, { + props: {}, + }); + + expect(html).toContain('data-sidebar-toggle'); + expect(html).toContain('Toggle sidebar'); + }); +}); diff --git a/tests/components/DocsToc.test.ts b/tests/components/DocsToc.test.ts new file mode 100644 index 0000000..995533b --- /dev/null +++ b/tests/components/DocsToc.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import { experimental_AstroContainer as AstroContainer } from 'astro/container'; +import DocsToc from '../../src/components/DocsToc/DocsToc.astro'; + +describe('DocsToc', () => { + it('renders nothing when headings is empty', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsToc, { + props: { headings: [] }, + }); + + expect(html).not.toContain('toc-sidebar'); + }); + + it('renders TOC with provided headings', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsToc, { + props: { + headings: [ + { depth: 2, slug: 'intro', text: 'Introduction' }, + { depth: 2, slug: 'usage', text: 'Usage' }, + ], + }, + }); + + expect(html).toContain('toc-sidebar'); + expect(html).toContain('Introduction'); + expect(html).toContain('Usage'); + }); + + it('renders with default label "On this page"', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsToc, { + props: { + headings: [{ depth: 2, slug: 'section', text: 'Section' }], + }, + }); + + expect(html).toContain('On this page'); + }); + + it('renders with custom label', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsToc, { + props: { + headings: [{ depth: 2, slug: 'section', text: 'Section' }], + label: 'Page contents', + }, + }); + + expect(html).toContain('Page contents'); + }); + + it('generates anchor links for each heading', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsToc, { + props: { + headings: [{ depth: 2, slug: 'my-section', text: 'My Section' }], + }, + }); + + expect(html).toContain('href="#my-section"'); + expect(html).toContain('My Section'); + }); +}); diff --git a/tests/components/DocsTree.test.ts b/tests/components/DocsTree.test.ts new file mode 100644 index 0000000..7bd98da --- /dev/null +++ b/tests/components/DocsTree.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest'; +import { experimental_AstroContainer as AstroContainer } from 'astro/container'; +import DocsTree from '../../src/components/DocsTree/DocsTree.astro'; +import type { TreeNode } from '../../src/components/DocsTree/types.ts'; + +const LEAF_NODE: TreeNode = { + id: 'getting-started', + label: 'Getting Started', + href: '/docs/getting-started/', +}; + +const GROUP_NODE: TreeNode = { + id: 'components', + label: 'Components', + children: [ + { id: 'button', label: 'Button', href: '/docs/button/' }, + { id: 'input', label: 'Input', href: '/docs/input/' }, + ], +}; + +describe('DocsTree', () => { + it('renders igc-tree with the correct data-variant for sidebar', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsTree, { + props: { nodes: [LEAF_NODE], variant: 'sidebar' }, + }); + + expect(html).toContain('data-variant="sidebar"'); + }); + + it('renders igc-tree with the correct data-variant for toc', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsTree, { + props: { nodes: [LEAF_NODE], variant: 'toc' }, + }); + + expect(html).toContain('data-variant="toc"'); + }); + + it('renders leaf node label and href', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsTree, { + props: { nodes: [LEAF_NODE], variant: 'sidebar' }, + }); + + expect(html).toContain('Getting Started'); + expect(html).toContain('href="/docs/getting-started/"'); + }); + + it('renders group node with children', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsTree, { + props: { nodes: [GROUP_NODE], variant: 'sidebar' }, + }); + + expect(html).toContain('Components'); + expect(html).toContain('Button'); + expect(html).toContain('Input'); + }); + + it('sets aria-label on igc-tree when ariaLabel is provided', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsTree, { + props: { + nodes: [LEAF_NODE], + variant: 'sidebar', + ariaLabel: 'Sidebar navigation', + }, + }); + + expect(html).toContain('aria-label="Sidebar navigation"'); + }); + + it('marks active node with aria-current="page"', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsTree, { + props: { + nodes: [LEAF_NODE], + variant: 'sidebar', + activeId: 'getting-started', + }, + }); + + expect(html).toContain('aria-current="page"'); + }); +}); diff --git a/tests/components/DocsTreeItem.test.ts b/tests/components/DocsTreeItem.test.ts new file mode 100644 index 0000000..13a4e0c --- /dev/null +++ b/tests/components/DocsTreeItem.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from 'vitest'; +import { experimental_AstroContainer as AstroContainer } from 'astro/container'; +import DocsTreeItem from '../../src/components/DocsTree/DocsTreeItem.astro'; +import type { TreeNode } from '../../src/components/DocsTree/types.ts'; + +const LEAF_NODE: TreeNode = { + id: 'leaf-1', + label: 'Leaf Item', + href: '/docs/leaf/', +}; + +const GROUP_NODE: TreeNode = { + id: 'group-1', + label: 'Group Item', + children: [ + { id: 'child-1', label: 'Child One', href: '/docs/child-one/' }, + ], +}; + +describe('DocsTreeItem', () => { + it('renders a leaf node with an anchor', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsTreeItem, { + props: { node: LEAF_NODE, depth: 0, variant: 'sidebar' }, + }); + + expect(html).toContain('href="/docs/leaf/"'); + expect(html).toContain('Leaf Item'); + }); + + it('sets data-tree-id on the tree item', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsTreeItem, { + props: { node: LEAF_NODE, depth: 0, variant: 'sidebar' }, + }); + + expect(html).toContain('data-tree-id="leaf-1"'); + }); + + it('sets data-depth on the tree item', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsTreeItem, { + props: { node: LEAF_NODE, depth: 2, variant: 'sidebar' }, + }); + + expect(html).toContain('data-depth="2"'); + }); + + it('renders group node without an anchor for the label', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsTreeItem, { + props: { node: GROUP_NODE, depth: 0, variant: 'sidebar' }, + }); + + expect(html).toContain('Group Item'); + expect(html).toContain('docs-tree-group'); + }); + + it('renders group node with children', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsTreeItem, { + props: { node: GROUP_NODE, depth: 0, variant: 'sidebar' }, + }); + + expect(html).toContain('Child One'); + expect(html).toContain('href="/docs/child-one/"'); + }); + + it('sets aria-current="page" on the active leaf', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsTreeItem, { + props: { + node: LEAF_NODE, + depth: 0, + variant: 'sidebar', + activeId: 'leaf-1', + }, + }); + + expect(html).toContain('aria-current="page"'); + }); + + it('does not set aria-current when the node is not active', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(DocsTreeItem, { + props: { + node: LEAF_NODE, + depth: 0, + variant: 'sidebar', + activeId: 'other-node', + }, + }); + + expect(html).not.toContain('aria-current'); + }); + + it('spreads itemData as data-* attributes', async () => { + const container = await AstroContainer.create(); + const nodeWithData: TreeNode = { + ...LEAF_NODE, + itemData: { path: '/docs/leaf/', groupKey: 'section-a' }, + }; + const html = await container.renderToString(DocsTreeItem, { + props: { node: nodeWithData, depth: 0, variant: 'sidebar' }, + }); + + expect(html).toContain('data-path="/docs/leaf/"'); + expect(html).toContain('data-group-key="section-a"'); + }); +}); diff --git a/tests/components/GlobalFooter.test.ts b/tests/components/GlobalFooter.test.ts new file mode 100644 index 0000000..4035e9a --- /dev/null +++ b/tests/components/GlobalFooter.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from 'vitest'; +import { experimental_AstroContainer as AstroContainer } from 'astro/container'; +import GlobalFooter from '../../src/components/GlobalFooter/GlobalFooter.astro'; + +describe('GlobalFooter', () => { + it('renders the footer container element', async () => { + const container = await AstroContainer.create(); + // The component fetches remote footer HTML; it renders an empty div when offline. + const html = await container.renderToString(GlobalFooter, { + props: { lang: 'en' }, + }); + + expect(html).toContain('data-igd-footer'); + }); +}); diff --git a/tests/components/GlobalNavBar.test.ts b/tests/components/GlobalNavBar.test.ts new file mode 100644 index 0000000..6205e9b --- /dev/null +++ b/tests/components/GlobalNavBar.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from 'vitest'; +import { experimental_AstroContainer as AstroContainer } from 'astro/container'; +import GlobalNavBar from '../../src/components/GlobalNavBar/GlobalNavBar.astro'; + +describe('GlobalNavBar', () => { + it('renders the nav bar container element', async () => { + const container = await AstroContainer.create(); + // The component fetches remote nav HTML; it renders an empty div when offline. + const html = await container.renderToString(GlobalNavBar, { + props: { lang: 'en' }, + }); + + expect(html).toContain('data-astro-transition-persist="global-nav-bar"'); + }); +}); diff --git a/tests/components/LicenseIndicator.test.ts b/tests/components/LicenseIndicator.test.ts new file mode 100644 index 0000000..7086aa8 --- /dev/null +++ b/tests/components/LicenseIndicator.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { experimental_AstroContainer as AstroContainer } from 'astro/container'; +import LicenseIndicator from '../../src/components/LicenseIndicator/LicenseIndicator.astro'; + +describe('LicenseIndicator', () => { + it('renders nothing when license is undefined', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(LicenseIndicator, { + props: {}, + }); + + expect(html.trim()).toBe(''); + }); + + it('renders nothing when license is false', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(LicenseIndicator, { + props: { license: false }, + }); + + expect(html.trim()).toBe(''); + }); + + it('renders a Premium badge when license="premium"', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(LicenseIndicator, { + props: { license: 'premium' }, + }); + + expect(html).toContain('Premium'); + expect(html).toContain('class="sidebar-badge premium"'); + }); + + it('renders an Open Source badge when license="opensource"', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(LicenseIndicator, { + props: { license: 'opensource' }, + }); + + expect(html).toContain('Open Source'); + expect(html).toContain('class="sidebar-badge opensource"'); + }); + + it('renders the premium icon when license="premium"', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(LicenseIndicator, { + props: { license: 'premium' }, + }); + + expect(html).toContain('name="premium"'); + }); + + it('does not render the premium icon for opensource', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(LicenseIndicator, { + props: { license: 'opensource' }, + }); + + expect(html).not.toContain('name="premium"'); + }); +}); diff --git a/tests/components/MobileSidebarToggle.test.ts b/tests/components/MobileSidebarToggle.test.ts new file mode 100644 index 0000000..1e5f4e6 --- /dev/null +++ b/tests/components/MobileSidebarToggle.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { experimental_AstroContainer as AstroContainer } from 'astro/container'; +import MobileSidebarToggle from '../../src/components/MobileSidebarToggle/MobileSidebarToggle.astro'; + +describe('MobileSidebarToggle', () => { + it('renders a button with the correct id', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(MobileSidebarToggle); + + expect(html).toContain('id="mobile-sidebar-toggle"'); + expect(html).toContain(' { + const container = await AstroContainer.create(); + const html = await container.renderToString(MobileSidebarToggle); + + expect(html).toContain('Components List'); + }); + + it('renders with a custom label', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(MobileSidebarToggle, { + props: { label: 'Navigation' }, + }); + + expect(html).toContain('Navigation'); + }); + + it('has aria-expanded="false" by default', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(MobileSidebarToggle); + + expect(html).toContain('aria-expanded="false"'); + }); + + it('has aria-controls pointing to docs-sidebar', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(MobileSidebarToggle); + + expect(html).toContain('aria-controls="docs-sidebar"'); + }); + + it('includes the aria-label with the custom label text', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(MobileSidebarToggle, { + props: { label: 'Menu' }, + }); + + expect(html).toContain('Toggle Menu sidebar'); + }); +}); diff --git a/tests/components/MobileTocToggle.test.ts b/tests/components/MobileTocToggle.test.ts new file mode 100644 index 0000000..1c30744 --- /dev/null +++ b/tests/components/MobileTocToggle.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; +import { experimental_AstroContainer as AstroContainer } from 'astro/container'; +import MobileTocToggle from '../../src/components/MobileTocToggle/MobileTocToggle.astro'; + +describe('MobileTocToggle', () => { + it('renders nothing when headings is empty', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(MobileTocToggle, { + props: { headings: [] }, + }); + + expect(html).not.toContain('mobile-toc'); + }); + + it('renders the toggle when headings are provided', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(MobileTocToggle, { + props: { + headings: [{ depth: 2, slug: 'intro', text: 'Introduction' }], + }, + }); + + expect(html).toContain('mobile-toc'); + expect(html).toContain('id="mobile-toc-trigger"'); + }); + + it('renders with default label "On this page"', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(MobileTocToggle, { + props: { + headings: [{ depth: 2, slug: 'intro', text: 'Introduction' }], + }, + }); + + expect(html).toContain('On this page'); + }); + + it('renders with custom label', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(MobileTocToggle, { + props: { + headings: [{ depth: 2, slug: 'intro', text: 'Introduction' }], + label: 'Contents', + }, + }); + + expect(html).toContain('Contents'); + }); + + it('renders heading anchors in the dropdown', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(MobileTocToggle, { + props: { + headings: [{ depth: 2, slug: 'my-section', text: 'My Section' }], + }, + }); + + expect(html).toContain('href="#my-section"'); + expect(html).toContain('My Section'); + }); + + it('has aria-expanded="false" on the trigger button', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(MobileTocToggle, { + props: { + headings: [{ depth: 2, slug: 'intro', text: 'Introduction' }], + }, + }); + + expect(html).toContain('aria-expanded="false"'); + }); +}); diff --git a/tests/components/PlatformBlock.test.ts b/tests/components/PlatformBlock.test.ts new file mode 100644 index 0000000..36243cf --- /dev/null +++ b/tests/components/PlatformBlock.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { experimental_AstroContainer as AstroContainer } from 'astro/container'; +import PlatformBlock from '../../src/components/mdx/PlatformBlock/PlatformBlock.astro'; +import { MOCK_PLATFORM } from '../setup.ts'; + +// MOCK_PLATFORM has name: 'React' +const locals = { platformContext: MOCK_PLATFORM }; + +describe('PlatformBlock', () => { + it('renders slot content when the platform matches', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(PlatformBlock, { + props: { for: 'React' }, + locals, + slots: { default: '

React content

' }, + }); + + expect(html).toContain('React content'); + }); + + it('renders nothing when the platform does not match', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(PlatformBlock, { + props: { for: 'Angular' }, + locals, + slots: { default: '

Angular content

' }, + }); + + expect(html).not.toContain('Angular content'); + }); + + it('renders when the platform is in a comma-separated list', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(PlatformBlock, { + props: { for: 'Angular, React, Blazor' }, + locals, + slots: { default: '

Multi-platform content

' }, + }); + + expect(html).toContain('Multi-platform content'); + }); + + it('does not render when none of the listed platforms match', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(PlatformBlock, { + props: { for: 'Angular, Blazor' }, + locals, + slots: { default: '

Non-matching content

' }, + }); + + expect(html).not.toContain('Non-matching content'); + }); +}); diff --git a/tests/components/Sample.test.ts b/tests/components/Sample.test.ts new file mode 100644 index 0000000..f77f073 --- /dev/null +++ b/tests/components/Sample.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest'; +import { experimental_AstroContainer as AstroContainer } from 'astro/container'; +import Sample from '../../src/components/mdx/Sample/Sample.astro'; +import { MOCK_PLATFORM, MOCK_ENV } from '../setup.ts'; + +const locals = { + platformContext: MOCK_PLATFORM, + envVars: MOCK_ENV, +}; + +describe('Sample', () => { + it('renders the demo widget container', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(Sample, { + props: { src: '/components/button' }, + locals, + }); + + expect(html).toContain('igd-code-view'); + expect(html).toContain('data-iframe-src'); + }); + + it('includes the iframe src derived from demosBaseUrl', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(Sample, { + props: { src: '/components/button' }, + locals, + }); + + expect(html).toContain('https://demo.example.com/components/button'); + }); + + it('uses dvDemosBaseUrl for DV-prefixed paths', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(Sample, { + props: { src: '/charts/data-chart/axis-crossing' }, + locals, + }); + + expect(html).toContain('https://dv-demo.example.com/charts/data-chart/axis-crossing'); + }); + + it('uses dvDemosBaseUrl when dv=true', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(Sample, { + props: { src: '/components/button', dv: true }, + locals, + }); + + expect(html).toContain('https://dv-demo.example.com/components/button'); + }); + + it('renders only the iframe when iframeOnly=true', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(Sample, { + props: { src: '/components/button', iframeOnly: true }, + locals, + }); + + expect(html).toContain('igd-sample-container'); + expect(html).not.toContain('igd-code-view'); + }); + + it('applies the custom height', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(Sample, { + props: { src: '/components/button', height: 600 }, + locals, + }); + + expect(html).toContain('600px'); + }); + + it('uses the platform name in the iframe title', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(Sample, { + props: { src: '/components/button', alt: '' }, + locals, + }); + + // platform name is 'React' + expect(html).toContain('React Example'); + }); +}); diff --git a/tests/components/Search.test.ts b/tests/components/Search.test.ts new file mode 100644 index 0000000..15f8f90 --- /dev/null +++ b/tests/components/Search.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { experimental_AstroContainer as AstroContainer } from 'astro/container'; +import Search from '../../src/components/Search/Search.astro'; + +describe('Search', () => { + it('renders the search trigger button', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(Search); + + expect(html).toContain('id="search-trigger"'); + expect(html).toContain('class="search-trigger"'); + }); + + it('has the correct aria-label', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(Search); + + expect(html).toContain('aria-label="Search documentation"'); + }); + + it('renders the search dialog', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(Search); + + expect(html).toContain('id="search-dialog"'); + expect(html).toContain('igd-search-dialog'); + }); + + it('renders the search input inside the dialog', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(Search); + + expect(html).toContain('igd-search-input'); + expect(html).toContain('placeholder="Search documentation"'); + }); + + it('renders the keyboard shortcut hint', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(Search); + + expect(html).toContain('search-trigger__hint'); + }); +}); diff --git a/tests/components/SearchAdvanced.test.ts b/tests/components/SearchAdvanced.test.ts new file mode 100644 index 0000000..53ff3f2 --- /dev/null +++ b/tests/components/SearchAdvanced.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; +import { experimental_AstroContainer as AstroContainer } from 'astro/container'; +import SearchAdvanced from '../../src/components/SearchAdvanced/SearchAdvanced.astro'; + +describe('SearchAdvanced', () => { + it('renders the api-search-advanced custom element', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(SearchAdvanced); + + expect(html).toContain('api-search-advanced'); + }); + + it('renders the search trigger button', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(SearchAdvanced); + + expect(html).toContain('data-open-modal'); + expect(html).toContain('class="search-trigger"'); + }); + + it('uses default placeholder "Search"', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(SearchAdvanced); + + expect(html).toContain('aria-label="Search"'); + }); + + it('uses custom placeholder when provided', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(SearchAdvanced, { + props: { placeholder: 'Search docs…' }, + }); + + expect(html).toContain('aria-label="Search docs…"'); + expect(html).toContain('Search docs…'); + }); + + it('serialises tabs to data-tabs', async () => { + const container = await AstroContainer.create(); + const tabs = [{ id: 'components', label: 'Components', kindCodes: ['__name__'] }]; + const html = await container.renderToString(SearchAdvanced, { + props: { tabs }, + }); + + expect(html).toContain('data-tabs='); + expect(html).toContain('components'); + }); + + it('sets data-show-scope="true" when showScope=true', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(SearchAdvanced, { + props: { showScope: true }, + }); + + expect(html).toContain('data-show-scope="true"'); + }); + + it('sets data-show-scope="false" by default', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(SearchAdvanced); + + expect(html).toContain('data-show-scope="false"'); + }); + + it('renders the search dialog', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(SearchAdvanced); + + expect(html).toContain('igd-search-dialog'); + }); +}); diff --git a/tests/components/SidebarFilterInput.test.ts b/tests/components/SidebarFilterInput.test.ts new file mode 100644 index 0000000..5d26ce8 --- /dev/null +++ b/tests/components/SidebarFilterInput.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { experimental_AstroContainer as AstroContainer } from 'astro/container'; +import SidebarFilterInput from '../../src/components/DocsSidebar/SidebarFilterInput.astro'; + +describe('SidebarFilterInput', () => { + it('renders the igc-input filter element', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(SidebarFilterInput); + + expect(html).toContain('id="sidebar-filter-input"'); + expect(html).toContain('type="search"'); + }); + + it('has the correct aria attributes', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(SidebarFilterInput); + + expect(html).toContain('aria-label="Filter navigation topics"'); + expect(html).toContain('aria-controls="docs-sidebar"'); + }); + + it('renders the filter clear button', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(SidebarFilterInput); + + expect(html).toContain('data-sidebar-filter-clear'); + }); + + it('renders the status paragraph', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(SidebarFilterInput); + + expect(html).toContain('role="status"'); + expect(html).toContain('data-sidebar-filter-status'); + }); +}); diff --git a/tests/components/SidebarLabel.test.ts b/tests/components/SidebarLabel.test.ts new file mode 100644 index 0000000..b28e5db --- /dev/null +++ b/tests/components/SidebarLabel.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import { experimental_AstroContainer as AstroContainer } from 'astro/container'; +import SidebarLabel from '../../src/components/DocsSidebar/SidebarLabel.astro'; +import type { TreeNode } from '../../src/components/DocsTree/types.ts'; + +const LEAF_NODE: TreeNode = { + id: 'button', + label: 'Button', + href: '/components/button/', +}; + +const GROUP_NODE: TreeNode = { + id: 'components', + label: 'Components', + children: [ + { id: 'button', label: 'Button', href: '/components/button/' }, + { id: 'input', label: 'Input', href: '/components/input/' }, + ], +}; + +describe('SidebarLabel', () => { + it('renders the leaf label text', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(SidebarLabel, { + props: { node: LEAF_NODE, depth: 0, isLeaf: true, isActive: false }, + }); + + expect(html).toContain('Button'); + expect(html).toContain('docs-tree-label'); + }); + + it('renders the group label text', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(SidebarLabel, { + props: { node: GROUP_NODE, depth: 0, isLeaf: false, isActive: false }, + }); + + expect(html).toContain('Components'); + expect(html).toContain('group-label-text'); + }); + + it('renders child count for group nodes', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(SidebarLabel, { + props: { node: GROUP_NODE, depth: 0, isLeaf: false, isActive: false }, + }); + + expect(html).toContain('/2/'); + }); + + it('renders badges for leaf nodes', async () => { + const container = await AstroContainer.create(); + const nodeWithBadge: TreeNode = { + ...LEAF_NODE, + meta: { badges: [{ text: 'New', variant: 'new' }] }, + }; + const html = await container.renderToString(SidebarLabel, { + props: { node: nodeWithBadge, depth: 0, isLeaf: true, isActive: false }, + }); + + expect(html).toContain('New'); + expect(html).toContain('sidebar-badge new'); + }); + + it('renders premium icon for premium badge', async () => { + const container = await AstroContainer.create(); + const nodeWithPremium: TreeNode = { + ...LEAF_NODE, + meta: { badges: [{ text: 'Premium', variant: 'premium' }] }, + }; + const html = await container.renderToString(SidebarLabel, { + props: { node: nodeWithPremium, depth: 0, isLeaf: true, isActive: false }, + }); + + expect(html).toContain('name="premium"'); + expect(html).toContain('sidebar-premium-icon'); + }); + + it('does not render badges for group nodes', async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(SidebarLabel, { + props: { node: GROUP_NODE, depth: 0, isLeaf: false, isActive: false }, + }); + + expect(html).not.toContain('sidebar-badge'); + }); +}); diff --git a/tests/components/ThemingWidget.test.ts b/tests/components/ThemingWidget.test.ts new file mode 100644 index 0000000..df70338 --- /dev/null +++ b/tests/components/ThemingWidget.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from 'vitest'; +import { experimental_AstroContainer as AstroContainer } from 'astro/container'; +import ThemingWidget from '../../src/components/ThemingWidget/ThemingWidget.astro'; + +describe('ThemingWidget', () => { + it('renders nothing when themeApiUrl is empty (default mock)', async () => { + // The virtual mock exports themeApiUrl = '' so nothing should render. + const container = await AstroContainer.create(); + const html = await container.renderToString(ThemingWidget, { + props: {}, + }); + + expect(html).not.toContain('theming-widget-container'); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..af8424b --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,44 @@ +import type { PlatformContext } from '../src/lib/types.ts'; + +/** + * Shared mock `platformContext` injected via `AstroContainer` locals + * for every component that reads `Astro.locals.platformContext`. + */ +export const MOCK_PLATFORM: PlatformContext = { + name: 'React', + lower: 'react', + prefix: 'Igr', + productName: 'Ignite UI for React', + productSpinal: 'ignite-ui-react', + apiPackages: { + core: { + docRoot: 'https://example.com/react/igniteui-react/latest', + packageId: 'igniteui-react', + noPackagePrefix: true, + preserveCase: true, + }, + charts: { + docRoot: 'https://example.com/react/igniteui-react-charts/latest', + packageId: 'igniteui-react-charts', + noPackagePrefix: true, + preserveCase: true, + }, + }, + packages: { + common: '@igniteui/react', + charts: '@igniteui/react-charts', + grids: '@igniteui/react-grids', + gauges: '@igniteui/react-gauges', + maps: '@igniteui/react-maps', + }, + links: { + github: 'https://github.com/IgniteUI/igniteui-react', + forums: 'https://www.infragistics.com/community/forums', + repoSamples: 'https://github.com/IgniteUI/igniteui-react-examples', + }, +}; + +export const MOCK_ENV: Record = { + demosBaseUrl: 'https://demo.example.com', + dvDemosBaseUrl: 'https://dv-demo.example.com', +}; diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..8289d29 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,63 @@ +import { getViteConfig } from 'astro/config'; + +/** + * Stub for `virtual:docs-template/site-meta` and `virtual:docs-template/nav-html`. + * These modules are normally provided by `siteMetaIntegration` at build time. + * The stubs supply the minimal exports the components need so they compile and + * render without a real Astro site. + */ +function virtualDocsMocks() { + const SITE_META_ID = 'virtual:docs-template/site-meta'; + const NAV_HTML_ID = 'virtual:docs-template/nav-html'; + const prefix = '\0'; + + return { + name: 'vitest:virtual-docs-mocks', + resolveId(id: string) { + if (id === SITE_META_ID) return prefix + SITE_META_ID; + if (id === NAV_HTML_ID) return prefix + NAV_HTML_ID; + return null; + }, + load(id: string) { + if (id === prefix + SITE_META_ID) { + return ` +export const title = 'Test Site'; +export const sidebar = []; +export const productLinks = []; +export const headEntries = []; +export const trailingSlash = 'ignore'; +export const navLang = 'en'; + `.trim(); + } + if (id === prefix + NAV_HTML_ID) { + return ` +export const platform = 'react'; +export const navLang = 'en'; +export const themeApiUrl = ''; +export const widgetScriptSrc = ''; +export const prefetched = false; +export const headerHtml = ''; +export const uiFooterHtml = ''; +export const footerHtml = ''; +export const abPrefetched = false; +export const abHeaderHtml = ''; +export const abFooterHtml = ''; +export const abFooterUtilsHtml = ''; +export const abFooterCopyrightHtml = ''; +export const abContactSalesHtml = ''; + `.trim(); + } + return null; + }, + }; +} + +export default getViteConfig({ + // @ts-expect-error — Vite plugin array type mismatch between astro and vitest; safe at runtime + plugins: [virtualDocsMocks()], + test: { + globals: false, + environment: 'node', + setupFiles: ['./tests/setup.ts'], + }, +});