diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..656d502 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,28 @@ +name: Test + +on: + push: + 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 + - 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: + name: coverage-report + path: coverage/ + retention-days: 14 diff --git a/.gitignore b/.gitignore index cd0d007..177b80d 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ -*.sh \ No newline at end of file +*.sh +.vscode/ +node_modules/ +coverage/ \ No newline at end of file diff --git a/README.md b/README.md index da303a0..192f52d 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 **< >** @@ -56,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. @@ -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,68 @@ ![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) | +| `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`. + +## 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..ca6841c 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: 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. * @param {string} symbol - stock ticket with exchange (e.g. "NYSEARCA:VOO") @@ -519,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."); } } @@ -613,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; } @@ -1295,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}`); } @@ -1592,8 +1596,6 @@ class CacheFinanceTestStatus { } } - - /** * @classdesc Concrete implementations for each finance website access. */ @@ -2071,7 +2073,7 @@ class YahooApi { try { html = UrlFetchApp.fetch(URL).getContentText(); } - catch (ex) { + catch { return new StockAttributes(); } @@ -2133,7 +2135,7 @@ class YahooApi { stockData.stockName = data.chart.result[0].meta.longName; } } - catch (ex) { + catch { Logger.log(`Failed to parse JSON: ${symbol}`); } @@ -2177,7 +2179,7 @@ class GlobeAndMail { try { html = UrlFetchApp.fetch(URL).getContentText(); } - catch (ex) { + catch { return new StockAttributes(); } @@ -2350,7 +2352,7 @@ class FinnHub { jsonStr = UrlFetchApp.fetch(URL).getContentText(); data = FinnHub.parseResponse(jsonStr, symbol, attribute); } - catch (ex) { + catch { return data; } @@ -2451,7 +2453,7 @@ class AlphaVantage { jsonStr = UrlFetchApp.fetch(URL).getContentText(); data = AlphaVantage.parseResponse(jsonStr, symbol, attribute); } - catch (ex) { + catch { return data; } @@ -2556,7 +2558,7 @@ class GoogleWebSiteFinance { try { html = UrlFetchApp.fetch(URL).getContentText(); } - catch (ex) { + catch { return new StockAttributes(); } @@ -2755,7 +2757,7 @@ class TwelveData { jsonStr = UrlFetchApp.fetch(URL).getContentText(); data = TwelveData.parseResponse(jsonStr, symbol, attribute); } - catch (ex) { + catch { return data; } @@ -2888,7 +2890,7 @@ class CoinMarket { jsonStr = UrlFetchApp.fetch(URL).getContentText(); data = CoinMarket.parseResponse(jsonStr, symbol, attribute); } - catch (ex) { + catch { return data; } @@ -2980,8 +2982,6 @@ class CoinMarket { } } - - /** * @classdesc Multi-purpose functions used within the cache finance custom functions. */ @@ -3484,4 +3484,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..d137284 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2487 @@ +{ + "name": "cachefinance", + "version": "1.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cachefinance", + "version": "1.1.0", + "devDependencies": { + "@vitest/coverage-v8": "^3.2.7", + "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..ba9383a --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "cachefinance", + "version": "1.1.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..2f5f577 --- /dev/null +++ b/scripts/build-cachefinance.mjs @@ -0,0 +1,59 @@ +#!/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"); + +/** + * 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; + } + + console.error("dist/CacheFinance.js is out of date. Run: npm run build"); + process.exit(1); + } + + const result = writeCacheFinanceBundle(); + + if (result.written) { + // skipcq: JS-0002 + console.log( + `Built ${paths.dist} (${formatBytes(result.bytes)}, ` + + `${result.sectionCount} files, hash ${result.sourceHash})` + ); + return; + } + + // skipcq: JS-0002 + 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..06e94df --- /dev/null +++ b/scripts/gas-source.mjs @@ -0,0 +1,182 @@ +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 (content.includes("function testYieldPct")) { + errors.push("contains optional Apps Script test helpers"); + } + if (/^[ \t]*import\s/m.test(content) || /^[ \t]*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..19f7e0f 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 ***/ /** diff --git a/src/CacheFinance3rdParty.js b/src/CacheFinance3rdParty.js index 96e74f1..05f20cf 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) { @@ -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 new file mode 100644 index 0000000..4c19646 --- /dev/null +++ b/src/CacheFinanceAppScriptTests.js @@ -0,0 +1,35 @@ +/** + * 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}`); +} + +/** + * 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"]; + const thirdPartyFinanceValues = [15]; + + const newGoogleFinance = CacheFinance.updateMasterWithMissed(symbols, googleFinanceValues, symbolsWithNoData, thirdPartyFinanceValues); + Logger.log(newGoogleFinance); +} 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..5bd19e3 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 { @@ -491,7 +489,7 @@ class YahooApi { try { html = UrlFetchApp.fetch(URL).getContentText(); } - catch (ex) { + catch { return new StockAttributes(); } @@ -553,7 +551,7 @@ class YahooApi { stockData.stockName = data.chart.result[0].meta.longName; } } - catch (ex) { + catch { Logger.log(`Failed to parse JSON: ${symbol}`); } @@ -597,7 +595,7 @@ class GlobeAndMail { try { html = UrlFetchApp.fetch(URL).getContentText(); } - catch (ex) { + catch { return new StockAttributes(); } @@ -770,7 +768,7 @@ class FinnHub { jsonStr = UrlFetchApp.fetch(URL).getContentText(); data = FinnHub.parseResponse(jsonStr, symbol, attribute); } - catch (ex) { + catch { return data; } @@ -871,7 +869,7 @@ class AlphaVantage { jsonStr = UrlFetchApp.fetch(URL).getContentText(); data = AlphaVantage.parseResponse(jsonStr, symbol, attribute); } - catch (ex) { + catch { return data; } @@ -976,7 +974,7 @@ class GoogleWebSiteFinance { try { html = UrlFetchApp.fetch(URL).getContentText(); } - catch (ex) { + catch { return new StockAttributes(); } @@ -1175,7 +1173,7 @@ class TwelveData { jsonStr = UrlFetchApp.fetch(URL).getContentText(); data = TwelveData.parseResponse(jsonStr, symbol, attribute); } - catch (ex) { + catch { return data; } @@ -1308,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 new file mode 100644 index 0000000..4c6367b --- /dev/null +++ b/src/GasMocks.js @@ -0,0 +1,218 @@ +/** + * 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} store - Destination map. + * @param {Record} entries - Source key/value pairs. + */ +function copyEntriesInto(store, entries) { + for (const [key, value] of Object.entries(entries)) { + store.set(key, value); + } +} + +/** + * In-memory mock for Apps Script script cache storage. + */ +class ScriptCache { + constructor() { + /** @type {Map} */ + 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; + } + + getAll(keys) { + /** @type {Record} */ + const result = {}; + for (const key of keys) { + if (this.store.has(key)) { + result[key] = this.store.get(key); + } + } + 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); + } + + /** + * Store multiple cache entries at once. + * @param {Record} obj - Key/value pairs to store. + * @param {number} _seconds - Ignored; included for Apps Script API compatibility. + */ + putAll(obj, _seconds) { + copyEntriesInto(this.store, obj); + } + + /** + * 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); + } + } + + /** + * Remove all entries from the in-memory script cache. + */ + clear() { + this.store.clear(); + } +} + +/** + * In-memory mock for Apps Script script properties storage. + */ +class ScriptProperties { + constructor() { + /** @type {Map} */ + 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; + } + + getProperties() { + /** @type {Record} */ + const result = {}; + for (const [key, value] of this.store.entries()) { + result[key] = value; + } + 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} obj - Key/value pairs to store. + */ + setProperties(obj) { + copyEntriesInto(this.store, obj); + } + + /** + * Remove a single script property. + * @param {string} key - Property key to delete. + */ + deleteProperty(key) { + this.store.delete(key); + } + + /** + * Remove all entries from the in-memory script properties store. + */ + 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..bb37b52 100644 --- a/src/ScriptSettings.js +++ b/src/ScriptSettings.js @@ -1,8 +1,8 @@ /* *** 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"; +import { CacheFinance } from "./CacheFinance.js"; class Logger { static log(msg) { @@ -61,7 +61,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."); } } @@ -155,7 +155,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/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..832bdd5 --- /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: () => undefined + }; + + 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/CacheFinanceWebSites.test.js b/test/CacheFinanceWebSites.test.js new file mode 100644 index 0000000..126e156 --- /dev/null +++ b/test/CacheFinanceWebSites.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..63da06f --- /dev/null +++ b/test/SiteThrottle.test.js @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { CacheService } from "../src/GasMocks.js"; +import { SiteThrottle, ThresholdPeriod } from "../src/CacheFinanceUtils.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..ee94d11 --- /dev/null +++ b/test/build.test.js @@ -0,0 +1,73 @@ +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("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(); + + 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..7452cd0 --- /dev/null +++ b/test/setup.js @@ -0,0 +1,23 @@ +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(_message) { + return; + } +}; +globalThis.CacheFinance = { + deleteFromShortCache(_key) { + return; + } +}; + +beforeEach(() => { + CacheService.reset(); + PropertiesService.reset(); + UrlFetchApp.reset(); +}); diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..bebd7b4 --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,38 @@ +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); + } + + return null; + } + } + ] +});