diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml new file mode 100644 index 00000000..e77046f7 --- /dev/null +++ b/.github/workflows/frontend-tests.yml @@ -0,0 +1,66 @@ +name: Frontend Tests + +# Runs on every PR to main (and pushes to main) that touch the frontend, so a +# broken change is caught here instead of when a user opens Word. Also keeps the +# manual trigger for ad-hoc runs. +on: + push: + branches: + - main + paths: + - 'frontend/**' + - '.github/workflows/frontend-tests.yml' + pull_request: + branches: + - main + paths: + - 'frontend/**' + - '.github/workflows/frontend-tests.yml' + workflow_dispatch: + +defaults: + run: + working-directory: frontend + +jobs: + unit: + name: Unit tests (Vitest) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v6 + with: + node-version: lts/* + cache: npm + cache-dependency-path: frontend/package-lock.json + - name: Install dependencies + run: npm ci + - name: Run unit tests + run: npm test + + e2e: + name: E2E tests (Playwright) + timeout-minutes: 60 + runs-on: ubuntu-latest + # Pinned to match the installed @playwright/test version (1.60.0); bump both + # together so the committed visual snapshots keep matching. + container: + image: mcr.microsoft.com/playwright:v1.60.0-noble + options: --user 1001 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v6 + with: + node-version: lts/* + - name: Install dependencies + run: npm ci + - name: Build Frontend + run: npm run build + - name: Run Playwright tests + run: npx playwright test + - uses: actions/upload-artifact@v7 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: frontend/playwright-report/ + retention-days: 30 diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml deleted file mode 100644 index a72202ba..00000000 --- a/.github/workflows/playwright.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Playwright Visual Regression Tests -on: - workflow_dispatch: # Manual trigger only - -defaults: - run: - working-directory: frontend - -jobs: - test: - timeout-minutes: 60 - runs-on: ubuntu-latest - container: - image: mcr.microsoft.com/playwright:v1.60.0-noble - options: --user 1001 - steps: - - uses: actions/checkout@v5 - - uses: actions/setup-node@v6 - with: - node-version: lts/* - - name: Install dependencies - run: npm ci - - name: Build Frontend - run: npm run build - - name: Run Playwright tests - run: npx playwright test - - uses: actions/upload-artifact@v7 - if: ${{ !cancelled() }} - with: - name: playwright-report - path: frontend/playwright-report/ - retention-days: 30 - diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 71855284..ee8412c9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -41,6 +41,8 @@ "@tailwindcss/cli": "^4.1.8", "@tailwindcss/postcss": "^4.1.8", "@tailwindcss/typography": "^0.5.16", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", "@types/mocha": "^10.0.6", "@types/office-js": "^1.0.377", "@types/office-runtime": "^1.0.35", @@ -66,6 +68,7 @@ "html-webpack-plugin": "^5.6.0", "http-server": "^14.1.1", "husky": "^9.1.7", + "jsdom": "^29.1.1", "less": "^4.2.0", "less-loader": "^12.2.0", "lint-staged": "^14.0.1", @@ -271,6 +274,152 @@ "license": "MIT", "peer": true }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-color-parser": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@auth0/auth0-react": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/@auth0/auth0-react/-/auth0-react-2.8.0.tgz", @@ -2565,6 +2714,19 @@ "node": ">=14.21.3" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -2624,6 +2786,26 @@ "@csstools/css-tokenizer": "^3.0.4" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@csstools/css-parser-algorithms": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", @@ -2646,6 +2828,31 @@ "@csstools/css-tokenizer": "^3.0.4" } }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", + "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, "node_modules/@csstools/css-tokenizer": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", @@ -7747,6 +7954,54 @@ "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -7786,6 +8041,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -9112,6 +9374,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", @@ -9625,6 +9897,16 @@ "dev": true, "license": "MIT" }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/big.js": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", @@ -10879,13 +11161,13 @@ } }, "node_modules/css-tree": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "license": "MIT", "dependencies": { - "mdn-data": "2.12.2", - "source-map-js": "^1.0.1" + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" @@ -11070,6 +11352,91 @@ "node": ">= 12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/data-urls/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -11152,6 +11519,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -11307,6 +11681,16 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -11446,6 +11830,13 @@ "node": ">=0.10.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dom-converter": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", @@ -14735,6 +15126,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -15063,6 +15461,167 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/jsdom/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -16261,6 +16820,16 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -16420,9 +16989,9 @@ } }, "node_modules/mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", "license": "CC0-1.0" }, "node_modules/mdurl": { @@ -20733,6 +21302,34 @@ "renderkid": "^3.0.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/prismjs": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", @@ -21092,6 +21689,13 @@ "react": "*" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/react-remark": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/react-remark/-/react-remark-2.1.0.tgz", @@ -21905,6 +22509,19 @@ "node": ">=11.0.0" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -23096,6 +23713,13 @@ "node": ">= 6" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/synckit": { "version": "0.11.11", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", @@ -23885,6 +24509,16 @@ "license": "MIT", "peer": true }, + "node_modules/undici": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.1.tgz", + "integrity": "sha512-UDdpiex+mzigiyrXrGbiUaF4HzTNhKbh2vRNFaTMzcqmLIPrZxaCtwo/1TMSuWoM1Xz3WiTo9KdgI3kRqYzJGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -24796,6 +25430,24 @@ } } }, + "node_modules/vitest/node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/vscode-jsonrpc": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-4.0.0.tgz", @@ -24807,6 +25459,19 @@ "node": ">=8.0.0 || >=10.0.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/watchpack": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", @@ -25223,6 +25888,16 @@ "dev": true, "license": "MIT" }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -25508,6 +26183,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/xml-naming": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", @@ -25549,6 +26234,13 @@ "node": ">=4.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4485fcd0..7c805c9d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -77,6 +77,8 @@ "@tailwindcss/cli": "^4.1.8", "@tailwindcss/postcss": "^4.1.8", "@tailwindcss/typography": "^0.5.16", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", "@types/mocha": "^10.0.6", "@types/office-js": "^1.0.377", "@types/office-runtime": "^1.0.35", @@ -102,6 +104,7 @@ "html-webpack-plugin": "^5.6.0", "http-server": "^14.1.1", "husky": "^9.1.7", + "jsdom": "^29.1.1", "less": "^4.2.0", "less-loader": "^12.2.0", "lint-staged": "^14.0.1", diff --git a/frontend/src/api/__tests__/wordEditorAPI.test.ts b/frontend/src/api/__tests__/wordEditorAPI.test.ts new file mode 100644 index 00000000..e9e704c6 --- /dev/null +++ b/frontend/src/api/__tests__/wordEditorAPI.test.ts @@ -0,0 +1,179 @@ +// @vitest-environment node +// +// Unit tests for the Office.js / Word integration layer (src/api/wordEditorAPI.ts). +// +// The add-in talks to Word through the `Office` and `Word` globals that Office.js +// injects at runtime. We can't run Word here, so we stub those globals and verify +// our own logic: how getDocContext assembles ranges and normalizes line endings, +// how selectPhrase searches and selects, and that the selection-change handlers +// register with the right event type. This catches the class of breakage that the +// browser-only E2E specs (which run the standalone editor, not Word) can't see. +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { wordEditorAPI } from '../wordEditorAPI'; + +// `Office` / `Word` are ambient runtime globals; tests assign fakes to them. +const g = globalThis as unknown as { Office?: unknown; Word?: unknown }; + +afterEach(() => { + delete g.Office; + delete g.Word; +}); + +/** + * Stub `Word.run` with a fake RequestContext for getDocContext. The selection's + * start/end ranges expand to the before/after ranges, each carrying `.text` + * exactly as Word would populate after `context.sync()`. + */ +function stubWordForDocContext(parts: { + before: string; + selected: string; + after: string; +}) { + const beforeRange = { text: parts.before }; + const afterRange = { text: parts.after }; + const selection = { + text: parts.selected, + getRange: vi.fn((loc: string) => ({ + expandTo: vi.fn(() => (loc === 'Start' ? beforeRange : afterRange)), + })), + }; + const context = { + document: { + body: { getRange: vi.fn(() => ({})) }, + getSelection: vi.fn(() => selection), + }, + load: vi.fn(), + sync: vi.fn().mockResolvedValue(undefined), + }; + g.Word = { + run: vi.fn((cb: (c: typeof context) => Promise) => + cb(context), + ), + }; +} + +/** Stub `Word.run` for selectPhrase with a search returning `itemCount` hits. */ +function stubWordForSearch(itemCount: number) { + const selectSpy = vi.fn(); + const items = Array.from({ length: itemCount }, () => ({ + select: selectSpy, + })); + const searchSpy = vi.fn(() => ({ items })); + const context = { + document: { body: { search: searchSpy } }, + load: vi.fn(), + sync: vi.fn().mockResolvedValue(undefined), + }; + g.Word = { + run: vi.fn((cb: (c: typeof context) => Promise) => + cb(context), + ), + }; + return { selectSpy, searchSpy }; +} + +/** Stub the `Office.context.document` selection-change handler surface. */ +function stubOffice() { + const addHandlerAsync = vi.fn(); + const removeHandlerAsync = vi.fn(); + g.Office = { + context: { document: { addHandlerAsync, removeHandlerAsync } }, + EventType: { DocumentSelectionChanged: 'documentSelectionChanged' }, + }; + return { addHandlerAsync, removeHandlerAsync }; +} + +describe('wordEditorAPI.getDocContext', () => { + it('returns the before/selected/after text read from the Word ranges', async () => { + stubWordForDocContext({ + before: 'Hello ', + selected: 'beautiful', + after: ' world', + }); + + const result = await wordEditorAPI.getDocContext(); + + expect(result).toEqual({ + beforeCursor: 'Hello ', + selectedText: 'beautiful', + afterCursor: ' world', + }); + }); + + it('normalizes carriage returns to newlines in all three fields', async () => { + stubWordForDocContext({ + before: 'First\rSecond ', + selected: 'mid\rdle', + after: ' end\rtail', + }); + + const result = await wordEditorAPI.getDocContext(); + + expect(result).toEqual({ + beforeCursor: 'First\nSecond ', + selectedText: 'mid\ndle', + afterCursor: ' end\ntail', + }); + }); + + it('rejects when Word.run fails', async () => { + g.Word = { + run: vi.fn(() => Promise.reject(new Error('Word boom'))), + }; + + await expect(wordEditorAPI.getDocContext()).rejects.toThrow('Word boom'); + }); +}); + +describe('wordEditorAPI.selectPhrase', () => { + it('selects the first match and passes Word search options', async () => { + const { selectSpy, searchSpy } = stubWordForSearch(2); + + await expect( + wordEditorAPI.selectPhrase('find me'), + ).resolves.toBeUndefined(); + + expect(searchSpy).toHaveBeenCalledWith('find me', { + ignorePunct: true, + ignoreSpace: true, + matchCase: false, + matchWildcards: false, + }); + expect(selectSpy).toHaveBeenCalledTimes(1); + }); + + it('throws "Phrase not found" when there are no matches', async () => { + const { selectSpy } = stubWordForSearch(0); + + await expect(wordEditorAPI.selectPhrase('missing')).rejects.toThrow( + 'Phrase not found', + ); + expect(selectSpy).not.toHaveBeenCalled(); + }); +}); + +describe('wordEditorAPI selection-change handlers', () => { + it('registers the handler for the DocumentSelectionChanged event', () => { + const { addHandlerAsync } = stubOffice(); + const handler = vi.fn(); + + wordEditorAPI.addSelectionChangeHandler(handler); + + expect(addHandlerAsync).toHaveBeenCalledWith( + 'documentSelectionChanged', + handler, + ); + }); + + it('removes the handler for the DocumentSelectionChanged event', () => { + const { removeHandlerAsync } = stubOffice(); + const handler = vi.fn(); + + wordEditorAPI.removeSelectionChangeHandler(handler); + + expect(removeHandlerAsync).toHaveBeenCalledWith( + 'documentSelectionChanged', + handler, + ); + }); +}); diff --git a/frontend/src/components/chatMessage/__tests__/chatMessage.test.tsx b/frontend/src/components/chatMessage/__tests__/chatMessage.test.tsx new file mode 100644 index 00000000..c5ce8497 --- /dev/null +++ b/frontend/src/components/chatMessage/__tests__/chatMessage.test.tsx @@ -0,0 +1,52 @@ +// @vitest-environment jsdom +// +// Component tests for ChatMessage (src/components/chatMessage/index.tsx). +// +// ChatMessage is a pure presentational component: it renders one chat bubble, +// aligned by role, with its markdown content rendered via react-remark. We +// render it in isolation, feed props, and assert what it puts in the DOM. The +// refresh/delete/comment toolbar is commented out in the component, so those +// callback props are unwired and we just pass no-ops. +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import ChatMessage from '../index'; + +afterEach(cleanup); + +// Required by the props type but unused (toolbar is commented out). +const callbacks = { + index: 0, + refresh: () => {}, + deleteMessage: () => {}, + convertToComment: () => {}, +}; + +describe('ChatMessage', () => { + it('renders the message content', async () => { + render(); + + // findByText throws if the text never appears, so this is the assertion. + await screen.findByText('Hello world'); + }); + + it.each([ + ['user', 'justify-end'], + ['assistant', 'justify-start'], + ])('aligns a %s message with %s', (role, expectedClass) => { + const { container } = render( + , + ); + + expect((container.firstChild as HTMLElement).className).toContain( + expectedClass, + ); + }); + + it('renders markdown as real HTML elements', async () => { + render(); + + // "**bold**" should become a , not literal asterisks. + const strong = await screen.findByText('bold'); + expect(strong.tagName).toBe('STRONG'); + }); +}); diff --git a/frontend/src/components/navbar/__tests__/navbar.test.tsx b/frontend/src/components/navbar/__tests__/navbar.test.tsx new file mode 100644 index 00000000..6a9a2dac --- /dev/null +++ b/frontend/src/components/navbar/__tests__/navbar.test.tsx @@ -0,0 +1,56 @@ +// @vitest-environment jsdom +// +// Component tests for Navbar (src/components/navbar/index.tsx). +// +// Navbar renders one tab button per page and drives `pageNameAtom` (Jotai) when +// a tab is clicked. We render it wired to a fresh Jotai store, query buttons by +// accessible role the way a user would, and assert the atom it controls — not +// CSS module class names (Vitest doesn't resolve them and they're brittle). This +// also covers the pageContext atom transition for free. +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { Provider, createStore } from 'jotai'; +import { afterEach, describe, expect, it } from 'vitest'; +import { PageName, pageNameAtom } from '@/contexts/pageContext'; +import Navbar from '../index'; + +afterEach(cleanup); + +// Fresh store per test so clicks can't leak between tests. +function renderNavbar() { + const store = createStore(); + render( + + + , + ); + return store; +} + +const tab = (name: RegExp) => screen.getByRole('button', { name }); + +describe('Navbar', () => { + it('renders a button for each page', () => { + renderNavbar(); + + // getByRole throws if a button is missing, so these are the assertions. + tab(/Draft/); + tab(/Revise/); + tab(/Chat/); + }); + + it('starts on the Draft page', () => { + const store = renderNavbar(); + + expect(store.get(pageNameAtom)).toBe(PageName.Draft); + }); + + it('switches the page atom when a tab is clicked', () => { + const store = renderNavbar(); + + fireEvent.click(tab(/Chat/)); + expect(store.get(pageNameAtom)).toBe(PageName.Chat); + + fireEvent.click(tab(/Revise/)); + expect(store.get(pageNameAtom)).toBe(PageName.Revise); + }); +}); diff --git a/frontend/tests/chat-revise-flows.spec.ts b/frontend/tests/chat-revise-flows.spec.ts new file mode 100644 index 00000000..c2763675 --- /dev/null +++ b/frontend/tests/chat-revise-flows.spec.ts @@ -0,0 +1,51 @@ +import { test, expect } from '@playwright/test'; +import { setupMockBackend } from './mockBackend'; + +// Mock-backed E2E for the Chat and Revise pages on the standalone editor. +// These exercise a full request/response round trip against the mocked +// OpenAI-compatible endpoint (see mockBackend.ts), so they need no real backend. +test.beforeEach(async ({ page }) => { + await setupMockBackend(page); + await page.goto('/editor.html?page=demo'); + // Draft is the default page — wait for it to confirm the app has loaded. + await expect(page.locator('button[aria-label="Examples"]')).toBeVisible({ + timeout: 15000, + }); +}); + +test('Chat: sending a message shows the user message and the assistant reply', async ({ + page, +}) => { + await page.locator('button', { hasText: 'Chat' }).click(); + + const input = page.locator('textarea[placeholder*="Ask"]'); + // Use a message that is NOT one of the suggestion chips, so the assertion + // can't accidentally match the welcome screen. + await input.fill('Is the tone consistent?'); + await page.locator('button[title="Send message"]').click(); + + // The user's message is echoed into the conversation. + await expect(page.getByText('Is the tone consistent?')).toBeVisible(); + // The mocked assistant reply streams in. + await expect( + page.getByText('This is a mock assistant reply about your document.'), + ).toBeVisible({ timeout: 5000 }); +}); + +test('Revise: running a selected feature shows a result', async ({ page }) => { + // Type something so Revise isn't in its empty-document state. + const editor = page.locator('[contenteditable="true"]'); + await editor.click(); + await editor.pressSequentially('Some text to analyze'); + + await page.locator('button', { hasText: 'Revise' }).click(); + + // Select a feature, then run it via the sticky footer button. + await page.locator('button', { hasText: 'Hierarchical Outline' }).click(); + await page.locator('button', { hasText: /^Run / }).click(); + + // The mocked visualization result is rendered. + await expect( + page.getByText('A mock structural observation about your document.'), + ).toBeVisible({ timeout: 5000 }); +}); diff --git a/frontend/tests/demo-page-visual.spec.ts b/frontend/tests/demo-page-visual.spec.ts index f6d1c143..e9387ec2 100644 --- a/frontend/tests/demo-page-visual.spec.ts +++ b/frontend/tests/demo-page-visual.spec.ts @@ -9,11 +9,7 @@ test('demo page - visual regression', async ({ page }) => { await expect(page.getByRole('banner')).toContainText('Thoughtful'); - // Allow a small pixel budget so sub-visible rendering noise (font antialiasing, - // subpixel shifts) doesn't fail the test. A real layout regression moves far - // more than this many pixels. await expect(page).toHaveScreenshot('demo-page.png', { fullPage: true, - maxDiffPixels: 100, }); }); \ No newline at end of file diff --git a/frontend/tests/demo-page-visual.spec.ts-snapshots/demo-page-chromium-linux.png b/frontend/tests/demo-page-visual.spec.ts-snapshots/demo-page-chromium-linux.png index e50eff4e..419ad1ff 100644 Binary files a/frontend/tests/demo-page-visual.spec.ts-snapshots/demo-page-chromium-linux.png and b/frontend/tests/demo-page-visual.spec.ts-snapshots/demo-page-chromium-linux.png differ diff --git a/frontend/tests/demo-page-visual.spec.ts-snapshots/demo-page-firefox-linux.png b/frontend/tests/demo-page-visual.spec.ts-snapshots/demo-page-firefox-linux.png index 3637d885..3d4692d9 100644 Binary files a/frontend/tests/demo-page-visual.spec.ts-snapshots/demo-page-firefox-linux.png and b/frontend/tests/demo-page-visual.spec.ts-snapshots/demo-page-firefox-linux.png differ diff --git a/frontend/tests/demo-page-visual.spec.ts-snapshots/demo-page-webkit-linux.png b/frontend/tests/demo-page-visual.spec.ts-snapshots/demo-page-webkit-linux.png index daf9596c..3d4692d9 100644 Binary files a/frontend/tests/demo-page-visual.spec.ts-snapshots/demo-page-webkit-linux.png and b/frontend/tests/demo-page-visual.spec.ts-snapshots/demo-page-webkit-linux.png differ diff --git a/frontend/tests/editor.spec.ts b/frontend/tests/editor.spec.ts index 12e83a73..50162f23 100644 --- a/frontend/tests/editor.spec.ts +++ b/frontend/tests/editor.spec.ts @@ -41,6 +41,77 @@ test('can switch to Chat page and see message input', async ({ page }) => { await expect(page.locator('textarea[placeholder*="Ask"]')).toBeVisible(); }); +test('Rewording without a selection prompts the user to select text', async ({ page }) => { + // Rewording short-circuits before any backend call when no text is selected + // (draft/index.tsx), so this needs no mock backend. + await page.locator('button[aria-label="Rewording"]').click(); + await expect( + page.getByText('Please select some text to get rewording suggestions'), + ).toBeVisible(); +}); + +test('Revise shows an empty-document message when nothing is written', async ({ page }) => { + // No text was typed in beforeEach, so the document is empty. + await page.locator('button', { hasText: 'Revise' }).click(); + await expect(page.getByText('The document seems to be empty')).toBeVisible(); +}); + +test('Chat send button is disabled until the input has text', async ({ page }) => { + await page.locator('button', { hasText: 'Chat' }).click(); + + const input = page.locator('textarea[placeholder*="Ask"]'); + const sendButton = page.locator('button[title="Send message"]'); + + await expect(input).toBeVisible(); + await expect(sendButton).toBeDisabled(); + + await input.fill('What is my main argument?'); + await expect(sendButton).toBeEnabled(); + + // Clearing the input disables the button again. + await input.fill(''); + await expect(sendButton).toBeDisabled(); +}); + +test('word count updates as text is typed', async ({ page }) => { + // Demo mode shows a "Words: N" counter next to the editor. + await expect(page.getByText('Words: 0')).toBeVisible(); + + const editor = page.locator('[contenteditable="true"]'); + await editor.click(); + await editor.pressSequentially('one two three'); + + await expect(page.getByText('Words: 3')).toBeVisible(); +}); + +test('Chat welcome screen shows the suggestion chips', async ({ page }) => { + await page.locator('button', { hasText: 'Chat' }).click(); + + for (const prompt of [ + 'What is my main argument?', + 'How can I improve clarity?', + 'Is my structure logical?', + 'What am I missing?', + ]) { + await expect(page.getByRole('button', { name: prompt })).toBeVisible(); + } +}); + +test('Revise Run button enables only after a feature is selected', async ({ page }) => { + // Revise needs a non-empty document to show its feature list. + const editor = page.locator('[contenteditable="true"]'); + await editor.click(); + await editor.pressSequentially('Some text to analyze'); + + await page.locator('button', { hasText: 'Revise' }).click(); + + const runButton = page.getByRole('button', { name: /Run/ }); + await expect(runButton).toBeDisabled(); + + await page.locator('button', { hasText: 'Hierarchical Outline' }).click(); + await expect(runButton).toBeEnabled(); +}); + test('can navigate between all three tabs', async ({ page }) => { // Start on Draft (default) await expect(page.locator('button[aria-label="Examples"]')).toBeVisible(); diff --git a/frontend/tests/mockBackend.ts b/frontend/tests/mockBackend.ts index f89ee76a..62bb7703 100644 --- a/frontend/tests/mockBackend.ts +++ b/frontend/tests/mockBackend.ts @@ -16,10 +16,18 @@ const RESULTS = { '- First reader perspective\n\n- Second reader perspective\n\n- Third reader perspective', proposal_advice: '- First piece of advice\n\n- Second piece of advice\n\n- Third piece of advice', + example_rewording: + '- First rewording option\n\n- Second rewording option\n\n- Third rewording option', + // Revise (visualization): includes a doctext link like the real responses do. + revise: + '- A mock structural observation about your document.\n\n- [opening line](doctext:Some%20text%20to%20analyze) could be expanded.', + // Chat assistant reply. + chat: 'This is a mock assistant reply about your document.', }; -// gtype is no longer sent in the request; infer it from distinctive prompt text -// (see the prompts in src/api/prompts.ts). +// gtype is no longer sent in the request; infer it from distinctive prompt text. +// Draft prompts live in src/api/prompts.ts; Chat and Revise build their own +// system/user messages in their page components. function resultForMessages(messages: { content: string }[]): string { const text = messages.map((m) => m.content).join('\n'); if (text.includes('inspiring and fresh possible next sentences')) @@ -28,6 +36,13 @@ function resultForMessages(messages: { content: string }[]): string { return RESULTS.analysis_readerPerspective; if (text.includes('directive (but not prescriptive) advice')) return RESULTS.proposal_advice; + if (text.includes('three alternative rewordings')) + return RESULTS.example_rewording; + // Revise wraps the document in tags (revise/index.tsx). + if (text.includes('')) return RESULTS.revise; + // Chat is identified by its system prompt (chat/index.tsx). + if (text.includes('Encourage the user towards critical thinking')) + return RESULTS.chat; return ''; }