From 9f5f89fa57d18e23375eef1dbbf8c43c3a09c0d0 Mon Sep 17 00:00:00 2001 From: danielstahl Date: Sat, 12 Sep 2026 15:07:32 +0000 Subject: [PATCH 01/15] build: pin the toolchain and make the OpenAPI spec bootstrap first-class Covers workspace-pge and workspace-ojh. Toolchain (workspace-pge): - Declare @ant-design/icons, react and react-dom as explicit dependencies; they were imported but never declared. - Add typecheck / test / test:watch scripts, engines "^22.12.0 || >=24.0.0" and .nvmrc 24. - src/setupTests.js now imports '@testing-library/jest-dom/vitest' and is wired in via setupFiles; previously the setup file was never loaded. - Add a harness test so a silently-broken test harness cannot ship again. Spec bootstrap (workspace-ojh): - src/swagger_spec.json is git-ignored yet imported by App.tsx, so a fresh clone could not build. `npm run spec` now materializes it. - scripts/downloadYML.js fetches scripts/releases.json itself, treats the GitHub token as optional (the asset is public), retries with backoff, validates the YAML, and writes only on full success so a failed run can never clobber a good spec. - scripts/checkSpec.js runs as prebuild/predev, so a missing spec fails with a message naming `npm run spec` instead of an unresolved-import error. - CI: both workflows use `npm run spec`, .nvmrc and npm caching. test.yml now actually runs typecheck and `npm test` - it previously only built, which was a workspace-pge criterion that had not been implemented. Note: regenerating the spec picked up a real upstream change vs the stale local copy - /cgmy/riskmetric and /cgmyse/riskmetric differ in where `additionalProperties: false` attaches (semantically minor). --- .github/workflows/deploy.yml | 16 +- .github/workflows/test.yml | 30 ++- .nvmrc | 1 + README.md | 34 +++ package-lock.json | 432 ++++++++++++++++++++++++++++++----- package.json | 17 +- scripts/checkSpec.js | 45 ++++ scripts/downloadYML.js | 174 +++++++++++--- src/harness.test.ts | 22 ++ src/jest-dom.d.ts | 4 + src/setupTests.js | 11 +- vitetest.config.ts | 5 +- 12 files changed, 678 insertions(+), 113 deletions(-) create mode 100644 .nvmrc create mode 100644 scripts/checkSpec.js create mode 100644 src/harness.test.ts create mode 100644 src/jest-dom.d.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6cec2e8..5ffad6c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -12,16 +12,20 @@ jobs: - name: nodejs uses: actions/setup-node@v4 with: - node-version: "23.5.0" - - name: setup for deploy + node-version-file: .nvmrc + cache: npm + - name: install dependencies + run: npm ci + - name: generate OpenAPI spec + # Also writes scripts/releases.json, which scripts/outputTag reads below. + run: npm run spec + env: + ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }} + - name: build run: | - for i in {1..5}; do curl -L -H "Authorization: token ${{ secrets.ACCESS_TOKEN }}" -o ./scripts/releases.json https://api.github.com/repos/realoptions/option_price_faas/releases/latest && break || sleep 10; done tag=$(node ./scripts/outputTag) echo "VITE_TAG=$tag" >> .env - echo "this is the tag #: $tag" echo "VITE_FirebaseAPIKey=${{ secrets.FIREBASE_API_KEY }}" >> .env - npm ci - access_token=${{ secrets.ACCESS_TOKEN }} node ./scripts/downloadYML.js npm run build env: CI: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5eb0825..0e5969f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,16 +9,24 @@ jobs: - name: nodejs uses: actions/setup-node@v4 with: - node-version: "23.5.0" - - name: test install - run: | - npm ci - for i in {1..5}; do curl -L -H "Authorization: token ${{ secrets.ACCESS_TOKEN }}" -o ./scripts/releases.json https://api.github.com/repos/realoptions/option_price_faas/releases/latest && break || sleep 10; done - tag=$(node ./scripts/outputTag) - echo "VITE_TAG=$tag" >> .env - echo "this is the tag #: $tag" - echo "VITE_FirebaseAPIKey=${{ secrets.FIREBASE_API_KEY }}" >> .env - access_token=${{ secrets.ACCESS_TOKEN }} node ./scripts/downloadYML.js - npm run build + node-version-file: .nvmrc + cache: npm + - name: install dependencies + run: npm ci + - name: generate OpenAPI spec + # src/swagger_spec.json is git-ignored and imported by src/App.tsx, so it must + # be generated before typecheck/build. The token guards against the anonymous + # GitHub API rate limit that shared CI runner IPs are prone to hit. + run: npm run spec + env: + ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }} + - name: typecheck + run: npm run typecheck + - name: install browser test dependencies + run: npx playwright install --with-deps chromium + - name: test + run: npm test + - name: build + run: npm run build env: CI: true diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/README.md b/README.md index 54ef094..31e426e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,37 @@ +# Developer site + +## Quick start + +Requires Node as pinned in [`.nvmrc`](.nvmrc) (`nvm use` picks it up). + +```bash +npm ci +npm run spec # generates src/swagger_spec.json (required, see below) +npm run dev # or: npm run build +``` + +### Why `npm run spec` is required + +`src/swagger_spec.json` is imported by `src/App.tsx` but is **generated and git-ignored**, +so it does not exist in a fresh clone. `npm run spec` downloads `openapi_gcp.yml` from the +latest [`realoptions/option_price_faas`](https://github.com/realoptions/option_price_faas) +release and writes it as JSON. + +A GitHub token is **optional** — it is only needed if that repo becomes private or you hit +the anonymous API rate limit: + +```bash +ACCESS_TOKEN=ghp_xxx npm run spec +``` + +`npm run dev` and `npm run build` check for the file first and fail with a message pointing +at `npm run spec`, rather than an unresolved-import error. + +Other commands: `npm run typecheck`, `npm test` (browser tests via Playwright — run +`npx playwright install --with-deps chromium` once). + +--- + This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). ## Available Scripts diff --git a/package-lock.json b/package-lock.json index ee5c56f..eba6245 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,16 +8,21 @@ "name": "developer_site", "version": "0.1.0", "dependencies": { + "@ant-design/icons": "^6.1.0", "antd": "^6.3.1", "firebase": "^12.10.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", "react-social-login-buttons": "^4.1.1", "swagger-ui-react": "^5.32.11" }, "devDependencies": { + "@testing-library/jest-dom": "^7.0.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/swagger-ui-react": "^5.18.0", "@vitejs/plugin-react": "^5.2.0", + "@vitest/browser-playwright": "^4.1.8", "follow-redirects": "^1.16.0", "js-yaml": "^4.3.1", "playwright": "^1.58.2", @@ -25,8 +30,19 @@ "vite": "^8.0.16", "vitest": "^4.1.8", "vitest-browser-react": "^2.0.5" + }, + "engines": { + "node": "^22.12.0 || >=24.0.0", + "npm": ">=10.0.0" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@ant-design/colors": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-8.0.1.tgz", @@ -423,6 +439,13 @@ "node": ">=6.9.0" } }, + "node_modules/@blazediff/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz", + "integrity": "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==", + "dev": true, + "license": "MIT" + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -1187,6 +1210,13 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -2944,6 +2974,63 @@ "node": ">=12.20.0" } }, + "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", + "peer": true, + "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/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -2955,6 +3042,14 @@ "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", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3019,9 +3114,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -3128,17 +3223,64 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/@vitest/browser": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.11.tgz", + "integrity": "sha512-bwMovvAeuTFOK5kIFevw4VEf+1gVEICv4SYK4k3knJOxl6b1zEWud8mYKD73e1B0odAn174h1MofURy2TPWf3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@blazediff/core": "1.9.1", + "@vitest/mocker": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.1.0", + "ws": "^8.19.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.11" + } + }, + "node_modules/@vitest/browser-playwright": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.11.tgz", + "integrity": "sha512-riLBxPqwnJ0lWs2DN2WeUfYeKLoAjbP2Xx8cLQdSddzMi20sksIa6K2mPz79DyMZKKVKH2ksOC2yJvtNcZg8cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/browser": "4.1.11", + "@vitest/mocker": "4.1.11", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "4.1.11" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": false + } + } + }, "node_modules/@vitest/expect": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", - "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -3147,13 +3289,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", - "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.8", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -3174,9 +3316,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", - "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -3187,13 +3329,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", - "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.8", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -3201,14 +3343,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", - "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -3217,9 +3359,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", - "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -3227,13 +3369,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", - "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -3353,6 +3495,16 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "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/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -3822,6 +3974,16 @@ "node": ">=0.4.0" } }, + "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/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3832,6 +3994,14 @@ "node": ">=8" } }, + "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", + "peer": true + }, "node_modules/dompurify": { "version": "3.4.13", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", @@ -4341,6 +4511,16 @@ "integrity": "sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==", "license": "MIT" }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -4838,6 +5018,17 @@ "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", + "peer": true, + "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", @@ -4878,6 +5069,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minim": { "version": "0.23.8", "resolved": "https://registry.npmjs.org/minim/-/minim-0.23.8.tgz", @@ -4905,6 +5106,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5105,6 +5316,16 @@ "node": ">=18" } }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -5143,6 +5364,44 @@ "node": "^10 || ^12 || >=14" } }, + "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", + "peer": true, + "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", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/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", + "peer": true + }, "node_modules/prismjs": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", @@ -5270,7 +5529,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5293,7 +5551,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5392,6 +5649,20 @@ "react": ">= 0.14.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -5552,8 +5823,7 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/scroll-into-view-if-needed": { "version": "3.1.0", @@ -5643,6 +5913,21 @@ "dev": true, "license": "ISC" }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5715,6 +6000,19 @@ "node": ">=8" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/stylis": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", @@ -5914,6 +6212,16 @@ "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", "license": "MIT" }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/tree-sitter": { "version": "0.21.1", "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.21.1.tgz", @@ -6169,19 +6477,19 @@ } }, "node_modules/vitest": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", - "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.8", - "@vitest/mocker": "4.1.8", - "@vitest/pretty-format": "4.1.8", - "@vitest/runner": "4.1.8", - "@vitest/snapshot": "4.1.8", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -6209,12 +6517,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.8", - "@vitest/browser-preview": "4.1.8", - "@vitest/browser-webdriverio": "4.1.8", - "@vitest/coverage-istanbul": "4.1.8", - "@vitest/coverage-v8": "4.1.8", - "@vitest/ui": "4.1.8", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -6374,6 +6682,28 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", diff --git a/package.json b/package.json index ca35414..8c9160c 100644 --- a/package.json +++ b/package.json @@ -3,15 +3,24 @@ "version": "0.1.0", "private": true, "dependencies": { + "@ant-design/icons": "^6.1.0", "antd": "^6.3.1", "firebase": "^12.10.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", "react-social-login-buttons": "^4.1.1", "swagger-ui-react": "^5.32.11" }, "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "spec": "node ./scripts/downloadYML.js", + "predev": "node ./scripts/checkSpec.js", + "prebuild": "node ./scripts/checkSpec.js", + "typecheck": "tsc -p tsconfig.app.json --noEmit", + "test": "vitest run --config vitetest.config.ts", + "test:watch": "vitest --config vitetest.config.ts" }, "browserslist": { "production": [ @@ -26,6 +35,8 @@ ] }, "devDependencies": { + "@testing-library/jest-dom": "^7.0.1", + "@vitest/browser-playwright": "^4.1.8", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/swagger-ui-react": "^5.18.0", @@ -37,5 +48,9 @@ "vite": "^8.0.16", "vitest": "^4.1.8", "vitest-browser-react": "^2.0.5" + }, + "engines": { + "node": "^22.12.0 || >=24.0.0", + "npm": ">=10.0.0" } } diff --git a/scripts/checkSpec.js b/scripts/checkSpec.js new file mode 100644 index 0000000..38dfed5 --- /dev/null +++ b/scripts/checkSpec.js @@ -0,0 +1,45 @@ +#!/usr/bin/env node +/** + * Pre-flight check for the generated OpenAPI spec. + * + * `src/swagger_spec.json` is git-ignored but imported by `src/App.tsx`. Without it, + * Vite fails with a bare "Could not resolve ./swagger_spec.json" that tells a new + * contributor nothing about the actual fix. This script runs as the `prebuild` / + * `predev` hook so the failure names the remedy instead. + */ +const fs = require("fs"); +const path = require("path"); + +const SPEC_PATH = path.join(__dirname, "..", "src", "swagger_spec.json"); +const HOW_TO_FIX = "Run `npm run spec` to generate it (see README.md)."; + +function fail(problem) { + console.error(`\n✗ Missing required build input: src/swagger_spec.json`); + console.error(` ${problem}`); + console.error(` ${HOW_TO_FIX}\n`); + process.exit(1); +} + +if (!fs.existsSync(SPEC_PATH)) { + fail("The file does not exist. It is generated, not committed."); +} + +const raw = fs.readFileSync(SPEC_PATH, "utf8"); +if (raw.trim() === "") { + fail("The file is empty — the previous generation probably failed."); +} + +let spec; +try { + spec = JSON.parse(raw); +} catch (error) { + fail(`The file is not valid JSON: ${error.message}`); +} + +if (!spec || typeof spec !== "object" || Object.keys(spec.paths || {}).length === 0) { + fail('The file parses but contains no "paths" — it is not a usable OpenAPI document.'); +} + +console.log( + `✓ src/swagger_spec.json present (${Object.keys(spec.paths).length} paths, ${raw.length} bytes).`, +); diff --git a/scripts/downloadYML.js b/scripts/downloadYML.js index c8c6244..c4295ef 100644 --- a/scripts/downloadYML.js +++ b/scripts/downloadYML.js @@ -1,43 +1,141 @@ +#!/usr/bin/env node +/** + * Bootstrap the OpenAPI spec this app renders. + * + * `src/swagger_spec.json` is a GENERATED artifact and is git-ignored, yet `src/App.tsx` + * imports it. A fresh clone therefore cannot build until this script has been run once: + * + * npm run spec + * + * It downloads `openapi_gcp.yml` from the latest release of + * `realoptions/option_price_faas`, converts it to JSON, and writes it to + * `src/swagger_spec.json`. + * + * A GitHub token is OPTIONAL — it is only needed if the source repo is private or the + * anonymous API rate limit is being hit: + * + * ACCESS_TOKEN=ghp_xxx npm run spec + * + * `scripts/releases.json` (also git-ignored) is fetched automatically when absent, so + * no manual `curl` step is required. + */ +const fs = require("fs"); const path = require("path"); const { https } = require("follow-redirects"); -const fs = require("fs"); -const process = require("process"); -const cwd = process.cwd(); -const releasePath = path.join(cwd, "scripts", "releases.json"); -const { assets } = require(releasePath); -const { access_token } = process.env; -const assetName = "openapi_gcp.yml"; const yaml = require("js-yaml"); -const asset = assets.find((val) => val.name === assetName); -const writePath = path.join(cwd, assetName); -const jsonPath = path.join(cwd, "src", "swagger_spec.json"); -const file = fs.createWriteStream(writePath); -https - .get( - asset.browser_download_url, - { - headers: { - Authorization: `token ${access_token}`, - }, - }, - (response, err) => { - if (err) { - return console.log(err); - } - response.pipe(file); - file.on("finish", () => { - file.close(); - const jsobj = yaml.load(fs.readFileSync(writePath, "utf8")); - fs.writeFile(jsonPath, JSON.stringify(jsobj), "utf8", (err) => { - if (err) console.log(err); - fs.unlink(writePath, () => console.log("done")); - }); - }); - }, - ) - .on("error", function (err) { - // Handle errors - fs.unlink(writePath, () => console.log("deleted")); // Delete the file async. (But we don't check the result) - console.log(err); +const ROOT = path.resolve(__dirname, ".."); +const RELEASES_PATH = path.join(__dirname, "releases.json"); +const SPEC_PATH = path.join(ROOT, "src", "swagger_spec.json"); + +const RELEASE_REPO = "realoptions/option_price_faas"; +const RELEASE_API = `https://api.github.com/repos/${RELEASE_REPO}/releases/latest`; +const ASSET_NAME = "openapi_gcp.yml"; +const USER_AGENT = "developer_site-spec-bootstrap"; + +const accessToken = process.env.ACCESS_TOKEN || process.env.access_token; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +function get(url) { + return new Promise((resolve, reject) => { + const headers = { "User-Agent": USER_AGENT }; + if (accessToken) headers.Authorization = `token ${accessToken}`; + https + .get(url, { headers }, (res) => { + const chunks = []; + res.on("data", (chunk) => chunks.push(chunk)); + res.on("end", () => + resolve({ + status: res.statusCode, + body: Buffer.concat(chunks).toString("utf8"), + }), + ); + }) + .on("error", reject); }); +} + +async function getWithRetry(url, attempts = 3) { + let lastError; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + const response = await get(url); + if (response.status >= 200 && response.status < 300) return response; + const hint = + response.status === 401 && accessToken + ? " — ACCESS_TOKEN was rejected." + : response.status === 403 && !accessToken + ? " — GitHub rate-limits unauthenticated requests; set ACCESS_TOKEN and retry." + : response.status === 404 + ? ` — is ${RELEASE_REPO} reachable, and does it have a release?` + : ""; + lastError = new Error(`HTTP ${response.status} for ${url}${hint}`); + } catch (error) { + lastError = error; + } + if (attempt < attempts) await sleep(1000 * attempt); + } + throw lastError; +} + +/** Load the cached release metadata, fetching it if it is missing or unreadable. */ +async function loadRelease() { + if (fs.existsSync(RELEASES_PATH)) { + try { + const cached = JSON.parse(fs.readFileSync(RELEASES_PATH, "utf8")); + if (cached && Array.isArray(cached.assets)) { + console.log(`Using cached release metadata: ${cached.tag_name}`); + return cached; + } + } catch { + console.log("Cached scripts/releases.json is unreadable; re-fetching."); + } + } + console.log(`Fetching latest release metadata from ${RELEASE_REPO}...`); + const response = await getWithRetry(RELEASE_API); + const release = JSON.parse(response.body); + fs.writeFileSync(RELEASES_PATH, response.body, "utf8"); + return release; +} + +async function main() { + const release = await loadRelease(); + const asset = (release.assets || []).find((item) => item.name === ASSET_NAME); + if (!asset) { + throw new Error( + `Release asset "${ASSET_NAME}" not found on ${release.tag_name}. ` + + `Available: ${(release.assets || []).map((a) => a.name).join(", ") || "none"}`, + ); + } + + console.log(`Downloading ${ASSET_NAME} (${asset.size} bytes) from ${release.tag_name}...`); + const download = await getWithRetry(asset.browser_download_url); + + let parsed; + try { + parsed = yaml.load(download.body); + } catch (error) { + throw new Error( + `Failed to parse ${ASSET_NAME} as YAML: ${error.message}. ` + + `The release asset may be corrupt or truncated.`, + ); + } + if (!parsed || typeof parsed !== "object" || !parsed.paths) { + throw new Error( + `Parsed ${ASSET_NAME} has no "paths" section — this does not look like an OpenAPI document.`, + ); + } + + fs.writeFileSync(SPEC_PATH, JSON.stringify(parsed), "utf8"); + console.log( + `Wrote ${SPEC_PATH} (${Object.keys(parsed.paths).length} paths, ` + + `${fs.statSync(SPEC_PATH).size} bytes). You can now run \`npm run dev\` or \`npm run build\`.`, + ); +} + +main().catch((error) => { + console.error(`\n✗ ${error.message}`); + console.error(" See the header of scripts/downloadYML.js or README.md for details."); + process.exitCode = 1; +}); diff --git a/src/harness.test.ts b/src/harness.test.ts new file mode 100644 index 0000000..4f9fde6 --- /dev/null +++ b/src/harness.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; + +// Harness smoke test: guards the wiring this repo was missing. +// If setupFiles/@testing-library-jest-dom registration breaks, the matcher +// below stops existing and this test fails loudly instead of silently skipping. +describe("test harness", () => { + it("runs in a real browser DOM", () => { + expect(typeof document).toBe("object"); + expect(document.body).toBeTruthy(); + }); + + it("has jest-dom matchers registered via src/setupTests.js", () => { + const el = document.createElement("p"); + el.textContent = "harness ready"; + document.body.append(el); + + expect(el).toHaveTextContent("harness ready"); + expect(el).toBeVisible(); + + el.remove(); + }); +}); diff --git a/src/jest-dom.d.ts b/src/jest-dom.d.ts new file mode 100644 index 0000000..044ccf7 --- /dev/null +++ b/src/jest-dom.d.ts @@ -0,0 +1,4 @@ +// Makes @testing-library/jest-dom's Vitest matcher types (toHaveTextContent, toBeVisible, ...) +// visible to the files under this tsconfig. The runtime registration lives in src/setupTests.js, +// which is plain JS and therefore not type-checked. +/// diff --git a/src/setupTests.js b/src/setupTests.js index 74b1a27..9a26e0b 100644 --- a/src/setupTests.js +++ b/src/setupTests.js @@ -1,5 +1,6 @@ -// jest-dom adds custom jest matchers for asserting on DOM nodes. -// allows you to do things like: -// expect(element).toHaveTextContent(/react/i) -// learn more: https://github.com/testing-library/jest-dom -import '@testing-library/jest-dom/extend-expect'; +// jest-dom adds custom matchers for asserting on DOM nodes, e.g.: +// expect(element).toHaveTextContent(/react/i) +// The `/vitest` subpath registers the matchers with Vitest's `expect`. +// (The old `@testing-library/jest-dom/extend-expect` subpath was removed in jest-dom v6; +// it went unnoticed here because this file was never referenced by a test runner config.) +import '@testing-library/jest-dom/vitest'; diff --git a/vitetest.config.ts b/vitetest.config.ts index a59ed07..6d0786f 100644 --- a/vitetest.config.ts +++ b/vitetest.config.ts @@ -1,12 +1,15 @@ /// import { defineConfig } from "vitest/config"; +import { playwright } from "@vitest/browser-playwright"; import react from "@vitejs/plugin-react"; export default defineConfig({ plugins: [react()], test: { + setupFiles: ['./src/setupTests.js'], browser: { - provider: "playwright", // or 'webdriverio' + // Vitest 4 takes a provider factory, not a string. + provider: playwright(), enabled: true, headless: true, // at least one instance is required From 74718420f4de8e701d16ece7ee002b2b10006293 Mon Sep 17 00:00:00 2001 From: danielstahl Date: Sat, 12 Sep 2026 15:07:32 +0000 Subject: [PATCH 02/15] refactor: fix Logo, rename non-JSX modules, extract Firebase init from App Covers workspace-938 and workspace-7da. Logo and module renames (workspace-938): - Remove the unused `import React` that failed noUnusedLocals under strict TS. - Move Logo to src/components/Logo.tsx with a typed LogoProps interface (height/width/className, defaulting to 1em/1em/logo-primary). - Rename styles.tsx -> styles.ts and copyToClipboard.tsx -> .ts; neither contains JSX, so the .tsx extension was wrong. Firebase initialization (workspace-7da): - src/firebase.ts now owns config composition (src/config.json plus the build-time VITE_FirebaseAPIKey) and a lazy, memoised singleton. - App.tsx no longer calls initializeApp()/getAuth() at module scope, so importing it performs no side effects and components can be rendered in tests without live Firebase wiring, or with a stubbed module. - Also drops the stale `process.env.REACT_APP_FirebaseAPIKey` CRA comment. Verified: tsc clean, build passes, tests green. The lazy-init invariant was proven in a browser by asserting getApps() is empty after importing App.tsx and equals one only after the first getFirebaseAuth() call. --- src/App.tsx | 18 ++++---- src/{ => components}/Logo.tsx | 10 ++++- ...copyToClipboard.tsx => copyToClipboard.ts} | 0 src/firebase.ts | 41 +++++++++++++++++++ src/{styles.tsx => styles.ts} | 0 5 files changed, 56 insertions(+), 13 deletions(-) rename src/{ => components}/Logo.tsx (82%) rename src/{copyToClipboard.tsx => copyToClipboard.ts} (100%) create mode 100644 src/firebase.ts rename src/{styles.tsx => styles.ts} (100%) diff --git a/src/App.tsx b/src/App.tsx index 489c7b6..d8e618c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,25 +1,19 @@ import { useState, useEffect } from "react"; import { Layout, Menu, Button, message, Alert } from "antd"; import "./App.css"; -import { initializeApp } from "firebase/app"; -import { getAuth, onAuthStateChanged, signOut, type User } from "firebase/auth"; +import { onAuthStateChanged, signOut, type User } from "firebase/auth"; +import { getFirebaseAuth } from "./firebase.ts"; import LoginButton from "./FirebaseLogin.tsx"; import { CopyOutlined as Copy } from "@ant-design/icons"; import type { MenuProps } from "antd"; import SwaggerUI from "swagger-ui-react"; import "swagger-ui-react/swagger-ui.css"; -import Logo from "./Logo.tsx"; -import { menuHeight, logoHeight, paddingTop } from "./styles.tsx"; -import { copyToClipboard } from "./copyToClipboard.tsx"; +import Logo from "./components/Logo.tsx"; +import { menuHeight, logoHeight, paddingTop } from "./styles.ts"; +import { copyToClipboard } from "./copyToClipboard.ts"; type MenuItem = Required["items"][number]; import apiSpec from "./swagger_spec.json"; -import config from "./config.json"; const { Header, Content, Footer } = Layout; -const firebase = initializeApp({ - ...config, - apiKey: import.meta.env.VITE_FirebaseAPIKey, // process.env.REACT_APP_FirebaseAPIKey, -}); -const auth = getAuth(firebase); const info = () => { message.info("Token copied"); }; @@ -59,6 +53,8 @@ const menuItems: MenuItem[] = [{ key: "1", label: "Log Out" }]; const DevHome = () => { const [user, setUser] = useState(null); const [token, setToken] = useState(""); + // Lazy + memoised inside the module, so this is a cached read after the first call. + const auth = getFirebaseAuth(); useEffect(() => { const unregisterAuthObserver = onAuthStateChanged(auth, (user) => { diff --git a/src/Logo.tsx b/src/components/Logo.tsx similarity index 82% rename from src/Logo.tsx rename to src/components/Logo.tsx index 4bb4e6a..d82301b 100644 --- a/src/Logo.tsx +++ b/src/components/Logo.tsx @@ -1,9 +1,15 @@ -import React from 'react' +// The react-jsx runtime supplies JSX, so no React import is needed here. +type LogoProps = { + height?: string | number; + width?: string | number; + className?: string; +} + const Logo = ({ height = '1em', width = '1em', className = 'logo-primary' -}: { height: string | number, width: string | number, className: string }) => ( +}: LogoProps) => ( 0 ? getApp() : initializeApp(firebaseOptions); + } + return cachedApp; +} + +/** Lazily creates (and caches) the Auth service for the default Firebase app. */ +export function getFirebaseAuth(): Auth { + if (!cachedAuth) { + cachedAuth = getAuth(getFirebaseApp()); + } + return cachedAuth; +} diff --git a/src/styles.tsx b/src/styles.ts similarity index 100% rename from src/styles.tsx rename to src/styles.ts From c9d5b11b7690c61a27261c490959d1c3d1db5069 Mon Sep 17 00:00:00 2001 From: danielstahl Date: Sat, 12 Sep 2026 16:09:10 +0000 Subject: [PATCH 03/15] refactor: replace deprecated execCommand clipboard with the async Clipboard API copyToClipboard was a vendored gist built on the deprecated document.execCommand('copy'). It injected a hidden
@@ -26,10 +37,7 @@ const Description = ({ token }: { token: string }) => ( diff --git a/src/copyToClipboard.test.ts b/src/copyToClipboard.test.ts new file mode 100644 index 0000000..3e2685b --- /dev/null +++ b/src/copyToClipboard.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ClipboardUnsupportedError, copyToClipboard } from "./copyToClipboard.ts"; + +// `clipboard` normally lives on Navigator.prototype, so defining it as an own +// property shadows it for the test and deleting it restores the real lookup. +const setClipboard = (value: unknown) => { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + writable: true, + value, + }); +}; + +afterEach(() => { + Reflect.deleteProperty(navigator, "clipboard"); + Reflect.deleteProperty(document, "execCommand"); +}); + +describe("copyToClipboard", () => { + it("writes the text through the async Clipboard API and resolves", async () => { + const writeText = vi.fn(async () => undefined); + setClipboard({ writeText }); + + await expect(copyToClipboard("token-123")).resolves.toBeUndefined(); + + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText).toHaveBeenCalledWith("token-123"); + }); + + it("rejects when the write is refused, so the caller can report failure", async () => { + const writeText = vi.fn(async () => { + throw new DOMException("Permission denied", "NotAllowedError"); + }); + setClipboard({ writeText }); + + await expect(copyToClipboard("token-123")).rejects.toThrow(/Permission denied/); + + expect(writeText).toHaveBeenCalledTimes(1); + }); + + it("rejects with ClipboardUnsupportedError when there is no clipboard API", async () => { + setClipboard(undefined); + + await expect(copyToClipboard("token-123")).rejects.toBeInstanceOf( + ClipboardUnsupportedError, + ); + }); + + it("does not fall back to the deprecated document.execCommand", async () => { + // Replace execCommand rather than spy on it, so this does not depend on the + // browser still exposing the deprecated method. + const execCommand = vi.fn(() => true); + Object.defineProperty(document, "execCommand", { + configurable: true, + writable: true, + value: execCommand, + }); + setClipboard({ writeText: vi.fn(async () => undefined) }); + + await copyToClipboard("token-123"); + + expect(execCommand).not.toHaveBeenCalled(); + }); + + it("does not inject a hidden textarea to perform the copy", async () => { + const createElement = vi.spyOn(document, "createElement"); + setClipboard({ writeText: vi.fn(async () => undefined) }); + + await copyToClipboard("token-123"); + + const injectedTextareas = createElement.mock.calls.filter( + ([tag]) => tag === "textarea", + ); + expect(injectedTextareas).toHaveLength(0); + }); +}); diff --git a/src/copyToClipboard.ts b/src/copyToClipboard.ts index 0737e63..0f04dc1 100644 --- a/src/copyToClipboard.ts +++ b/src/copyToClipboard.ts @@ -1,34 +1,50 @@ -//https://gist.github.com/interactiveRob/39a3eb36c7403f1ba43c190fc88f972f -export const copyToClipboard = (str: string) => { - /* ——— Derived from: https://hackernoon.com/copying-text-to-clipboard-with-javascript-df4d4988697f - improved to add iOS device compatibility——— */ - const el = document.createElement("textarea"); // Create a