From ad7eec2a330bc610a7da68e38584ce383ded64a5 Mon Sep 17 00:00:00 2001 From: Benjamin Po Date: Wed, 8 Jul 2026 09:51:16 +0100 Subject: [PATCH 01/10] Add package.json, package-lock.json, and update .gitignore - Created package.json and package-lock.json for project dependencies and scripts. - Updated .gitignore to exclude node_modules and coverage directories. - Added initial development scripts for building and testing the project. --- .github/workflows/test.yml | 28 + .gitignore | 4 +- README.md | 74 +- dist/CacheFinance.js | 39 +- package-lock.json | 2487 +++++++++++++++++++++++++++++ package.json | 18 + scripts/build-cachefinance.mjs | 48 + scripts/gas-source.mjs | 179 +++ src/CacheFinance.js | 60 +- src/CacheFinance3rdParty.js | 4 +- src/CacheFinanceUtils.js | 2 +- src/CacheFinanceWebSites.js | 4 +- src/GasMocks.js | 157 ++ src/ScriptSettings.js | 3 +- test/CacheFinance.test.js | 286 ++++ test/CacheFinance3rdParty.test.js | 195 +++ test/CacheFinanceUtils.test.js | 89 ++ test/FinanceWebSites.test.js | 314 ++++ test/ScriptSettings.test.js | 94 ++ test/SiteThrottle.test.js | 67 + test/build.test.js | 65 + test/setup.js | 19 + vitest.config.js | 36 + 23 files changed, 4220 insertions(+), 52 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/build-cachefinance.mjs create mode 100644 scripts/gas-source.mjs create mode 100644 src/GasMocks.js create mode 100644 test/CacheFinance.test.js create mode 100644 test/CacheFinance3rdParty.test.js create mode 100644 test/CacheFinanceUtils.test.js create mode 100644 test/FinanceWebSites.test.js create mode 100644 test/ScriptSettings.test.js create mode 100644 test/SiteThrottle.test.js create mode 100644 test/build.test.js create mode 100644 test/setup.js create mode 100644 vitest.config.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..ef95a3e --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,28 @@ +name: Test + +on: + push: + branches: + - master + - main + pull_request: + types: [opened, synchronize, reopened] + +jobs: + test: + name: Unit Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run validate + - uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-report + path: coverage/ + retention-days: 14 diff --git a/.gitignore b/.gitignore index cd0d007..40fc0f5 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ -*.sh \ No newline at end of file +*.sh +node_modules/ +coverage/ \ No newline at end of file diff --git a/README.md b/README.md index da303a0..05180fa 100644 --- a/README.md +++ b/README.md @@ -35,13 +35,12 @@ # Installing -* Googles guide to adding custom functions: [Google Help](https://developers.google.com/apps-script/guides/sheets/functions#creating_a_custom_function) -* Copy files manually. -* In the ./dist folder there is one file. Only one is required. +* Google's guide to adding custom functions: [Google Help](https://developers.google.com/apps-script/guides/sheets/functions#creating_a_custom_function) +* The `./dist` folder contains the single file used by Google Sheets. * [CacheFinance.js](https://github.com/demmings/cachefinance/blob/main/dist/CacheFinance.js) - * Caches GOOGLEFINANCE results AND does 3'rd party website lookups when all else fails. - * This file is an amalgamation of the files in the **/src** folder. - * Therefore do NOT use the files in **/src** folder. + * Caches GOOGLEFINANCE results AND does 3rd party website lookups when all else fails. + * This file is built automatically from the modules in the **`/src`** folder. + * Do **not** copy individual files from `/src` into Apps Script. * The simple approach is to copy and paste **CacheFinance.js**. * From your sheets Select **Extensions** and then **Apps Script** * Ensure that Editor is selected. It is the **< >** @@ -130,7 +129,7 @@ * ```=CACHEFINANCE("currency:CADEUR", "Price", "get")``` * "SET", "SETBLOCKED" - special case. Sets the preferred site (SET) and blocked site (SETBLOCKED) * ```=CACHEFINANCE("TSE:CJP", "PRICE", "SET", "YAHOO")``` - * "LIST - special case. Displays the ID for each web site lookup supported. + * "LIST" - special case. Displays the ID for each web site lookup supported. * "?" - special case. Displays all supported special case commands. * "REMOVE" - special case. Takes the preferred site and moves it to the blocked site. * ```=CACHEFINANCE("TSE:CJP", "PRICE", "REMOVE")``` @@ -186,6 +185,67 @@ ![Bulk Currency Setup](img/currencyConversions.png) +# Development + +This repository includes a Node.js toolchain for building, testing, and validating the Apps Script bundle. + +## Project layout + +| Path | Purpose | +| --- | --- | +| `src/` | Source modules edited during development | +| `dist/CacheFinance.js` | Single-file Apps Script bundle (generated) | +| `scripts/` | Build utilities (`gas-source.mjs`, `build-cachefinance.mjs`) | +| `test/` | Vitest unit tests | +| `src/GasMocks.js` | Google Apps Script service mocks (tests only) | + +Each file in `src/` contains a `DEBUG` block at the top for Node.js imports during testing. That block is stripped automatically when building `dist/CacheFinance.js`. + +## Prerequisites + +* [Node.js](https://nodejs.org/) 22 or later +* npm + +## Setup + +```bash +npm ci +``` + +## Build + +Generate or refresh `dist/CacheFinance.js` from `src/`: + +```bash +npm run build +``` + +Verify the committed bundle is up to date (used in CI): + +```bash +npm run build:check +``` + +The build only rewrites `dist/CacheFinance.js` when the output changes. The bundle header includes a source hash so unchanged sources produce an identical file. + +## Test + +```bash +npm test # run unit tests +npm run test:watch # watch mode +npm run test:coverage # tests with coverage report +npm run validate # build:check + coverage (CI command) +``` + +Coverage reports are written to `coverage/` (HTML report: `coverage/index.html`). + +## Contributing changes + +1. Edit files in `src/`. +2. Run `npm run build` to regenerate `dist/CacheFinance.js`. +3. Run `npm run validate` before opening a pull request. +4. Commit both `src/` and `dist/` changes. + # Roll Your Own Web Site Scraper * Why Roll Your Own? diff --git a/dist/CacheFinance.js b/dist/CacheFinance.js index 37d90ca..c8d23cd 100644 --- a/dist/CacheFinance.js +++ b/dist/CacheFinance.js @@ -1,4 +1,8 @@ - +/* + * CacheFinance Apps Script bundle. + * Generated from src/. Do not edit this file directly. + * Source hash: a345df336b49 + */ /** * Enhancement to GOOGLEFINANCE function for stock/ETF symbols that a) return "#N/A" (temporary or consistently), b) data never available like 'yieldpct' for ETF's. * @param {string} symbol - stock ticket with exchange (e.g. "NYSEARCA:VOO") @@ -470,6 +474,34 @@ class CacheFinance { } } +// Functions only used for manual testing in the Google Apps Script editor. +// skipcq: JS-0128 +function testYieldPct() { + const val = CACHEFINANCE("TSE:CJP", "yieldpct"); // skipcq: JS-0128 + Logger.log(`Test CacheFinance TSE:CJP(yieldpct)=${val}`); +} + +function testCacheFinances() { // skipcq: JS-0128 + const symbols = SpreadsheetApp.getActiveSpreadsheet().getRangeByName("A30:A165").getValues(); + const data = SpreadsheetApp.getActiveSpreadsheet().getRangeByName("E30:E165").getValues(); + + const cacheData = CACHEFINANCES(symbols, "PRICE", data); + + const singleSymbols = CacheFinanceUtils.convertRowsToSingleArray(symbols); + + Logger.log(`BULK CACHE TEST Success${cacheData} . ${singleSymbols}`); +} + +function testUpdateMaster() { + const symbols = ["TSE:ZTL", "TSE:FTN-A", "TSE:ZTL"]; + const googleFinanceValues = [null, 10.0, null]; + const symbolsWithNoData = ["TSE:ZTL"]; + const thirdPartyFinanceValues = [15]; + + const newGoogleFinance = CacheFinance.updateMasterWithMissed(symbols, googleFinanceValues, symbolsWithNoData, thirdPartyFinanceValues); + Logger.log(newGoogleFinance); +} + /** @classdesc * Stores settings for the SCRIPT. Long term cache storage for small tables. */ class ScriptSettings { // skipcq: JS-0128 @@ -1592,8 +1624,6 @@ class CacheFinanceTestStatus { } } - - /** * @classdesc Concrete implementations for each finance website access. */ @@ -2980,8 +3010,6 @@ class CoinMarket { } } - - /** * @classdesc Multi-purpose functions used within the cache finance custom functions. */ @@ -3484,4 +3512,3 @@ class ThresholdPeriod { // skipcq: JS-0128 this._maxPerPeriod = val; } } - diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a7defc4 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2487 @@ +{ + "name": "cachefinance", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cachefinance", + "version": "1.0.0", + "devDependencies": { + "@vitest/coverage-v8": "^3.2.4", + "vitest": "^3.2.4" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/coverage-v8": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.7.tgz", + "integrity": "sha512-NEGWJS2XNu2PfRLQwOO3CTKj1tTETxNBdk454vDxVBhxJYhPaA/eS0nAI0c+1El1P7a60z8+i+ZrQoGESweGKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.7", + "vitest": "3.2.7" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", + "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..04c195d --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "cachefinance", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "node scripts/build-cachefinance.mjs", + "build:check": "node scripts/build-cachefinance.mjs --check", + "validate": "npm run build:check && npm run test:coverage", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" + }, + "devDependencies": { + "@vitest/coverage-v8": "^3.2.7", + "vitest": "^3.2.4" + } +} diff --git a/scripts/build-cachefinance.mjs b/scripts/build-cachefinance.mjs new file mode 100644 index 0000000..5850d7e --- /dev/null +++ b/scripts/build-cachefinance.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + isCacheFinanceBundleCurrent, + paths, + SOURCE_FILES, + writeCacheFinanceBundle +} from "./gas-source.mjs"; + +const args = new Set(process.argv.slice(2)); +const checkOnly = args.has("--check"); + +function formatBytes(bytes) { + return `${bytes.toLocaleString()} bytes`; +} + +function main() { + if (checkOnly) { + if (isCacheFinanceBundleCurrent()) { + console.log(`dist/CacheFinance.js is up to date (${SOURCE_FILES.length} source files)`); + return; + } + + console.error("dist/CacheFinance.js is out of date. Run: npm run build"); + process.exit(1); + } + + const result = writeCacheFinanceBundle(); + + if (result.written) { + console.log( + `Built ${paths.dist} (${formatBytes(result.bytes)}, ` + + `${result.sectionCount} files, hash ${result.sourceHash})` + ); + return; + } + + console.log( + `dist/CacheFinance.js is up to date (${formatBytes(result.bytes)}, hash ${result.sourceHash})` + ); +} + +const scriptPath = path.resolve(fileURLToPath(import.meta.url)); + +if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) { + main(); +} diff --git a/scripts/gas-source.mjs b/scripts/gas-source.mjs new file mode 100644 index 0000000..cd2122b --- /dev/null +++ b/scripts/gas-source.mjs @@ -0,0 +1,179 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export const DEBUG_BLOCK_START = "/* *** DEBUG START ***"; +export const DEBUG_BLOCK_END = "// *** DEBUG END ***/"; +const DEBUG_BLOCK_PATTERN = /\/\*\s+\*\*\* DEBUG START \*\*\*[\s\S]*?\/\/\s+\*\*\* DEBUG END \*\*\*\//g; +const NODE_TEST_BANNER = "Remove comments for testing in NODE"; + +/** @type {readonly string[]} */ +export const SOURCE_FILES = Object.freeze([ + "CacheFinance.js", + "ScriptSettings.js", + "CacheFinance3rdParty.js", + "CacheFinanceTest.js", + "CacheFinanceWebSites.js", + "CacheFinanceUtils.js" +]); + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +export const paths = Object.freeze({ + root: rootDir, + src: path.join(rootDir, "src"), + dist: path.join(rootDir, "dist", "CacheFinance.js") +}); + +/** + * Remove Node.js-only DEBUG blocks from Apps Script source files. + * @param {string} content + * @returns {string} + */ +export function stripDebugBlocks(content) { + return content.replace(DEBUG_BLOCK_PATTERN, "").trim(); +} + +/** + * Uncomment DEBUG blocks so Vitest can import src modules in Node. + * @param {string} content + * @returns {string} + */ +export function enableDebugExports(content) { + if (!content.includes(DEBUG_BLOCK_START)) { + return content; + } + + const startIdx = content.indexOf(DEBUG_BLOCK_START); + const endIdx = content.indexOf(DEBUG_BLOCK_END, startIdx); + if (endIdx === -1) { + return content; + } + + const before = content.slice(0, startIdx); + const after = content.slice(endIdx + DEBUG_BLOCK_END.length); + const debugBody = content.slice(startIdx + DEBUG_BLOCK_START.length, endIdx); + + const uncommented = debugBody + .split("\n") + .map((line) => line.replace(/^\/\/\s?/, "")) + .filter((line) => line.trim() !== NODE_TEST_BANNER) + .join("\n"); + + return `${before}${uncommented}${after}`; +} + +/** + * @param {string[]} sections + * @returns {string} + */ +export function hashSections(sections) { + return crypto + .createHash("sha256") + .update(sections.join("\0")) + .digest("hex") + .slice(0, 12); +} + +/** + * @param {string} sourceHash + * @returns {string} + */ +export function createBundleBanner(sourceHash) { + return [ + "/*", + " * CacheFinance Apps Script bundle.", + " * Generated from src/. Do not edit this file directly.", + ` * Source hash: ${sourceHash}`, + " */", + "" + ].join("\n"); +} + +/** + * @param {string} content + */ +export function validateBundleContent(content) { + const errors = []; + + if (!content.includes("function CACHEFINANCE")) { + errors.push("missing CACHEFINANCE custom function"); + } + if (!content.includes("function CACHEFINANCES")) { + errors.push("missing CACHEFINANCES custom function"); + } + if (content.includes("DEBUG START")) { + errors.push("contains DEBUG block markers"); + } + if (content.includes("GasMocks")) { + errors.push("contains test-only GasMocks references"); + } + if (/^\s*import\s+/m.test(content) || /^\s*export\s+/m.test(content)) { + errors.push("contains ES module import/export statements"); + } + + if (errors.length > 0) { + throw new Error(`Invalid bundle: ${errors.join(", ")}`); + } +} + +/** + * @returns {string[]} + */ +export function readSourceSections() { + return SOURCE_FILES.map((fileName) => { + const filePath = path.join(paths.src, fileName); + if (!fs.existsSync(filePath)) { + throw new Error(`Missing source file: ${filePath}`); + } + + return stripDebugBlocks(fs.readFileSync(filePath, "utf8")); + }); +} + +/** + * @returns {{ bundle: string, sourceHash: string, sectionCount: number }} + */ +export function buildCacheFinanceBundle() { + const sections = readSourceSections(); + const sourceHash = hashSections(sections); + const bundle = `${createBundleBanner(sourceHash)}${sections.join("\n\n")}\n`; + + validateBundleContent(bundle); + + return { + bundle, + sourceHash, + sectionCount: sections.length + }; +} + +/** + * @returns {{ written: boolean, bundle: string, sourceHash: string, bytes: number }} + */ +export function writeCacheFinanceBundle() { + const { bundle, sourceHash, sectionCount } = buildCacheFinanceBundle(); + const bytes = Buffer.byteLength(bundle, "utf8"); + const previous = fs.existsSync(paths.dist) ? fs.readFileSync(paths.dist, "utf8") : null; + + if (previous === bundle) { + return { written: false, bundle, sourceHash, bytes, sectionCount }; + } + + fs.mkdirSync(path.dirname(paths.dist), { recursive: true }); + fs.writeFileSync(paths.dist, bundle, "utf8"); + + return { written: true, bundle, sourceHash, bytes, sectionCount }; +} + +/** + * @returns {boolean} + */ +export function isCacheFinanceBundleCurrent() { + if (!fs.existsSync(paths.dist)) { + return false; + } + + const { bundle } = buildCacheFinanceBundle(); + return fs.readFileSync(paths.dist, "utf8") === bundle; +} diff --git a/src/CacheFinance.js b/src/CacheFinance.js index 98a5112..bc97eaf 100644 --- a/src/CacheFinance.js +++ b/src/CacheFinance.js @@ -1,47 +1,19 @@ /* *** DEBUG START *** // Remove comments for testing in NODE -import { ScriptSettings } from "./SQL/ScriptSettings.js"; +import { ScriptSettings } from "./ScriptSettings.js"; import { ThirdPartyFinance, FinanceWebsiteSearch } from "./CacheFinance3rdParty.js"; import { cacheFinanceTest } from "./CacheFinanceTest.js"; import { StockAttributes, FinanceWebSites } from "./CacheFinanceWebSites.js"; import { CacheService, SpreadsheetApp } from "./GasMocks.js"; import { CacheFinanceUtils } from "./CacheFinanceUtils.js"; -export { CACHEFINANCE, CacheFinance }; +export { CACHEFINANCE, CACHEFINANCES, CacheFinance }; class Logger { static log(msg) { console.log(msg); } } - -// Function only used for testing in google sheets app script. -// skipcq: JS-0128 -function testYieldPct() { - const val = CACHEFINANCE("TSE:CJP", "yieldpct"); // skipcq: JS-0128 - Logger.log(`Test CacheFinance TSE:CJP(yieldpct)=${val}`); -} - -function testCacheFinances() { // skipcq: JS-0128 - const symbols = SpreadsheetApp.getActiveSpreadsheet().getRangeByName("A30:A165").getValues(); - const data = SpreadsheetApp.getActiveSpreadsheet().getRangeByName("E30:E165").getValues(); - - const cacheData = CACHEFINANCES(symbols, "PRICE", data); - - const singleSymbols = CacheFinanceUtils.convertRowsToSingleArray(symbols); - - Logger.log(`BULK CACHE TEST Success${cacheData} . ${singleSymbols}`); -} - -function testUpdateMaster() { - const symbols = ["TSE:ZTL", "TSE:FTN-A", "TSE:ZTL"]; - const googleFinanceValues = [null, 10.0, null]; - const symbolsWithNoData = ["TSE:ZTL"]; - const thirdPartyFinanceValues = [15]; - - const newGoogleFinance = CacheFinance.updateMasterWithMissed(symbols, googleFinanceValues, symbolsWithNoData, thirdPartyFinanceValues); - Logger.log(newGoogleFinance); -} // *** DEBUG END ***/ /** @@ -513,4 +485,32 @@ class CacheFinance { return siteNames; } +} + +// Functions only used for manual testing in the Google Apps Script editor. +// skipcq: JS-0128 +function testYieldPct() { + const val = CACHEFINANCE("TSE:CJP", "yieldpct"); // skipcq: JS-0128 + Logger.log(`Test CacheFinance TSE:CJP(yieldpct)=${val}`); +} + +function testCacheFinances() { // skipcq: JS-0128 + const symbols = SpreadsheetApp.getActiveSpreadsheet().getRangeByName("A30:A165").getValues(); + const data = SpreadsheetApp.getActiveSpreadsheet().getRangeByName("E30:E165").getValues(); + + const cacheData = CACHEFINANCES(symbols, "PRICE", data); + + const singleSymbols = CacheFinanceUtils.convertRowsToSingleArray(symbols); + + Logger.log(`BULK CACHE TEST Success${cacheData} . ${singleSymbols}`); +} + +function testUpdateMaster() { + const symbols = ["TSE:ZTL", "TSE:FTN-A", "TSE:ZTL"]; + const googleFinanceValues = [null, 10.0, null]; + const symbolsWithNoData = ["TSE:ZTL"]; + const thirdPartyFinanceValues = [15]; + + const newGoogleFinance = CacheFinance.updateMasterWithMissed(symbols, googleFinanceValues, symbolsWithNoData, thirdPartyFinanceValues); + Logger.log(newGoogleFinance); } \ No newline at end of file diff --git a/src/CacheFinance3rdParty.js b/src/CacheFinance3rdParty.js index 96e74f1..4b971bc 100644 --- a/src/CacheFinance3rdParty.js +++ b/src/CacheFinance3rdParty.js @@ -1,10 +1,10 @@ /* *** DEBUG START *** // Remove comments for testing in NODE -import { ScriptSettings } from "./SQL/ScriptSettings.js"; +import { ScriptSettings } from "./ScriptSettings.js"; import { FinanceWebSites, StockAttributes, FinanceWebSite } from "./CacheFinanceWebSites.js"; import { CacheFinanceUtils, SiteThrottle } from "./CacheFinanceUtils.js"; -export { ThirdPartyFinance, FinanceWebsiteSearch }; +export { ThirdPartyFinance, FinanceWebsiteSearch, StockWebURL, FinanceSiteLookupAnalyzer, FinanceSiteList, FinanceSiteLookupStats }; class Logger { static log(msg) { diff --git a/src/CacheFinanceUtils.js b/src/CacheFinanceUtils.js index dc57ee4..f1be663 100644 --- a/src/CacheFinanceUtils.js +++ b/src/CacheFinanceUtils.js @@ -1,7 +1,7 @@ /* *** DEBUG START *** // Remove comments for testing in NODE -import { ScriptSettings } from "./SQL/ScriptSettings.js"; +import { ScriptSettings } from "./ScriptSettings.js"; export { CacheFinanceUtils, SiteThrottle, ThresholdPeriod }; class Logger { diff --git a/src/CacheFinanceWebSites.js b/src/CacheFinanceWebSites.js index d9e4fda..8028545 100644 --- a/src/CacheFinanceWebSites.js +++ b/src/CacheFinanceWebSites.js @@ -2,9 +2,7 @@ // Remove comments for testing in NODE import { SiteThrottle, ThresholdPeriod } from "./CacheFinanceUtils.js"; -export { FinanceWebSites }; -export { StockAttributes }; -export { FinanceWebSite }; +export { FinanceWebSites, FinanceWebSite, StockAttributes }; export { GlobeAndMail, YahooFinance, YahooApi, FinnHub, AlphaVantage, GoogleWebSiteFinance, TwelveData, CoinMarket }; class Logger { diff --git a/src/GasMocks.js b/src/GasMocks.js new file mode 100644 index 0000000..0a4cf52 --- /dev/null +++ b/src/GasMocks.js @@ -0,0 +1,157 @@ +/** + * In-memory mocks for Google Apps Script services used during Node.js tests. + */ + +class ScriptCache { + constructor() { + /** @type {Map} */ + this.store = new Map(); + } + + get(key) { + return this.store.has(key) ? this.store.get(key) : null; + } + + getAll(keys) { + /** @type {Record} */ + const result = {}; + for (const key of keys) { + if (this.store.has(key)) { + result[key] = this.store.get(key); + } + } + return result; + } + + put(key, value, _seconds) { + this.store.set(key, value); + } + + putAll(obj, _seconds) { + for (const [key, value] of Object.entries(obj)) { + this.store.set(key, value); + } + } + + remove(key) { + this.store.delete(key); + } + + removeAll(keys) { + for (const key of keys) { + this.store.delete(key); + } + } + + clear() { + this.store.clear(); + } +} + +class ScriptProperties { + constructor() { + /** @type {Map} */ + this.store = new Map(); + } + + getProperty(key) { + return this.store.has(key) ? this.store.get(key) : null; + } + + getProperties() { + /** @type {Record} */ + const result = {}; + for (const [key, value] of this.store.entries()) { + result[key] = value; + } + return result; + } + + setProperty(key, value) { + this.store.set(key, value); + } + + setProperties(obj) { + for (const [key, value] of Object.entries(obj)) { + this.store.set(key, value); + } + } + + deleteProperty(key) { + this.store.delete(key); + } + + clear() { + this.store.clear(); + } +} + +const scriptCache = new ScriptCache(); +const scriptProperties = new ScriptProperties(); + +export const CacheService = { + getScriptCache() { + return scriptCache; + }, + reset() { + scriptCache.clear(); + } +}; + +export const PropertiesService = { + getScriptProperties() { + return scriptProperties; + }, + reset() { + scriptProperties.clear(); + } +}; + +export const UrlFetchApp = { + /** @type {Map} */ + responses: new Map(), + + fetch(url) { + const response = this.responses.get(url); + if (response?.throws) { + throw response.throws; + } + + return { + getContentText() { + return response?.content ?? ""; + } + }; + }, + + reset() { + this.responses.clear(); + }, + + mockResponse(url, content) { + this.responses.set(url, { content }); + }, + + fetchAll(requests) { + return requests.map((request) => ({ + getContentText() { + const response = UrlFetchApp.responses.get(request.url); + return response?.content ?? ""; + } + })); + } +}; + +export const SpreadsheetApp = { + getActiveSpreadsheet() { + return { + getRangeByName(_name) { + return { + getValues() { + return []; + } + }; + } + }; + } +}; diff --git a/src/ScriptSettings.js b/src/ScriptSettings.js index 2b951d2..6ea1db8 100644 --- a/src/ScriptSettings.js +++ b/src/ScriptSettings.js @@ -1,8 +1,7 @@ /* *** DEBUG START *** // Remove comments for testing in NODE export { ScriptSettings, PropertyData }; -import { CacheFinance } from "../CacheFinance.js"; -import { PropertiesService } from "../GasMocks.js"; +import { PropertiesService } from "./GasMocks.js"; class Logger { static log(msg) { diff --git a/test/CacheFinance.test.js b/test/CacheFinance.test.js new file mode 100644 index 0000000..2ec7429 --- /dev/null +++ b/test/CacheFinance.test.js @@ -0,0 +1,286 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CACHEFINANCE, CACHEFINANCES, CacheFinance } from "../src/CacheFinance.js"; +import { ThirdPartyFinance } from "../src/CacheFinance3rdParty.js"; +import { StockAttributes } from "../src/CacheFinanceWebSites.js"; +import { CacheFinanceUtils } from "../src/CacheFinanceUtils.js"; +import { CacheService } from "../src/GasMocks.js"; + +function mockThirdPartyLookup(valuesByAttribute = {}) { + vi.spyOn(ThirdPartyFinance, "getMissingStockAttributesFromThirdParty") + .mockImplementation((symbols, attribute) => symbols.map(() => { + const data = new StockAttributes(); + const value = valuesByAttribute[attribute]; + + if (attribute === "PRICE") { + data.stockPrice = value ?? 42.5; + } + else if (attribute === "NAME") { + data.stockName = value ?? "Test Corp"; + } + else if (attribute === "YIELDPCT") { + data.yieldPct = value ?? 0.05; + } + + return data; + })); +} + +describe("CacheFinance helpers", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("identifies symbols missing valid default values", () => { + expect(CacheFinance.getSymbolsWithNoValidData( + ["TSE:A", "TSE:B", "TSE:C"], + [10, "#N/A", ""] + )).toEqual(["TSE:B", "TSE:C"]); + }); + + it("deduplicates symbols with missing data", () => { + expect(CacheFinance.getSymbolsWithNoValidData( + ["TSE:ZTL", "TSE:FTN-A", "TSE:ZTL"], + ["#N/A", 10, null] + )).toEqual(["TSE:ZTL"]); + }); + + it("merges third-party values into duplicate symbol positions", () => { + const merged = CacheFinance.updateMasterWithMissed( + ["TSE:ZTL", "TSE:FTN-A", "TSE:ZTL"], + [null, 10, null], + ["TSE:ZTL"], + [15] + ); + + expect(merged).toEqual([15, 10, 15]); + }); + + it("maps stock attributes back to finance values", () => { + const attrs = [new StockAttributes(), new StockAttributes()]; + attrs[0].stockPrice = 12.34; + attrs[1].stockName = "Example ETF"; + + expect(CacheFinance.getValuesFromStockAttributes(attrs, "PRICE")).toEqual([12.34, ""]); + expect(CacheFinance.getValuesFromStockAttributes(attrs, "NAME")).toEqual(["", "Example ETF"]); + }); + + it("reads a value from the short cache", () => { + const key = "PRICE|TSE:ZTL"; + CacheService.getScriptCache().put(key, JSON.stringify(88.8), 1200); + + expect(CacheFinance.getFinanceValueFromShortCache(key)).toBe(88.8); + expect(CacheFinance.getFinanceValueFromShortCache("MISSING|KEY")).toBeNull(); + }); + + it("ignores short-cache entries that contain errors", () => { + const key = "PRICE|TSE:ZTL"; + CacheService.getScriptCache().put(key, "#ERROR!", 1200); + expect(CacheFinance.getFinanceValueFromShortCache(key)).toBeNull(); + + CacheService.getScriptCache().put(key, JSON.stringify("#ERROR!"), 1200); + expect(CacheFinance.getFinanceValueFromShortCache(key)).toBeNull(); + }); + + it("skips short-cache reads when all google values are valid", () => { + const spy = vi.spyOn(CacheFinanceUtils, "bulkShortCacheGet"); + + const values = CacheFinance.updateMissingValuesFromShortCache( + ["TSE:A"], + "PRICE", + [12.5] + ); + + expect(values).toEqual([12.5]); + expect(spy).not.toHaveBeenCalled(); + }); + + it("reports when all default values are valid", () => { + expect(CacheFinance.isAllGoogleDefaultValuesValid(["TSE:A"], [10])).toBe(true); + expect(CacheFinance.isAllGoogleDefaultValuesValid(["TSE:A"], ["#N/A"])).toBe(false); + expect(CacheFinance.isAllGoogleDefaultValuesValid(["TSE:A", "TSE:B"], [10])).toBe(false); + }); + + it("deletes short and long cache entries for a symbol", () => { + const settingsKey = "PRICE|TSE:ZTL"; + CacheFinanceUtils.putFinanceValuesIntoShortCache([settingsKey], [10], 1200); + CacheFinanceUtils.bulkLongCachePut(["TSE:ZTL"], "PRICE", [10], 1); + + CacheFinance.deleteFromCache("TSE:ZTL", "PRICE"); + + expect(CacheFinance.getFinanceValueFromShortCache(settingsKey)).toBeNull(); + expect(CacheFinanceUtils.bulkLongCacheGet(["TSE:ZTL"], "PRICE")).toEqual([null]); + }); +}); + +describe("CacheFinance.getBulkFinanceData", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("returns valid google finance values as a column range", () => { + const result = CacheFinance.getBulkFinanceData( + ["TSE:A", "TSE:B"], + "PRICE", + [10.5, 20.25] + ); + + expect(result).toEqual([[10.5], [20.25]]); + }); + + it("fills missing values from the short cache", () => { + CacheFinanceUtils.putFinanceValuesIntoShortCache( + ["PRICE|TSE:ZTL"], + [55.5], + 1200 + ); + + const result = CacheFinance.getBulkFinanceData( + ["TSE:ZTL"], + "PRICE", + ["#N/A"] + ); + + expect(result).toEqual([[55.5]]); + }); + + it("uses third-party lookups when cache and defaults are missing", () => { + mockThirdPartyLookup({ PRICE: 77.7 }); + + const result = CacheFinance.getBulkFinanceData( + ["TSE:ZTL"], + "PRICE", + ["#N/A"] + ); + + expect(result).toEqual([[77.7]]); + }); + + it("falls back to long cache values as a last resort", () => { + vi.spyOn(ThirdPartyFinance, "getMissingStockAttributesFromThirdParty") + .mockImplementation((symbols) => symbols.map(() => new StockAttributes())); + CacheFinanceUtils.bulkLongCachePut(["TSE:ZTL"], "PRICE", [33.3], 1); + + const result = CacheFinance.getBulkFinanceData( + ["TSE:ZTL"], + "PRICE", + ["#N/A"] + ); + + expect(result).toEqual([[33.3]]); + }); +}); + +describe("CacheFinance backdoor commands", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("returns help text for ?", () => { + const help = CacheFinance.backDoorCommands("", "", "?", ""); + expect(help[0][0]).toContain("Valid commands"); + }); + + it("lists supported providers", () => { + const providers = CacheFinance.backDoorCommands("", "", "LIST", ""); + expect(providers.flat()).toEqual(expect.arrayContaining(["YAHOOAPI", "FINNHUB", "GLOBE"])); + }); + + it("stores and reads preferred providers", () => { + CacheFinance.backDoorCommands("TSE:CJP", "PRICE", "SET", "YAHOO"); + expect(CacheFinance.backDoorCommands("TSE:CJP", "PRICE", "GET", "")) + .toBe("YAHOO"); + }); + + it("rejects invalid provider names", () => { + expect(CacheFinance.backDoorCommands("TSE:CJP", "PRICE", "SET", "NOT_A_SITE")) + .toBe("Invalid provider name. No change made."); + }); + + it("clears cache for a specific symbol and attribute", () => { + CacheFinanceUtils.putFinanceValuesIntoShortCache(["PRICE|TSE:CJP"], [12], 1200); + expect(CacheFinance.backDoorCommands("TSE:CJP", "PRICE", "CLEARCACHE", "")) + .toBe("Cache Cleared"); + expect(CacheFinance.getFinanceValueFromShortCache("PRICE|TSE:CJP")).toBeNull(); + }); + + it("clears all cache entries when symbol and attribute are blank", () => { + CacheFinanceUtils.bulkLongCachePut(["TSE:A"], "PRICE", [10], 1); + expect(CacheFinance.backDoorCommands("", "", "CLEARCACHE", "")).toBe("Cache Cleared"); + expect(CacheFinanceUtils.bulkLongCacheGet(["TSE:A"], "PRICE")).toEqual([null]); + }); + + it("removes old cache entries with EXPIRECACHE", () => { + CacheFinanceUtils.bulkLongCachePut(["TSE:OLD"], "PRICE", [5], 1); + expect(CacheFinance.backDoorCommands("", "", "EXPIRECACHE", "")) + .toBe("Old Cache Items Removed"); + }); + + it("moves the preferred provider to the blocked list on REMOVE", () => { + CacheFinance.backDoorCommands("TSE:CJP", "PRICE", "SET", "YAHOO"); + const message = CacheFinance.backDoorCommands("TSE:CJP", "PRICE", "REMOVE", ""); + + expect(message).toContain("Site removed"); + expect(CacheFinance.backDoorCommands("TSE:CJP", "PRICE", "GET", "")) + .toBe("No site set."); + expect(CacheFinance.backDoorCommands("TSE:CJP", "PRICE", "GETBLOCKED", "")) + .toBe("YAHOO"); + }); + + it("stores and reads blocked providers", () => { + CacheFinance.backDoorCommands("TSE:CJP", "PRICE", "SETBLOCKED", "GLOBE"); + expect(CacheFinance.backDoorCommands("TSE:CJP", "PRICE", "GETBLOCKED", "")) + .toBe("GLOBE"); + }); + + it("clears a provider preference when SET is called with an empty site", () => { + CacheFinance.backDoorCommands("TSE:CJP", "PRICE", "SET", "YAHOO"); + CacheFinance.backDoorCommands("TSE:CJP", "PRICE", "SET", ""); + expect(CacheFinance.backDoorCommands("TSE:CJP", "PRICE", "GET", "")) + .toBe("No site set."); + }); + + it("reports when no preferred provider exists during REMOVE", () => { + expect(CacheFinance.backDoorCommands("TSE:NEW", "PRICE", "REMOVE", "")) + .toBe("Currently no preferred site for TSE:NEW PRICE"); + }); +}); + +describe("CACHEFINANCE custom function", () => { + beforeEach(() => { + vi.restoreAllMocks(); + mockThirdPartyLookup({ PRICE: 99.1 }); + }); + + it("returns a single looked-up value", () => { + expect(CACHEFINANCE("TSE:ZTL", "price", "#N/A")).toBe(99.1); + }); + + it("returns an empty string for blank symbol or attribute", () => { + expect(CACHEFINANCE("", "price")).toBe(""); + expect(CACHEFINANCE("TSE:ZTL", "")).toBe(""); + }); +}); + +describe("CACHEFINANCES custom function", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("returns a 2D array for range lookups", () => { + const result = CACHEFINANCES([["TSE:A"], ["TSE:B"]], "price", [[10], [20]]); + expect(result).toEqual([[10], [20]]); + }); + + it("returns a scalar for single-value lookups", () => { + expect(CACHEFINANCES("TSE:A", "price", 10)).toBe(10); + }); + + it("throws when symbol and default ranges differ in size", () => { + expect(() => CACHEFINANCES([["TSE:A"], ["TSE:B"]], "price", [[10]])) + .toThrow("Stock symbol RANGE must match default values range."); + }); + + it("returns an empty string when no symbols are provided", () => { + expect(CACHEFINANCES([], "price", [])).toBe(""); + }); +}); diff --git a/test/CacheFinance3rdParty.test.js b/test/CacheFinance3rdParty.test.js new file mode 100644 index 0000000..63d31d1 --- /dev/null +++ b/test/CacheFinance3rdParty.test.js @@ -0,0 +1,195 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + FinanceSiteLookupAnalyzer, + FinanceSiteLookupStats, + FinanceWebsiteSearch, + StockWebURL +} from "../src/CacheFinance3rdParty.js"; +import { FinanceWebSite, StockAttributes } from "../src/CacheFinanceWebSites.js"; +import { ScriptSettings } from "../src/ScriptSettings.js"; +import { UrlFetchApp } from "../src/GasMocks.js"; + +function buildParseResponse(price) { + return (_html, _symbol, attribute) => { + const data = new StockAttributes(); + if (attribute === "PRICE") { + data.stockPrice = price; + } + return data; + }; +} + +describe("StockWebURL", () => { + it("prioritizes the preferred provider URL", () => { + const stock = new StockWebURL("TSE:ZTL"); + stock.addSiteURL("GLOBE", "", "", "http://globe", buildParseResponse(1), null); + stock.addSiteURL("YAHOO", "YAHOO", "", "http://yahoo", buildParseResponse(2), null); + + expect(stock.getURL()).toBe("http://yahoo"); + expect(stock.siteName[0]).toBe("YAHOO"); + }); + + it("skips blocked sites and empty URLs", () => { + const stock = new StockWebURL("TSE:ZTL"); + stock.addSiteURL("YAHOO", "", "YAHOO", "http://yahoo", buildParseResponse(2), null); + stock.addSiteURL("GLOBE", "", "", "http://globe", buildParseResponse(1), null); + + expect(stock.getURL()).toBe("http://globe"); + }); + + it("parses responses and advances to the next site", () => { + const stock = new StockWebURL("TSE:ZTL"); + stock.addSiteURL("YAHOO", "", "", "http://yahoo", buildParseResponse(12.34), null); + + const data = stock.parseResponse("", "PRICE"); + expect(data.stockPrice).toBe(12.34); + expect(data.isAttributeSet("PRICE")).toBe(true); + + stock.skipToNextSite(); + expect(stock.isSitesDone()).toBe(true); + }); + + it("records the working provider for future lookups", () => { + const stock = new StockWebURL("TSE:ZTL"); + stock.addSiteURL("YAHOO", "", "", "http://yahoo", buildParseResponse(12.34), null); + stock.parseResponse("", "PRICE"); + + const bestStockSites = {}; + stock.updateBestSites(bestStockSites, "PRICE"); + + expect(bestStockSites["PRICE|TSE:ZTL"]).toBe("YAHOO"); + }); +}); + +describe("FinanceWebsiteSearch", () => { + beforeEach(() => { + UrlFetchApp.reset(); + }); + + it("creates cache keys for lookup plans", () => { + expect(FinanceWebsiteSearch.makeCacheKey("TSE:ZTL")).toBe("WebSearch|TSE:ZTL"); + }); + + it("reads and writes preferred provider metadata", () => { + FinanceWebsiteSearch.writeBestStockWebsites({ "PRICE|TSE:ZTL": "YAHOO" }); + expect(FinanceWebsiteSearch.readBestStockWebsites()).toEqual({ "PRICE|TSE:ZTL": "YAHOO" }); + }); + + it("builds the next URL batch from pending stock lookups", () => { + const stock = new StockWebURL("TSE:ZTL"); + stock.addSiteURL("YAHOO", "", "", "http://yahoo", buildParseResponse(10), null); + stock.addSiteURL("GLOBE", "", "", "http://globe", buildParseResponse(11), null); + + const [urls, batch] = FinanceWebsiteSearch.getNextUrlBatch([stock]); + + expect(urls).toEqual(["http://yahoo"]); + expect(batch).toHaveLength(1); + }); + + it("fetches and applies website responses in bulk", () => { + const stock = new StockWebURL("TSE:ZTL"); + stock.addSiteURL("YAHOO", "", "", "http://yahoo", buildParseResponse(15.5), null); + const bestStockSites = {}; + + UrlFetchApp.mockResponse("http://yahoo", ""); + + FinanceWebsiteSearch.updateStockResults( + [stock], + ["http://yahoo"], + [""], + "PRICE", + bestStockSites + ); + + expect(stock.stockAttributes.stockPrice).toBe(15.5); + expect(bestStockSites["PRICE|TSE:ZTL"]).toBe("YAHOO"); + }); + + it("returns empty results when no symbols are requested", () => { + expect(FinanceWebsiteSearch.getAll([], "PRICE")).toEqual([]); + }); + + it("fetches multiple URLs through UrlFetchApp.fetchAll", () => { + UrlFetchApp.mockResponse("http://one", "one"); + UrlFetchApp.mockResponse("http://two", "two"); + + expect(FinanceWebsiteSearch.bulkSiteFetch(["http://one", "http://two", ""])) + .toEqual(["one", "two"]); + }); + + it("builds stock lookup objects for each symbol", () => { + const bestStockSites = { "PRICE|TSE:ZTL": "YAHOO" }; + const stockUrls = FinanceWebsiteSearch.getAllStockWebSiteFunctions( + ["TSE:ZTL"], + "PRICE", + bestStockSites + ); + + expect(stockUrls).toHaveLength(1); + expect(stockUrls[0].symbol).toBe("TSE:ZTL"); + expect(stockUrls[0].siteName[0]).toBe("YAHOO"); + }); + + it("skips throttled providers when building the next URL batch", () => { + const stock = new StockWebURL("TSE:ZTL"); + const throttle = { + checkAndIncrement: () => false, + update: () => {} + }; + + stock.addSiteURL("YAHOO", "", "", "http://yahoo", buildParseResponse(10), throttle); + stock.addSiteURL("GLOBE", "", "", "http://globe", buildParseResponse(11), null); + + const [urls] = FinanceWebsiteSearch.getNextUrlBatch([stock]); + expect(urls).toEqual(["http://globe"]); + }); + + it("deletes cached lookup plans", () => { + const settings = new ScriptSettings(); + settings.put("WebSearch|TSE:ZTL", { priceSites: ["YAHOO"] }, 1); + + FinanceWebsiteSearch.deleteLookupPlan("TSE:ZTL"); + + expect(settings.get("WebSearch|TSE:ZTL")).toBeNull(); + }); +}); + +describe("FinanceSiteLookupAnalyzer", () => { + it("orders sites by response time and extracts the fastest values", () => { + const slowSite = new FinanceWebSite("Slow", {}); + const fastSite = new FinanceWebSite("Fast", {}); + + const slowAttrs = new StockAttributes(); + slowAttrs.stockPrice = 20; + const fastAttrs = new StockAttributes(); + fastAttrs.stockPrice = 10; + + const slowStats = new FinanceSiteLookupStats("TSE:ZTL", slowSite) + .setSearchTime(500) + .setAttributes(slowAttrs); + const fastStats = new FinanceSiteLookupStats("TSE:ZTL", fastSite) + .setSearchTime(100) + .setAttributes(fastAttrs); + + const analyzer = new FinanceSiteLookupAnalyzer("TSE:ZTL"); + analyzer.analyzeSiteStatus([slowStats, fastStats]); + + const siteList = analyzer.createFinanceSiteList(); + expect(siteList.priceSites).toEqual(["FAST", "SLOW"]); + + const attributes = analyzer.getStockAttributes(); + expect(attributes.stockPrice).toBe(10); + }); + + it("selects attribute-specific site lists from stored plans", () => { + const plan = { + symbol: "TSE:ZTL", + priceSites: ["Fast"], + nameSites: ["NameSite"], + yieldSites: ["YieldSite"] + }; + + const priceData = FinanceSiteLookupAnalyzer.getStockAttribute(plan, "PRICE"); + expect(priceData).toBeInstanceOf(StockAttributes); + }); +}); diff --git a/test/CacheFinanceUtils.test.js b/test/CacheFinanceUtils.test.js new file mode 100644 index 0000000..dc30366 --- /dev/null +++ b/test/CacheFinanceUtils.test.js @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { CacheFinanceUtils } from "../src/CacheFinanceUtils.js"; + +describe("CacheFinanceUtils", () => { + it("builds uppercase cache keys from symbol and attribute", () => { + expect(CacheFinanceUtils.makeCacheKey("tse:ztl", "price")).toBe("PRICE|TSE:ZTL"); + }); + + it("builds ignore-site cache keys", () => { + expect(CacheFinanceUtils.makeIgnoreSiteCacheKey("TSE:ZTL", "price")) + .toBe("IGNORE|PRICE|TSE:ZTL"); + }); + + it("creates cache keys for each symbol in a list", () => { + expect(CacheFinanceUtils.createCacheKeyList(["tse:a", "neo:b"], "yieldpct")) + .toEqual(["YIELDPCT|TSE:A", "YIELDPCT|NEO:B"]); + }); + + it.each([ + [42, true], + ["123.45", true], + [null, false], + [undefined, false], + ["#N/A", false], + ["#ERROR!", false], + ["", false] + ])("isValidGoogleValue(%s) returns %s", (value, expected) => { + expect(CacheFinanceUtils.isValidGoogleValue(value)).toBe(expected); + }); + + it("removes trailing blank rows from sheet data", () => { + const table = [ + ["TSE:ZTL", "10"], + ["", ""], + ["", ""] + ]; + + expect(CacheFinanceUtils.removeEmptyRecordsAtEndOfTable(table)) + .toEqual([["TSE:ZTL", "10"]]); + }); + + it("converts a column from a 2D range into a flat array", () => { + const range = [["A"], ["B"], ["C"]]; + expect(CacheFinanceUtils.convertRowsToSingleArray(range, 0)) + .toEqual(["A", "B", "C"]); + }); + + it("converts a flat array into a 2D column range", () => { + expect(CacheFinanceUtils.convertSingleToDoubleArray(["A", "B"])) + .toEqual([["A"], ["B"]]); + }); + + it("stores and retrieves values from the short cache in bulk", () => { + const keys = ["PRICE|TSE:ZTL", "PRICE|NEO:CJP"]; + const values = [12.34, 56.78]; + + CacheFinanceUtils.putFinanceValuesIntoShortCache(keys, values, 1200); + expect(CacheFinanceUtils.getFinanceValuesFromShortCache(keys)) + .toEqual(values); + }); + + it("skips invalid values when writing to the short cache", () => { + const keys = ["PRICE|TSE:ZTL", "PRICE|NEO:CJP"]; + const values = ["#N/A", 10.5]; + + CacheFinanceUtils.putFinanceValuesIntoShortCache(keys, values, 1200); + expect(CacheFinanceUtils.getFinanceValuesFromShortCache(keys)) + .toEqual([null, 10.5]); + }); + + it("stores and retrieves values from the long cache in bulk", () => { + CacheFinanceUtils.bulkLongCachePut(["TSE:A", "TSE:B"], "PRICE", [10, "#N/A"], 1); + + expect(CacheFinanceUtils.bulkLongCacheGet(["TSE:A", "TSE:B"], "PRICE")) + .toEqual([10, null]); + }); + + it("removes all short-cache entries for the requested symbols", () => { + CacheFinanceUtils.bulkShortCachePut(["TSE:A"], "PRICE", [10], 1200); + CacheFinanceUtils.bulkShortCacheRemoveAll(["TSE:A"], "PRICE"); + + expect(CacheFinanceUtils.bulkShortCacheGet(["TSE:A"], "PRICE")).toEqual([null]); + }); + + it("returns non-array inputs unchanged from range helpers", () => { + expect(CacheFinanceUtils.removeEmptyRecordsAtEndOfTable("not-an-array")).toBe("not-an-array"); + expect(CacheFinanceUtils.convertRowsToSingleArray("not-an-array")).toBe("not-an-array"); + }); +}); diff --git a/test/FinanceWebSites.test.js b/test/FinanceWebSites.test.js new file mode 100644 index 0000000..126e156 --- /dev/null +++ b/test/FinanceWebSites.test.js @@ -0,0 +1,314 @@ +import { describe, expect, it } from "vitest"; +import { + AlphaVantage, + CoinMarket, + FinanceWebSites, + FinanceWebSite, + GlobeAndMail, + GoogleWebSiteFinance, + TwelveData, + YahooApi, + YahooFinance, + FinnHub, + StockAttributes +} from "../src/CacheFinanceWebSites.js"; +import { PropertiesService } from "../src/GasMocks.js"; + +describe("FinanceWebSites.getTickerCountryCode", () => { + it.each([ + ["NASDAQ:AAPL", "us"], + ["NYSEARCA:VOO", "us"], + ["TSE:ZTL", "ca"], + ["NEO:CJP", "ca"], + ["CURRENCY:USDEUR", "fx"], + ["CF:DYN2752", "mut"] + ])("maps %s to %s", (symbol, countryCode) => { + expect(FinanceWebSites.getTickerCountryCode(symbol)).toBe(countryCode); + }); +}); + +describe("GlobeAndMail", () => { + it("formats Canadian tickers for Globe and Mail URLs", () => { + expect(GlobeAndMail.getTicker("TSE:FTN-A")).toBe("FTN-PR-A-T"); + expect(GlobeAndMail.getTicker("NEO:CJP")).toBe("CJP-NE"); + expect(GlobeAndMail.getTicker("CF:DYN2752")).toBe("DYN2752.CF"); + }); + + it("builds stock and mutual fund URLs", () => { + expect(GlobeAndMail.getURL("TSE:FTN-A", "PRICE")) + .toBe("https://www.theglobeandmail.com/investing/markets/stocks/FTN-PR-A-T"); + expect(GlobeAndMail.getURL("CF:DYN2752", "PRICE")) + .toBe("https://www.theglobeandmail.com/investing/markets/funds/DYN2752.CF"); + expect(GlobeAndMail.getURL("CURRENCY:USDEUR", "PRICE")).toBe(""); + }); + + it("parses price, yield, and name from HTML", () => { + const html = ` + "symbolName":"Canadian Premium Fund" + "lastPrice" value="12.34" + name="dividendYieldTrailing" type="percent" value="3.25%" + `; + + const data = GlobeAndMail.parseResponse(html); + expect(data.stockName).toBe("Canadian Premium Fund"); + expect(data.stockPrice).toBe(12.34); + expect(data.yieldPct).toBeCloseTo(0.0325); + }); +}); + +describe("YahooFinance", () => { + it("translates exchange codes into Yahoo ticker format", () => { + expect(YahooFinance.getTicker("TSE:ZTL")).toBe("ZTL.TO"); + expect(YahooFinance.getTicker("NEO:CJP")).toBe("CJP.NE"); + }); + + it("parses price, yield, and name from HTML", () => { + const html = ` + Vanguard S&P 500 ETF(VOO) + <span title="Yield">Yield</span> <span>1.42</span> + <fin-streamer data-field="regularMarketPrice" data-symbol="VOO" data-value="412.34" class="qsp-price">412.34</fin-streamer> + `; + + const data = YahooFinance.parseResponse(html, "NYSEARCA:VOO"); + expect(data.stockName).toBe("Vanguard S&P 500 ETF"); + expect(data.yieldPct).toBeCloseTo(0.0142); + expect(data.stockPrice).toBe(412.34); + }); +}); + +describe("FinnHub", () => { + it("returns a quote URL only for supported US price lookups", () => { + expect(FinnHub.getURL("NYSEARCA:VOO", "PRICE", "test-key")) + .toBe("https://finnhub.io/api/v1/quote?symbol=VOO&token=test-key"); + expect(FinnHub.getURL("TSE:ZTL", "PRICE", "test-key")).toBe(""); + expect(FinnHub.getURL("NYSEARCA:VOO", "NAME", "test-key")).toBe(""); + }); + + it("parses Finnhub quote JSON", () => { + const data = FinnHub.parseResponse('{"c": 123.456}', "NYSEARCA:VOO", "PRICE"); + expect(data.stockPrice).toBe(123.46); + }); +}); + +describe("StockAttributes", () => { + it("rounds stock prices to two decimal places", () => { + const data = new StockAttributes(); + data.stockPrice = 12.3456; + expect(data.stockPrice).toBe(12.35); + }); + + it("rounds exchange rates to four decimal places", () => { + const data = new StockAttributes(); + data.exchangeRate = 1.234567; + expect(data.exchangeRate).toBe(1.2346); + }); + + it("returns attribute values and detects whether they are set", () => { + const data = new StockAttributes(); + data.stockPrice = 10; + data.yieldPct = 0.02; + data.stockName = "Example"; + + expect(data.getValue("PRICE")).toBe(10); + expect(data.getValue("YIELDPCT")).toBe(0.02); + expect(data.getValue("NAME")).toBe("Example"); + expect(data.isAttributeSet("PRICE")).toBe(true); + expect(data.isAttributeSet("YIELDPCT")).toBe(true); + expect(data.isAttributeSet("LOW52")).toBe(false); + }); +}); + +describe("FinanceWebSites helpers", () => { + it("extracts base tickers and currency pairs", () => { + expect(FinanceWebSites.getBaseTicker("NYSEARCA:VOO")).toBe("VOO"); + expect(FinanceWebSites.getCurrencyTickers("CURRENCY:USDEUR")).toEqual({ + fromCurrency: "USD", + toCurrency: "EUR" + }); + }); + + it("reads API keys from script properties", () => { + PropertiesService.getScriptProperties().setProperty("FINNHUB_API_KEY", "secret-key"); + expect(FinanceWebSites.getApiKey("FINNHUB_API_KEY")).toBe("secret-key"); + }); +}); + +describe("AlphaVantage", () => { + it("builds US and FX quote URLs when an API key is present", () => { + expect(AlphaVantage.getURL("NYSEARCA:VOO", "PRICE", "abc")) + .toBe("https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol=VOO&apikey=abc"); + expect(AlphaVantage.getURL("CURRENCY:USDEUR", "PRICE", "abc")) + .toBe("https://www.alphavantage.co/query?function=CURRENCY_EXCHANGE_RATE&from_currency=USD&to_currency=EUR&apikey=abc"); + }); + + it("parses stock and FX JSON responses", () => { + const stock = AlphaVantage.parseResponse( + JSON.stringify({ "Global Quote": { "05. price": "123.45" } }), + "NYSEARCA:VOO", + "PRICE" + ); + const fx = AlphaVantage.parseResponse( + JSON.stringify({ "Realtime Currency Exchange Rate": { "5. Exchange Rate": "1.2345" } }), + "CURRENCY:USDEUR", + "PRICE" + ); + + expect(stock.stockPrice).toBe(123.45); + expect(fx.exchangeRate).toBe(1.2345); + }); +}); + +describe("TwelveData", () => { + it("builds quote URLs for stocks and currencies", () => { + PropertiesService.getScriptProperties().setProperty("TWELVE_DATA_API_KEY", "td-key"); + + expect(TwelveData.getURL("NYSEARCA:VOO", "PRICE", "td-key")) + .toBe("https://api.twelvedata.com/quote?symbol=VOO&apikey=td-key"); + expect(TwelveData.getURL("CURRENCY:USDEUR", "PRICE", "td-key")) + .toBe("https://api.twelvedata.com/quote?symbol=USD/EUR&apikey=td-key"); + }); + + it("parses name and price fields from JSON", () => { + const nameData = TwelveData.parseResponse( + JSON.stringify({ name: "Vanguard ETF" }), + "NYSEARCA:VOO", + "NAME" + ); + const priceData = TwelveData.parseResponse( + JSON.stringify({ close: "412.3" }), + "NYSEARCA:VOO", + "PRICE" + ); + + expect(nameData.stockName).toBe("Vanguard ETF"); + expect(priceData.stockPrice).toBe(412.3); + }); +}); + +describe("GoogleWebSiteFinance", () => { + it("skips US yield lookups", () => { + expect(GoogleWebSiteFinance.getURL("NYSEARCA:VOO", "YIELDPCT")).toBe(""); + }); + + it("returns empty data when Google reports no match", () => { + const data = GoogleWebSiteFinance.parseResponse( + "We couldn't find any match for your search.", + "TSE:ZTL" + ); + + expect(data.stockPrice).toBeNull(); + expect(data.stockName).toBeNull(); + }); +}); + +describe("CoinMarket", () => { + it("builds crypto conversion URLs when an API key is present", () => { + expect(CoinMarket.getURL("CURRENCY:BTCUSD", "PRICE", "cmc-key")) + .toBe("https://pro-api.coinmarketcap.com/v1/cryptocurrency/quotes/latest?symbol=BTC&convert=USD&CMC_PRO_API_KEY=cmc-key"); + }); + + it("parses crypto quote JSON", () => { + const json = JSON.stringify({ + data: { + BTC: { + name: "Bitcoin", + quote: { + USD: { price: 50000.12 } + } + } + } + }); + + const data = CoinMarket.parseResponse(json, "CURRENCY:BTCUSD", "PRICE"); + expect(data.stockName).toBe("Bitcoin"); + expect(data.exchangeRate).toBe(50000.12); + }); +}); + +describe("YahooApi", () => { + it("builds chart API URLs for supported attributes", () => { + expect(YahooApi.getURL("NASDAQ:VTC", "PRICE")) + .toBe("https://query1.finance.yahoo.com/v8/finance/chart/VTC"); + expect(YahooApi.getURL("NASDAQ:VTC", "YIELDPCT")).toBe(""); + expect(YahooApi.getURL("CURRENCY:USDEUR", "PRICE")).toBe(""); + }); + + it("parses chart JSON responses", () => { + const json = JSON.stringify({ + chart: { + result: [{ + meta: { + regularMarketPrice: 123.456, + longName: "Vanguard Tax-Exempt Bond" + } + }] + } + }); + + const data = YahooApi.parseResponse(json, "NASDAQ:VTC"); + expect(data.stockPrice).toBe(123.46); + expect(data.stockName).toBe("Vanguard Tax-Exempt Bond"); + }); +}); + +describe("GoogleWebSiteFinance", () => { + it("formats tickers for stocks and currencies", () => { + expect(GoogleWebSiteFinance.getTicker("TSE:ZTL")).toBe("ZTL:TSE"); + expect(GoogleWebSiteFinance.getTicker("CURRENCY:USDEUR")).toBe("USD-EUR?hl=en"); + }); + + it("extracts yield, price, and name from Google Finance HTML", () => { + const html = ` + <title>Canadian Premium Fund(ZTL) + Dividend yield</span>2.15%</div> + data-last-price="18.76" + `; + + expect(GoogleWebSiteFinance.extractYieldPct(html, "TSE:ZTL")).toBeCloseTo(0.0215); + expect(GoogleWebSiteFinance.extractStockPrice(html, "TSE:ZTL")).toBe(18.76); + expect(GoogleWebSiteFinance.extractStockName(html, "TSE:ZTL")).toBe("Canadian Premium Fund"); + }); + + it("parses a complete Google Finance response for stocks", () => { + const html = ` + <title>Canadian Premium Fund(ZTL) + Dividend yield</span>2.15%</div> + data-last-price="18.76" + `; + + const data = GoogleWebSiteFinance.parseResponse(html, "TSE:ZTL"); + expect(data.stockPrice).toBe(18.76); + expect(data.yieldPct).toBeCloseTo(0.0215); + expect(data.stockName).toBe("Canadian Premium Fund"); + }); +}); + +describe("FinanceWebSites registry", () => { + it("looks up finance site objects by name", () => { + const sites = new FinanceWebSites(); + const yahoo = sites.getByName("yahoo"); + + expect(yahoo).not.toBeNull(); + expect(yahoo.siteName).toBe("YAHOO"); + expect(sites.getByName("missing")).toBeNull(); + }); + + it("exposes all configured providers", () => { + const sites = new FinanceWebSites().get().map((site) => site.siteName); + expect(sites).toEqual(expect.arrayContaining(["YAHOOAPI", "FINNHUB", "COINMARKET"])); + }); +}); + +describe("FinanceWebSite", () => { + it("stores site metadata and parser references", () => { + const parser = { getInfo: () => new StockAttributes() }; + const site = new FinanceWebSite("Custom", parser); + + expect(site.siteName).toBe("CUSTOM"); + expect(site.siteObject).toBe(parser); + + site.siteName = "Updated"; + site.siteObject = parser; + expect(site.siteName).toBe("Updated"); + }); +}); diff --git a/test/ScriptSettings.test.js b/test/ScriptSettings.test.js new file mode 100644 index 0000000..a57eef1 --- /dev/null +++ b/test/ScriptSettings.test.js @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { ScriptSettings, PropertyData } from "../src/ScriptSettings.js"; + +describe("PropertyData", () => { + it("stores and retrieves JSON-serializable values", () => { + const property = new PropertyData({ price: 12.34 }, 1); + expect(PropertyData.getData(property)).toEqual({ price: 12.34 }); + }); + + it("reports expiry based on days to hold", () => { + const property = new PropertyData("stale", 0); + property.expiry = Date.now() - 1000; + expect(PropertyData.isExpired(property)).toBe(true); + }); +}); + +describe("ScriptSettings", () => { + it("stores and retrieves values before expiry", () => { + const settings = new ScriptSettings(); + settings.put("PRICE|TSE:ZTL", 10.5, 1); + + expect(settings.get("PRICE|TSE:ZTL")).toBe(10.5); + }); + + it("returns null for missing keys", () => { + const settings = new ScriptSettings(); + expect(settings.get("MISSING|KEY")).toBeNull(); + }); + + it("retrieves multiple keys with one properties lookup", () => { + ScriptSettings.putAllKeysWithData( + ["PRICE|TSE:A", "PRICE|TSE:B"], + [10, 20], + 1 + ); + + expect(ScriptSettings.getAll(["PRICE|TSE:A", "PRICE|TSE:B", "PRICE|TSE:C"])) + .toEqual([10, 20, null]); + }); + + it("deletes a specific key", () => { + const settings = new ScriptSettings(); + settings.put("PRICE|TSE:ZTL", 10.5, 1); + settings.delete("PRICE|TSE:ZTL"); + + expect(settings.get("PRICE|TSE:ZTL")).toBeNull(); + }); + + it("returns null and deletes expired values on read", () => { + const settings = new ScriptSettings(); + settings.put("PRICE|TSE:ZTL", 10.5, 1); + + const raw = settings.scriptProperties.getProperty("PRICE|TSE:ZTL"); + const parsed = JSON.parse(raw); + parsed.expiry = Date.now() - 1000; + settings.scriptProperties.setProperty("PRICE|TSE:ZTL", JSON.stringify(parsed)); + + expect(settings.get("PRICE|TSE:ZTL")).toBeNull(); + expect(settings.scriptProperties.getProperty("PRICE|TSE:ZTL")).toBeNull(); + }); + + it("removes expired properties during expire cleanup", () => { + const settings = new ScriptSettings(); + settings.put("PRICE|TSE:OLD", 1, 1); + + const raw = settings.scriptProperties.getProperty("PRICE|TSE:OLD"); + const parsed = JSON.parse(raw); + parsed.expiry = Date.now() - 1000; + settings.scriptProperties.setProperty("PRICE|TSE:OLD", JSON.stringify(parsed)); + + ScriptSettings.expire(false, 10); + + expect(settings.get("PRICE|TSE:OLD")).toBeNull(); + }); + + it("removes all cache properties when deleteAll is true", () => { + const settings = new ScriptSettings(); + settings.put("PRICE|TSE:A", 10, 30); + settings.put("PRICE|TSE:B", 20, 30); + + ScriptSettings.expire(true); + + expect(settings.get("PRICE|TSE:A")).toBeNull(); + expect(settings.get("PRICE|TSE:B")).toBeNull(); + }); + + it("handles invalid JSON in script properties during expire", () => { + const settings = new ScriptSettings(); + settings.scriptProperties.setProperty("NOT_CACHEFINANCE", "plain-text"); + + expect(() => ScriptSettings.expire(false, 1)).not.toThrow(); + expect(settings.scriptProperties.getProperty("NOT_CACHEFINANCE")).toBe("plain-text"); + }); +}); diff --git a/test/SiteThrottle.test.js b/test/SiteThrottle.test.js new file mode 100644 index 0000000..c344dfb --- /dev/null +++ b/test/SiteThrottle.test.js @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { CacheService } from "../src/GasMocks.js"; +import { SiteThrottle, ThresholdPeriod } from "../src/CacheFinanceUtils.js"; +import { ScriptSettings } from "../src/ScriptSettings.js"; + +describe("SiteThrottle", () => { + beforeEach(() => { + CacheService.reset(); + }); + + it("allows requests below the per-second limit", () => { + const throttle = new SiteThrottle("TESTSITE", [ + new ThresholdPeriod("SECOND", 3) + ]); + + expect(throttle.checkAndIncrement()).toBe(true); + expect(throttle.checkAndIncrement()).toBe(true); + expect(throttle.checkAndIncrement()).toBe(false); + }); + + it("persists updated counters to the short cache", () => { + const throttle = new SiteThrottle("TESTSITE", [ + new ThresholdPeriod("SECOND", 5) + ]); + + throttle.checkAndIncrement(); + throttle.update(); + + const key = SiteThrottle.createSecondKey("TESTSITE"); + expect(SiteThrottle.currentForSecond(key)).toBe(1); + }); + + it("tracks day-based limits in script settings", () => { + const throttle = new SiteThrottle("DAYSITE", [ + new ThresholdPeriod("DAY", 2) + ]); + + expect(throttle.checkAndIncrement()).toBe(true); + throttle.update(); + expect(throttle.checkAndIncrement()).toBe(false); + }); + + it("builds stable throttle keys for each interval", () => { + expect(SiteThrottle.makeKey("SITE", "MIN", 12)).toBe("SITE:MIN:12"); + expect(SiteThrottle.createMinuteKey("SITE")).toMatch(/^SITE:MIN:\d+$/); + expect(SiteThrottle.createDayKey("SITE")).toMatch(/^SITE:DAY:\d+$/); + expect(SiteThrottle.createMonthKey("SITE")).toMatch(/^SITE:MONTH:\d+$/); + }); + + it("throws for unsupported threshold periods", () => { + expect(() => SiteThrottle.getCurrentThresholds( + [new ThresholdPeriod("WEEK", 1)], + "SITE" + )).toThrow("Invalid threshold period WEEK"); + }); +}); + +describe("ThresholdPeriod", () => { + it("stores period name and max request count", () => { + const period = new ThresholdPeriod("MINUTE", 8); + period.periodName = "SECOND"; + period.maxPerPeriod = 10; + + expect(period.periodName).toBe("SECOND"); + expect(period.maxPerPeriod).toBe(10); + }); +}); diff --git a/test/build.test.js b/test/build.test.js new file mode 100644 index 0000000..e6e3d5b --- /dev/null +++ b/test/build.test.js @@ -0,0 +1,65 @@ +import fs from "node:fs"; +import { describe, expect, it } from "vitest"; +import { + buildCacheFinanceBundle, + enableDebugExports, + hashSections, + isCacheFinanceBundleCurrent, + paths, + stripDebugBlocks, + validateBundleContent, + writeCacheFinanceBundle +} from "../scripts/gas-source.mjs"; + +describe("gas-source transforms", () => { + it("removes DEBUG blocks from source files", () => { + const source = `/* *** DEBUG START *** +// import { Foo } from "./Foo.js"; +// export { Bar }; +// *** DEBUG END ***/ + +class Bar {}`; + + expect(stripDebugBlocks(source)).toBe("class Bar {}"); + }); + + it("uncomments DEBUG exports for Node tests", () => { + const source = `/* *** DEBUG START *** +// Remove comments for testing in NODE +// export { Widget }; +// *** DEBUG END ***/ + +class Widget {}`; + + const transformed = enableDebugExports(source); + expect(transformed).toContain("export { Widget };"); + expect(transformed).toContain("class Widget {}"); + }); + + it("creates stable hashes for unchanged source sections", () => { + const sections = ["class A {}", "class B {}"]; + expect(hashSections(sections)).toBe(hashSections([...sections])); + }); +}); + +describe("cachefinance bundle", () => { + it("builds a valid Apps Script bundle", () => { + const { bundle, sourceHash, sectionCount } = buildCacheFinanceBundle(); + + expect(sectionCount).toBe(6); + expect(sourceHash).toMatch(/^[a-f0-9]{12}$/); + expect(bundle).toContain(`Source hash: ${sourceHash}`); + expect(bundle).toContain("function CACHEFINANCE"); + expect(bundle).toContain("class CacheFinanceUtils"); + expect(() => validateBundleContent(bundle)).not.toThrow(); + }); + + it("writes dist/CacheFinance.js only when content changes", () => { + const first = writeCacheFinanceBundle(); + const second = writeCacheFinanceBundle(); + + expect(fs.existsSync(paths.dist)).toBe(true); + expect(first.bundle).toBe(second.bundle); + expect(isCacheFinanceBundleCurrent()).toBe(true); + }); +}); diff --git a/test/setup.js b/test/setup.js new file mode 100644 index 0000000..8fec45c --- /dev/null +++ b/test/setup.js @@ -0,0 +1,19 @@ +import { beforeEach } from "vitest"; +import { CacheService, PropertiesService, SpreadsheetApp, UrlFetchApp } from "../src/GasMocks.js"; + +globalThis.CacheService = CacheService; +globalThis.PropertiesService = PropertiesService; +globalThis.UrlFetchApp = UrlFetchApp; +globalThis.SpreadsheetApp = SpreadsheetApp; +globalThis.Logger = { + log() {} +}; +globalThis.CacheFinance = { + deleteFromShortCache() {} +}; + +beforeEach(() => { + CacheService.reset(); + PropertiesService.reset(); + UrlFetchApp.reset(); +}); diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..97bf4ac --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,36 @@ +import { defineConfig } from "vitest/config"; +import { enableDebugExports } from "./scripts/gas-source.mjs"; + +export default defineConfig({ + test: { + environment: "node", + setupFiles: ["./test/setup.js"], + include: ["test/**/*.test.js"], + coverage: { + provider: "v8", + reporter: ["text", "text-summary", "html", "lcov"], + reportsDirectory: "./coverage", + include: ["src/**/*.js"], + exclude: [ + "src/GasMocks.js", + "src/CacheFinanceTest.js" + ], + thresholds: { + lines: 85, + functions: 85, + branches: 80, + statements: 85 + } + } + }, + plugins: [ + { + name: "cachefinance-gas-debug", + transform(code, id) { + if (id.includes("/src/") && id.endsWith(".js")) { + return enableDebugExports(code); + } + } + } + ] +}); From 6887e4428df69cce80dbdad145b813746036e71d Mon Sep 17 00:00:00 2001 From: Benjamin Po <benjaminpo212@gmail.com> Date: Wed, 8 Jul 2026 09:54:22 +0100 Subject: [PATCH 02/10] Update version in package.json to 1.0.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 04c195d..4fbd4d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cachefinance", - "version": "1.0.0", + "version": "1.0.1", "private": true, "type": "module", "scripts": { From 17755d4cce431cd038d46e55ed576e944e18d068 Mon Sep 17 00:00:00 2001 From: Benjamin Po <benjaminpo212@gmail.com> Date: Wed, 8 Jul 2026 10:11:56 +0100 Subject: [PATCH 03/10] Updated README and remove manual test functions from CacheFinance - Updated README instructions for authorizing the script. - Removed optional manual testing functions from CacheFinance.js to streamline the codebase. - Updated gas-source.mjs to validate exclusion of test helpers from the bundle. - Added tests to ensure optional test functions are not included in the final bundle. --- README.md | 3 ++- dist/CacheFinance.js | 30 +---------------------------- scripts/gas-source.mjs | 3 +++ src/CacheFinance.js | 28 --------------------------- src/CacheFinanceAppScriptTests.js | 32 +++++++++++++++++++++++++++++++ test/build.test.js | 8 ++++++++ 6 files changed, 46 insertions(+), 58 deletions(-) create mode 100644 src/CacheFinanceAppScriptTests.js diff --git a/README.md b/README.md index 05180fa..192f52d 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ # Using * After adding the script, it will require new permissions. -* You need to open the script inside the Google Script editor, go to the Run menu and choose 'testYieldPct' from the dropdown. This will prompt you to authorize the script with the correct permissions. +* To authorize, run any function from the Apps Script editor **Run** menu that calls `CACHEFINANCE` (for example, paste `src/CacheFinanceAppScriptTests.js` into a second script file and run `testYieldPct`), or trigger the function from a sheet cell. * **Fast REST API Websites** * For faster stock price lookups when external finance data is used, add the key to **Apps Script** ==> **Project Settings** ==> **Script Properties** * The free API will have limitied functionality in all cases. They are all throttled and may only provide end of day pricing. You can always get a paid subscription and use that API key if your needs are greater. @@ -198,6 +198,7 @@ This repository includes a Node.js toolchain for building, testing, and validati | `scripts/` | Build utilities (`gas-source.mjs`, `build-cachefinance.mjs`) | | `test/` | Vitest unit tests | | `src/GasMocks.js` | Google Apps Script service mocks (tests only) | +| `src/CacheFinanceAppScriptTests.js` | Optional manual GAS test runners (not bundled) | Each file in `src/` contains a `DEBUG` block at the top for Node.js imports during testing. That block is stripped automatically when building `dist/CacheFinance.js`. diff --git a/dist/CacheFinance.js b/dist/CacheFinance.js index c8d23cd..ea4c9cb 100644 --- a/dist/CacheFinance.js +++ b/dist/CacheFinance.js @@ -1,7 +1,7 @@ /* * CacheFinance Apps Script bundle. * Generated from src/. Do not edit this file directly. - * Source hash: a345df336b49 + * Source hash: 9f185994869b */ /** * Enhancement to GOOGLEFINANCE function for stock/ETF symbols that a) return "#N/A" (temporary or consistently), b) data never available like 'yieldpct' for ETF's. @@ -474,34 +474,6 @@ class CacheFinance { } } -// Functions only used for manual testing in the Google Apps Script editor. -// skipcq: JS-0128 -function testYieldPct() { - const val = CACHEFINANCE("TSE:CJP", "yieldpct"); // skipcq: JS-0128 - Logger.log(`Test CacheFinance TSE:CJP(yieldpct)=${val}`); -} - -function testCacheFinances() { // skipcq: JS-0128 - const symbols = SpreadsheetApp.getActiveSpreadsheet().getRangeByName("A30:A165").getValues(); - const data = SpreadsheetApp.getActiveSpreadsheet().getRangeByName("E30:E165").getValues(); - - const cacheData = CACHEFINANCES(symbols, "PRICE", data); - - const singleSymbols = CacheFinanceUtils.convertRowsToSingleArray(symbols); - - Logger.log(`BULK CACHE TEST Success${cacheData} . ${singleSymbols}`); -} - -function testUpdateMaster() { - const symbols = ["TSE:ZTL", "TSE:FTN-A", "TSE:ZTL"]; - const googleFinanceValues = [null, 10.0, null]; - const symbolsWithNoData = ["TSE:ZTL"]; - const thirdPartyFinanceValues = [15]; - - const newGoogleFinance = CacheFinance.updateMasterWithMissed(symbols, googleFinanceValues, symbolsWithNoData, thirdPartyFinanceValues); - Logger.log(newGoogleFinance); -} - /** @classdesc * Stores settings for the SCRIPT. Long term cache storage for small tables. */ class ScriptSettings { // skipcq: JS-0128 diff --git a/scripts/gas-source.mjs b/scripts/gas-source.mjs index cd2122b..b85b361 100644 --- a/scripts/gas-source.mjs +++ b/scripts/gas-source.mjs @@ -108,6 +108,9 @@ export function validateBundleContent(content) { if (content.includes("GasMocks")) { errors.push("contains test-only GasMocks references"); } + if (content.includes("function testYieldPct")) { + errors.push("contains optional Apps Script test helpers"); + } if (/^\s*import\s+/m.test(content) || /^\s*export\s+/m.test(content)) { errors.push("contains ES module import/export statements"); } diff --git a/src/CacheFinance.js b/src/CacheFinance.js index bc97eaf..19f7e0f 100644 --- a/src/CacheFinance.js +++ b/src/CacheFinance.js @@ -485,32 +485,4 @@ class CacheFinance { return siteNames; } -} - -// Functions only used for manual testing in the Google Apps Script editor. -// skipcq: JS-0128 -function testYieldPct() { - const val = CACHEFINANCE("TSE:CJP", "yieldpct"); // skipcq: JS-0128 - Logger.log(`Test CacheFinance TSE:CJP(yieldpct)=${val}`); -} - -function testCacheFinances() { // skipcq: JS-0128 - const symbols = SpreadsheetApp.getActiveSpreadsheet().getRangeByName("A30:A165").getValues(); - const data = SpreadsheetApp.getActiveSpreadsheet().getRangeByName("E30:E165").getValues(); - - const cacheData = CACHEFINANCES(symbols, "PRICE", data); - - const singleSymbols = CacheFinanceUtils.convertRowsToSingleArray(symbols); - - Logger.log(`BULK CACHE TEST Success${cacheData} . ${singleSymbols}`); -} - -function testUpdateMaster() { - const symbols = ["TSE:ZTL", "TSE:FTN-A", "TSE:ZTL"]; - const googleFinanceValues = [null, 10.0, null]; - const symbolsWithNoData = ["TSE:ZTL"]; - const thirdPartyFinanceValues = [15]; - - const newGoogleFinance = CacheFinance.updateMasterWithMissed(symbols, googleFinanceValues, symbolsWithNoData, thirdPartyFinanceValues); - Logger.log(newGoogleFinance); } \ No newline at end of file diff --git a/src/CacheFinanceAppScriptTests.js b/src/CacheFinanceAppScriptTests.js new file mode 100644 index 0000000..2853d41 --- /dev/null +++ b/src/CacheFinanceAppScriptTests.js @@ -0,0 +1,32 @@ +/** + * Optional Apps Script helpers for manual authorization and debugging. + * Not included in dist/CacheFinance.js — paste into a separate script file in your + * Google Sheets project if you want Run-menu test entry points. + */ + +// skipcq: JS-0128 +function testYieldPct() { + const val = CACHEFINANCE("TSE:CJP", "yieldpct"); // skipcq: JS-0128 + Logger.log(`Test CacheFinance TSE:CJP(yieldpct)=${val}`); +} + +function testCacheFinances() { // skipcq: JS-0128 + const symbols = SpreadsheetApp.getActiveSpreadsheet().getRangeByName("A30:A165").getValues(); + const data = SpreadsheetApp.getActiveSpreadsheet().getRangeByName("E30:E165").getValues(); + + const cacheData = CACHEFINANCES(symbols, "PRICE", data); + + const singleSymbols = CacheFinanceUtils.convertRowsToSingleArray(symbols); + + Logger.log(`BULK CACHE TEST Success${cacheData} . ${singleSymbols}`); +} + +function testUpdateMaster() { + const symbols = ["TSE:ZTL", "TSE:FTN-A", "TSE:ZTL"]; + const googleFinanceValues = [null, 10.0, null]; + const symbolsWithNoData = ["TSE:ZTL"]; + const thirdPartyFinanceValues = [15]; + + const newGoogleFinance = CacheFinance.updateMasterWithMissed(symbols, googleFinanceValues, symbolsWithNoData, thirdPartyFinanceValues); + Logger.log(newGoogleFinance); +} diff --git a/test/build.test.js b/test/build.test.js index e6e3d5b..ee94d11 100644 --- a/test/build.test.js +++ b/test/build.test.js @@ -54,6 +54,14 @@ describe("cachefinance bundle", () => { expect(() => validateBundleContent(bundle)).not.toThrow(); }); + it("excludes optional Apps Script test helpers from the bundle", () => { + const { bundle } = buildCacheFinanceBundle(); + + expect(bundle).not.toContain("function testYieldPct"); + expect(bundle).not.toContain("function testCacheFinances"); + expect(bundle).not.toContain("function testUpdateMaster"); + }); + it("writes dist/CacheFinance.js only when content changes", () => { const first = writeCacheFinanceBundle(); const second = writeCacheFinanceBundle(); From 7e9aeed5dab2f088d4c8a5889b055347bb6b36b9 Mon Sep 17 00:00:00 2001 From: Benjamin Po <benjaminpo212@gmail.com> Date: Wed, 8 Jul 2026 10:20:25 +0100 Subject: [PATCH 04/10] Update version to 1.0.1 and enhance error handling - Bump package version to 1.0.1 in package-lock.json. - Update devDependency "@vitest/coverage-v8" to version 3.2.7. - Simplify error handling in ScriptSettings and GasMocks by removing exception parameters. - Add clear methods to ScriptCache and ScriptProperties with documentation. - Modify test setup to prevent unnecessary logging and improve clarity in test cases. --- dist/CacheFinance.js | 6 +++--- package-lock.json | 6 +++--- src/GasMocks.js | 6 ++++++ src/ScriptSettings.js | 4 ++-- test/CacheFinance3rdParty.test.js | 2 +- test/SiteThrottle.test.js | 1 - test/setup.js | 8 ++++++-- vitest.config.js | 2 ++ 8 files changed, 23 insertions(+), 12 deletions(-) diff --git a/dist/CacheFinance.js b/dist/CacheFinance.js index ea4c9cb..dd4c5e3 100644 --- a/dist/CacheFinance.js +++ b/dist/CacheFinance.js @@ -1,7 +1,7 @@ /* * CacheFinance Apps Script bundle. * Generated from src/. Do not edit this file directly. - * Source hash: 9f185994869b + * Source hash: aff687d613ca */ /** * Enhancement to GOOGLEFINANCE function for stock/ETF symbols that a) return "#N/A" (temporary or consistently), b) data never available like 'yieldpct' for ETF's. @@ -523,7 +523,7 @@ class ScriptSettings { // skipcq: JS-0128 try { this.scriptProperties.setProperty(propertyKey, jsonData); } - catch (ex) { + catch { throw new Error("Cache Limit Exceeded. Long cache times have limited storage available. Only cache small tables for long periods."); } } @@ -617,7 +617,7 @@ class ScriptSettings { // skipcq: JS-0128 try { propertyValue = JSON.parse(allProperties[key]); } - catch (e) { + catch { // A property that is NOT cached by CACHEFINANCE continue; } diff --git a/package-lock.json b/package-lock.json index a7defc4..c7879e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,14 @@ { "name": "cachefinance", - "version": "1.0.0", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cachefinance", - "version": "1.0.0", + "version": "1.0.1", "devDependencies": { - "@vitest/coverage-v8": "^3.2.4", + "@vitest/coverage-v8": "^3.2.7", "vitest": "^3.2.4" } }, diff --git a/src/GasMocks.js b/src/GasMocks.js index 0a4cf52..a82bfaf 100644 --- a/src/GasMocks.js +++ b/src/GasMocks.js @@ -43,6 +43,9 @@ class ScriptCache { } } + /** + * Remove all entries from the in-memory script cache. + */ clear() { this.store.clear(); } @@ -81,6 +84,9 @@ class ScriptProperties { this.store.delete(key); } + /** + * Remove all entries from the in-memory script properties store. + */ clear() { this.store.clear(); } diff --git a/src/ScriptSettings.js b/src/ScriptSettings.js index 6ea1db8..7afe2c1 100644 --- a/src/ScriptSettings.js +++ b/src/ScriptSettings.js @@ -60,7 +60,7 @@ class ScriptSettings { // skipcq: JS-0128 try { this.scriptProperties.setProperty(propertyKey, jsonData); } - catch (ex) { + catch { throw new Error("Cache Limit Exceeded. Long cache times have limited storage available. Only cache small tables for long periods."); } } @@ -154,7 +154,7 @@ class ScriptSettings { // skipcq: JS-0128 try { propertyValue = JSON.parse(allProperties[key]); } - catch (e) { + catch { // A property that is NOT cached by CACHEFINANCE continue; } diff --git a/test/CacheFinance3rdParty.test.js b/test/CacheFinance3rdParty.test.js index 63d31d1..832bdd5 100644 --- a/test/CacheFinance3rdParty.test.js +++ b/test/CacheFinance3rdParty.test.js @@ -134,7 +134,7 @@ describe("FinanceWebsiteSearch", () => { const stock = new StockWebURL("TSE:ZTL"); const throttle = { checkAndIncrement: () => false, - update: () => {} + update: () => undefined }; stock.addSiteURL("YAHOO", "", "", "http://yahoo", buildParseResponse(10), throttle); diff --git a/test/SiteThrottle.test.js b/test/SiteThrottle.test.js index c344dfb..63da06f 100644 --- a/test/SiteThrottle.test.js +++ b/test/SiteThrottle.test.js @@ -1,7 +1,6 @@ import { beforeEach, describe, expect, it } from "vitest"; import { CacheService } from "../src/GasMocks.js"; import { SiteThrottle, ThresholdPeriod } from "../src/CacheFinanceUtils.js"; -import { ScriptSettings } from "../src/ScriptSettings.js"; describe("SiteThrottle", () => { beforeEach(() => { diff --git a/test/setup.js b/test/setup.js index 8fec45c..7452cd0 100644 --- a/test/setup.js +++ b/test/setup.js @@ -6,10 +6,14 @@ globalThis.PropertiesService = PropertiesService; globalThis.UrlFetchApp = UrlFetchApp; globalThis.SpreadsheetApp = SpreadsheetApp; globalThis.Logger = { - log() {} + log(_message) { + return; + } }; globalThis.CacheFinance = { - deleteFromShortCache() {} + deleteFromShortCache(_key) { + return; + } }; beforeEach(() => { diff --git a/vitest.config.js b/vitest.config.js index 97bf4ac..bebd7b4 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -30,6 +30,8 @@ export default defineConfig({ if (id.includes("/src/") && id.endsWith(".js")) { return enableDebugExports(code); } + + return null; } } ] From a2f312744249819132374994167b6c14d73a55e0 Mon Sep 17 00:00:00 2001 From: Benjamin Po <benjaminpo212@gmail.com> Date: Wed, 8 Jul 2026 10:26:46 +0100 Subject: [PATCH 05/10] Refactor GitHub Actions workflow and enhance documentation in GasMocks and ScriptSettings - Removed branch restrictions from the GitHub Actions workflow for pushes. - Updated the workflow to include a verification step for the bundle and run unit tests with coverage. - Added detailed JSDoc comments for new methods in GasMocks and ScriptSettings to improve code clarity and maintainability. --- .github/workflows/test.yml | 8 ++++---- src/GasMocks.js | 34 ++++++++++++++++++++++++++++++++++ src/ScriptSettings.js | 1 + 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ef95a3e..656d502 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,9 +2,6 @@ name: Test on: push: - branches: - - master - - main pull_request: types: [opened, synchronize, reopened] @@ -19,7 +16,10 @@ jobs: node-version: 22 cache: npm - run: npm ci - - run: npm run validate + - name: Verify bundle is up to date + run: npm run build:check + - name: Run unit tests with coverage + run: npm run test:coverage - uses: actions/upload-artifact@v4 if: always() with: diff --git a/src/GasMocks.js b/src/GasMocks.js index a82bfaf..7768722 100644 --- a/src/GasMocks.js +++ b/src/GasMocks.js @@ -27,16 +27,29 @@ class ScriptCache { this.store.set(key, value); } + /** + * Store multiple cache entries at once. + * @param {Record<string, string>} obj - Key/value pairs to store. + * @param {number} _seconds - Ignored; included for Apps Script API compatibility. + */ putAll(obj, _seconds) { for (const [key, value] of Object.entries(obj)) { this.store.set(key, value); } } + /** + * Remove a single cache entry. + * @param {string} key - Cache key to remove. + */ remove(key) { this.store.delete(key); } + /** + * Remove multiple cache entries. + * @param {string[]} keys - Cache keys to remove. + */ removeAll(keys) { for (const key of keys) { this.store.delete(key); @@ -51,12 +64,20 @@ class ScriptCache { } } +/** + * In-memory mock for Apps Script script properties storage. + */ class ScriptProperties { constructor() { /** @type {Map<string, string>} */ this.store = new Map(); } + /** + * Read a single script property. + * @param {string} key - Property key to read. + * @returns {string|null} Stored value, or null when missing. + */ getProperty(key) { return this.store.has(key) ? this.store.get(key) : null; } @@ -70,16 +91,29 @@ class ScriptProperties { return result; } + /** + * Store a single script property. + * @param {string} key - Property key to set. + * @param {string} value - Value to store. + */ setProperty(key, value) { this.store.set(key, value); } + /** + * Store multiple script properties at once. + * @param {Record<string, string>} obj - Key/value pairs to store. + */ setProperties(obj) { for (const [key, value] of Object.entries(obj)) { this.store.set(key, value); } } + /** + * Remove a single script property. + * @param {string} key - Property key to delete. + */ deleteProperty(key) { this.store.delete(key); } diff --git a/src/ScriptSettings.js b/src/ScriptSettings.js index 7afe2c1..bb37b52 100644 --- a/src/ScriptSettings.js +++ b/src/ScriptSettings.js @@ -2,6 +2,7 @@ // Remove comments for testing in NODE export { ScriptSettings, PropertyData }; import { PropertiesService } from "./GasMocks.js"; +import { CacheFinance } from "./CacheFinance.js"; class Logger { static log(msg) { From c019b1ca79ca60eedd61aaa7c2b43a7342b26dcb Mon Sep 17 00:00:00 2001 From: Benjamin Po <benjaminpo212@gmail.com> Date: Wed, 8 Jul 2026 10:33:03 +0100 Subject: [PATCH 06/10] Refactor error handling in API classes and enhance documentation in GasMocks - Simplified error handling in various API classes by removing exception parameters from catch blocks. - Updated documentation in GasMocks to include JSDoc comments for new methods, improving code clarity and maintainability. - Updated source hash in CacheFinance.js to reflect recent changes. --- dist/CacheFinance.js | 18 +++++++++--------- src/CacheFinanceWebSites.js | 16 ++++++++-------- src/GasMocks.js | 11 +++++++++++ 3 files changed, 28 insertions(+), 17 deletions(-) diff --git a/dist/CacheFinance.js b/dist/CacheFinance.js index dd4c5e3..9f9075f 100644 --- a/dist/CacheFinance.js +++ b/dist/CacheFinance.js @@ -1,7 +1,7 @@ /* * CacheFinance Apps Script bundle. * Generated from src/. Do not edit this file directly. - * Source hash: aff687d613ca + * Source hash: 9df45b296c4e */ /** * Enhancement to GOOGLEFINANCE function for stock/ETF symbols that a) return "#N/A" (temporary or consistently), b) data never available like 'yieldpct' for ETF's. @@ -2073,7 +2073,7 @@ class YahooApi { try { html = UrlFetchApp.fetch(URL).getContentText(); } - catch (ex) { + catch { return new StockAttributes(); } @@ -2135,7 +2135,7 @@ class YahooApi { stockData.stockName = data.chart.result[0].meta.longName; } } - catch (ex) { + catch { Logger.log(`Failed to parse JSON: ${symbol}`); } @@ -2179,7 +2179,7 @@ class GlobeAndMail { try { html = UrlFetchApp.fetch(URL).getContentText(); } - catch (ex) { + catch { return new StockAttributes(); } @@ -2352,7 +2352,7 @@ class FinnHub { jsonStr = UrlFetchApp.fetch(URL).getContentText(); data = FinnHub.parseResponse(jsonStr, symbol, attribute); } - catch (ex) { + catch { return data; } @@ -2453,7 +2453,7 @@ class AlphaVantage { jsonStr = UrlFetchApp.fetch(URL).getContentText(); data = AlphaVantage.parseResponse(jsonStr, symbol, attribute); } - catch (ex) { + catch { return data; } @@ -2558,7 +2558,7 @@ class GoogleWebSiteFinance { try { html = UrlFetchApp.fetch(URL).getContentText(); } - catch (ex) { + catch { return new StockAttributes(); } @@ -2757,7 +2757,7 @@ class TwelveData { jsonStr = UrlFetchApp.fetch(URL).getContentText(); data = TwelveData.parseResponse(jsonStr, symbol, attribute); } - catch (ex) { + catch { return data; } @@ -2890,7 +2890,7 @@ class CoinMarket { jsonStr = UrlFetchApp.fetch(URL).getContentText(); data = CoinMarket.parseResponse(jsonStr, symbol, attribute); } - catch (ex) { + catch { return data; } diff --git a/src/CacheFinanceWebSites.js b/src/CacheFinanceWebSites.js index 8028545..5bd19e3 100644 --- a/src/CacheFinanceWebSites.js +++ b/src/CacheFinanceWebSites.js @@ -489,7 +489,7 @@ class YahooApi { try { html = UrlFetchApp.fetch(URL).getContentText(); } - catch (ex) { + catch { return new StockAttributes(); } @@ -551,7 +551,7 @@ class YahooApi { stockData.stockName = data.chart.result[0].meta.longName; } } - catch (ex) { + catch { Logger.log(`Failed to parse JSON: ${symbol}`); } @@ -595,7 +595,7 @@ class GlobeAndMail { try { html = UrlFetchApp.fetch(URL).getContentText(); } - catch (ex) { + catch { return new StockAttributes(); } @@ -768,7 +768,7 @@ class FinnHub { jsonStr = UrlFetchApp.fetch(URL).getContentText(); data = FinnHub.parseResponse(jsonStr, symbol, attribute); } - catch (ex) { + catch { return data; } @@ -869,7 +869,7 @@ class AlphaVantage { jsonStr = UrlFetchApp.fetch(URL).getContentText(); data = AlphaVantage.parseResponse(jsonStr, symbol, attribute); } - catch (ex) { + catch { return data; } @@ -974,7 +974,7 @@ class GoogleWebSiteFinance { try { html = UrlFetchApp.fetch(URL).getContentText(); } - catch (ex) { + catch { return new StockAttributes(); } @@ -1173,7 +1173,7 @@ class TwelveData { jsonStr = UrlFetchApp.fetch(URL).getContentText(); data = TwelveData.parseResponse(jsonStr, symbol, attribute); } - catch (ex) { + catch { return data; } @@ -1306,7 +1306,7 @@ class CoinMarket { jsonStr = UrlFetchApp.fetch(URL).getContentText(); data = CoinMarket.parseResponse(jsonStr, symbol, attribute); } - catch (ex) { + catch { return data; } diff --git a/src/GasMocks.js b/src/GasMocks.js index 7768722..498d221 100644 --- a/src/GasMocks.js +++ b/src/GasMocks.js @@ -8,6 +8,11 @@ class ScriptCache { this.store = new Map(); } + /** + * Read a single cache entry. + * @param {string} key - Cache key to read. + * @returns {string|null} Stored value, or null when missing. + */ get(key) { return this.store.has(key) ? this.store.get(key) : null; } @@ -23,6 +28,12 @@ class ScriptCache { return result; } + /** + * Store a single cache entry. + * @param {string} key - Cache key to set. + * @param {string} value - Value to store. + * @param {number} _seconds - Ignored; included for Apps Script API compatibility. + */ put(key, value, _seconds) { this.store.set(key, value); } From c620ea910e6713b09e62244b721f3c93ef04dafe Mon Sep 17 00:00:00 2001 From: Benjamin Po <benjaminpo212@gmail.com> Date: Wed, 8 Jul 2026 10:37:53 +0100 Subject: [PATCH 07/10] Refactor error handling and enhance documentation across multiple files - Simplified error handling in FinanceSiteLookupAnalyzer by removing exception parameters from catch blocks. - Updated documentation in build-cachefinance.mjs and GasMocks to include JSDoc comments for new methods, improving code clarity. - Adjusted validation logic in gas-source.mjs to accommodate whitespace variations in import/export statements. - Updated source hash in CacheFinance.js to reflect recent changes. --- dist/CacheFinance.js | 4 ++-- scripts/build-cachefinance.mjs | 11 +++++++++++ scripts/gas-source.mjs | 2 +- src/CacheFinance3rdParty.js | 2 +- src/CacheFinanceAppScriptTests.js | 5 ++++- src/GasMocks.js | 19 +++++++++++++------ 6 files changed, 32 insertions(+), 11 deletions(-) diff --git a/dist/CacheFinance.js b/dist/CacheFinance.js index 9f9075f..ca6841c 100644 --- a/dist/CacheFinance.js +++ b/dist/CacheFinance.js @@ -1,7 +1,7 @@ /* * CacheFinance Apps Script bundle. * Generated from src/. Do not edit this file directly. - * Source hash: 9df45b296c4e + * Source hash: f8c95689ce7c */ /** * Enhancement to GOOGLEFINANCE function for stock/ETF symbols that a) return "#N/A" (temporary or consistently), b) data never available like 'yieldpct' for ETF's. @@ -1299,7 +1299,7 @@ class FinanceSiteLookupAnalyzer { try { data = siteFunction.siteObject.getInfo(symbol, attribute); } - catch (ex) { + catch { Logger.log(`No SITE Object. Symbol=${symbol}. Attrib=${attribute}. Site=${site}`); } diff --git a/scripts/build-cachefinance.mjs b/scripts/build-cachefinance.mjs index 5850d7e..2f5f577 100644 --- a/scripts/build-cachefinance.mjs +++ b/scripts/build-cachefinance.mjs @@ -11,13 +11,22 @@ import { const args = new Set(process.argv.slice(2)); const checkOnly = args.has("--check"); +/** + * Format a byte count for CLI output. + * @param {number} bytes + * @returns {string} + */ function formatBytes(bytes) { return `${bytes.toLocaleString()} bytes`; } +/** + * Build or verify the Apps Script bundle from source modules. + */ function main() { if (checkOnly) { if (isCacheFinanceBundleCurrent()) { + // skipcq: JS-0002 console.log(`dist/CacheFinance.js is up to date (${SOURCE_FILES.length} source files)`); return; } @@ -29,6 +38,7 @@ function main() { const result = writeCacheFinanceBundle(); if (result.written) { + // skipcq: JS-0002 console.log( `Built ${paths.dist} (${formatBytes(result.bytes)}, ` + `${result.sectionCount} files, hash ${result.sourceHash})` @@ -36,6 +46,7 @@ function main() { return; } + // skipcq: JS-0002 console.log( `dist/CacheFinance.js is up to date (${formatBytes(result.bytes)}, hash ${result.sourceHash})` ); diff --git a/scripts/gas-source.mjs b/scripts/gas-source.mjs index b85b361..06e94df 100644 --- a/scripts/gas-source.mjs +++ b/scripts/gas-source.mjs @@ -111,7 +111,7 @@ export function validateBundleContent(content) { if (content.includes("function testYieldPct")) { errors.push("contains optional Apps Script test helpers"); } - if (/^\s*import\s+/m.test(content) || /^\s*export\s+/m.test(content)) { + if (/^[ \t]*import\s/m.test(content) || /^[ \t]*export\s/m.test(content)) { errors.push("contains ES module import/export statements"); } diff --git a/src/CacheFinance3rdParty.js b/src/CacheFinance3rdParty.js index 4b971bc..05f20cf 100644 --- a/src/CacheFinance3rdParty.js +++ b/src/CacheFinance3rdParty.js @@ -612,7 +612,7 @@ class FinanceSiteLookupAnalyzer { try { data = siteFunction.siteObject.getInfo(symbol, attribute); } - catch (ex) { + catch { Logger.log(`No SITE Object. Symbol=${symbol}. Attrib=${attribute}. Site=${site}`); } diff --git a/src/CacheFinanceAppScriptTests.js b/src/CacheFinanceAppScriptTests.js index 2853d41..4c19646 100644 --- a/src/CacheFinanceAppScriptTests.js +++ b/src/CacheFinanceAppScriptTests.js @@ -21,7 +21,10 @@ function testCacheFinances() { // skipcq: JS-01 Logger.log(`BULK CACHE TEST Success${cacheData} . ${singleSymbols}`); } -function testUpdateMaster() { +/** + * Manual Apps Script runner for updateMasterWithMissed. + */ +function testUpdateMaster() { // skipcq: JS-0128 const symbols = ["TSE:ZTL", "TSE:FTN-A", "TSE:ZTL"]; const googleFinanceValues = [null, 10.0, null]; const symbolsWithNoData = ["TSE:ZTL"]; diff --git a/src/GasMocks.js b/src/GasMocks.js index 498d221..1275aa7 100644 --- a/src/GasMocks.js +++ b/src/GasMocks.js @@ -2,6 +2,17 @@ * In-memory mocks for Google Apps Script services used during Node.js tests. */ +/** + * Copy every own enumerable entry from a plain object into a Map. + * @param {Map<string, string>} store - Destination map. + * @param {Record<string, string>} entries - Source key/value pairs. + */ +function copyEntriesInto(store, entries) { + for (const [key, value] of Object.entries(entries)) { + store.set(key, value); + } +} + class ScriptCache { constructor() { /** @type {Map<string, string>} */ @@ -44,9 +55,7 @@ class ScriptCache { * @param {number} _seconds - Ignored; included for Apps Script API compatibility. */ putAll(obj, _seconds) { - for (const [key, value] of Object.entries(obj)) { - this.store.set(key, value); - } + copyEntriesInto(this.store, obj); } /** @@ -116,9 +125,7 @@ class ScriptProperties { * @param {Record<string, string>} obj - Key/value pairs to store. */ setProperties(obj) { - for (const [key, value] of Object.entries(obj)) { - this.store.set(key, value); - } + copyEntriesInto(this.store, obj); } /** From 87a029c2d9ecd3750f2bc96e5c3fe000dd3736f2 Mon Sep 17 00:00:00 2001 From: Benjamin Po <benjaminpo212@gmail.com> Date: Wed, 8 Jul 2026 10:40:20 +0100 Subject: [PATCH 08/10] Add JSDoc comment for ScriptCache class in GasMocks - Introduced a JSDoc comment to document the in-memory mock for Apps Script script cache storage in the GasMocks.js file, enhancing code clarity and maintainability. --- src/GasMocks.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/GasMocks.js b/src/GasMocks.js index 1275aa7..4c6367b 100644 --- a/src/GasMocks.js +++ b/src/GasMocks.js @@ -13,6 +13,9 @@ function copyEntriesInto(store, entries) { } } +/** + * In-memory mock for Apps Script script cache storage. + */ class ScriptCache { constructor() { /** @type {Map<string, string>} */ From 4e11bac4aa2d29841d4eb70c386db3f0e57a7d91 Mon Sep 17 00:00:00 2001 From: Benjamin Po <benjaminpo212@gmail.com> Date: Wed, 8 Jul 2026 11:23:05 +0100 Subject: [PATCH 09/10] Update version to 1.1.0 and add rename test FinanceWebSites to CacheFinanceWebSites - Bump package version to 1.1.0 in package.json and package-lock.json. - Rename test FinanceWebSites to CacheFinanceWebSites --- package-lock.json | 4 ++-- package.json | 2 +- .../{FinanceWebSites.test.js => CacheFinanceWebSites.test.js} | 0 3 files changed, 3 insertions(+), 3 deletions(-) rename test/{FinanceWebSites.test.js => CacheFinanceWebSites.test.js} (100%) diff --git a/package-lock.json b/package-lock.json index c7879e1..d137284 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cachefinance", - "version": "1.0.1", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cachefinance", - "version": "1.0.1", + "version": "1.1.0", "devDependencies": { "@vitest/coverage-v8": "^3.2.7", "vitest": "^3.2.4" diff --git a/package.json b/package.json index 4fbd4d1..ba9383a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cachefinance", - "version": "1.0.1", + "version": "1.1.0", "private": true, "type": "module", "scripts": { diff --git a/test/FinanceWebSites.test.js b/test/CacheFinanceWebSites.test.js similarity index 100% rename from test/FinanceWebSites.test.js rename to test/CacheFinanceWebSites.test.js From 4b2f34e006f35bef587d5bae9e7d7a1f2c441cad Mon Sep 17 00:00:00 2001 From: Benjamin Po <benjaminpo212@gmail.com> Date: Wed, 8 Jul 2026 16:34:36 +0100 Subject: [PATCH 10/10] Update .gitignore to exclude .vscode directory - Added .vscode/ to .gitignore to prevent IDE-specific files from being tracked in the repository. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 40fc0f5..177b80d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ *.sh +.vscode/ node_modules/ coverage/ \ No newline at end of file