diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..56cf5d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +coverage/ +*.log +.env +.env.* +!.env.example +tests/resources/application.properties diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..68f9def --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +node_modules +dist +coverage +package-lock.json diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..ef2237c --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2, + "endOfLine": "lf" +} diff --git a/README.md b/README.md index 520c17a..31952c0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,88 @@ -# playwright-plugin -TestRigor plug-in for PlayWright +# @testrigor/playwright-plugin + +Playwright integration for the testRigor cloud extension — natural-language locators, fluent commands, and self-healing. + +## Install + +```bash +npm install @testrigor/playwright-plugin playwright +npx playwright install chromium +``` + +## Quick start + +```typescript +import { TestRigor } from '@testrigor/playwright-plugin'; + +const driver = await TestRigor.createBrowserPage(process.env.TESTRIGOR_API_TOKEN!); +driver.setTestContext('my_test'); + +await driver.get('https://example.com'); +const button = await driver.findElement(TestRigor.byUserDescription('Sign in')); +await button.click(); + +await driver.quit(); +``` + +Attach to an existing Playwright page: + +```typescript +import { TestRigor } from '@testrigor/playwright-plugin'; + +const driver = TestRigor.extendPage(page, apiToken); +driver.setTestContext('my_test'); +``` + +## Fluent commands + +```typescript +import { TestRigor } from '@testrigor/playwright-plugin'; + +const { actions, validations, queries } = TestRigor; + +await actions(driver).openUrl('https://example.com').click('Empty Page').execute(); +await validations(driver).checkPageContains('expected text').execute(); +const value = await queries(driver).grabValue('Some field'); +``` + +`setTestContext` is required before locator self-healing (scopes healing data on the server). + +## Configuration + +Environment variables: + +| Variable | Default | +| ------------------------------- | ---------------------------------- | +| `TESTRIGOR_API_TOKEN` | none - must be provided | +| `TESTRIGOR_GRPC_URI` | `selenium-extension.testrigor.com` | +| `TESTRIGOR_GRPC_PORT` | `443` | +| `TESTRIGOR_GRPC_USE_TLS` | auto (TLS enabled for port 443) | +| `TESTRIGOR_PLAYWRIGHT_BROWSER` | `chromium` | +| `TESTRIGOR_PLAYWRIGHT_HEADLESS` | `false` | + +Manual integration tests load settings from `tests/resources/application.properties` (copy from `application.properties.example`). Override the path with `CONFIG_FILE` or `config.file`. + +The API token is **not** read automatically by the library — pass it to `createBrowserPage` / `extendPage`, or set `testrigor.apiToken` / `TESTRIGOR_API_TOKEN` for manual tests only. + +## Development + +```bash +npm install +npm run check # lint + format + build + unit tests +npm run test:manual # live gRPC integration tests (requires service + config) +npm run test:manual:one # single manual test by name (-t flag) +``` + +## Layout + +``` +src/ + application/ Extension service, gRPC connection, driver + protocol/ Remote driver commands → Playwright + elements/ Element registry and wrappers + locators/ Locators and self-healing finder + session/ Browser launch and page wrapping + commons/ gRPC client, command facades, shared types + testrigor.ts Public entry point +tests/manual/ Live gRPC integration tests +``` diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..17fa1b7 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,23 @@ +import eslint from '@eslint/js'; +import eslintConfigPrettier from 'eslint-config-prettier'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + ignores: ['dist/**', 'node_modules/**', 'coverage/**'], + }, + eslint.configs.recommended, + ...tseslint.configs.recommended, + eslintConfigPrettier, + { + files: ['**/*.ts'], + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-empty-object-type': 'off', + }, + }, +); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5206f77 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,5080 @@ +{ + "name": "@testrigor/playwright-plugin", + "version": "0.1.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "@testrigor/playwright-plugin", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@grpc/grpc-js": "^1.12.6", + "@grpc/proto-loader": "^0.7.13", + "playwright": ">=1.40.0", + "protobufjs": "^7.6.4" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "@types/node": "^22.10.0", + "eslint": "^9.17.0", + "eslint-config-prettier": "^9.1.0", + "prettier": "^3.4.2", + "typescript": "^5.7.2", + "typescript-eslint": "^8.18.2", + "vitest": "^2.1.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "playwright": ">=1.40.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "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 + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==" + }, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "optional": true, + "os": [ + "win32" + ] + }, + "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 + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.0.tgz", + "integrity": "sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.0.tgz", + "integrity": "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.0.tgz", + "integrity": "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.62.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "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==", + "engines": { + "node": ">=8" + } + }, + "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==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "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, + "engines": { + "node": ">=12" + } + }, + "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 + }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "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, + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "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, + "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/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "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, + "engines": { + "node": ">= 16" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "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==", + "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==" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "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, + "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, + "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, + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "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==" + }, + "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 + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz", + "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", + "dev": true, + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/eslint/node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "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, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "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, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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, + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==" + }, + "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 + }, + "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, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "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 + }, + "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" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "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, + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "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, + "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 + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "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" + } + ], + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", + "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/protobufjs": { + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", + "hasInstallScript": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "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, + "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, + "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, + "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, + "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 + }, + "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, + "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 + }, + "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 + }, + "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==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "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==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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 + }, + "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 + }, + "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, + "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, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.0.tgz", + "integrity": "sha512-8QxXi+ZACKX0kaqO4gY8kn0RSD9gFfaHDWwjqtEN48aWCBkX4MJaufWN+c3BzlrXLOxfywDL8CaoqUwcRq4j4Q==", + "dev": true, + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.62.0", + "@typescript-eslint/parser": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0", + "@typescript-eslint/utils": "8.62.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.0.tgz", + "integrity": "sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.62.0", + "@typescript-eslint/type-utils": "8.62.0", + "@typescript-eslint/utils": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.62.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.0.tgz", + "integrity": "sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0", + "@typescript-eslint/utils": "8.62.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin/node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.0.tgz", + "integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.62.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.0.tgz", + "integrity": "sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==", + "dev": true, + "dependencies": { + "@typescript-eslint/project-service": "8.62.0", + "@typescript-eslint/tsconfig-utils": "8.62.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/project-service": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.0.tgz", + "integrity": "sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.62.0", + "@typescript-eslint/types": "^8.62.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.0.tgz", + "integrity": "sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree/node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.0.tgz", + "integrity": "sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.62.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/utils/node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/typescript-eslint/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, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/typescript-eslint/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/typescript-eslint/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/typescript-eslint/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/typescript-eslint/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, + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/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, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/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, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "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, + "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, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/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==", + "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/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + }, + "dependencies": { + "@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "dev": true, + "optional": true + }, + "@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "dev": true, + "optional": true + }, + "@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "dev": true, + "optional": true + }, + "@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "dev": true, + "optional": true + }, + "@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "dev": true, + "optional": true + }, + "@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "dev": true, + "optional": true + }, + "@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "dev": true, + "optional": true + }, + "@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "dev": true, + "optional": true + }, + "@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true + }, + "@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "requires": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + } + }, + "@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "requires": { + "@eslint/core": "^0.17.0" + } + }, + "@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.15" + } + }, + "@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "requires": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + } + }, + "@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true + }, + "@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true + }, + "@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "requires": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + } + }, + "@grpc/grpc-js": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "requires": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "dependencies": { + "@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "requires": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + } + } + } + }, + "@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "requires": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + } + }, + "@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "requires": { + "@humanfs/types": "^0.15.0" + } + }, + "@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "requires": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + } + }, + "@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true + }, + "@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true + }, + "@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true + }, + "@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 + }, + "@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==" + }, + "@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" + }, + "@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" + }, + "@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==" + }, + "@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==" + }, + "@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "requires": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" + }, + "@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" + }, + "@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" + }, + "@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==" + }, + "@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==", + "dev": true, + "optional": true + }, + "@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==", + "dev": true, + "optional": true + }, + "@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==", + "dev": true, + "optional": true + }, + "@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==", + "dev": true, + "optional": true + }, + "@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==", + "dev": true, + "optional": true + }, + "@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==", + "dev": true, + "optional": true + }, + "@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==", + "dev": true, + "optional": true + }, + "@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==", + "dev": true, + "optional": true + }, + "@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==", + "dev": true, + "optional": true + }, + "@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==", + "dev": true, + "optional": true + }, + "@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==", + "dev": true, + "optional": true + }, + "@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==", + "dev": true, + "optional": true + }, + "@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==", + "dev": true, + "optional": true + }, + "@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==", + "dev": true, + "optional": true + }, + "@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==", + "dev": true, + "optional": true + }, + "@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==", + "dev": true, + "optional": true + }, + "@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==", + "dev": true, + "optional": true + }, + "@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==", + "dev": true, + "optional": true + }, + "@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==", + "dev": true, + "optional": true + }, + "@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==", + "dev": true, + "optional": true + }, + "@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==", + "dev": true, + "optional": true + }, + "@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==", + "dev": true, + "optional": true + }, + "@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==", + "dev": true, + "optional": true + }, + "@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==", + "dev": true, + "optional": true + }, + "@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==", + "dev": true, + "optional": true + }, + "@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 + }, + "@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "requires": { + "undici-types": "~6.21.0" + } + }, + "@typescript-eslint/scope-manager": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.0.tgz", + "integrity": "sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==", + "dev": true, + "requires": { + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0" + } + }, + "@typescript-eslint/types": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.0.tgz", + "integrity": "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==", + "dev": true + }, + "@typescript-eslint/visitor-keys": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.0.tgz", + "integrity": "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==", + "dev": true, + "requires": { + "@typescript-eslint/types": "8.62.0", + "eslint-visitor-keys": "^5.0.0" + }, + "dependencies": { + "eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true + } + } + }, + "@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "requires": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + } + }, + "@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "requires": { + "tinyrainbow": "^1.2.0" + } + }, + "@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "requires": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + } + }, + "@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "requires": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + } + }, + "@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "requires": { + "tinyspy": "^3.0.2" + } + }, + "@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "requires": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + } + }, + "acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true + }, + "acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "requires": {} + }, + "ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "requires": { + "color-convert": "^2.0.1" + } + }, + "argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true + }, + "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 + }, + "brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "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 + }, + "callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true + }, + "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, + "requires": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "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 + }, + "cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + } + }, + "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==", + "requires": { + "color-name": "~1.1.4" + } + }, + "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==" + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "requires": { + "ms": "^2.1.3" + } + }, + "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 + }, + "deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "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==" + }, + "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 + }, + "esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "requires": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==" + }, + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true + }, + "eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "requires": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "dependencies": { + "@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^3.4.3" + }, + "dependencies": { + "eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true + } + } + } + } + }, + "eslint-config-prettier": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz", + "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", + "dev": true, + "requires": {} + }, + "eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "requires": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + } + }, + "eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true + }, + "espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "requires": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + } + }, + "esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "requires": { + "estraverse": "^5.1.0" + } + }, + "esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "requires": { + "estraverse": "^5.2.0" + } + }, + "estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true + }, + "estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "requires": { + "@types/estree": "^1.0.0" + } + }, + "esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true + }, + "expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true + }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "requires": {} + }, + "file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "requires": { + "flat-cache": "^4.0.0" + } + }, + "find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "requires": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + } + }, + "flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "requires": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + } + }, + "flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true + }, + "fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "optional": true + }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" + }, + "glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "requires": { + "is-glob": "^4.0.3" + } + }, + "globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true + }, + "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 + }, + "ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true + }, + "import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "requires": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + } + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true + }, + "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==" + }, + "is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "requires": { + "argparse": "^2.0.1" + } + }, + "json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "requires": { + "json-buffer": "3.0.1" + } + }, + "levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + } + }, + "locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "requires": { + "p-locate": "^5.0.0" + } + }, + "lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" + }, + "lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==" + }, + "loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true + }, + "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, + "requires": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "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 + }, + "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 + }, + "natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "requires": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + } + }, + "p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "requires": { + "yocto-queue": "^0.1.0" + } + }, + "p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "requires": { + "p-limit": "^3.0.2" + } + }, + "parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "requires": { + "callsites": "^3.0.0" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "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 + }, + "pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "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 + }, + "picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true + }, + "playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "requires": { + "fsevents": "2.3.2", + "playwright-core": "1.61.1" + } + }, + "playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==" + }, + "postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "requires": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + } + }, + "prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true + }, + "prettier": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", + "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", + "dev": true + }, + "protobufjs": { + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", + "requires": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + } + }, + "punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true + }, + "require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==" + }, + "resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true + }, + "rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "requires": { + "@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", + "@types/estree": "1.0.9", + "fsevents": "~2.3.2" + } + }, + "semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true + }, + "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, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "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 + }, + "siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, + "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 + }, + "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 + }, + "std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true + }, + "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==", + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + } + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "requires": { + "ansi-regex": "^5.0.1" + } + }, + "strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true + }, + "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, + "requires": { + "has-flag": "^4.0.0" + } + }, + "tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "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 + }, + "tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "requires": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + } + }, + "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 + }, + "tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true + }, + "tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true + }, + "type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1" + } + }, + "typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true + }, + "typescript-eslint": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.0.tgz", + "integrity": "sha512-8QxXi+ZACKX0kaqO4gY8kn0RSD9gFfaHDWwjqtEN48aWCBkX4MJaufWN+c3BzlrXLOxfywDL8CaoqUwcRq4j4Q==", + "dev": true, + "requires": { + "@typescript-eslint/eslint-plugin": "8.62.0", + "@typescript-eslint/parser": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0", + "@typescript-eslint/utils": "8.62.0" + }, + "dependencies": { + "@typescript-eslint/eslint-plugin": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.0.tgz", + "integrity": "sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==", + "dev": true, + "requires": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.62.0", + "@typescript-eslint/type-utils": "8.62.0", + "@typescript-eslint/utils": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "dependencies": { + "@typescript-eslint/type-utils": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.0.tgz", + "integrity": "sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==", + "dev": true, + "requires": { + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0", + "@typescript-eslint/utils": "8.62.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + } + }, + "ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "requires": {} + } + } + }, + "@typescript-eslint/parser": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.0.tgz", + "integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==", + "dev": true, + "requires": { + "@typescript-eslint/scope-manager": "8.62.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0", + "debug": "^4.4.3" + } + }, + "@typescript-eslint/typescript-estree": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.0.tgz", + "integrity": "sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==", + "dev": true, + "requires": { + "@typescript-eslint/project-service": "8.62.0", + "@typescript-eslint/tsconfig-utils": "8.62.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "dependencies": { + "@typescript-eslint/project-service": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.0.tgz", + "integrity": "sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==", + "dev": true, + "requires": { + "@typescript-eslint/tsconfig-utils": "^8.62.0", + "@typescript-eslint/types": "^8.62.0", + "debug": "^4.4.3" + } + }, + "@typescript-eslint/tsconfig-utils": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.0.tgz", + "integrity": "sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==", + "dev": true, + "requires": {} + }, + "ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "requires": {} + } + } + }, + "@typescript-eslint/utils": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.0.tgz", + "integrity": "sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==", + "dev": true, + "requires": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.62.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0" + }, + "dependencies": { + "@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^3.4.3" + } + } + } + }, + "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 + }, + "brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "requires": { + "balanced-match": "^4.0.2" + } + }, + "eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true + }, + "ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true + }, + "minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "requires": { + "brace-expansion": "^5.0.5" + } + } + } + }, + "undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" + }, + "uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + }, + "vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "requires": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "dependencies": { + "fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "optional": true + }, + "vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "requires": { + "esbuild": "^0.21.3", + "fsevents": "~2.3.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + } + } + } + }, + "vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "requires": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "dependencies": { + "@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "requires": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + } + }, + "fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "optional": true + }, + "vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "requires": { + "esbuild": "^0.21.3", + "fsevents": "~2.3.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + } + } + } + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "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, + "requires": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + } + }, + "word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true + }, + "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==", + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + }, + "y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==" + }, + "yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "requires": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + } + }, + "yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==" + }, + "yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..026918b --- /dev/null +++ b/package.json @@ -0,0 +1,59 @@ +{ + "name": "@testrigor/playwright-plugin", + "version": "0.1.0", + "description": "testRigor Playwright integration — gRPC-backed locators, commands, and self-healing", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "vitest run", + "test:manual": "vitest run --config vitest.config.manual.ts", + "test:manual:one": "vitest run --config vitest.config.manual.ts -t test_find_by_user_description", + "test:watch": "vitest", + "typecheck": "tsc -p tsconfig.json --noEmit", + "lint": "eslint . && npm run typecheck", + "lint:fix": "eslint . --fix", + "format": "prettier --write .", + "format:check": "prettier --check .", + "check": "npm run lint && npm run format:check && npm run build && npm test" + }, + "keywords": [ + "playwright", + "testrigor", + "testing", + "automation" + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "dependencies": { + "@grpc/grpc-js": "^1.12.6", + "@grpc/proto-loader": "^0.7.13", + "protobufjs": "^7.6.4", + "playwright": ">=1.40.0" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "@types/node": "^22.10.0", + "eslint": "^9.17.0", + "eslint-config-prettier": "^9.1.0", + "prettier": "^3.4.2", + "typescript": "^5.7.2", + "typescript-eslint": "^8.18.2", + "vitest": "^2.1.8" + }, + "peerDependencies": { + "playwright": ">=1.40.0" + } +} diff --git a/src/application/extension-service.ts b/src/application/extension-service.ts new file mode 100644 index 0000000..d738531 --- /dev/null +++ b/src/application/extension-service.ts @@ -0,0 +1,339 @@ +import { AssertionError } from 'node:assert'; +import { TestRigorContext } from '../commons/application/context/TestRigorContext.js'; +import { GrpcEndpointConfig } from '../commons/application/grpc/GrpcEndpointConfig.js'; +import { ExtensionGrpcActions } from '../commons/application/grpc/ExtensionGrpcActions.js'; +import { deserializeJson } from '../commons/application/utils/JsonHelpers.js'; +import { Action } from '../commons/domain/model/Action.js'; +import { Locator } from '../commons/domain/model/Locator.js'; +import { locatorTypeFromName } from '../commons/domain/model/LocatorType.js'; +import { GrpcNotFoundException } from '../commons/infrastructure/exceptions/GrpcNotFoundException.js'; +import { GrpcTransportException } from '../commons/infrastructure/exceptions/GrpcTransportException.js'; +import { TestRigorExtensionException } from '../commons/infrastructure/exceptions/TestRigorExtensionException.js'; +import { ResolvedElement } from '../elements/resolved-element.js'; +import type { PlaywrightBrowserSession } from '../session/playwright-browser-session.js'; +import { PlaywrightGrpcConnection } from './grpc-connection.js'; + +export class PlaywrightExtensionService { + readonly browserSession: PlaywrightBrowserSession; + private readonly apiToken: string; + private readonly grpcEndpoint: GrpcEndpointConfig; + private grpcConnection: PlaywrightGrpcConnection | null = null; + private connectionClosed = false; + private lockTail: Promise = Promise.resolve(); + + constructor( + browserSession: PlaywrightBrowserSession, + apiToken: string, + grpcEndpoint: GrpcEndpointConfig, + ) { + this.browserSession = browserSession; + this.apiToken = apiToken; + this.grpcEndpoint = grpcEndpoint; + } + + setTestContext(testId: string): void { + TestRigorContext.setTestContext(testId); + } + + clearTestContext(): void { + TestRigorContext.clearTestContext(); + } + + async saveAction(action: Action): Promise { + PlaywrightExtensionService.requireTestIdForLocatorHealing(); + const loc = action.locator; + const params = PlaywrightExtensionService.locatorParams(loc); + try { + await this.executeWithReconnectRetry( + `executeAction:${ExtensionGrpcActions.SAVE_LOCATOR_SNAPSHOT}`, + async () => { + await this.getOrCreateConnection().executeAction( + ExtensionGrpcActions.SAVE_LOCATOR_SNAPSHOT, + params, + ); + }, + ); + } catch (error) { + console.warn( + `saveLocatorSnapshot failed: ${PlaywrightExtensionService.rootCauseMessage(error)}`, + ); + } + } + + async getHealedLocator(locator: Locator): Promise { + PlaywrightExtensionService.requireTestIdForLocatorHealing(); + const params = PlaywrightExtensionService.locatorParams(locator); + try { + const result = await this.executeWithReconnectRetry( + `executeAction:${ExtensionGrpcActions.GET_HEALED_LOCATOR}`, + () => + this.getOrCreateConnection().executeAction( + ExtensionGrpcActions.GET_HEALED_LOCATOR, + params, + ), + ); + if (typeof result !== 'string') { + return null; + } + const map = deserializeJson>(result); + return new Locator( + locatorTypeFromName(String(map[ExtensionGrpcActions.KEY_LOCATOR_TYPE])), + String(map[ExtensionGrpcActions.KEY_LOCATOR_VALUE]), + ); + } catch (error) { + if (PlaywrightExtensionService.containsGrpcNotFound(error)) { + return null; + } + throw error; + } + } + + async recordHealedFind(original: Locator, healed: Locator): Promise { + PlaywrightExtensionService.requireTestIdForLocatorHealing(); + const params: Record = { + [ExtensionGrpcActions.KEY_ORIGINAL_LOCATOR_TYPE]: original.type, + [ExtensionGrpcActions.KEY_ORIGINAL_LOCATOR_VALUE]: original.value, + [ExtensionGrpcActions.KEY_HEALED_LOCATOR_TYPE]: healed.type, + [ExtensionGrpcActions.KEY_HEALED_LOCATOR_VALUE]: healed.value, + }; + try { + await this.executeWithReconnectRetry( + `executeAction:${ExtensionGrpcActions.RECORD_LOCATOR_MAPPING}`, + async () => { + await this.getOrCreateConnection().executeAction( + ExtensionGrpcActions.RECORD_LOCATOR_MAPPING, + params, + ); + }, + ); + } catch (error) { + console.debug( + `recordLocatorMapping failed: ${PlaywrightExtensionService.rootCauseMessage(error)}`, + ); + } + } + + async findResolvedByUserDescription(description: string): Promise { + return this.executeWithReconnectRetry('findByUserDescription', () => + this.getOrCreateConnection().findByUserDescription(description), + ); + } + + async executePrompt(prompt: string): Promise { + await this.executeWithReconnectRetry('executePrompt', async () => { + await this.getOrCreateConnection().executePrompt(prompt); + }); + } + + async executeAction(actionName: string, parameters: Record): Promise { + return this.executeActionInternal(actionName, parameters, false); + } + + async click(elementDescription: string): Promise { + await this.executeAction( + 'click', + PlaywrightExtensionService.singleParameter('elementDescription', elementDescription), + ); + } + + async checkPageContains(text: string): Promise { + try { + await this.executeActionInternal( + 'checkPageContains', + PlaywrightExtensionService.singleParameter('text', text), + true, + ); + } catch (error) { + if (error instanceof TestRigorExtensionException) { + throw PlaywrightExtensionService.toAssertionError(error); + } + throw error; + } + } + + async grabValue(elementDescription: string): Promise { + const result = await this.executeAction( + 'grabValue', + PlaywrightExtensionService.singleParameter('elementDescription', elementDescription), + ); + return result == null ? '' : String(result); + } + + async closeConnection(): Promise { + await this.withLock(async () => { + if (this.grpcConnection != null) { + try { + await this.grpcConnection.close(); + } catch (error) { + console.warn( + `Error closing gRPC connection: ${error instanceof Error ? error.message : String(error)}`, + ); + } + this.grpcConnection = null; + } + this.connectionClosed = true; + }); + } + + private async executeActionInternal( + actionName: string, + parameters: Record, + validationCommand: boolean, + ): Promise { + try { + return await this.executeWithReconnectRetry(`executeAction:${actionName}`, () => + this.getOrCreateConnection().executeAction(actionName, parameters), + ); + } catch (error) { + if (validationCommand && error instanceof TestRigorExtensionException) { + throw PlaywrightExtensionService.toAssertionError(error); + } + if (error instanceof TestRigorExtensionException) { + throw error; + } + throw new TestRigorExtensionException(`Failed to execute action ${actionName}`, { + cause: error, + }); + } + } + + private getOrCreateConnection(): PlaywrightGrpcConnection { + if (this.connectionClosed) { + throw new TestRigorExtensionException('Driver connection already closed'); + } + if (this.grpcConnection == null) { + this.grpcConnection = new PlaywrightGrpcConnection( + this.browserSession, + this.grpcEndpoint, + this.apiToken, + ); + } + return this.grpcConnection; + } + + private async executeWithReconnectRetry( + operation: string, + operationSupplier: () => Promise, + ): Promise { + let attempt = 1; + while (true) { + try { + return await this.withLock(operationSupplier); + } catch (error) { + const cause = PlaywrightExtensionService.unwrapCompletionException(error); + if (attempt === 1 && this.shouldReconnectAndRetry(cause)) { + console.warn( + `Retrying operation after gRPC reconnect. operation=${operation}, retryableCause=${ + cause == null ? 'unknown' : cause.constructor.name + }`, + cause, + ); + await this.reconnectGrpcClient(cause); + attempt++; + continue; + } + throw this.toExtensionException(cause ?? error, operation); + } + } + } + + private async reconnectGrpcClient(cause: unknown): Promise { + await this.withLock(async () => { + if (this.connectionClosed) { + throw new TestRigorExtensionException('Driver connection already closed', { cause }); + } + this.getOrCreateConnection().reconnect(); + }); + } + + private shouldReconnectAndRetry(cause: unknown): boolean { + if (cause == null || this.connectionClosed) { + return false; + } + if (cause instanceof GrpcTransportException) { + return cause.retryable; + } + if (this.grpcConnection == null) { + return false; + } + return this.grpcConnection.isRetryableTransportFailure(cause); + } + + private toExtensionException(cause: unknown, operation: string): TestRigorExtensionException { + if (cause instanceof TestRigorExtensionException) { + return cause; + } + return new TestRigorExtensionException(`Failed to execute ${operation}`, { cause }); + } + + private async withLock(fn: () => Promise): Promise { + const previous = this.lockTail; + let releaseLock!: () => void; + this.lockTail = new Promise((resolve) => { + releaseLock = resolve; + }); + await previous; + try { + return await fn(); + } finally { + releaseLock(); + } + } + + private static requireTestIdForLocatorHealing(): void { + const testId = TestRigorContext.getTestId(); + if (testId == null || testId.trim() === '') { + throw new Error('testId is required for locator healing'); + } + } + + private static locatorParams(loc: Locator): Record { + return { + [ExtensionGrpcActions.KEY_LOCATOR_TYPE]: loc.type, + [ExtensionGrpcActions.KEY_LOCATOR_VALUE]: loc.value, + }; + } + + private static singleParameter(key: string, value: string): Record { + return { [key]: value }; + } + + private static containsGrpcNotFound(error: unknown): boolean { + let current: unknown = error; + while (current != null) { + if (current instanceof GrpcNotFoundException) { + return true; + } + if (current instanceof Error && current.cause) { + current = current.cause; + } else { + break; + } + } + return false; + } + + private static rootCauseMessage(error: unknown): string { + let current: unknown = error; + while (current instanceof Error && current.cause) { + current = current.cause; + } + if (current instanceof Error) { + return current.message || current.constructor.name; + } + return String(current); + } + + private static toAssertionError(error: TestRigorExtensionException): AssertionError { + const assertion = new AssertionError({ message: error.message }); + assertion.cause = error; + return assertion; + } + + private static unwrapCompletionException(throwable: unknown): unknown { + if (throwable instanceof Error && throwable.name === 'CompletionException' && throwable.cause) { + return throwable.cause; + } + return throwable; + } +} diff --git a/src/application/grpc-connection.capabilities.test.ts b/src/application/grpc-connection.capabilities.test.ts new file mode 100644 index 0000000..016f33c --- /dev/null +++ b/src/application/grpc-connection.capabilities.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { PlaywrightGrpcConnection } from './grpc-connection.js'; +import { GrpcEndpointConfig } from '../commons/application/grpc/GrpcEndpointConfig.js'; +import { deserializeJson } from '../commons/application/utils/JsonHelpers.js'; +import type { PlaywrightCommandExecutor } from '../protocol/command-executor.js'; +import { PlaywrightBrowserSession } from '../session/playwright-browser-session.js'; + +describe('PlaywrightGrpcConnection capabilities', () => { + it('getDriverInfo serializes browser capabilities', () => { + const executor = {} as PlaywrightCommandExecutor; + const browserSession = new PlaywrightBrowserSession('session-id', executor); + const connection = new PlaywrightGrpcConnection( + browserSession, + GrpcEndpointConfig.of('localhost', 9091), + 'token', + ); + + const serialized = deserializeJson>( + connection.getDriverInfo(null).capabilitiesJson, + ); + + expect(serialized).toMatchObject({ + browserName: 'chromium', + platformName: 'ANY', + }); + }); +}); diff --git a/src/application/grpc-connection.commands.test.ts b/src/application/grpc-connection.commands.test.ts new file mode 100644 index 0000000..54064d6 --- /dev/null +++ b/src/application/grpc-connection.commands.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest'; +import { PlaywrightGrpcConnection } from './grpc-connection.js'; +import { GrpcEndpointConfig } from '../commons/application/grpc/GrpcEndpointConfig.js'; +import type { ClientMessage } from '../commons/grpc/testrigor-grpc.js'; +import type { PlaywrightCommandExecutor } from '../protocol/command-executor.js'; +import { PlaywrightBrowserSession } from '../session/playwright-browser-session.js'; + +describe('PlaywrightGrpcConnection driver commands', () => { + it('writes a JSON payload for getCurrentUrl responses', async () => { + const executor = { + execute: vi.fn().mockResolvedValue({ + sessionId: 'session-1', + status: 0, + state: '', + value: 'http://example.com', + }), + } as unknown as PlaywrightCommandExecutor; + const browserSession = new PlaywrightBrowserSession('session-1', executor); + const connection = new PlaywrightGrpcConnection( + browserSession, + GrpcEndpointConfig.of('localhost', 9091), + 'token', + ); + + const written: ClientMessage[] = []; + await connection.executeCommand('cmd-1', 'session-1', 'getCurrentUrl', '{}', { + write: (message) => written.push(message), + }); + + expect(written).toHaveLength(1); + const payload = written[0]?.payload?.response?.valuePayload?.toString('utf8'); + expect(payload).toBe('"http://example.com"'); + }); + + it('writes a driver error response when command execution fails', async () => { + const executor = { + execute: vi.fn().mockRejectedValue(new Error('unsupported')), + } as unknown as PlaywrightCommandExecutor; + const browserSession = new PlaywrightBrowserSession('session-1', executor); + const connection = new PlaywrightGrpcConnection( + browserSession, + GrpcEndpointConfig.of('localhost', 9091), + 'token', + ); + + const written: ClientMessage[] = []; + await connection.executeCommand('cmd-2', 'session-1', 'missingCommand', '{}', { + write: (message) => written.push(message), + }); + + expect(written).toHaveLength(1); + expect(written[0]?.payload?.response?.status).toBe(13); + expect(written[0]?.payload?.response?.valuePayload?.toString('utf8')).toContain('unsupported'); + }); +}); diff --git a/src/application/grpc-connection.ts b/src/application/grpc-connection.ts new file mode 100644 index 0000000..13ce260 --- /dev/null +++ b/src/application/grpc-connection.ts @@ -0,0 +1,165 @@ +import { TestRigorContext } from '../commons/application/context/TestRigorContext.js'; +import { DriverCommandValueCodec } from '../commons/application/grpc/DriverCommandValueCodec.js'; +import { DriverInfo } from '../commons/application/grpc/DriverInfo.js'; +import type { + ClientMessageWriter, + GrpcDriverAdapter, +} from '../commons/application/grpc/GrpcDriverAdapter.js'; +import { GrpcEndpointConfig } from '../commons/application/grpc/GrpcEndpointConfig.js'; +import { TestRigorGrpcClient } from '../commons/application/grpc/TestRigorGrpcClient.js'; +import { deserializeJson, serializeJson } from '../commons/application/utils/JsonHelpers.js'; +import type { DriverCommandResponse } from '../commons/grpc/testrigor-grpc.js'; +import { TestRigorExtensionException } from '../commons/infrastructure/exceptions/TestRigorExtensionException.js'; +import { ResolvedElement } from '../elements/resolved-element.js'; +import type { PlaywrightBrowserSession } from '../session/playwright-browser-session.js'; + +export class PlaywrightGrpcConnection implements GrpcDriverAdapter { + private readonly browserSession: PlaywrightBrowserSession; + private readonly grpcEndpoint: GrpcEndpointConfig; + private readonly apiToken: string; + private client: TestRigorGrpcClient; + private closed = false; + + constructor( + browserSession: PlaywrightBrowserSession, + grpcEndpoint: GrpcEndpointConfig, + apiToken: string, + ) { + this.browserSession = browserSession; + this.grpcEndpoint = grpcEndpoint; + this.apiToken = apiToken; + this.client = new TestRigorGrpcClient(grpcEndpoint, this, apiToken); + } + + getDriverInfo(testIdOverride?: string | null): DriverInfo { + const sessionId = this.browserSession.sessionId; + const capabilityMap = { ...this.browserSession.capabilities }; + const testId = + testIdOverride != null && testIdOverride.trim() !== '' + ? testIdOverride + : TestRigorContext.getTestId(); + const capabilitiesJson = serializeJson(capabilityMap); + return new DriverInfo(sessionId, capabilitiesJson, testId); + } + + async executeCommand( + messageId: string, + sessionId: string, + commandName: string, + parametersJson: string, + responseWriter: ClientMessageWriter, + ): Promise { + const parameters = deserializeJson>(parametersJson); + try { + const response = await this.browserSession.executeCommand(commandName, parameters); + this.writeCommandResponse(messageId, response, responseWriter); + } catch (error) { + console.error( + `Driver command failed. command=${commandName}, messageId=${messageId}`, + error instanceof Error ? error.message : String(error), + ); + this.writeCommandErrorResponse( + messageId, + sessionId || this.browserSession.sessionId, + error, + responseWriter, + ); + } + } + + private writeCommandResponse( + messageId: string, + response: { sessionId: string; status: number; state: string; value: unknown }, + responseWriter: ClientMessageWriter, + ): void { + const valueJson = response.value == null ? '' : serializeJson(response.value); + const responseBuilder: DriverCommandResponse = { + sessionId: response.sessionId ?? '', + status: response.status, + state: response.state ?? '', + }; + DriverCommandValueCodec.setEncodedValue(responseBuilder, valueJson); + responseWriter.write({ + id: messageId, + payload: { response: responseBuilder }, + }); + } + + private writeCommandErrorResponse( + messageId: string, + sessionId: string, + error: unknown, + responseWriter: ClientMessageWriter, + ): void { + const message = error instanceof Error ? error.message : String(error); + const responseBuilder: DriverCommandResponse = { + sessionId, + status: 13, + state: 'unknown error', + }; + DriverCommandValueCodec.setEncodedValue( + responseBuilder, + serializeJson({ + message, + error: [message], + }), + ); + responseWriter.write({ + id: messageId, + payload: { response: responseBuilder }, + }); + } + + resolveElementFromXpath(xpath: string): Promise { + return this.browserSession.commandExecutor.resolveElementByXpath(xpath); + } + + findByUserDescription(description: string): Promise { + return this.client.findByUserDescription(description).then((obj) => obj as ResolvedElement); + } + + executePrompt(prompt: string): Promise { + return this.client.executePrompt(prompt); + } + + executeAction( + actionName: string, + parameters: Record | null | undefined, + ): Promise { + const parametersJson = parameters == null ? '' : serializeJson(parameters); + return this.client.executeAction(actionName, parametersJson); + } + + reconnect(): void { + if (this.closed) { + throw new TestRigorExtensionException('Driver connection already closed'); + } + console.warn(`Reconnecting gRPC client for session ${this.browserSession.sessionId}`); + this.safelyCloseClient(this.client); + this.client = new TestRigorGrpcClient(this.grpcEndpoint, this, this.apiToken); + } + + isRetryableTransportFailure(throwable: unknown): boolean { + return TestRigorGrpcClient.isRetryableTransportFailure(throwable); + } + + async close(): Promise { + if (this.closed) { + return; + } + this.closed = true; + this.safelyCloseClient(this.client); + this.client = null!; + } + + private safelyCloseClient(clientToClose: TestRigorGrpcClient | null | undefined): void { + if (clientToClose == null) { + return; + } + void clientToClose.close().catch((error) => { + console.warn( + `Error closing gRPC client: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + } +} diff --git a/src/application/playwright-driver.ts b/src/application/playwright-driver.ts new file mode 100644 index 0000000..e797b6f --- /dev/null +++ b/src/application/playwright-driver.ts @@ -0,0 +1,99 @@ +import type { Page } from 'playwright'; +import type { TestRigorCommandDriver } from '../commons/application/commands/TestRigorCommandDriver.js'; +import { PlaywrightElement } from '../elements/playwright-element.js'; +import { PlaywrightElementFinder } from '../locators/playwright-element-finder.js'; +import { PlaywrightLocator } from '../locators/playwright-locator.js'; +import type { PlaywrightSession } from '../session/playwright-session.js'; +import type { PlaywrightExtensionService } from './extension-service.js'; + +export class TestrigorPlaywrightDriver implements TestRigorCommandDriver { + private readonly playwrightSession: PlaywrightSession; + private readonly extensionService: PlaywrightExtensionService; + private readonly elementFinder: PlaywrightElementFinder; + + constructor(session: PlaywrightSession, extensionService: PlaywrightExtensionService) { + this.playwrightSession = session; + this.extensionService = extensionService; + this.elementFinder = new PlaywrightElementFinder( + extensionService, + session.browserSession.commandExecutor, + ); + } + + getPage(): Page { + return this.playwrightSession.page; + } + + async get(url: string): Promise { + await this.getPage().goto(url); + } + + getCurrentUrl(): string { + return this.getPage().url(); + } + + async getTitle(): Promise { + return this.getPage().title(); + } + + async back(): Promise { + await this.getPage().goBack(); + } + + async forward(): Promise { + await this.getPage().goForward(); + } + + async reload(): Promise { + await this.getPage().reload(); + } + + async quit(): Promise { + await this.extensionService.closeConnection(); + await this.playwrightSession.close(); + } + + async close(): Promise { + await this.quit(); + } + + setTestContext(testId: string): void { + this.extensionService.setTestContext(testId); + } + + clearTestContext(): void { + this.extensionService.clearTestContext(); + } + + async executePrompt(prompt: string): Promise { + await this.extensionService.executePrompt(prompt); + } + + async executeAction(actionName: string, parameters: Record): Promise { + return this.extensionService.executeAction(actionName, parameters); + } + + async click(elementDescription: string): Promise { + await this.extensionService.click(elementDescription); + } + + async checkPageContains(text: string): Promise { + await this.extensionService.checkPageContains(text); + } + + async grabValue(elementDescription: string): Promise { + return this.extensionService.grabValue(elementDescription); + } + + async findByUserDescription(description: string): Promise { + return this.findElement(PlaywrightLocator.byUserDescription(description)); + } + + async findElement(locator: PlaywrightLocator): Promise { + return this.elementFinder.findElement(locator); + } + + async findElements(locator: PlaywrightLocator): Promise { + return this.elementFinder.findElements(locator); + } +} diff --git a/src/commons/application/commands/StepEscaping.ts b/src/commons/application/commands/StepEscaping.ts new file mode 100644 index 0000000..669c3c5 --- /dev/null +++ b/src/commons/application/commands/StepEscaping.ts @@ -0,0 +1,15 @@ +/** + * Escapes user-supplied strings for testRigor prompt lines. + * Double-quoted segments in testRigor use backslash to escape " and \. + */ +export function quoted(value: string | null | undefined): string { + if (value == null) { + return ''; + } + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +/** Wraps the value in double quotes after escaping. E.g. Login -> "Login". */ +export function quotedSegment(value: string | null | undefined): string { + return `"${quoted(value)}"`; +} diff --git a/src/commons/application/commands/TestRigorActions.ts b/src/commons/application/commands/TestRigorActions.ts new file mode 100644 index 0000000..ab5cff1 --- /dev/null +++ b/src/commons/application/commands/TestRigorActions.ts @@ -0,0 +1,595 @@ +import type { TestRigorCommandDriver } from './TestRigorCommandDriver.js'; +import { TestRigorSteps, type ClickTarget, type EnterTarget } from './TestRigorSteps.js'; + +/** + * Fluent facade for execute-only testRigor actions. + */ + +export class TestRigorActions { + static actions(driver: TestRigorCommandDriver): TestRigorActions { + return new TestRigorActions(driver, TestRigorSteps.create()); + } + + static with(driver: TestRigorCommandDriver): TestRigorActions { + return TestRigorActions.actions(driver); + } + + private constructor( + private readonly driver: TestRigorCommandDriver, + private readonly steps: TestRigorSteps, + ) {} + + private withSteps(next: TestRigorSteps): TestRigorActions { + return new TestRigorActions(this.driver, next); + } + + /** @internal Used by fluent enter helpers. */ + applyStepMutation(mutator: (steps: TestRigorSteps) => TestRigorSteps): TestRigorActions { + return this.withSteps(mutator(this.steps)); + } + + /** @internal */ + buildEnterTarget(value: string, fieldDescription: string): EnterTarget { + return this.steps.enterFluent(value).into(fieldDescription); + } + + /** @internal */ + replaceSteps(next: TestRigorSteps): TestRigorActions { + return this.withSteps(next); + } + + and(): TestRigorActions { + return this; + } + + clickOn(elementDescription: string): ContextualClick { + return new ContextualClick(this, this.steps.clickOn(elementDescription)); + } + + enter(value: string): EnterInto { + return new EnterInto(value, this); + } + + enterFluent(value: string): EnterFluent { + return new EnterFluent(value, this); + } + + enterStoredValue(varName: string): EnterStoredInto { + return new EnterStoredInto(varName, this); + } + + enterKey(keyOrCombo: string): EnterKeyInto { + return new EnterKeyInto(keyOrCombo, this); + } + + click(elementDescription: string): TestRigorActions; + click(nth: number, elementDescription: string): TestRigorActions; + click(elementDescriptionOrNth: string | number, elementDescription?: string): TestRigorActions { + if (typeof elementDescriptionOrNth === 'number') { + return this.withSteps(this.steps.click(elementDescriptionOrNth, elementDescription!)); + } + return this.withSteps(this.steps.click(elementDescriptionOrNth)); + } + + doubleClick(elementDescription: string): TestRigorActions { + return this.withSteps(this.steps.doubleClick(elementDescription)); + } + + rightClick(elementDescription: string): TestRigorActions { + return this.withSteps(this.steps.rightClick(elementDescription)); + } + + longClick(elementDescription: string): TestRigorActions { + return this.withSteps(this.steps.longClick(elementDescription)); + } + + tripleClick(elementDescription: string): TestRigorActions { + return this.withSteps(this.steps.tripleClick(elementDescription)); + } + + middleClick(elementDescription: string): TestRigorActions { + return this.withSteps(this.steps.middleClick(elementDescription)); + } + + wheelClick(elementDescription: string): TestRigorActions { + return this.withSteps(this.steps.wheelClick(elementDescription)); + } + + clickTimes(elementDescription: string, times: number): TestRigorActions { + return this.withSteps(this.steps.clickTimes(elementDescription, times)); + } + + doubleClickTimes(elementDescription: string, times: number): TestRigorActions { + return this.withSteps(this.steps.doubleClickTimes(elementDescription, times)); + } + + tripleClickTimes(elementDescription: string, times: number): TestRigorActions { + return this.withSteps(this.steps.tripleClickTimes(elementDescription, times)); + } + + rightClickTimes(elementDescription: string, times: number): TestRigorActions { + return this.withSteps(this.steps.rightClickTimes(elementDescription, times)); + } + + middleClickTimes(elementDescription: string, times: number): TestRigorActions { + return this.withSteps(this.steps.middleClickTimes(elementDescription, times)); + } + + wheelClickTimes(elementDescription: string, times: number): TestRigorActions { + return this.withSteps(this.steps.wheelClickTimes(elementDescription, times)); + } + + clickIfExists(elementDescription: string): TestRigorActions { + return this.withSteps(this.steps.clickIfExists(elementDescription)); + } + + clickIfExistsWithWaiting(elementDescription: string): TestRigorActions { + return this.withSteps(this.steps.clickIfExistsWithWaiting(elementDescription)); + } + + clickIfExistsWithoutWaiting(elementDescription: string): TestRigorActions { + return this.withSteps(this.steps.clickIfExistsWithoutWaiting(elementDescription)); + } + + clickAndSwitchToNewTab(elementDescription: string): TestRigorActions { + return this.withSteps(this.steps.clickAndSwitchToNewTab(elementDescription)); + } + + clickIfExistsAndSwitchToNewTab(elementDescription: string): TestRigorActions { + return this.withSteps(this.steps.clickIfExistsAndSwitchToNewTab(elementDescription)); + } + + clickIfExistsWithWaitingAndSwitchToNewTab(elementDescription: string): TestRigorActions { + return this.withSteps(this.steps.clickIfExistsWithWaitingAndSwitchToNewTab(elementDescription)); + } + + clickIfPageContains(elementDescription: string, expectedText: string): TestRigorActions { + return this.withSteps(this.steps.clickIfPageContains(elementDescription, expectedText)); + } + + clickIfPageContainsWithWaiting( + elementDescription: string, + expectedText: string, + ): TestRigorActions { + return this.withSteps( + this.steps.clickIfPageContainsWithWaiting(elementDescription, expectedText), + ); + } + + clickIfPageContainsWithoutWaiting( + elementDescription: string, + expectedText: string, + ): TestRigorActions { + return this.withSteps( + this.steps.clickIfPageContainsWithoutWaiting(elementDescription, expectedText), + ); + } + + clickIfPageDoesNotContain(elementDescription: string, expectedText: string): TestRigorActions { + return this.withSteps(this.steps.clickIfPageDoesNotContain(elementDescription, expectedText)); + } + + clickIfUrlContains(elementDescription: string, expectedText: string): TestRigorActions { + return this.withSteps(this.steps.clickIfUrlContains(elementDescription, expectedText)); + } + + pressIfPageContains(elementDescription: string, expectedText: string): TestRigorActions { + return this.withSteps(this.steps.pressIfPageContains(elementDescription, expectedText)); + } + + select(value: string, dropdownDescription: string): TestRigorActions { + return this.withSteps(this.steps.select(value, dropdownDescription)); + } + + choose(value: string, dropdownDescription: string): TestRigorActions { + return this.withSteps(this.steps.choose(value, dropdownDescription)); + } + + insert(value: string, fieldDescription: string): TestRigorActions { + return this.withSteps(this.steps.insert(value, fieldDescription)); + } + + selectNthOption(nthOption: number, dropdownDescription: string): TestRigorActions { + return this.withSteps(this.steps.selectNthOption(nthOption, dropdownDescription)); + } + + selectOption(optionNumber: number, dropdownDescription: string): TestRigorActions { + return this.withSteps(this.steps.selectOption(optionNumber, dropdownDescription)); + } + + hoverOver(elementDescription: string): TestRigorActions; + hoverOver(nth: number, elementDescription: string): TestRigorActions; + hoverOver( + elementDescriptionOrNth: string | number, + elementDescription?: string, + ): TestRigorActions { + if (typeof elementDescriptionOrNth === 'number') { + return this.withSteps(this.steps.hoverOver(elementDescriptionOrNth, elementDescription!)); + } + return this.withSteps(this.steps.hoverOver(elementDescriptionOrNth)); + } + + scrollDown(): TestRigorActions { + return this.withSteps(this.steps.scrollDown()); + } + + scrollUp(): TestRigorActions { + return this.withSteps(this.steps.scrollUp()); + } + + scrollLeft(): TestRigorActions { + return this.withSteps(this.steps.scrollLeft()); + } + + scrollRight(): TestRigorActions { + return this.withSteps(this.steps.scrollRight()); + } + + scrollDownOn(elementDescription: string): TestRigorActions { + return this.withSteps(this.steps.scrollDownOn(elementDescription)); + } + + scrollUpOn(elementDescription: string): TestRigorActions { + return this.withSteps(this.steps.scrollUpOn(elementDescription)); + } + + scrollDownUntilPageContains(text: string, maxTimes?: number): TestRigorActions { + return this.withSteps(this.steps.scrollDownUntilPageContains(text, maxTimes)); + } + + clickUntilPageContains( + elementDescription: string, + expectedText: string, + maxTimes?: number, + ): TestRigorActions { + return this.withSteps( + this.steps.clickUntilPageContains(elementDescription, expectedText, maxTimes), + ); + } + + clickUntilPageContainsStoredValue(elementDescription: string, varName: string): TestRigorActions { + return this.withSteps( + this.steps.clickUntilPageContainsStoredValue(elementDescription, varName), + ); + } + + clickUntilPageContainsWithWaiting( + elementDescription: string, + expectedText: string, + maxTimes?: number, + ): TestRigorActions { + return this.withSteps( + this.steps.clickUntilPageContainsWithWaiting(elementDescription, expectedText, maxTimes), + ); + } + + clickUntilPageContainsWithoutWaiting( + elementDescription: string, + expectedText: string, + ): TestRigorActions { + return this.withSteps( + this.steps.clickUntilPageContainsWithoutWaiting(elementDescription, expectedText), + ); + } + + openUrl(url: string): TestRigorActions { + return this.withSteps(this.steps.openUrl(url)); + } + + goBack(): TestRigorActions { + return this.withSteps(this.steps.goBack()); + } + + goForward(): TestRigorActions { + return this.withSteps(this.steps.goForward()); + } + + reload(): TestRigorActions { + return this.withSteps(this.steps.reload()); + } + + waitSec(seconds: number): TestRigorActions { + return this.withSteps(this.steps.waitSec(seconds)); + } + + waitUntilPageContains(expectedText: string, maxTimes?: number): TestRigorActions { + return this.withSteps(this.steps.waitUntilPageContains(expectedText, maxTimes)); + } + + waitUntilPageContainsWithinSeconds( + expectedText: string, + timeoutSeconds: number, + ): TestRigorActions { + return this.withSteps( + this.steps.waitUntilPageContainsWithinSeconds(expectedText, timeoutSeconds), + ); + } + + waitUntilPageContainsWithinSecondsWithWaiting( + expectedText: string, + timeoutSeconds: number, + ): TestRigorActions { + return this.withSteps( + this.steps.waitUntilPageContainsWithinSecondsWithWaiting(expectedText, timeoutSeconds), + ); + } + + drag(sourceDescription: string, targetDescription: string): TestRigorActions { + return this.withSteps(this.steps.drag(sourceDescription, targetDescription)); + } + + login(): TestRigorActions { + return this.withSteps(this.steps.login()); + } + + fillOutForm(): TestRigorActions { + return this.withSteps(this.steps.fillOutForm()); + } + + fillOutRequiredFieldsInForm(): TestRigorActions { + return this.withSteps(this.steps.fillOutRequiredFieldsInForm()); + } + + type(text: string): TestRigorActions { + return this.withSteps(this.steps.type(text)); + } + + typeInto(text: string, fieldDescription: string): TestRigorActions { + return this.withSteps(this.steps.typeInto(text, fieldDescription)); + } + + enterUntilPageContains( + value: string, + fieldDescription: string, + expectedText: string, + ): TestRigorActions { + return this.withSteps(this.steps.enterUntilPageContains(value, fieldDescription, expectedText)); + } + + enterStoredValueUntilPageContains( + varName: string, + fieldDescription: string, + expectedText: string, + ): TestRigorActions { + return this.withSteps( + this.steps.enterStoredValueUntilPageContains(varName, fieldDescription, expectedText), + ); + } + + enterStoredValueUntilPageContainsStoredValue( + varName: string, + fieldDescription: string, + conditionVarName: string, + ): TestRigorActions { + return this.withSteps( + this.steps.enterStoredValueUntilPageContainsStoredValue( + varName, + fieldDescription, + conditionVarName, + ), + ); + } + + typeKey(keyOrCombo: string): TestRigorActions { + return this.withSteps(this.steps.typeKey(keyOrCombo)); + } + + typeKeyIfPageContains(keyOrCombo: string, expectedText: string): TestRigorActions { + return this.withSteps(this.steps.typeKeyIfPageContains(keyOrCombo, expectedText)); + } + + typeEnter(): TestRigorActions { + return this.withSteps(this.steps.typeEnter()); + } + + typeTab(): TestRigorActions { + return this.withSteps(this.steps.typeTab()); + } + + pressKey(keyOrCombo: string): TestRigorActions { + return this.withSteps(this.steps.pressKey(keyOrCombo)); + } + + pressKeyIfPageContains(keyOrCombo: string, expectedText: string): TestRigorActions { + return this.withSteps(this.steps.pressKeyIfPageContains(keyOrCombo, expectedText)); + } + + saveValue(value: string, varName: string): TestRigorActions { + return this.withSteps(this.steps.saveValue(value, varName)); + } + + grabValueFrom(elementDescription: string, varName: string): TestRigorActions { + return this.withSteps(this.steps.grabValueFrom(elementDescription, varName)); + } + + openNewTab(): TestRigorActions { + return this.withSteps(this.steps.openNewTab()); + } + + switchToTab(tabIndex: number): TestRigorActions; + switchToTab(name: string): TestRigorActions; + switchToTab(tabIndexOrName: number | string): TestRigorActions { + return this.withSteps(this.steps.switchToTab(tabIndexOrName as never)); + } + + closeTab(): TestRigorActions { + return this.withSteps(this.steps.closeTab()); + } + + callApi(url: string, varName: string): TestRigorActions { + return this.withSteps(this.steps.callApi(url, varName)); + } + + paste(): TestRigorActions { + return this.withSteps(this.steps.paste()); + } + + acceptPromptWithValue(value: string): TestRigorActions { + return this.withSteps(this.steps.acceptPromptWithValue(value)); + } + + acceptAlert(): TestRigorActions { + return this.withSteps(this.steps.acceptAlert()); + } + + setGeoLocation(latLong: string): TestRigorActions { + return this.withSteps(this.steps.setGeoLocation(latLong)); + } + + setGeoLocationStoredValue(varName: string): TestRigorActions { + return this.withSteps(this.steps.setGeoLocationStoredValue(varName)); + } + + async execute(): Promise { + await this.driver.executePrompt(this.steps.build()); + } + + buildPrompt(): string { + return this.steps.build(); + } +} + +export class EnterInto { + constructor( + private readonly value: string, + private readonly parent: TestRigorActions, + ) {} + + into(fieldDescription: string): TestRigorActions { + return this.parent.applyStepMutation((steps) => steps.enter(this.value).into(fieldDescription)); + } +} + +export class EnterFluent { + constructor( + private readonly value: string, + private readonly parent: TestRigorActions, + ) {} + + into(fieldDescription: string): ContextualEnter { + return new ContextualEnter( + this.parent, + this.parent.buildEnterTarget(this.value, fieldDescription), + ); + } +} + +export class EnterStoredInto { + constructor( + private readonly varName: string, + private readonly parent: TestRigorActions, + ) {} + + into(fieldDescription: string): TestRigorActions { + return this.parent.applyStepMutation((steps) => + steps.enterStoredValue(this.varName).into(fieldDescription), + ); + } +} + +export class EnterKeyInto { + constructor( + private readonly keyOrCombo: string, + private readonly parent: TestRigorActions, + ) {} + + into(fieldDescription: string): TestRigorActions { + return this.parent.applyStepMutation((steps) => + steps.enterKey(this.keyOrCombo).into(fieldDescription), + ); + } +} + +export class ContextualClick { + constructor( + private readonly parent: TestRigorActions, + private readonly clickTarget: ClickTarget, + ) {} + + withinTable(tableDescription: string): ContextualClick { + return new ContextualClick(this.parent, this.clickTarget.withinTable(tableDescription)); + } + + rowContaining(rowContaining: string): ContextualClick { + return new ContextualClick(this.parent, this.clickTarget.rowContaining(rowContaining)); + } + + column(columnDescription: string): ContextualClick { + return new ContextualClick(this.parent, this.clickTarget.column(columnDescription)); + } + + inContext(contextDescription: string): ContextualClick { + return new ContextualClick(this.parent, this.clickTarget.inContext(contextDescription)); + } + + below(anchorDescription: string): ContextualClick { + return new ContextualClick(this.parent, this.clickTarget.below(anchorDescription)); + } + + roughlyBelow(anchorDescription: string): ContextualClick { + return new ContextualClick(this.parent, this.clickTarget.roughlyBelow(anchorDescription)); + } + + completelyBelow(anchorDescription: string): ContextualClick { + return new ContextualClick(this.parent, this.clickTarget.completelyBelow(anchorDescription)); + } + + rightOf(anchorDescription: string): ContextualClick { + return new ContextualClick(this.parent, this.clickTarget.rightOf(anchorDescription)); + } + + and(): ContextualClick { + return this; + } + + add(): TestRigorActions { + return this.parent.replaceSteps(this.clickTarget.add()); + } +} + +export class ContextualEnter { + constructor( + private readonly parent: TestRigorActions, + private readonly enterTarget: EnterTarget, + ) {} + + withinTable(tableDescription: string): ContextualEnter { + return new ContextualEnter(this.parent, this.enterTarget.withinTable(tableDescription)); + } + + rowContaining(rowContaining: string): ContextualEnter { + return new ContextualEnter(this.parent, this.enterTarget.rowContaining(rowContaining)); + } + + column(columnDescription: string): ContextualEnter { + return new ContextualEnter(this.parent, this.enterTarget.column(columnDescription)); + } + + inContext(contextDescription: string): ContextualEnter { + return new ContextualEnter(this.parent, this.enterTarget.inContext(contextDescription)); + } + + below(anchorDescription: string): ContextualEnter { + return new ContextualEnter(this.parent, this.enterTarget.below(anchorDescription)); + } + + roughlyBelow(anchorDescription: string): ContextualEnter { + return new ContextualEnter(this.parent, this.enterTarget.roughlyBelow(anchorDescription)); + } + + completelyBelow(anchorDescription: string): ContextualEnter { + return new ContextualEnter(this.parent, this.enterTarget.completelyBelow(anchorDescription)); + } + + rightOf(anchorDescription: string): ContextualEnter { + return new ContextualEnter(this.parent, this.enterTarget.rightOf(anchorDescription)); + } + + and(): ContextualEnter { + return this; + } + + add(): TestRigorActions { + return this.parent.replaceSteps(this.enterTarget.add()); + } +} diff --git a/src/commons/application/commands/TestRigorCommandDriver.ts b/src/commons/application/commands/TestRigorCommandDriver.ts new file mode 100644 index 0000000..be2b2a5 --- /dev/null +++ b/src/commons/application/commands/TestRigorCommandDriver.ts @@ -0,0 +1,4 @@ +export interface TestRigorCommandDriver { + executePrompt(prompt: string): void | Promise; + grabValue(elementDescription: string): string | Promise; +} diff --git a/src/commons/application/commands/TestRigorQueries.ts b/src/commons/application/commands/TestRigorQueries.ts new file mode 100644 index 0000000..2259a9f --- /dev/null +++ b/src/commons/application/commands/TestRigorQueries.ts @@ -0,0 +1,472 @@ +import { quotedSegment } from './StepEscaping.js'; +import type { TestRigorCommandDriver } from './TestRigorCommandDriver.js'; + +/** + * Query facade for commands that return values. + * Element parameters follow https://testrigor.com/docs/language#referencing + */ +export class TestRigorQueries { + private constructor(private readonly driver: TestRigorCommandDriver) {} + + static queries(driver: TestRigorCommandDriver): TestRigorQueries { + return new TestRigorQueries(driver); + } + + static with(driver: TestRigorCommandDriver): TestRigorQueries { + return TestRigorQueries.queries(driver); + } + + grabValue(): ContextualGrab; + grabValue(elementDescription: string): Promise | string; + grabValue(elementType: string, elementDescription: string): Promise | string; + grabValue( + elementDescriptionOrType?: string, + elementDescription?: string, + ): ContextualGrab | Promise | string { + if (elementDescriptionOrType === undefined) { + return new ContextualGrab(this, null, null, null, null, null, null, null, null); + } + if (elementDescription === undefined) { + return this.driver.grabValue(elementDescriptionOrType); + } + return this.driver.grabValue( + `grab value from ${normalizeElementType(elementDescriptionOrType)} ${quotedSegment(elementDescription)}`, + ); + } + + grabValueFrom(elementDescription: string): ContextualGrab { + return new ContextualGrab(this, elementDescription, null, null, null, null, null, null, null); + } + + grabValueByTemplate(template: string): GrabByTemplate; + grabValueByTemplate(template: string, elementDescription: string): Promise | string; + grabValueByTemplate( + template: string, + elementType: string, + elementDescription: string, + ): Promise | string; + grabValueByTemplate( + template: string, + second?: string, + third?: string, + ): GrabByTemplate | Promise | string { + if (second === undefined) { + return new GrabByTemplate(this, template); + } + if (third === undefined) { + return this.driver.grabValue( + `grab value of template ${quotedSegment(template)} from ${quotedSegment(second)}`, + ); + } + return this.driver.grabValue( + `grab value of template ${quotedSegment(template)} from ${normalizeElementType(second)} ${quotedSegment(third)}`, + ); + } + + grabValueByTemplateFromPage(template: string): Promise | string { + return this.driver.grabValue(`grab value of template ${quotedSegment(template)}`); + } + + grabValueOfRegex(regex: string): Promise | string; + grabValueOfRegex(regex: string, elementDescription: string): Promise | string; + grabValueOfRegex( + regex: string, + elementType: string, + elementDescription: string, + ): Promise | string; + grabValueOfRegex(regex: string, second?: string, third?: string): Promise | string { + if (second === undefined) { + return this.driver.grabValue(`grab value of regex ${quotedSegment(regex)}`); + } + if (third === undefined) { + return this.driver.grabValue( + `grab value of regex ${quotedSegment(regex)} from ${quotedSegment(second)}`, + ); + } + return this.driver.grabValue( + `grab value of regex ${quotedSegment(regex)} from ${normalizeElementType(second)} ${quotedSegment(third)}`, + ); + } + + grabValueByRegex(regex: string, elementDescription: string): Promise | string { + return this.grabValueOfRegex(regex, elementDescription); + } + + grabValueOfAttribute(attribute: string, elementDescription: string): Promise | string { + return this.driver.grabValue( + `grab value of attribute ${quotedSegment(attribute)} from ${quotedSegment(elementDescription)}`, + ); + } + + grabValueOfCssProperty( + cssProperty: string, + elementDescription: string, + ): Promise | string { + return this.driver.grabValue( + `grab value of css property ${quotedSegment(cssProperty)} from ${quotedSegment(elementDescription)}`, + ); + } + + async grabValuesFromTableAtFirstColumn(tableDescription: string): Promise { + const raw = await this.driver.grabValue( + `grab values from table ${quotedSegment(tableDescription)} at first column`, + ); + return parseListResult(raw); + } + + async grabValuesFromTableAtFirstRow(tableDescription: string): Promise { + const raw = await this.driver.grabValue( + `grab values from table ${quotedSegment(tableDescription)} at first row`, + ); + return parseListResult(raw); + } + + /** @internal */ + runGrab(elementDescription: string): Promise | string { + return this.driver.grabValue(elementDescription); + } +} + +export class GrabByTemplate { + constructor( + private readonly parent: TestRigorQueries, + private readonly template: string, + ) {} + + from(elementDescription: string): Promise | string { + return this.parent.grabValueByTemplate(this.template, elementDescription) as + | Promise + | string; + } +} + +export class ContextualGrab { + constructor( + private readonly parent: TestRigorQueries, + private readonly elementDescription: string | null, + private readonly elementType: string | null, + private readonly tableDescription: string | null, + private readonly rowContainingValue: string | null, + private readonly columnDescription: string | null, + private readonly contextDescription: string | null, + private readonly belowAnchor: Anchor | null, + private readonly rightAnchor: Anchor | null, + ) {} + + ofType(elementType: string): ContextualGrab { + return new ContextualGrab( + this.parent, + this.elementDescription, + normalizeElementType(elementType), + this.tableDescription, + this.rowContainingValue, + this.columnDescription, + this.contextDescription, + this.belowAnchor, + this.rightAnchor, + ); + } + + withinTable(tableDescription: string): ContextualGrab { + return new ContextualGrab( + this.parent, + this.elementDescription, + this.elementType, + tableDescription, + this.rowContainingValue, + this.columnDescription, + this.contextDescription, + this.belowAnchor, + this.rightAnchor, + ); + } + + rowContaining(rowContaining: string): ContextualGrab { + return new ContextualGrab( + this.parent, + this.elementDescription, + this.elementType, + this.tableDescription, + rowContaining, + this.columnDescription, + this.contextDescription, + this.belowAnchor, + this.rightAnchor, + ); + } + + column(columnDescription: string): ContextualGrab { + return new ContextualGrab( + this.parent, + this.elementDescription, + this.elementType, + this.tableDescription, + this.rowContainingValue, + columnDescription, + this.contextDescription, + this.belowAnchor, + this.rightAnchor, + ); + } + + inContext(contextDescription: string): ContextualGrab { + return new ContextualGrab( + this.parent, + this.elementDescription, + this.elementType, + this.tableDescription, + this.rowContainingValue, + this.columnDescription, + contextDescription, + this.belowAnchor, + this.rightAnchor, + ); + } + + below(anchorDescription: string): ContextualGrab { + return new ContextualGrab( + this.parent, + this.elementDescription, + this.elementType, + this.tableDescription, + this.rowContainingValue, + this.columnDescription, + this.contextDescription, + Anchor.below(anchorDescription), + this.rightAnchor, + ); + } + + roughlyBelow(anchorDescription: string): ContextualGrab { + return new ContextualGrab( + this.parent, + this.elementDescription, + this.elementType, + this.tableDescription, + this.rowContainingValue, + this.columnDescription, + this.contextDescription, + Anchor.roughlyBelow(anchorDescription), + this.rightAnchor, + ); + } + + completelyBelow(anchorDescription: string): ContextualGrab { + return new ContextualGrab( + this.parent, + this.elementDescription, + this.elementType, + this.tableDescription, + this.rowContainingValue, + this.columnDescription, + this.contextDescription, + Anchor.completelyBelow(anchorDescription), + this.rightAnchor, + ); + } + + rightOf(anchorDescription: string): ContextualGrab { + return new ContextualGrab( + this.parent, + this.elementDescription, + this.elementType, + this.tableDescription, + this.rowContainingValue, + this.columnDescription, + this.contextDescription, + this.belowAnchor, + Anchor.rightOf(anchorDescription), + ); + } + + and(): ContextualGrab { + return this; + } + + get(): Promise | string { + return this.parent.runGrab(this.renderElementDescription()); + } + + byTemplate(template: string): Promise | string { + return this.parent.runGrab(this.buildTemplateStep(template)); + } + + byRegex(regex: string): Promise | string { + return this.parent.runGrab(this.buildRegexStep(regex)); + } + + private renderElementDescription(): string { + const baseDescriptor = this.elementType ?? 'element'; + let line = baseDescriptor; + if (this.tableDescription != null) { + line += ` within the context of table ${quotedSegment(this.tableDescription)}`; + if (this.rowContainingValue != null) { + line += ` at row containing ${quotedSegment(this.rowContainingValue)}`; + } + if (this.columnDescription != null) { + line += this.rowContainingValue != null ? ' and' : ' at'; + line += ` column ${quotedSegment(this.columnDescription)}`; + } + } else if (this.contextDescription != null) { + line += ` within the context of ${quotedSegment(this.contextDescription)}`; + } + + if (this.belowAnchor != null) { + line += ` ${this.belowAnchor.render()}`; + } + if (this.rightAnchor != null) { + line += + this.belowAnchor == null + ? ` ${this.rightAnchor.render()}` + : ` and ${this.rightAnchor.render()}`; + } + + if (line === baseDescriptor) { + if (this.elementDescription == null) { + return baseDescriptor; + } + if (this.elementType != null) { + return `${this.elementType} ${quotedSegment(this.elementDescription)}`; + } + return this.elementDescription; + } + if (this.elementDescription == null || this.elementDescription.trim() === '') { + return line; + } + line += ` containing ${quotedSegment(this.elementDescription)}`; + return line; + } + + private renderContextOnly(): string { + let line = ''; + if (this.tableDescription != null) { + line += `within the context of table ${quotedSegment(this.tableDescription)}`; + if (this.rowContainingValue != null) { + line += ` at row containing ${quotedSegment(this.rowContainingValue)}`; + } + if (this.columnDescription != null) { + line += this.rowContainingValue != null ? ' and' : ' at'; + line += ` column ${quotedSegment(this.columnDescription)}`; + } + } else if (this.contextDescription != null) { + line += `within the context of ${quotedSegment(this.contextDescription)}`; + } + + if (this.belowAnchor != null) { + if (line.length > 0) { + line += ' '; + } + line += this.belowAnchor.render(); + } + if (this.rightAnchor != null) { + if (line.length > 0) { + line += this.belowAnchor != null ? ' and ' : ' '; + } + line += this.rightAnchor.render(); + } + if (this.elementType != null && this.elementType.trim() !== '') { + if (line.length > 0) { + line = `${this.elementType} ${line}`; + } else { + line += this.elementType; + } + } + if ( + line.length > 0 && + this.elementDescription != null && + this.elementDescription.trim() !== '' + ) { + line += ` containing ${quotedSegment(this.elementDescription)}`; + } + return line; + } + + private buildRegexStep(regex: string): string { + const context = this.renderContextOnly(); + if (context.trim() !== '') { + if (this.elementType != null) { + return `grab value of regex ${quotedSegment(regex)} from ${context}`; + } + return `grab value of regex ${quotedSegment(regex)} from element ${context}`; + } + if (this.elementDescription == null || this.elementDescription.trim() === '') { + return `grab value of regex ${quotedSegment(regex)}`; + } + if (this.elementType != null) { + return `grab value of regex ${quotedSegment(regex)} from ${this.elementType} ${quotedSegment(this.elementDescription)}`; + } + return `grab value of regex ${quotedSegment(regex)} from ${quotedSegment(this.elementDescription)}`; + } + + private buildTemplateStep(template: string): string { + const context = this.renderContextOnly(); + if (context.trim() !== '') { + if (this.elementType != null) { + return `grab value of template ${quotedSegment(template)} from ${context}`; + } + return `grab value of template ${quotedSegment(template)} from element ${context}`; + } + if (this.elementDescription == null || this.elementDescription.trim() === '') { + return `grab value of template ${quotedSegment(template)}`; + } + if (this.elementType != null) { + return `grab value of template ${quotedSegment(template)} from ${this.elementType} ${quotedSegment(this.elementDescription)}`; + } + return `grab value of template ${quotedSegment(template)} from ${quotedSegment(this.elementDescription)}`; + } +} + +class Anchor { + private constructor( + private readonly relationPrefix: string, + private readonly anchorDescription: string, + ) {} + + static below(anchorDescription: string): Anchor { + return new Anchor('below ', anchorDescription); + } + + static roughlyBelow(anchorDescription: string): Anchor { + return new Anchor('roughly below ', anchorDescription); + } + + static completelyBelow(anchorDescription: string): Anchor { + return new Anchor('completely below ', anchorDescription); + } + + static rightOf(anchorDescription: string): Anchor { + return new Anchor('to the right of ', anchorDescription); + } + + render(): string { + return this.relationPrefix + quotedSegment(this.anchorDescription); + } +} + +function normalizeElementType(elementType: string): string { + if (elementType == null || elementType.trim() === '') { + throw new Error('Element type must not be blank'); + } + return elementType.trim().toLowerCase(); +} + +function parseListResult(raw: string | null | undefined): string[] { + if (raw == null || raw.trim() === '') { + return []; + } + const trimmed = raw.trim(); + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + const content = trimmed.substring(1, trimmed.length - 1).trim(); + if (content === '') { + return []; + } + return content + .split(/\s*,\s*/) + .map((value) => value.trim()) + .filter((value) => value !== ''); + } + return trimmed + .split(/\r?\n/) + .map((value) => value.trim()) + .filter((value) => value !== ''); +} diff --git a/src/commons/application/commands/TestRigorSteps.ts b/src/commons/application/commands/TestRigorSteps.ts new file mode 100644 index 0000000..a433870 --- /dev/null +++ b/src/commons/application/commands/TestRigorSteps.ts @@ -0,0 +1,1805 @@ +import { quotedSegment } from './StepEscaping.js'; + +const MAX_ORDINAL_INDEX = 20; + +/** + * Fluent builder for testRigor plain-English command steps. + * Each step is one line; build() returns the full prompt (newline-separated). + * Syntax follows https://testrigor.com/docs/language. + */ +export class TestRigorSteps { + static create(): TestRigorSteps { + return new TestRigorSteps([]); + } + + private constructor(private readonly steps: string[]) {} + + private appendStep(line: string): TestRigorSteps { + return new TestRigorSteps([...this.steps, line]); + } + + /** @internal */ + stepWith(line: string): TestRigorSteps { + return this.appendStep(line); + } + + /** @internal */ + static renderClickLinePublic( + elementDescription: string, + tableDescription: string | null, + rowContaining: string | null, + columnDescription: string | null, + contextDescription: string | null, + belowAnchor: Anchor | null, + rightAnchor: Anchor | null, + ): string { + return TestRigorSteps.renderClickLine( + elementDescription, + tableDescription, + rowContaining, + columnDescription, + contextDescription, + belowAnchor, + rightAnchor, + ); + } + + /** @internal */ + static renderEnterLinePublic( + value: string, + fieldDescription: string, + tableDescription: string | null, + rowContaining: string | null, + columnDescription: string | null, + contextDescription: string | null, + belowAnchor: Anchor | null, + rightAnchor: Anchor | null, + ): string { + return TestRigorSteps.renderEnterLine( + value, + fieldDescription, + tableDescription, + rowContaining, + columnDescription, + contextDescription, + belowAnchor, + rightAnchor, + ); + } + + /** @internal */ + static rawTokenPublic(value: string | null | undefined): string { + return TestRigorSteps.rawToken(value); + } + + and(): TestRigorSteps { + return this; + } + + clickOn(elementDescription: string): ClickTarget { + return new ClickTarget(this, elementDescription, null, null, null, null, null, null); + } + + enter(value: string): EnterInto { + return new EnterInto(value, this); + } + + enterFluent(value: string): EnterTargetInto { + return new EnterTargetInto(value, this); + } + + enterStoredValue(varName: string): EnterStoredInto { + return new EnterStoredInto(varName, this); + } + + enterKey(keyOrCombo: string): EnterKeyInto { + return new EnterKeyInto(keyOrCombo, this); + } + + build(): string { + return this.steps.join('\n'); + } + + private static ordinal(position: number): string { + if (position < 1 || position > MAX_ORDINAL_INDEX) { + return String(position); + } + const ordinals = [ + '1st', + '2nd', + '3rd', + '4th', + '5th', + '6th', + '7th', + '8th', + '9th', + '10th', + '11th', + '12th', + '13th', + '14th', + '15th', + '16th', + '17th', + '18th', + '19th', + '20th', + ]; + return ordinals[position - 1]!; + } + + private clickTimesLine( + clickPrefix: string, + elementDescription: string, + times: number, + ): TestRigorSteps { + return this.appendStep(`${clickPrefix} ${quotedSegment(elementDescription)} ${times} times`); + } + + private static renderClickLine( + elementDescription: string, + tableDescription: string | null, + rowContaining: string | null, + columnDescription: string | null, + contextDescription: string | null, + belowAnchor: Anchor | null, + rightAnchor: Anchor | null, + ): string { + const line = `click on ${quotedSegment(elementDescription)}`; + return TestRigorSteps.appendContextAndAnchors( + line, + tableDescription, + rowContaining, + columnDescription, + contextDescription, + belowAnchor, + rightAnchor, + ); + } + + private static renderEnterLine( + value: string, + fieldDescription: string, + tableDescription: string | null, + rowContaining: string | null, + columnDescription: string | null, + contextDescription: string | null, + belowAnchor: Anchor | null, + rightAnchor: Anchor | null, + ): string { + const line = `enter ${quotedSegment(value)} into ${quotedSegment(fieldDescription)}`; + return TestRigorSteps.appendContextAndAnchors( + line, + tableDescription, + rowContaining, + columnDescription, + contextDescription, + belowAnchor, + rightAnchor, + ); + } + + private static appendContextAndAnchors( + line: string, + tableDescription: string | null, + rowContaining: string | null, + columnDescription: string | null, + contextDescription: string | null, + belowAnchor: Anchor | null, + rightAnchor: Anchor | null, + ): string { + let result = line; + if (tableDescription != null) { + result += ` within the context of table ${quotedSegment(tableDescription)}`; + if (rowContaining != null) { + result += ` at row containing ${quotedSegment(rowContaining)}`; + } + if (columnDescription != null) { + result += rowContaining != null ? ' and' : ' at'; + result += ` column ${quotedSegment(columnDescription)}`; + } + } else if (contextDescription != null) { + result += ` within the context of ${quotedSegment(contextDescription)}`; + } + + if (belowAnchor != null) { + result += ` ${belowAnchor.render()}`; + } + if (rightAnchor != null) { + result += belowAnchor == null ? ` ${rightAnchor.render()}` : ` and ${rightAnchor.render()}`; + } + return result; + } + + private static rawToken(value: string | null | undefined): string { + return value?.trim() ?? ''; + } + + click(elementDescription: string): TestRigorSteps; + click(nth: number, elementDescription: string): TestRigorSteps; + click(elementDescriptionOrNth: string | number, elementDescription?: string): TestRigorSteps { + if (typeof elementDescriptionOrNth === 'number') { + return this.appendStep( + 'click on the ' + + TestRigorSteps.ordinal(elementDescriptionOrNth) + + ' ' + + quotedSegment(elementDescription!), + ); + } + return this.appendStep('click ' + quotedSegment(elementDescriptionOrNth)); + } + + clickTimes(elementDescription: string, times: number): TestRigorSteps { + return this.clickTimesLine('click', elementDescription, times); + } + + doubleClickTimes(elementDescription: string, times: number): TestRigorSteps { + return this.clickTimesLine('double click', elementDescription, times); + } + + tripleClickTimes(elementDescription: string, times: number): TestRigorSteps { + return this.clickTimesLine('triple click', elementDescription, times); + } + + rightClickTimes(elementDescription: string, times: number): TestRigorSteps { + return this.clickTimesLine('right click', elementDescription, times); + } + + middleClickTimes(elementDescription: string, times: number): TestRigorSteps { + return this.clickTimesLine('middle click', elementDescription, times); + } + + wheelClickTimes(elementDescription: string, times: number): TestRigorSteps { + return this.clickTimesLine('wheel click', elementDescription, times); + } + + tap(elementDescription: string): TestRigorSteps { + return this.appendStep('tap on ' + quotedSegment(elementDescription)); + } + + press(elementDescription: string): TestRigorSteps { + return this.appendStep('press ' + quotedSegment(elementDescription)); + } + + push(elementDescription: string): TestRigorSteps { + return this.appendStep('push ' + quotedSegment(elementDescription)); + } + + follow(elementDescription: string): TestRigorSteps { + return this.appendStep('follow ' + quotedSegment(elementDescription)); + } + + doubleClick(elementDescription: string): TestRigorSteps { + return this.appendStep('double click on ' + quotedSegment(elementDescription)); + } + + rightClick(elementDescription: string): TestRigorSteps { + return this.appendStep('right click on ' + quotedSegment(elementDescription)); + } + + longClick(elementDescription: string): TestRigorSteps { + return this.appendStep('long click on ' + quotedSegment(elementDescription)); + } + + tripleClick(elementDescription: string): TestRigorSteps { + return this.appendStep('triple click ' + quotedSegment(elementDescription)); + } + + middleClick(elementDescription: string): TestRigorSteps { + return this.appendStep('middle click ' + quotedSegment(elementDescription)); + } + + wheelClick(elementDescription: string): TestRigorSteps { + return this.appendStep('wheel click ' + quotedSegment(elementDescription)); + } + + clickIfExists(elementDescription: string): TestRigorSteps { + return this.appendStep('click ' + quotedSegment(elementDescription) + ' if exists'); + } + + clickIfExistsWithWaiting(elementDescription: string): TestRigorSteps { + return this.appendStep( + 'click ' + quotedSegment(elementDescription) + ' if exists with waiting', + ); + } + + clickIfExistsWithoutWaiting(elementDescription: string): TestRigorSteps { + return this.appendStep( + 'click ' + quotedSegment(elementDescription) + ' if exists without waiting', + ); + } + + clickAndSwitchToNewTab(elementDescription: string): TestRigorSteps { + return this.appendStep( + 'click ' + quotedSegment(elementDescription) + ' and switch to the new tab', + ); + } + + clickIfExistsAndSwitchToNewTab(elementDescription: string): TestRigorSteps { + return this.appendStep( + 'click ' + quotedSegment(elementDescription) + ' if exists and switch to the new tab', + ); + } + + clickIfExistsWithWaitingAndSwitchToNewTab(elementDescription: string): TestRigorSteps { + return this.appendStep( + 'click ' + + quotedSegment(elementDescription) + + ' if exists with waiting and switch to the new tab', + ); + } + + clickIfPageContains(elementDescription: string, expectedText: string): TestRigorSteps { + return this.appendStep( + 'click ' + + quotedSegment(elementDescription) + + ' if page contains ' + + quotedSegment(expectedText), + ); + } + + clickIfPageContainsWithWaiting(elementDescription: string, expectedText: string): TestRigorSteps { + return this.appendStep( + 'click ' + + quotedSegment(elementDescription) + + ' if page contains ' + + quotedSegment(expectedText) + + ' with waiting', + ); + } + + clickIfPageContainsWithoutWaiting( + elementDescription: string, + expectedText: string, + ): TestRigorSteps { + return this.appendStep( + 'click ' + + quotedSegment(elementDescription) + + ' if page contains ' + + quotedSegment(expectedText) + + ' without waiting', + ); + } + + clickIfPageDoesNotContain(elementDescription: string, expectedText: string): TestRigorSteps { + return this.appendStep( + 'click ' + + quotedSegment(elementDescription) + + ' if page does not contain ' + + quotedSegment(expectedText), + ); + } + + clickIfUrlContains(elementDescription: string, expectedText: string): TestRigorSteps { + return this.appendStep( + 'click ' + + quotedSegment(elementDescription) + + ' if url contains ' + + quotedSegment(expectedText), + ); + } + + pressIfPageContains(elementDescription: string, expectedText: string): TestRigorSteps { + return this.appendStep( + 'press ' + + quotedSegment(elementDescription) + + ' if page contains ' + + quotedSegment(expectedText), + ); + } + + select(value: string, dropdownDescription: string): TestRigorSteps { + return this.appendStep( + 'select ' + quotedSegment(value) + ' from ' + quotedSegment(dropdownDescription), + ); + } + + choose(value: string, dropdownDescription: string): TestRigorSteps { + return this.appendStep( + 'choose ' + quotedSegment(value) + ' from ' + quotedSegment(dropdownDescription), + ); + } + + insert(value: string, fieldDescription: string): TestRigorSteps { + return this.appendStep( + 'insert ' + quotedSegment(value) + ' into ' + quotedSegment(fieldDescription), + ); + } + + selectNthOption(nthOption: number, dropdownDescription: string): TestRigorSteps { + return this.appendStep( + 'select ' + + TestRigorSteps.ordinal(nthOption) + + ' option from ' + + quotedSegment(dropdownDescription), + ); + } + + selectOption(optionNumber: number, dropdownDescription: string): TestRigorSteps { + return this.appendStep( + 'select option ' + optionNumber + ' from ' + quotedSegment(dropdownDescription), + ); + } + + checkPageContains(text: string): TestRigorSteps { + return this.appendStep('check that page contains ' + quotedSegment(text)); + } + + checkPageDoesNotContain(text: string): TestRigorSteps { + return this.appendStep('check that page does not contain ' + quotedSegment(text)); + } + + checkPageContainsStoredValue(varName: string): TestRigorSteps { + return this.appendStep('check that page contains stored value from ' + quotedSegment(varName)); + } + + checkPageContainsRegex(regex: string): TestRigorSteps { + return this.appendStep('check that page has regex ' + quotedSegment(regex)); + } + + checkPageDoesNotContainRegex(regex: string): TestRigorSteps { + return this.appendStep('check that page does not have regex ' + quotedSegment(regex)); + } + + checkPageContainsTemplate(template: string): TestRigorSteps { + return this.appendStep('check that page has simple template ' + quotedSegment(template)); + } + + checkPageDoesNotContainTemplate(template: string): TestRigorSteps { + return this.appendStep( + 'check that page does not have simple template ' + quotedSegment(template), + ); + } + + checkPageDidNotChange(): TestRigorSteps { + return this.appendStep("check that page didn't change"); + } + + checkUrlContains(text: string): TestRigorSteps { + return this.appendStep('check that url contains ' + quotedSegment(text)); + } + + checkUrlDoesNotContain(text: string): TestRigorSteps { + return this.appendStep('check that url does not contain ' + quotedSegment(text)); + } + + checkUrlStartsWith(prefix: string): TestRigorSteps { + return this.appendStep('check that url starts with ' + quotedSegment(prefix)); + } + + checkUrlDoesNotStartWith(prefix: string): TestRigorSteps { + return this.appendStep('check that url does not start with ' + quotedSegment(prefix)); + } + + checkUrlEndsWith(suffix: string): TestRigorSteps { + return this.appendStep('check that url ends with ' + quotedSegment(suffix)); + } + + checkUrlDoesNotEndWith(suffix: string): TestRigorSteps { + return this.appendStep('check that url does not end with ' + quotedSegment(suffix)); + } + + checkUrlIs(url: string): TestRigorSteps { + return this.appendStep('check that url is ' + quotedSegment(url)); + } + + checkUrlIsNot(url: string): TestRigorSteps { + return this.appendStep('check that url is not ' + quotedSegment(url)); + } + + checkUrlMatchesRegex(regex: string): TestRigorSteps { + return this.appendStep('check that url matches regex ' + quotedSegment(regex)); + } + + checkUrlDoesNotMatchRegex(regex: string): TestRigorSteps { + return this.appendStep('check that url does not match regex ' + quotedSegment(regex)); + } + + checkPageTitleIs(title: string): TestRigorSteps { + return this.appendStep('check that page title is ' + quotedSegment(title)); + } + + checkPageTitleContains(text: string): TestRigorSteps { + return this.appendStep('check that page title contains ' + quotedSegment(text)); + } + + checkPageReturnCode(code: string): TestRigorSteps { + return this.appendStep('check that page return code is ' + quotedSegment(code)); + } + + checkButtonDisabled(buttonDescription: string): TestRigorSteps { + return this.appendStep( + 'check that button ' + quotedSegment(buttonDescription) + ' is disabled', + ); + } + + checkButtonEnabled(buttonDescription: string): TestRigorSteps { + return this.appendStep('check that button ' + quotedSegment(buttonDescription) + ' is enabled'); + } + + checkThatElementContains(elementDescription: string, text: string): TestRigorSteps { + return this.appendStep( + 'check that ' + quotedSegment(elementDescription) + ' contains ' + quotedSegment(text), + ); + } + + checkThatElementDoesNotContain(elementDescription: string, text: string): TestRigorSteps { + return this.appendStep( + 'check that ' + + quotedSegment(elementDescription) + + ' does not contain ' + + quotedSegment(text), + ); + } + + checkThatElementContainsStoredValue(elementDescription: string, varName: string): TestRigorSteps { + return this.appendStep( + 'check that ' + + quotedSegment(elementDescription) + + ' contains stored value from ' + + quotedSegment(varName), + ); + } + + checkThatElementDoesNotContainStoredValue( + elementDescription: string, + varName: string, + ): TestRigorSteps { + return this.appendStep( + 'check that ' + + quotedSegment(elementDescription) + + ' does not contain stored value from ' + + quotedSegment(varName), + ); + } + + checkThatInputHasValue(elementDescription: string, value: string): TestRigorSteps { + return this.appendStep( + 'check that input ' + + quotedSegment(elementDescription) + + ' has value ' + + quotedSegment(value), + ); + } + + checkThatInputHasStoredValue(elementDescription: string, varName: string): TestRigorSteps { + return this.appendStep( + 'check that input ' + + quotedSegment(elementDescription) + + ' has value stored value from ' + + quotedSegment(varName), + ); + } + + checkThatInputDoesNotHaveValue(elementDescription: string, value: string): TestRigorSteps { + return this.appendStep( + 'check that input ' + + quotedSegment(elementDescription) + + ' does not have value ' + + quotedSegment(value), + ); + } + + checkThatInputDoesNotHaveStoredValue( + elementDescription: string, + varName: string, + ): TestRigorSteps { + return this.appendStep( + 'check that input ' + + quotedSegment(elementDescription) + + ' does not have value stored value from ' + + quotedSegment(varName), + ); + } + + checkThatCheckboxIsChecked(elementDescription: string): TestRigorSteps { + return this.appendStep( + 'check that checkbox ' + quotedSegment(elementDescription) + ' is checked', + ); + } + + checkThatCheckboxIsUnchecked(elementDescription: string): TestRigorSteps { + return this.appendStep( + 'check that checkbox ' + quotedSegment(elementDescription) + ' is unchecked', + ); + } + + checkThatElementIsEnabled(elementDescription: string): TestRigorSteps { + return this.appendStep('check that ' + quotedSegment(elementDescription) + ' is enabled'); + } + + checkThatElementIsDisabled(elementDescription: string): TestRigorSteps { + return this.appendStep('check that ' + quotedSegment(elementDescription) + ' is disabled'); + } + + checkThatElementIsVisible(elementDescription: string): TestRigorSteps { + return this.appendStep('check that ' + quotedSegment(elementDescription) + ' is visible'); + } + + checkThatElementIsInvisible(elementDescription: string): TestRigorSteps { + return this.appendStep('check that ' + quotedSegment(elementDescription) + ' is invisible'); + } + + checkThatElementIsClickable(elementDescription: string): TestRigorSteps { + return this.appendStep('check that ' + quotedSegment(elementDescription) + ' is clickable'); + } + + checkThatElementIsNotClickable(elementDescription: string): TestRigorSteps { + return this.appendStep('check that ' + quotedSegment(elementDescription) + ' is not clickable'); + } + + checkThatSelectHasOptionSelected(elementDescription: string, option: string): TestRigorSteps { + return this.appendStep( + 'check that select ' + + quotedSegment(elementDescription) + + ' has option selected ' + + quotedSegment(option), + ); + } + + checkThatSelectDoesNotHaveOptionSelected( + elementDescription: string, + option: string, + ): TestRigorSteps { + return this.appendStep( + 'check that select ' + + quotedSegment(elementDescription) + + ' does not have option selected ' + + quotedSegment(option), + ); + } + + checkThatElementMatchesRegex(elementDescription: string, regex: string): TestRigorSteps { + return this.appendStep( + 'check that ' + quotedSegment(elementDescription) + ' matches regex ' + quotedSegment(regex), + ); + } + + checkThatElementDoesNotMatchRegex(elementDescription: string, regex: string): TestRigorSteps { + return this.appendStep( + 'check that ' + + quotedSegment(elementDescription) + + ' does not match regex ' + + quotedSegment(regex), + ); + } + + checkThatElementMatchesTemplate(elementDescription: string, template: string): TestRigorSteps { + return this.appendStep( + 'check that ' + + quotedSegment(elementDescription) + + ' matches template ' + + quotedSegment(template), + ); + } + + checkThatElementDoesNotMatchTemplate( + elementDescription: string, + template: string, + ): TestRigorSteps { + return this.appendStep( + 'check that ' + + quotedSegment(elementDescription) + + ' does not match template ' + + quotedSegment(template), + ); + } + + checkThatElementHasAttribute( + elementDescription: string, + attributeName: string, + expectedValue: string, + ): TestRigorSteps { + return this.appendStep( + 'check that ' + + quotedSegment(elementDescription) + + ' has attribute ' + + quotedSegment(attributeName) + + ' equal to ' + + quotedSegment(expectedValue), + ); + } + + checkThatElementDoesNotHaveAttribute( + elementDescription: string, + attributeName: string, + ): TestRigorSteps { + return this.appendStep( + 'check that ' + + quotedSegment(elementDescription) + + ' does not have attribute ' + + quotedSegment(attributeName), + ); + } + + checkThatElementHasProperty( + elementDescription: string, + propertyName: string, + expectedValue: string, + ): TestRigorSteps { + return this.appendStep( + 'check that ' + + quotedSegment(elementDescription) + + ' has property ' + + quotedSegment(propertyName) + + ' equal to ' + + quotedSegment(expectedValue), + ); + } + + checkThatElementDoesNotHaveProperty( + elementDescription: string, + propertyName: string, + ): TestRigorSteps { + return this.appendStep( + 'check that ' + + quotedSegment(elementDescription) + + ' does not have property ' + + quotedSegment(propertyName), + ); + } + + checkThatElementHasCssClass(elementDescription: string, cssClass: string): TestRigorSteps { + return this.appendStep( + 'check that ' + + quotedSegment(elementDescription) + + ' has css class ' + + quotedSegment(cssClass), + ); + } + + checkThatElementDoesNotHaveCssClass( + elementDescription: string, + cssClass: string, + ): TestRigorSteps { + return this.appendStep( + 'check that ' + + quotedSegment(elementDescription) + + ' does not have css class ' + + quotedSegment(cssClass), + ); + } + + checkThatElementColorIs(elementDescription: string, color: string): TestRigorSteps { + return this.appendStep( + 'check that ' + quotedSegment(elementDescription) + ' color is ' + quotedSegment(color), + ); + } + + checkThatElementColorIsNot(elementDescription: string, color: string): TestRigorSteps { + return this.appendStep( + 'check that ' + quotedSegment(elementDescription) + ' color is not ' + quotedSegment(color), + ); + } + + checkThatElementBackgroundColorIs(elementDescription: string, color: string): TestRigorSteps { + return this.appendStep( + 'check that ' + + quotedSegment(elementDescription) + + ' background color is ' + + quotedSegment(color), + ); + } + + checkThatElementBackgroundColorIsNot(elementDescription: string, color: string): TestRigorSteps { + return this.appendStep( + 'check that ' + + quotedSegment(elementDescription) + + ' background color is not ' + + quotedSegment(color), + ); + } + + checkThatElementCursorIs(elementDescription: string, cursor: string): TestRigorSteps { + return this.appendStep( + 'check that ' + quotedSegment(elementDescription) + ' cursor is ' + quotedSegment(cursor), + ); + } + + checkThatElementCursorIsNot(elementDescription: string, cursor: string): TestRigorSteps { + return this.appendStep( + 'check that ' + quotedSegment(elementDescription) + ' cursor is not ' + quotedSegment(cursor), + ); + } + + checkThatElementHasLineStyle(elementDescription: string, lineStyle: string): TestRigorSteps { + return this.appendStep( + 'check that ' + + quotedSegment(elementDescription) + + ' has line style ' + + quotedSegment(lineStyle), + ); + } + + checkThatElementHasNotLineStyle(elementDescription: string, lineStyle: string): TestRigorSteps { + return this.appendStep( + 'check that ' + + quotedSegment(elementDescription) + + ' has not line style ' + + quotedSegment(lineStyle), + ); + } + + checkThatElementIsBlank(elementDescription: string): TestRigorSteps { + return this.appendStep('check that ' + quotedSegment(elementDescription) + ' is blank'); + } + + checkThatElementIsNotBlank(elementDescription: string): TestRigorSteps { + return this.appendStep('check that ' + quotedSegment(elementDescription) + ' is not blank'); + } + + checkThatElementIsNull(elementDescription: string): TestRigorSteps { + return this.appendStep('check that ' + quotedSegment(elementDescription) + ' is null'); + } + + checkThatElementIsNotNull(elementDescription: string): TestRigorSteps { + return this.appendStep('check that ' + quotedSegment(elementDescription) + ' is not null'); + } + + checkThatElementIsEqual(elementDescription: string, value: string): TestRigorSteps { + return this.appendStep( + 'check that ' + quotedSegment(elementDescription) + ' is equal to ' + quotedSegment(value), + ); + } + + checkThatElementIsNotEqual(elementDescription: string, value: string): TestRigorSteps { + return this.appendStep( + 'check that ' + + quotedSegment(elementDescription) + + ' is not equal to ' + + quotedSegment(value), + ); + } + + checkThatElementIsEqualAsNumber(elementDescription: string, value: string): TestRigorSteps { + return this.appendStep( + 'check that ' + + quotedSegment(elementDescription) + + ' is equal as number to ' + + quotedSegment(value), + ); + } + + checkThatElementIsNotEqualAsNumber(elementDescription: string, value: string): TestRigorSteps { + return this.appendStep( + 'check that ' + + quotedSegment(elementDescription) + + ' is not equal as number to ' + + quotedSegment(value), + ); + } + + checkThatElementIsGreaterThan(elementDescription: string, value: string): TestRigorSteps { + return this.appendStep( + 'check that ' + + quotedSegment(elementDescription) + + ' is greater than ' + + quotedSegment(value), + ); + } + + checkThatElementIsGreaterThanOrEqualTo( + elementDescription: string, + value: string, + ): TestRigorSteps { + return this.appendStep( + 'check that ' + + quotedSegment(elementDescription) + + ' is greater than or equal to ' + + quotedSegment(value), + ); + } + + checkThatElementIsLessThan(elementDescription: string, value: string): TestRigorSteps { + return this.appendStep( + 'check that ' + quotedSegment(elementDescription) + ' is less than ' + quotedSegment(value), + ); + } + + checkThatElementIsLessThanOrEqualTo(elementDescription: string, value: string): TestRigorSteps { + return this.appendStep( + 'check that ' + + quotedSegment(elementDescription) + + ' is less than or equal to ' + + quotedSegment(value), + ); + } + + checkThatElementIsLexicographicallyBefore( + elementDescription: string, + value: string, + ): TestRigorSteps { + return this.appendStep( + 'check that ' + + quotedSegment(elementDescription) + + ' lexicographically before ' + + quotedSegment(value), + ); + } + + checkThatElementIsLexicographicallyBeforeOrSame( + elementDescription: string, + value: string, + ): TestRigorSteps { + return this.appendStep( + 'check that ' + + quotedSegment(elementDescription) + + ' lexicographically before or same ' + + quotedSegment(value), + ); + } + + checkThatElementIsLexicographicallyAfter( + elementDescription: string, + value: string, + ): TestRigorSteps { + return this.appendStep( + 'check that ' + + quotedSegment(elementDescription) + + ' lexicographically after ' + + quotedSegment(value), + ); + } + + checkThatElementIsLexicographicallyAfterOrSame( + elementDescription: string, + value: string, + ): TestRigorSteps { + return this.appendStep( + 'check that ' + + quotedSegment(elementDescription) + + ' lexicographically after or same ' + + quotedSegment(value), + ); + } + + checkThatStoredValueItselfContains(varName: string, text: string): TestRigorSteps { + return this.appendStep( + 'check that stored value ' + + quotedSegment(varName) + + ' itself contains ' + + quotedSegment(text), + ); + } + + checkThatStoredValueItselfDoesNotContain(varName: string, text: string): TestRigorSteps { + return this.appendStep( + 'check that stored value ' + + quotedSegment(varName) + + ' itself does not contain ' + + quotedSegment(text), + ); + } + + checkThatStoredValueItselfMatchesRegex(varName: string, regex: string): TestRigorSteps { + return this.appendStep( + 'check that stored value ' + + quotedSegment(varName) + + ' itself matches regex ' + + quotedSegment(regex), + ); + } + + checkThatStoredValueItselfDoesNotMatchRegex(varName: string, regex: string): TestRigorSteps { + return this.appendStep( + 'check that stored value ' + + quotedSegment(varName) + + ' itself does not match regex ' + + quotedSegment(regex), + ); + } + + checkThatStoredValueItselfMatchesTemplate(varName: string, template: string): TestRigorSteps { + return this.appendStep( + 'check that stored value ' + + quotedSegment(varName) + + ' itself matches template ' + + quotedSegment(template), + ); + } + + checkThatStoredValueItselfDoesNotMatchTemplate( + varName: string, + template: string, + ): TestRigorSteps { + return this.appendStep( + 'check that stored value ' + + quotedSegment(varName) + + ' itself does not match template ' + + quotedSegment(template), + ); + } + + checkThatStoredValueItselfIsBlank(varName: string): TestRigorSteps { + return this.appendStep( + 'check that stored value ' + quotedSegment(varName) + ' itself is blank', + ); + } + + checkThatStoredValueItselfIsNotBlank(varName: string): TestRigorSteps { + return this.appendStep( + 'check that stored value ' + quotedSegment(varName) + ' itself is not blank', + ); + } + + checkThatStoredValueItselfIsNull(varName: string): TestRigorSteps { + return this.appendStep('check that stored value ' + quotedSegment(varName) + ' itself is null'); + } + + checkThatStoredValueItselfIsNotNull(varName: string): TestRigorSteps { + return this.appendStep( + 'check that stored value ' + quotedSegment(varName) + ' itself is not null', + ); + } + + checkThatStoredValueItselfIsEqual(varName: string, value: string): TestRigorSteps { + return this.appendStep( + 'check that stored value ' + + quotedSegment(varName) + + ' itself is equal to ' + + quotedSegment(value), + ); + } + + checkThatStoredValueItselfIsNotEqual(varName: string, value: string): TestRigorSteps { + return this.appendStep( + 'check that stored value ' + + quotedSegment(varName) + + ' itself is not equal to ' + + quotedSegment(value), + ); + } + + checkThatStoredValueItselfIsEqualAsNumber(varName: string, value: string): TestRigorSteps { + return this.appendStep( + 'check that stored value ' + + quotedSegment(varName) + + ' itself is equal as number to ' + + quotedSegment(value), + ); + } + + checkThatStoredValueItselfIsNotEqualAsNumber(varName: string, value: string): TestRigorSteps { + return this.appendStep( + 'check that stored value ' + + quotedSegment(varName) + + ' itself is not equal as number to ' + + quotedSegment(value), + ); + } + + checkThatStoredValueItselfIsGreaterThan(varName: string, value: string): TestRigorSteps { + return this.appendStep( + 'check that stored value ' + + quotedSegment(varName) + + ' itself is greater than ' + + quotedSegment(value), + ); + } + + checkThatStoredValueItselfIsGreaterThanOrEqualTo(varName: string, value: string): TestRigorSteps { + return this.appendStep( + 'check that stored value ' + + quotedSegment(varName) + + ' itself is greater than or equal to ' + + quotedSegment(value), + ); + } + + checkThatStoredValueItselfIsLessThan(varName: string, value: string): TestRigorSteps { + return this.appendStep( + 'check that stored value ' + + quotedSegment(varName) + + ' itself is less than ' + + quotedSegment(value), + ); + } + + checkThatStoredValueItselfIsLessThanOrEqualTo(varName: string, value: string): TestRigorSteps { + return this.appendStep( + 'check that stored value ' + + quotedSegment(varName) + + ' itself is less than or equal to ' + + quotedSegment(value), + ); + } + + hoverOver(elementDescription: string): TestRigorSteps; + hoverOver(nth: number, elementDescription: string): TestRigorSteps; + hoverOver(elementDescriptionOrNth: string | number, elementDescription?: string): TestRigorSteps { + if (typeof elementDescriptionOrNth === 'number') { + return this.appendStep( + 'hover over ' + + TestRigorSteps.ordinal(elementDescriptionOrNth) + + ' ' + + quotedSegment(elementDescription!), + ); + } + return this.appendStep('hover over ' + quotedSegment(elementDescriptionOrNth)); + } + + scrollDown(): TestRigorSteps { + return this.appendStep('scroll down'); + } + + scrollUp(): TestRigorSteps { + return this.appendStep('scroll up'); + } + + scrollLeft(): TestRigorSteps { + return this.appendStep('scroll left'); + } + + scrollRight(): TestRigorSteps { + return this.appendStep('scroll right'); + } + + scrollDownOn(elementDescription: string): TestRigorSteps { + return this.appendStep('scroll down on ' + quotedSegment(elementDescription)); + } + + scrollUpOn(elementDescription: string): TestRigorSteps { + return this.appendStep('scroll up on ' + quotedSegment(elementDescription)); + } + + scrollDownUntilPageContains(text: string, maxTimes?: number): TestRigorSteps { + if (maxTimes === undefined) { + return this.appendStep('scroll down until page contains ' + quotedSegment(text)); + } + return this.appendStep( + 'scroll down up to ' + maxTimes + ' times until page contains ' + quotedSegment(text), + ); + } + + clickUntilPageContains( + elementDescription: string, + expectedText: string, + maxTimes?: number, + ): TestRigorSteps { + if (maxTimes === undefined) { + return this.appendStep( + 'click ' + + quotedSegment(elementDescription) + + ' until page contains ' + + quotedSegment(expectedText), + ); + } + return this.appendStep( + 'click ' + + quotedSegment(elementDescription) + + ' up to ' + + maxTimes + + ' times until page contains ' + + quotedSegment(expectedText), + ); + } + + clickUntilPageContainsStoredValue(elementDescription: string, varName: string): TestRigorSteps { + return this.appendStep( + 'click ' + + quotedSegment(elementDescription) + + ' until page contains stored value ' + + quotedSegment(varName), + ); + } + + clickUntilPageContainsWithWaiting( + elementDescription: string, + expectedText: string, + maxTimes?: number, + ): TestRigorSteps { + if (maxTimes === undefined) { + return this.appendStep( + 'click ' + + quotedSegment(elementDescription) + + ' until page contains ' + + quotedSegment(expectedText) + + ' with waiting', + ); + } + return this.appendStep( + 'click ' + + quotedSegment(elementDescription) + + ' up to ' + + maxTimes + + ' times until page contains ' + + quotedSegment(expectedText) + + ' with waiting', + ); + } + + clickUntilPageContainsWithoutWaiting( + elementDescription: string, + expectedText: string, + ): TestRigorSteps { + return this.appendStep( + 'click ' + + quotedSegment(elementDescription) + + ' until page contains ' + + quotedSegment(expectedText) + + ' without waiting', + ); + } + + openUrl(url: string): TestRigorSteps { + return this.appendStep('open url ' + quotedSegment(url)); + } + + goBack(): TestRigorSteps { + return this.appendStep('go back'); + } + + goForward(): TestRigorSteps { + return this.appendStep('go forward'); + } + + reload(): TestRigorSteps { + return this.appendStep('reload'); + } + + waitSec(seconds: number): TestRigorSteps { + return this.appendStep('wait ' + seconds + ' sec'); + } + + waitUntilPageContains(expectedText: string, maxTimes?: number): TestRigorSteps { + if (maxTimes === undefined) { + return this.appendStep('wait 1 sec until page contains ' + quotedSegment(expectedText)); + } + return this.appendStep( + 'wait 1 sec up to ' + maxTimes + ' times until page contains ' + quotedSegment(expectedText), + ); + } + + waitUntilPageContainsWithinSeconds(expectedText: string, timeoutSeconds: number): TestRigorSteps { + return this.appendStep( + 'wait up to ' + + timeoutSeconds + + ' seconds until page contains ' + + quotedSegment(expectedText), + ); + } + + waitUntilPageContainsWithinSecondsWithWaiting( + expectedText: string, + timeoutSeconds: number, + ): TestRigorSteps { + return this.appendStep( + 'wait up to ' + + timeoutSeconds + + ' seconds until page contains ' + + quotedSegment(expectedText) + + ' with waiting', + ); + } + + drag(sourceDescription: string, targetDescription: string): TestRigorSteps { + return this.appendStep( + 'drag ' + quotedSegment(sourceDescription) + ' to ' + quotedSegment(targetDescription), + ); + } + + login(): TestRigorSteps { + return this.appendStep('login'); + } + + fillOutForm(): TestRigorSteps { + return this.appendStep('fill out form'); + } + + fillOutRequiredFieldsInForm(): TestRigorSteps { + return this.appendStep('fill out required fields in form'); + } + + type(text: string): TestRigorSteps { + return this.appendStep('type ' + quotedSegment(text)); + } + + typeInto(text: string, fieldDescription: string): TestRigorSteps { + return this.appendStep( + 'type ' + quotedSegment(text) + ' into ' + quotedSegment(fieldDescription), + ); + } + + enterUntilPageContains( + value: string, + fieldDescription: string, + expectedText: string, + ): TestRigorSteps { + return this.appendStep( + 'enter ' + + quotedSegment(value) + + ' into ' + + quotedSegment(fieldDescription) + + ' until page contains ' + + quotedSegment(expectedText), + ); + } + + enterStoredValueUntilPageContains( + varName: string, + fieldDescription: string, + expectedText: string, + ): TestRigorSteps { + return this.appendStep( + 'enter stored value ' + + quotedSegment(varName) + + ' into ' + + quotedSegment(fieldDescription) + + ' until page contains ' + + quotedSegment(expectedText), + ); + } + + enterStoredValueUntilPageContainsStoredValue( + varName: string, + fieldDescription: string, + conditionVarName: string, + ): TestRigorSteps { + return this.appendStep( + 'enter stored value ' + + quotedSegment(varName) + + ' into ' + + quotedSegment(fieldDescription) + + ' until page contains stored value ' + + quotedSegment(conditionVarName), + ); + } + + typeKey(keyOrCombo: string): TestRigorSteps { + return this.appendStep('type ' + TestRigorSteps.rawToken(keyOrCombo)); + } + + typeKeyIfPageContains(keyOrCombo: string, expectedText: string): TestRigorSteps { + return this.appendStep( + 'type ' + + TestRigorSteps.rawToken(keyOrCombo) + + ' if page contains ' + + quotedSegment(expectedText), + ); + } + + typeEnter(): TestRigorSteps { + return this.appendStep('type enter'); + } + + typeTab(): TestRigorSteps { + return this.appendStep('type tab'); + } + + pressKey(keyOrCombo: string): TestRigorSteps { + return this.appendStep('press ' + TestRigorSteps.rawToken(keyOrCombo)); + } + + pressKeyIfPageContains(keyOrCombo: string, expectedText: string): TestRigorSteps { + return this.appendStep( + 'press ' + + TestRigorSteps.rawToken(keyOrCombo) + + ' if page contains ' + + quotedSegment(expectedText), + ); + } + + saveValue(value: string, varName: string): TestRigorSteps { + return this.appendStep('save value ' + quotedSegment(value) + ' as ' + quotedSegment(varName)); + } + + grabValueFrom(elementDescription: string, varName: string): TestRigorSteps { + return this.appendStep( + 'grab value from ' + + quotedSegment(elementDescription) + + ' and save it as ' + + quotedSegment(varName), + ); + } + + openNewTab(): TestRigorSteps { + return this.appendStep('open new tab'); + } + + switchToTab(tabIndex: number): TestRigorSteps; + switchToTab(name: string): TestRigorSteps; + switchToTab(tabIndexOrName: number | string): TestRigorSteps { + if (typeof tabIndexOrName === 'number') { + return this.appendStep('switch to tab ' + tabIndexOrName); + } + return this.appendStep('switch to tab ' + quotedSegment(tabIndexOrName)); + } + + closeTab(): TestRigorSteps { + return this.appendStep('close tab'); + } + + callApi(url: string, varName: string): TestRigorSteps { + return this.appendStep( + 'call api ' + quotedSegment(url) + ' and save it as ' + quotedSegment(varName), + ); + } + + paste(): TestRigorSteps { + return this.appendStep('paste'); + } + + acceptPromptWithValue(value: string): TestRigorSteps { + return this.appendStep('accept prompt with value ' + quotedSegment(value)); + } + + acceptAlert(): TestRigorSteps { + return this.appendStep('accept prompt'); + } + + setGeoLocation(latLong: string): TestRigorSteps { + return this.appendStep('set geo location ' + quotedSegment(latLong)); + } + + setGeoLocationStoredValue(varName: string): TestRigorSteps { + return this.appendStep('set geo location stored value ' + quotedSegment(varName)); + } +} + +export class EnterInto { + constructor( + private readonly value: string, + private readonly parent: TestRigorSteps, + ) {} + + into(fieldDescription: string): TestRigorSteps { + return this.parent.stepWith( + `enter ${quotedSegment(this.value)} into ${quotedSegment(fieldDescription)}`, + ); + } +} + +export class EnterStoredInto { + constructor( + private readonly varName: string, + private readonly parent: TestRigorSteps, + ) {} + + into(fieldDescription: string): TestRigorSteps { + return this.parent.stepWith( + `enter stored value ${quotedSegment(this.varName)} into ${quotedSegment(fieldDescription)}`, + ); + } +} + +export class EnterKeyInto { + constructor( + private readonly keyOrCombo: string, + private readonly parent: TestRigorSteps, + ) {} + + into(fieldDescription: string): TestRigorSteps { + return this.parent.stepWith( + `enter ${TestRigorSteps.rawTokenPublic(this.keyOrCombo)} into ${quotedSegment(fieldDescription)}`, + ); + } +} + +export class EnterTargetInto { + constructor( + private readonly value: string, + private readonly parent: TestRigorSteps, + ) {} + + into(fieldDescription: string): EnterTarget { + return new EnterTarget( + this.parent, + this.value, + fieldDescription, + null, + null, + null, + null, + null, + null, + ); + } +} + +export class ClickTarget { + constructor( + private readonly parent: TestRigorSteps, + private readonly elementDescription: string, + private readonly tableDescription: string | null, + private readonly rowContainingValue: string | null, + private readonly columnDescription: string | null, + private readonly contextDescription: string | null, + private readonly belowAnchor: Anchor | null, + private readonly rightAnchor: Anchor | null, + ) {} + + withinTable(tableDescription: string): ClickTarget { + return new ClickTarget( + this.parent, + this.elementDescription, + tableDescription, + this.rowContainingValue, + this.columnDescription, + this.contextDescription, + this.belowAnchor, + this.rightAnchor, + ); + } + + rowContaining(rowContaining: string): ClickTarget { + return new ClickTarget( + this.parent, + this.elementDescription, + this.tableDescription, + rowContaining, + this.columnDescription, + this.contextDescription, + this.belowAnchor, + this.rightAnchor, + ); + } + + column(columnDescription: string): ClickTarget { + return new ClickTarget( + this.parent, + this.elementDescription, + this.tableDescription, + this.rowContainingValue, + columnDescription, + this.contextDescription, + this.belowAnchor, + this.rightAnchor, + ); + } + + inContext(contextDescription: string): ClickTarget { + return new ClickTarget( + this.parent, + this.elementDescription, + this.tableDescription, + this.rowContainingValue, + this.columnDescription, + contextDescription, + this.belowAnchor, + this.rightAnchor, + ); + } + + below(anchorDescription: string): ClickTarget { + return new ClickTarget( + this.parent, + this.elementDescription, + this.tableDescription, + this.rowContainingValue, + this.columnDescription, + this.contextDescription, + Anchor.below(anchorDescription), + this.rightAnchor, + ); + } + + roughlyBelow(anchorDescription: string): ClickTarget { + return new ClickTarget( + this.parent, + this.elementDescription, + this.tableDescription, + this.rowContainingValue, + this.columnDescription, + this.contextDescription, + Anchor.roughlyBelow(anchorDescription), + this.rightAnchor, + ); + } + + completelyBelow(anchorDescription: string): ClickTarget { + return new ClickTarget( + this.parent, + this.elementDescription, + this.tableDescription, + this.rowContainingValue, + this.columnDescription, + this.contextDescription, + Anchor.completelyBelow(anchorDescription), + this.rightAnchor, + ); + } + + rightOf(anchorDescription: string): ClickTarget { + return new ClickTarget( + this.parent, + this.elementDescription, + this.tableDescription, + this.rowContainingValue, + this.columnDescription, + this.contextDescription, + this.belowAnchor, + Anchor.rightOf(anchorDescription), + ); + } + + and(): ClickTarget { + return this; + } + + add(): TestRigorSteps { + return this.parent.stepWith( + TestRigorSteps.renderClickLinePublic( + this.elementDescription, + this.tableDescription, + this.rowContainingValue, + this.columnDescription, + this.contextDescription, + this.belowAnchor, + this.rightAnchor, + ), + ); + } +} + +export class EnterTarget { + constructor( + private readonly parent: TestRigorSteps, + private readonly value: string, + private readonly fieldDescription: string, + private readonly tableDescription: string | null, + private readonly rowContainingValue: string | null, + private readonly columnDescription: string | null, + private readonly contextDescription: string | null, + private readonly belowAnchor: Anchor | null, + private readonly rightAnchor: Anchor | null, + ) {} + + withinTable(tableDescription: string): EnterTarget { + return new EnterTarget( + this.parent, + this.value, + this.fieldDescription, + tableDescription, + this.rowContainingValue, + this.columnDescription, + this.contextDescription, + this.belowAnchor, + this.rightAnchor, + ); + } + + rowContaining(rowContaining: string): EnterTarget { + return new EnterTarget( + this.parent, + this.value, + this.fieldDescription, + this.tableDescription, + rowContaining, + this.columnDescription, + this.contextDescription, + this.belowAnchor, + this.rightAnchor, + ); + } + + column(columnDescription: string): EnterTarget { + return new EnterTarget( + this.parent, + this.value, + this.fieldDescription, + this.tableDescription, + this.rowContainingValue, + columnDescription, + this.contextDescription, + this.belowAnchor, + this.rightAnchor, + ); + } + + inContext(contextDescription: string): EnterTarget { + return new EnterTarget( + this.parent, + this.value, + this.fieldDescription, + this.tableDescription, + this.rowContainingValue, + this.columnDescription, + contextDescription, + this.belowAnchor, + this.rightAnchor, + ); + } + + below(anchorDescription: string): EnterTarget { + return new EnterTarget( + this.parent, + this.value, + this.fieldDescription, + this.tableDescription, + this.rowContainingValue, + this.columnDescription, + this.contextDescription, + Anchor.below(anchorDescription), + this.rightAnchor, + ); + } + + roughlyBelow(anchorDescription: string): EnterTarget { + return new EnterTarget( + this.parent, + this.value, + this.fieldDescription, + this.tableDescription, + this.rowContainingValue, + this.columnDescription, + this.contextDescription, + Anchor.roughlyBelow(anchorDescription), + this.rightAnchor, + ); + } + + completelyBelow(anchorDescription: string): EnterTarget { + return new EnterTarget( + this.parent, + this.value, + this.fieldDescription, + this.tableDescription, + this.rowContainingValue, + this.columnDescription, + this.contextDescription, + Anchor.completelyBelow(anchorDescription), + this.rightAnchor, + ); + } + + rightOf(anchorDescription: string): EnterTarget { + return new EnterTarget( + this.parent, + this.value, + this.fieldDescription, + this.tableDescription, + this.rowContainingValue, + this.columnDescription, + this.contextDescription, + this.belowAnchor, + Anchor.rightOf(anchorDescription), + ); + } + + and(): EnterTarget { + return this; + } + + add(): TestRigorSteps { + return this.parent.stepWith( + TestRigorSteps.renderEnterLinePublic( + this.value, + this.fieldDescription, + this.tableDescription, + this.rowContainingValue, + this.columnDescription, + this.contextDescription, + this.belowAnchor, + this.rightAnchor, + ), + ); + } +} + +class Anchor { + private constructor( + private readonly relationPrefix: string, + private readonly anchorDescription: string, + ) {} + + static below(anchorDescription: string): Anchor { + return new Anchor('below ', anchorDescription); + } + + static roughlyBelow(anchorDescription: string): Anchor { + return new Anchor('roughly below ', anchorDescription); + } + + static completelyBelow(anchorDescription: string): Anchor { + return new Anchor('completely below ', anchorDescription); + } + + static rightOf(anchorDescription: string): Anchor { + return new Anchor('to the right of ', anchorDescription); + } + + render(): string { + return this.relationPrefix + quotedSegment(this.anchorDescription); + } +} diff --git a/src/commons/application/commands/TestRigorValidations.ts b/src/commons/application/commands/TestRigorValidations.ts new file mode 100644 index 0000000..10b5e9c --- /dev/null +++ b/src/commons/application/commands/TestRigorValidations.ts @@ -0,0 +1,537 @@ +import type { TestRigorCommandDriver } from './TestRigorCommandDriver.js'; +import { TestRigorSteps } from './TestRigorSteps.js'; + +export class TestRigorValidations { + static validations(driver: TestRigorCommandDriver): TestRigorValidations { + return new TestRigorValidations(driver, TestRigorSteps.create()); + } + + static with(driver: TestRigorCommandDriver): TestRigorValidations { + return TestRigorValidations.validations(driver); + } + + private constructor( + private readonly driver: TestRigorCommandDriver, + private readonly steps: TestRigorSteps, + ) {} + + private withSteps(next: TestRigorSteps): TestRigorValidations { + return new TestRigorValidations(this.driver, next); + } + + and(): TestRigorValidations { + return this; + } + + checkPageContains(text: string): TestRigorValidations { + return this.withSteps(this.steps.checkPageContains(text)); + } + + checkPageDoesNotContain(text: string): TestRigorValidations { + return this.withSteps(this.steps.checkPageDoesNotContain(text)); + } + + checkPageContainsStoredValue(varName: string): TestRigorValidations { + return this.withSteps(this.steps.checkPageContainsStoredValue(varName)); + } + + checkPageContainsRegex(regex: string): TestRigorValidations { + return this.withSteps(this.steps.checkPageContainsRegex(regex)); + } + + checkPageDoesNotContainRegex(regex: string): TestRigorValidations { + return this.withSteps(this.steps.checkPageDoesNotContainRegex(regex)); + } + + checkPageContainsTemplate(template: string): TestRigorValidations { + return this.withSteps(this.steps.checkPageContainsTemplate(template)); + } + + checkPageDoesNotContainTemplate(template: string): TestRigorValidations { + return this.withSteps(this.steps.checkPageDoesNotContainTemplate(template)); + } + + checkPageDidNotChange(): TestRigorValidations { + return this.withSteps(this.steps.checkPageDidNotChange()); + } + + checkUrlContains(text: string): TestRigorValidations { + return this.withSteps(this.steps.checkUrlContains(text)); + } + + checkUrlDoesNotContain(text: string): TestRigorValidations { + return this.withSteps(this.steps.checkUrlDoesNotContain(text)); + } + + checkUrlStartsWith(prefix: string): TestRigorValidations { + return this.withSteps(this.steps.checkUrlStartsWith(prefix)); + } + + checkUrlDoesNotStartWith(prefix: string): TestRigorValidations { + return this.withSteps(this.steps.checkUrlDoesNotStartWith(prefix)); + } + + checkUrlEndsWith(suffix: string): TestRigorValidations { + return this.withSteps(this.steps.checkUrlEndsWith(suffix)); + } + + checkUrlDoesNotEndWith(suffix: string): TestRigorValidations { + return this.withSteps(this.steps.checkUrlDoesNotEndWith(suffix)); + } + + checkUrlIs(url: string): TestRigorValidations { + return this.withSteps(this.steps.checkUrlIs(url)); + } + + checkUrlIsNot(url: string): TestRigorValidations { + return this.withSteps(this.steps.checkUrlIsNot(url)); + } + + checkUrlMatchesRegex(regex: string): TestRigorValidations { + return this.withSteps(this.steps.checkUrlMatchesRegex(regex)); + } + + checkUrlDoesNotMatchRegex(regex: string): TestRigorValidations { + return this.withSteps(this.steps.checkUrlDoesNotMatchRegex(regex)); + } + + checkPageTitleIs(title: string): TestRigorValidations { + return this.withSteps(this.steps.checkPageTitleIs(title)); + } + + checkPageTitleContains(text: string): TestRigorValidations { + return this.withSteps(this.steps.checkPageTitleContains(text)); + } + + checkPageReturnCode(code: string): TestRigorValidations { + return this.withSteps(this.steps.checkPageReturnCode(code)); + } + + checkButtonDisabled(buttonDescription: string): TestRigorValidations { + return this.withSteps(this.steps.checkButtonDisabled(buttonDescription)); + } + + checkButtonEnabled(buttonDescription: string): TestRigorValidations { + return this.withSteps(this.steps.checkButtonEnabled(buttonDescription)); + } + + checkThatElementContains(elementDescription: string, text: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatElementContains(elementDescription, text)); + } + + checkThatElementDoesNotContain(elementDescription: string, text: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatElementDoesNotContain(elementDescription, text)); + } + + checkThatElementContainsStoredValue( + elementDescription: string, + varName: string, + ): TestRigorValidations { + return this.withSteps( + this.steps.checkThatElementContainsStoredValue(elementDescription, varName), + ); + } + + checkThatElementDoesNotContainStoredValue( + elementDescription: string, + varName: string, + ): TestRigorValidations { + return this.withSteps( + this.steps.checkThatElementDoesNotContainStoredValue(elementDescription, varName), + ); + } + + checkThatInputHasValue(elementDescription: string, value: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatInputHasValue(elementDescription, value)); + } + + checkThatInputHasStoredValue(elementDescription: string, varName: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatInputHasStoredValue(elementDescription, varName)); + } + + checkThatInputDoesNotHaveValue(elementDescription: string, value: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatInputDoesNotHaveValue(elementDescription, value)); + } + + checkThatInputDoesNotHaveStoredValue( + elementDescription: string, + varName: string, + ): TestRigorValidations { + return this.withSteps( + this.steps.checkThatInputDoesNotHaveStoredValue(elementDescription, varName), + ); + } + + checkThatCheckboxIsChecked(elementDescription: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatCheckboxIsChecked(elementDescription)); + } + + checkThatCheckboxIsUnchecked(elementDescription: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatCheckboxIsUnchecked(elementDescription)); + } + + checkThatElementIsEnabled(elementDescription: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatElementIsEnabled(elementDescription)); + } + + checkThatElementIsDisabled(elementDescription: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatElementIsDisabled(elementDescription)); + } + + checkThatElementIsVisible(elementDescription: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatElementIsVisible(elementDescription)); + } + + checkThatElementIsInvisible(elementDescription: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatElementIsInvisible(elementDescription)); + } + + checkThatElementIsClickable(elementDescription: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatElementIsClickable(elementDescription)); + } + + checkThatElementIsNotClickable(elementDescription: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatElementIsNotClickable(elementDescription)); + } + + checkThatSelectHasOptionSelected( + elementDescription: string, + option: string, + ): TestRigorValidations { + return this.withSteps(this.steps.checkThatSelectHasOptionSelected(elementDescription, option)); + } + + checkThatSelectDoesNotHaveOptionSelected( + elementDescription: string, + option: string, + ): TestRigorValidations { + return this.withSteps( + this.steps.checkThatSelectDoesNotHaveOptionSelected(elementDescription, option), + ); + } + + checkThatElementMatchesRegex(elementDescription: string, regex: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatElementMatchesRegex(elementDescription, regex)); + } + + checkThatElementDoesNotMatchRegex( + elementDescription: string, + regex: string, + ): TestRigorValidations { + return this.withSteps(this.steps.checkThatElementDoesNotMatchRegex(elementDescription, regex)); + } + + checkThatElementMatchesTemplate( + elementDescription: string, + template: string, + ): TestRigorValidations { + return this.withSteps(this.steps.checkThatElementMatchesTemplate(elementDescription, template)); + } + + checkThatElementDoesNotMatchTemplate( + elementDescription: string, + template: string, + ): TestRigorValidations { + return this.withSteps( + this.steps.checkThatElementDoesNotMatchTemplate(elementDescription, template), + ); + } + + checkThatElementHasAttribute( + elementDescription: string, + attributeName: string, + expectedValue: string, + ): TestRigorValidations { + return this.withSteps( + this.steps.checkThatElementHasAttribute(elementDescription, attributeName, expectedValue), + ); + } + + checkThatElementDoesNotHaveAttribute( + elementDescription: string, + attributeName: string, + ): TestRigorValidations { + return this.withSteps( + this.steps.checkThatElementDoesNotHaveAttribute(elementDescription, attributeName), + ); + } + + checkThatElementHasProperty( + elementDescription: string, + propertyName: string, + expectedValue: string, + ): TestRigorValidations { + return this.withSteps( + this.steps.checkThatElementHasProperty(elementDescription, propertyName, expectedValue), + ); + } + + checkThatElementDoesNotHaveProperty( + elementDescription: string, + propertyName: string, + ): TestRigorValidations { + return this.withSteps( + this.steps.checkThatElementDoesNotHaveProperty(elementDescription, propertyName), + ); + } + + checkThatElementHasCssClass(elementDescription: string, cssClass: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatElementHasCssClass(elementDescription, cssClass)); + } + + checkThatElementDoesNotHaveCssClass( + elementDescription: string, + cssClass: string, + ): TestRigorValidations { + return this.withSteps( + this.steps.checkThatElementDoesNotHaveCssClass(elementDescription, cssClass), + ); + } + + checkThatElementColorIs(elementDescription: string, color: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatElementColorIs(elementDescription, color)); + } + + checkThatElementColorIsNot(elementDescription: string, color: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatElementColorIsNot(elementDescription, color)); + } + + checkThatElementBackgroundColorIs( + elementDescription: string, + color: string, + ): TestRigorValidations { + return this.withSteps(this.steps.checkThatElementBackgroundColorIs(elementDescription, color)); + } + + checkThatElementBackgroundColorIsNot( + elementDescription: string, + color: string, + ): TestRigorValidations { + return this.withSteps( + this.steps.checkThatElementBackgroundColorIsNot(elementDescription, color), + ); + } + + checkThatElementCursorIs(elementDescription: string, cursor: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatElementCursorIs(elementDescription, cursor)); + } + + checkThatElementCursorIsNot(elementDescription: string, cursor: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatElementCursorIsNot(elementDescription, cursor)); + } + + checkThatElementHasLineStyle( + elementDescription: string, + lineStyle: string, + ): TestRigorValidations { + return this.withSteps(this.steps.checkThatElementHasLineStyle(elementDescription, lineStyle)); + } + + checkThatElementHasNotLineStyle( + elementDescription: string, + lineStyle: string, + ): TestRigorValidations { + return this.withSteps( + this.steps.checkThatElementHasNotLineStyle(elementDescription, lineStyle), + ); + } + + checkThatElementIsBlank(elementDescription: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatElementIsBlank(elementDescription)); + } + + checkThatElementIsNotBlank(elementDescription: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatElementIsNotBlank(elementDescription)); + } + + checkThatElementIsNull(elementDescription: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatElementIsNull(elementDescription)); + } + + checkThatElementIsNotNull(elementDescription: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatElementIsNotNull(elementDescription)); + } + + checkThatElementIsEqual(elementDescription: string, value: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatElementIsEqual(elementDescription, value)); + } + + checkThatElementIsNotEqual(elementDescription: string, value: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatElementIsNotEqual(elementDescription, value)); + } + + checkThatElementIsEqualAsNumber(elementDescription: string, value: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatElementIsEqualAsNumber(elementDescription, value)); + } + + checkThatElementIsNotEqualAsNumber( + elementDescription: string, + value: string, + ): TestRigorValidations { + return this.withSteps(this.steps.checkThatElementIsNotEqualAsNumber(elementDescription, value)); + } + + checkThatElementIsGreaterThan(elementDescription: string, value: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatElementIsGreaterThan(elementDescription, value)); + } + + checkThatElementIsGreaterThanOrEqualTo( + elementDescription: string, + value: string, + ): TestRigorValidations { + return this.withSteps( + this.steps.checkThatElementIsGreaterThanOrEqualTo(elementDescription, value), + ); + } + + checkThatElementIsLessThan(elementDescription: string, value: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatElementIsLessThan(elementDescription, value)); + } + + checkThatElementIsLessThanOrEqualTo( + elementDescription: string, + value: string, + ): TestRigorValidations { + return this.withSteps( + this.steps.checkThatElementIsLessThanOrEqualTo(elementDescription, value), + ); + } + + checkThatElementIsLexicographicallyBefore( + elementDescription: string, + value: string, + ): TestRigorValidations { + return this.withSteps( + this.steps.checkThatElementIsLexicographicallyBefore(elementDescription, value), + ); + } + + checkThatElementIsLexicographicallyBeforeOrSame( + elementDescription: string, + value: string, + ): TestRigorValidations { + return this.withSteps( + this.steps.checkThatElementIsLexicographicallyBeforeOrSame(elementDescription, value), + ); + } + + checkThatElementIsLexicographicallyAfter( + elementDescription: string, + value: string, + ): TestRigorValidations { + return this.withSteps( + this.steps.checkThatElementIsLexicographicallyAfter(elementDescription, value), + ); + } + + checkThatElementIsLexicographicallyAfterOrSame( + elementDescription: string, + value: string, + ): TestRigorValidations { + return this.withSteps( + this.steps.checkThatElementIsLexicographicallyAfterOrSame(elementDescription, value), + ); + } + + checkThatStoredValueItselfContains(varName: string, text: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatStoredValueItselfContains(varName, text)); + } + + checkThatStoredValueItselfDoesNotContain(varName: string, text: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatStoredValueItselfDoesNotContain(varName, text)); + } + + checkThatStoredValueItselfMatchesRegex(varName: string, regex: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatStoredValueItselfMatchesRegex(varName, regex)); + } + + checkThatStoredValueItselfDoesNotMatchRegex( + varName: string, + regex: string, + ): TestRigorValidations { + return this.withSteps(this.steps.checkThatStoredValueItselfDoesNotMatchRegex(varName, regex)); + } + + checkThatStoredValueItselfMatchesTemplate( + varName: string, + template: string, + ): TestRigorValidations { + return this.withSteps(this.steps.checkThatStoredValueItselfMatchesTemplate(varName, template)); + } + + checkThatStoredValueItselfDoesNotMatchTemplate( + varName: string, + template: string, + ): TestRigorValidations { + return this.withSteps( + this.steps.checkThatStoredValueItselfDoesNotMatchTemplate(varName, template), + ); + } + + checkThatStoredValueItselfIsBlank(varName: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatStoredValueItselfIsBlank(varName)); + } + + checkThatStoredValueItselfIsNotBlank(varName: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatStoredValueItselfIsNotBlank(varName)); + } + + checkThatStoredValueItselfIsNull(varName: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatStoredValueItselfIsNull(varName)); + } + + checkThatStoredValueItselfIsNotNull(varName: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatStoredValueItselfIsNotNull(varName)); + } + + checkThatStoredValueItselfIsEqual(varName: string, value: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatStoredValueItselfIsEqual(varName, value)); + } + + checkThatStoredValueItselfIsNotEqual(varName: string, value: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatStoredValueItselfIsNotEqual(varName, value)); + } + + checkThatStoredValueItselfIsEqualAsNumber(varName: string, value: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatStoredValueItselfIsEqualAsNumber(varName, value)); + } + + checkThatStoredValueItselfIsNotEqualAsNumber( + varName: string, + value: string, + ): TestRigorValidations { + return this.withSteps(this.steps.checkThatStoredValueItselfIsNotEqualAsNumber(varName, value)); + } + + checkThatStoredValueItselfIsGreaterThan(varName: string, value: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatStoredValueItselfIsGreaterThan(varName, value)); + } + + checkThatStoredValueItselfIsGreaterThanOrEqualTo( + varName: string, + value: string, + ): TestRigorValidations { + return this.withSteps( + this.steps.checkThatStoredValueItselfIsGreaterThanOrEqualTo(varName, value), + ); + } + + checkThatStoredValueItselfIsLessThan(varName: string, value: string): TestRigorValidations { + return this.withSteps(this.steps.checkThatStoredValueItselfIsLessThan(varName, value)); + } + + checkThatStoredValueItselfIsLessThanOrEqualTo( + varName: string, + value: string, + ): TestRigorValidations { + return this.withSteps(this.steps.checkThatStoredValueItselfIsLessThanOrEqualTo(varName, value)); + } + + async execute(): Promise { + await this.driver.executePrompt(this.steps.build()); + } + + buildPrompt(): string { + return this.steps.build(); + } +} diff --git a/src/commons/application/context/TestRigorContext.ts b/src/commons/application/context/TestRigorContext.ts new file mode 100644 index 0000000..6b1fd81 --- /dev/null +++ b/src/commons/application/context/TestRigorContext.ts @@ -0,0 +1,27 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + +const testIdStorage = new AsyncLocalStorage(); + +export class TestRigorContext { + private constructor() {} + + static setTestContext(testId: string | null | undefined): void { + setIfPresent(testId); + } + + static clearTestContext(): void { + testIdStorage.enterWith(undefined); + } + + static getTestId(): string | undefined { + return testIdStorage.getStore(); + } +} + +function setIfPresent(value: string | null | undefined): void { + if (value == null || value.trim() === '') { + testIdStorage.enterWith(undefined); + return; + } + testIdStorage.enterWith(value); +} diff --git a/src/commons/application/grpc/DriverCommandValueCodec.test.ts b/src/commons/application/grpc/DriverCommandValueCodec.test.ts new file mode 100644 index 0000000..3e92fe5 --- /dev/null +++ b/src/commons/application/grpc/DriverCommandValueCodec.test.ts @@ -0,0 +1,37 @@ +import protobuf from 'protobufjs'; +import { describe, expect, it } from 'vitest'; +import { DriverCommandValueCodec } from './DriverCommandValueCodec.js'; +import { + TESTRIGOR_GRPC_PROTO_PACKAGE, + TESTRIGOR_GRPC_SCHEMA, +} from '../../grpc/testrigor-grpc-schema.js'; +import type { DriverCommandResponse } from '../../grpc/testrigor-grpc.js'; + +describe('DriverCommandValueCodec wire encoding', () => { + it('serializes valuePayload on the wire using proto-loader field names', () => { + const root = protobuf.parse(TESTRIGOR_GRPC_SCHEMA).root; + root.resolveAll(); + const ClientMessage = root.lookupType(`${TESTRIGOR_GRPC_PROTO_PACKAGE}.ClientMessage`); + + const response: DriverCommandResponse = { + sessionId: 'session-1', + status: 0, + state: '', + }; + DriverCommandValueCodec.setEncodedValue(response, '"http://example.com"'); + + const encoded = ClientMessage.encode({ + id: 'cmd-1', + payload: { response }, + }).finish(); + const decoded = ClientMessage.toObject(ClientMessage.decode(encoded), { + defaults: true, + enums: String, + }); + + expect(Buffer.from(decoded.payload.response.valuePayload).toString('utf8')).toBe( + '"http://example.com"', + ); + expect(decoded.payload.response.valuePayload.length).toBeGreaterThan(0); + }); +}); diff --git a/src/commons/application/grpc/DriverCommandValueCodec.ts b/src/commons/application/grpc/DriverCommandValueCodec.ts new file mode 100644 index 0000000..0649dae --- /dev/null +++ b/src/commons/application/grpc/DriverCommandValueCodec.ts @@ -0,0 +1,31 @@ +import { gzipSync } from 'node:zlib'; +import type { DriverCommandResponse } from '../../grpc/testrigor-grpc.js'; + +const GZIP_MIN_BYTES = 512; + +/** + * Encodes driver command return values as valuePayload + valueEncoding for gRPC. + */ +export class DriverCommandValueCodec { + private constructor() {} + + static setEncodedValue( + builder: DriverCommandResponse, + jsonUtf8OrNull: string | null | undefined, + ): void { + const s = jsonUtf8OrNull ?? ''; + const utf8 = Buffer.from(s, 'utf8'); + if (utf8.length === 0) { + builder.valueEncoding = 'JSON_UTF8'; + builder.valuePayload = Buffer.alloc(0); + return; + } + if (utf8.length >= GZIP_MIN_BYTES) { + builder.valueEncoding = 'GZIP_JSON_UTF8'; + builder.valuePayload = gzipSync(utf8); + } else { + builder.valueEncoding = 'JSON_UTF8'; + builder.valuePayload = utf8; + } + } +} diff --git a/src/commons/application/grpc/DriverInfo.ts b/src/commons/application/grpc/DriverInfo.ts new file mode 100644 index 0000000..65f2f36 --- /dev/null +++ b/src/commons/application/grpc/DriverInfo.ts @@ -0,0 +1,7 @@ +export class DriverInfo { + constructor( + readonly sessionId: string, + readonly capabilitiesJson: string, + readonly testId?: string | null, + ) {} +} diff --git a/src/commons/application/grpc/ExtensionGrpcActions.ts b/src/commons/application/grpc/ExtensionGrpcActions.ts new file mode 100644 index 0000000..e87b4e8 --- /dev/null +++ b/src/commons/application/grpc/ExtensionGrpcActions.ts @@ -0,0 +1,18 @@ +/** + * gRPC executeAction names and parameter keys — must match the testRigor extension server. + */ +export class ExtensionGrpcActions { + static readonly SAVE_LOCATOR_SNAPSHOT = 'saveSeleniumLocator'; + static readonly GET_HEALED_LOCATOR = 'getHealedSeleniumLocator'; + static readonly RECORD_LOCATOR_MAPPING = 'recordSeleniumLocatorMapping'; + + static readonly KEY_LOCATOR_TYPE = 'locatorType'; + static readonly KEY_LOCATOR_VALUE = 'locatorValue'; + + static readonly KEY_ORIGINAL_LOCATOR_TYPE = 'originalLocatorType'; + static readonly KEY_ORIGINAL_LOCATOR_VALUE = 'originalLocatorValue'; + static readonly KEY_HEALED_LOCATOR_TYPE = 'healedLocatorType'; + static readonly KEY_HEALED_LOCATOR_VALUE = 'healedLocatorValue'; + + private constructor() {} +} diff --git a/src/commons/application/grpc/GrpcDriverAdapter.ts b/src/commons/application/grpc/GrpcDriverAdapter.ts new file mode 100644 index 0000000..146dc19 --- /dev/null +++ b/src/commons/application/grpc/GrpcDriverAdapter.ts @@ -0,0 +1,24 @@ +import type { ClientMessage } from '../../grpc/testrigor-grpc.js'; +import type { DriverInfo } from './DriverInfo.js'; + +export interface GrpcDriverAdapter { + /** + * @param testIdOverride if non-null and non-blank, used as testId for the gRPC Driver payload; + * otherwise implementations may fall back to TestRigorContext + */ + getDriverInfo(testIdOverride?: string | null): DriverInfo | Promise; + + executeCommand( + messageId: string, + sessionId: string, + commandName: string, + parametersJson: string, + responseWriter: ClientMessageWriter, + ): void | Promise; + + resolveElementFromXpath(xpath: string): unknown | Promise; +} + +export interface ClientMessageWriter { + write(message: ClientMessage): void; +} diff --git a/src/commons/application/grpc/GrpcEndpointConfig.ts b/src/commons/application/grpc/GrpcEndpointConfig.ts new file mode 100644 index 0000000..f7b8415 --- /dev/null +++ b/src/commons/application/grpc/GrpcEndpointConfig.ts @@ -0,0 +1,72 @@ +export const DEFAULT_GRPC_HOST = 'selenium-extension.testrigor.com'; +export const DEFAULT_GRPC_PORT = 443; + +export interface TestRigorGrpcConfig { + uri?: string; + port?: number; + useTls?: boolean; +} + +/** HOCON-style config keys accepted by {@link GrpcEndpointConfig.fromConfig}. */ +export interface GrpcHoconConfig { + 'testrigor.grpc.uri'?: string; + 'testrigor.grpc.port'?: number; + 'testrigor.grpc.use-tls'?: boolean; +} + +/** + * Immutable gRPC endpoint for the testRigor extension service. + */ +export class GrpcEndpointConfig { + readonly host: string; + readonly port: number; + /** When null, TLS matches legacy behavior: enabled for port 443, plaintext otherwise. */ + readonly useTls: boolean | null; + + private constructor(hostValue: string, portValue: number, useTlsValue: boolean | null) { + if (!hostValue || hostValue.trim() === '') { + throw new Error('gRPC host must not be blank'); + } + if (portValue < 1 || portValue > 65535) { + throw new Error(`gRPC port must be between 1 and 65535: ${portValue}`); + } + this.host = hostValue.trim(); + this.port = portValue; + this.useTls = useTlsValue; + } + + static of( + hostValue: string, + portValue: number, + useTlsValue: boolean | null = null, + ): GrpcEndpointConfig { + return new GrpcEndpointConfig(hostValue, portValue, useTlsValue); + } + + static fromConfig(config: GrpcHoconConfig | TestRigorGrpcConfig): GrpcEndpointConfig { + if ('host' in config || 'uri' in config) { + const simple = config as TestRigorGrpcConfig; + const hostValue = simple.uri?.trim() || DEFAULT_GRPC_HOST; + const portValue = simple.port ?? DEFAULT_GRPC_PORT; + return new GrpcEndpointConfig(hostValue, portValue, simple.useTls ?? null); + } + + const hocon = config as GrpcHoconConfig; + const uri = hocon['testrigor.grpc.uri'] ?? ''; + const hostValue = uri.trim() === '' ? DEFAULT_GRPC_HOST : uri.trim(); + const portValue = hocon['testrigor.grpc.port'] ?? DEFAULT_GRPC_PORT; + const useTlsValue = hocon['testrigor.grpc.use-tls'] ?? null; + return new GrpcEndpointConfig(hostValue, portValue, useTlsValue); + } + + useTransportSecurity(): boolean { + return GrpcEndpointConfig.resolveUseTls(this.port, this.useTls); + } + + static resolveUseTls(portValue: number, useTlsOverride: boolean | null | undefined): boolean { + if (useTlsOverride != null) { + return useTlsOverride; + } + return portValue === 443; + } +} diff --git a/src/commons/application/grpc/TestRigorGrpcClient.ts b/src/commons/application/grpc/TestRigorGrpcClient.ts new file mode 100644 index 0000000..1c7b7b7 --- /dev/null +++ b/src/commons/application/grpc/TestRigorGrpcClient.ts @@ -0,0 +1,574 @@ +import { randomUUID } from 'node:crypto'; +import * as grpc from '@grpc/grpc-js'; +import { status as GrpcStatus } from '@grpc/grpc-js'; +import { TestRigorContext } from '../context/TestRigorContext.js'; +import { GrpcActionExecutionException } from '../../infrastructure/exceptions/GrpcActionExecutionException.js'; +import { GrpcInternalException } from '../../infrastructure/exceptions/GrpcInternalException.js'; +import { GrpcInvalidArgumentException } from '../../infrastructure/exceptions/GrpcInvalidArgumentException.js'; +import { GrpcIssuesDetectedException } from '../../infrastructure/exceptions/GrpcIssuesDetectedException.js'; +import { GrpcMissingActionPayloadException } from '../../infrastructure/exceptions/GrpcMissingActionPayloadException.js'; +import { GrpcNonRetryableTransportException } from '../../infrastructure/exceptions/GrpcNonRetryableTransportException.js'; +import { GrpcNotFoundException } from '../../infrastructure/exceptions/GrpcNotFoundException.js'; +import { GrpcRetryableTransportException } from '../../infrastructure/exceptions/GrpcRetryableTransportException.js'; +import { GrpcServerStatusException } from '../../infrastructure/exceptions/GrpcServerStatusException.js'; +import { GrpcStreamClosedException } from '../../infrastructure/exceptions/GrpcStreamClosedException.js'; +import { GrpcUnsupportedActionException } from '../../infrastructure/exceptions/GrpcUnsupportedActionException.js'; +import { GrpcCode, grpcCodeFromNumber } from '../../infrastructure/exceptions/GrpcCode.js'; +import type { TestRigorExtensionException } from '../../infrastructure/exceptions/TestRigorExtensionException.js'; +import { + createTestRigorServiceClient, + type ClientMessage, + type ServerMessage, + type TestRigorServiceClient, +} from '../../grpc/testrigor-grpc.js'; +import type { GrpcDriverAdapter, ClientMessageWriter } from './GrpcDriverAdapter.js'; +import type { DriverInfo } from './DriverInfo.js'; +import { GrpcEndpointConfig } from './GrpcEndpointConfig.js'; + +const API_TOKEN_KEY = 'api-token'; + +type PendingFuture = { + resolve: (value: unknown) => void; + reject: (reason?: unknown) => void; +}; + +export class TestRigorGrpcClient implements AsyncDisposable { + private readonly client: TestRigorServiceClient; + private readonly futures = new Map(); + private readonly adapter: GrpcDriverAdapter; + private readonly apiToken: string; + private clientMessageStream: grpc.ClientDuplexStream | null = null; + private clientStreamClosed = true; + private firstFailureLogged = false; + private activeOperation = 'unknown'; + private activeMessageId = ''; + private activeDriverInfo: DriverInfo | null = null; + + constructor(endpoint: GrpcEndpointConfig, adapter: GrpcDriverAdapter, apiToken: string) { + this.adapter = adapter; + this.apiToken = TestRigorGrpcClient.normalizeToken(apiToken); + const address = `${endpoint.host}:${endpoint.port}`; + const credentials = endpoint.useTransportSecurity() + ? grpc.credentials.createSsl() + : grpc.credentials.createInsecure(); + this.client = createTestRigorServiceClient(address, credentials); + } + + static normalizeToken(token: string | null | undefined): string { + return token == null ? '' : token.trim(); + } + + processServerMessage(serverMessage: ServerMessage): void { + const messageCase = resolveServerMessageCase(serverMessage); + if (messageCase === 'command') { + const command = serverMessage.command!; + const commandName = command.payload?.name ?? ''; + const writer: ClientMessageWriter = { + write: (message) => this.clientMessageStream?.write(message), + }; + void Promise.resolve( + this.adapter.executeCommand( + serverMessage.id ?? '', + command.sessionId ?? '', + commandName, + command.payload?.parametersJson ?? '', + writer, + ), + ).catch((error: unknown) => { + console.error( + `Driver command failed. command=${commandName}, messageId=${serverMessage.id ?? ''}`, + error, + ); + }); + return; + } + + if (messageCase === 'result') { + this.processResult(serverMessage); + return; + } + + if (messageCase === 'status') { + this.processStatus(serverMessage); + } + } + + private processStatus(serverMessage: ServerMessage): void { + const status = serverMessage.status!; + let errorMessage = status.message ?? ''; + let reason = ''; + for (const detail of status.details ?? []) { + if (!detail.type_url?.endsWith('/google.rpc.ErrorInfo')) { + continue; + } + try { + const decoded = detail.value?.toString('utf8') ?? ''; + const reasonMatch = decoded.match(/reason[\s\S]*?([A-Za-z][\w\s]+)/); + if (reasonMatch?.[1]) { + reason = reasonMatch[1].trim(); + } + if (!errorMessage.trim() && reason) { + errorMessage = reason; + } + } catch { + // Ignore unpack failures due to runtime/protobuf skew. + } + } + + const future = this.futures.get(serverMessage.id ?? ''); + if (future) { + this.futures.delete(serverMessage.id ?? ''); + future.reject( + this.toServerStatusException( + status, + errorMessage, + reason, + serverMessage.id ?? '', + this.activeOperation, + ), + ); + } + } + + private processResult(serverMessage: ServerMessage): void { + const future = this.futures.get(serverMessage.id ?? ''); + if (!future) { + return; + } + this.futures.delete(serverMessage.id ?? ''); + + const result = serverMessage.result!; + if (result.elementXpath != null && result.elementXpath !== '') { + Promise.resolve(this.adapter.resolveElementFromXpath(result.elementXpath)) + .then((element) => future.resolve(element)) + .catch((e) => future.reject(e)); + return; + } + if (result.stringValue != null) { + future.resolve(result.stringValue); + return; + } + if (result.boolValue != null) { + future.resolve(result.boolValue); + return; + } + if (result.jsonValue != null) { + future.resolve(result.jsonValue); + return; + } + if (result.value != null && result.value !== '') { + Promise.resolve(this.adapter.resolveElementFromXpath(result.value)) + .then((element) => future.resolve(element)) + .catch((e) => future.reject(e)); + return; + } + future.resolve(undefined); + } + + findByUserDescription(description: string): Promise { + const messageId = randomUUID(); + const promise = new Promise((resolve, reject) => { + this.futures.set(messageId, { resolve, reject }); + }); + this.activeOperation = 'findElement'; + this.activeMessageId = messageId; + this.activeDriverInfo = null; + this.firstFailureLogged = false; + this.clientMessageStream = this.client.findElement(this.createAuthMetadata()); + this.clientStreamClosed = false; + this.attachStreamHandlers(this.clientMessageStream); + + const capturedTestId = TestRigorContext.getTestId(); + setImmediate(() => { + void (async () => { + try { + const info = await this.adapter.getDriverInfo(capturedTestId); + this.activeDriverInfo = info; + this.logSendStart(messageId, info); + const clientMessage: ClientMessage = { + id: messageId, + payload: { + driver: this.buildDriver(info), + message: description, + }, + }; + this.clientMessageStream?.write(clientMessage); + } catch (e) { + this.abortClientStream(e); + } + })(); + }); + + return promise; + } + + executePrompt(prompt: string): Promise { + const messageId = randomUUID(); + const promise = new Promise((resolve, reject) => { + this.futures.set(messageId, { resolve: () => resolve(), reject }); + }); + this.activeOperation = 'executePrompt'; + this.activeMessageId = messageId; + this.activeDriverInfo = null; + this.firstFailureLogged = false; + this.clientMessageStream = this.client.executePrompt(this.createAuthMetadata()); + this.clientStreamClosed = false; + this.attachStreamHandlers(this.clientMessageStream); + + const capturedTestId = TestRigorContext.getTestId(); + setImmediate(() => { + void (async () => { + try { + const info = await this.adapter.getDriverInfo(capturedTestId); + this.activeDriverInfo = info; + this.logSendStart(messageId, info); + const clientMessage: ClientMessage = { + id: messageId, + payload: { + driver: this.buildDriver(info), + message: prompt, + }, + }; + this.clientMessageStream?.write(clientMessage); + } catch (e) { + this.abortClientStream(e); + } + })(); + }); + + return promise; + } + + executeAction(actionName: string, parametersJson: string | null | undefined): Promise { + const messageId = randomUUID(); + const promise = new Promise((resolve, reject) => { + this.futures.set(messageId, { resolve, reject }); + }); + this.activeOperation = 'executeAction'; + this.activeMessageId = messageId; + this.activeDriverInfo = null; + this.firstFailureLogged = false; + this.clientMessageStream = this.client.executeAction(this.createAuthMetadata()); + this.clientStreamClosed = false; + this.attachStreamHandlers(this.clientMessageStream); + + const capturedTestId = TestRigorContext.getTestId(); + setImmediate(() => { + void (async () => { + try { + const info = await this.adapter.getDriverInfo(capturedTestId); + this.activeDriverInfo = info; + this.logSendStart(messageId, info); + const clientMessage: ClientMessage = { + id: messageId, + payload: { + driver: this.buildDriver(info), + action: { + name: actionName, + parametersJson: parametersJson ?? '', + }, + }, + }; + this.clientMessageStream?.write(clientMessage); + } catch (e) { + this.abortClientStream(e); + } + })(); + }); + + return promise; + } + + async close(): Promise { + this.completeClientStream(); + this.client.close(); + await new Promise((resolve) => { + const deadline = Date.now() + 5000; + this.client.waitForReady(deadline, (err) => { + if (err) { + this.client.getChannel().getConnectivityState(true); + } + resolve(); + }); + }); + } + + async [Symbol.asyncDispose](): Promise { + await this.close(); + } + + private createAuthMetadata(): grpc.Metadata | undefined { + if (!this.apiToken) { + return undefined; + } + const metadata = new grpc.Metadata(); + metadata.set(API_TOKEN_KEY, this.apiToken); + return metadata; + } + + private attachStreamHandlers( + stream: grpc.ClientDuplexStream, + ): void { + stream.on('data', (serverMessage) => this.processServerMessage(serverMessage)); + stream.on('error', (throwable) => this.handleStreamFailure(throwable)); + stream.on('end', () => { + this.clientStreamClosed = true; + const failure = new GrpcStreamClosedException( + 'gRPC stream completed before all responses were received', + this.activeOperation, + this.activeMessageId, + ); + this.failAllPendingFutures(failure); + }); + } + + private handleStreamFailure(throwable: unknown): void { + this.clientStreamClosed = true; + const root = + throwable == null + ? new Error('Unknown gRPC stream failure') + : throwable instanceof Error + ? throwable + : new Error(String(throwable)); + const retryable = TestRigorGrpcClient.isRetryableTransportFailure(root); + this.logStreamFailure(root, retryable); + this.failAllPendingFutures(this.toTransportException(root, retryable)); + } + + private failAllPendingFutures(throwable: unknown): void { + if (this.futures.size === 0) { + return; + } + for (const future of this.futures.values()) { + future.reject(throwable); + } + this.futures.clear(); + } + + private logStreamFailure(throwable: Error, retryable: boolean): void { + const statusCode = TestRigorGrpcClient.grpcStatusCode(throwable); + const description = TestRigorGrpcClient.grpcStatusDescription(throwable); + const context = this.streamContext(); + if (!this.firstFailureLogged) { + this.firstFailureLogged = true; + console.error( + `gRPC stream failure. operation=${this.activeOperation}, messageId=${this.activeMessageId}, status=${statusCode}, retryable=${retryable}, description='${description ?? ''}'${context}`, + throwable, + ); + return; + } + console.debug( + `gRPC stream failure (suppressed duplicate). operation=${this.activeOperation}, messageId=${this.activeMessageId}, status=${statusCode}, retryable=${retryable}, description='${description ?? ''}'${context}`, + ); + } + + private logSendStart(messageId: string, info: DriverInfo | null): void { + console.debug( + `Sending gRPC request. operation=${this.activeOperation}, messageId=${messageId}, sessionId=${info?.sessionId ?? ''}, testId=${info?.testId ?? ''}`, + ); + } + + private buildFailureMessage(throwable: Error, retryable: boolean): string { + return `gRPC stream failed. operation=${this.activeOperation}, messageId=${this.activeMessageId}, status=${TestRigorGrpcClient.grpcStatusCode(throwable)}, retryable=${retryable}, description=${TestRigorGrpcClient.grpcStatusDescription(throwable)}`; + } + + private toTransportException(throwable: Error, retryable: boolean): TestRigorExtensionException { + const statusCode = TestRigorGrpcClient.grpcStatusCode(throwable); + const statusDescription = TestRigorGrpcClient.grpcStatusDescription(throwable); + const message = this.buildFailureMessage(throwable, retryable); + if (retryable) { + return new GrpcRetryableTransportException( + message, + throwable, + this.activeOperation, + this.activeMessageId, + statusCode, + statusDescription, + ); + } + return new GrpcNonRetryableTransportException( + message, + throwable, + this.activeOperation, + this.activeMessageId, + statusCode, + statusDescription, + ); + } + + private toServerStatusException( + status: { code?: number; message?: string }, + errorMessage: string, + reason: string, + messageId: string, + operation: string, + ): GrpcServerStatusException { + const code = grpcCodeFromNumber(status.code ?? GrpcCode.UNKNOWN); + const safeMessage = errorMessage ?? ''; + const safeReason = reason ?? ''; + switch (code) { + case GrpcCode.INVALID_ARGUMENT: + if (matchesReason(safeReason, 'Unsupported action')) { + return new GrpcUnsupportedActionException(safeMessage, safeReason, operation, messageId); + } + if (matchesReason(safeReason, 'Missing action payload')) { + return new GrpcMissingActionPayloadException( + safeMessage, + safeReason, + operation, + messageId, + ); + } + return new GrpcInvalidArgumentException(safeMessage, safeReason, operation, messageId); + case GrpcCode.NOT_FOUND: + return new GrpcNotFoundException(safeMessage, safeReason, operation, messageId); + case GrpcCode.INTERNAL: + if (matchesReason(safeReason, 'Issues detected')) { + return new GrpcIssuesDetectedException(safeMessage, safeReason, operation, messageId); + } + if (matchesReason(safeReason, 'Error when processing action')) { + return new GrpcActionExecutionException(safeMessage, safeReason, operation, messageId); + } + return new GrpcInternalException(safeMessage, safeReason, operation, messageId); + default: + return new GrpcServerStatusException(safeMessage, code, safeReason, operation, messageId); + } + } + + private streamContext(): string { + const info = this.activeDriverInfo; + if (!info) { + return ''; + } + return `, sessionId=${info.sessionId ?? ''}, testId=${info.testId ?? ''}`; + } + + static grpcStatusCode(throwable: unknown): string { + const serviceError = findCause(throwable, isServiceError); + if (!serviceError) { + return 'UNKNOWN'; + } + return GrpcStatus[serviceError.code] ?? 'UNKNOWN'; + } + + static grpcStatusDescription(throwable: unknown): string { + const serviceError = findCause(throwable, isServiceError); + if (!serviceError) { + return throwable instanceof Error ? String(throwable.message) : String(throwable); + } + return serviceError.details ?? ''; + } + + static isRetryableTransportFailure(throwable: unknown): boolean { + const serviceError = findCause(throwable, isServiceError); + if (!serviceError) { + return false; + } + + const statusCode = serviceError.code; + if (statusCode === GrpcStatus.CANCELLED || statusCode === GrpcStatus.UNAVAILABLE) { + return true; + } + if (statusCode !== GrpcStatus.INTERNAL) { + return false; + } + + const description = serviceError.details; + if (!description) { + return false; + } + const normalized = description.toLowerCase(); + return ( + normalized.includes('rst_stream') || + normalized.includes('http/2 error code: cancel') || + normalized.includes('stream closed') || + normalized.includes('channel shutdown') || + normalized.includes('call already cancelled') || + normalized.includes('client cancelled') + ); + } + + private completeClientStream(): void { + const observer = this.clientMessageStream; + if (!observer || this.clientStreamClosed) { + return; + } + this.clientStreamClosed = true; + try { + observer.end(); + } catch { + // Client stream already half-closed. + } + } + + private abortClientStream(throwable: unknown): void { + const observer = this.clientMessageStream; + if (!observer || this.clientStreamClosed) { + return; + } + this.clientStreamClosed = true; + try { + observer.destroy(throwable instanceof Error ? throwable : new Error(String(throwable))); + } catch { + // Client stream already closed while aborting. + } + } + + private buildDriver(info: DriverInfo): NonNullable['driver'] { + const driver: NonNullable['driver'] = { + sessionId: info.sessionId, + capabilitiesJson: info.capabilitiesJson, + }; + if (info.testId != null && info.testId.trim() !== '') { + driver.testId = info.testId; + } + return driver; + } +} + +function matchesReason(actualReason: string, expectedReason: string): boolean { + return actualReason != null && actualReason.toLowerCase() === expectedReason.toLowerCase(); +} + +function resolveServerMessageCase( + serverMessage: ServerMessage, +): 'command' | 'result' | 'status' | undefined { + const explicit = (serverMessage as ServerMessage & { message?: string }).message; + if (explicit === 'command' || explicit === 'result' || explicit === 'status') { + return explicit; + } + if (serverMessage.status != null) { + return 'status'; + } + if (serverMessage.result != null) { + return 'result'; + } + if (serverMessage.command != null) { + return 'command'; + } + return undefined; +} + +function isServiceError(value: unknown): value is grpc.ServiceError { + return ( + typeof value === 'object' && + value != null && + 'code' in value && + typeof (value as grpc.ServiceError).code === 'number' + ); +} + +function findCause(throwable: unknown, predicate: (value: unknown) => value is T): T | null { + let current: unknown = throwable; + while (current != null) { + if (predicate(current)) { + return current; + } + if (current instanceof Error && current.cause) { + current = current.cause; + } else { + break; + } + } + return null; +} diff --git a/src/commons/application/grpc/driver-wire-encoding.test.ts b/src/commons/application/grpc/driver-wire-encoding.test.ts new file mode 100644 index 0000000..8068b7d --- /dev/null +++ b/src/commons/application/grpc/driver-wire-encoding.test.ts @@ -0,0 +1,31 @@ +import protobuf from 'protobufjs'; +import { describe, expect, it } from 'vitest'; +import { + TESTRIGOR_GRPC_PROTO_PACKAGE, + TESTRIGOR_GRPC_SCHEMA, +} from '../../grpc/testrigor-grpc-schema.js'; + +describe('Driver gRPC wire encoding', () => { + it('serializes testId on the wire using proto-loader field names', () => { + const root = protobuf.parse(TESTRIGOR_GRPC_SCHEMA).root; + root.resolveAll(); + const ClientMessage = root.lookupType(`${TESTRIGOR_GRPC_PROTO_PACKAGE}.ClientMessage`); + + const encoded = ClientMessage.encode({ + id: 'msg-1', + payload: { + driver: { + sessionId: 'session-1', + capabilitiesJson: '{}', + testId: 'test_self_healing_locator', + }, + }, + }).finish(); + const decoded = ClientMessage.toObject(ClientMessage.decode(encoded), { + defaults: true, + enums: String, + }); + + expect(decoded.payload.driver.testId).toBe('test_self_healing_locator'); + }); +}); diff --git a/src/commons/application/utils/JsonHelpers.ts b/src/commons/application/utils/JsonHelpers.ts new file mode 100644 index 0000000..97564e5 --- /dev/null +++ b/src/commons/application/utils/JsonHelpers.ts @@ -0,0 +1,21 @@ +import { TestRigorExtensionException } from '../../infrastructure/exceptions/TestRigorExtensionException.js'; + +export function serializeJson(value: unknown): string { + if (value == null) { + return ''; + } + try { + return JSON.stringify(value); + } catch (e) { + throw new TestRigorExtensionException('Failed to serialize value to JSON', { cause: e }); + } +} + +export function deserializeJson(json: string | null | undefined): T { + const effectiveJson = json == null || json.trim() === '' ? '{}' : json; + try { + return JSON.parse(effectiveJson) as T; + } catch (e) { + throw new TestRigorExtensionException('Failed to deserialize JSON', { cause: e }); + } +} diff --git a/src/commons/domain/model/Action.ts b/src/commons/domain/model/Action.ts new file mode 100644 index 0000000..2eb223e --- /dev/null +++ b/src/commons/domain/model/Action.ts @@ -0,0 +1,9 @@ +import type { ActionType } from './ActionType.js'; +import type { Locator } from './Locator.js'; + +export class Action { + constructor( + readonly type: ActionType, + readonly locator: Locator, + ) {} +} diff --git a/src/commons/domain/model/ActionType.ts b/src/commons/domain/model/ActionType.ts new file mode 100644 index 0000000..9698fd2 --- /dev/null +++ b/src/commons/domain/model/ActionType.ts @@ -0,0 +1,5 @@ +export enum ActionType { + FIND = 'FIND', + CLICK = 'CLICK', + ENTER = 'ENTER', +} diff --git a/src/commons/domain/model/Locator.ts b/src/commons/domain/model/Locator.ts new file mode 100644 index 0000000..7f015bc --- /dev/null +++ b/src/commons/domain/model/Locator.ts @@ -0,0 +1,8 @@ +import type { LocatorType } from './LocatorType.js'; + +export class Locator { + constructor( + readonly type: LocatorType, + readonly value: string, + ) {} +} diff --git a/src/commons/domain/model/LocatorType.ts b/src/commons/domain/model/LocatorType.ts new file mode 100644 index 0000000..2c9b01c --- /dev/null +++ b/src/commons/domain/model/LocatorType.ts @@ -0,0 +1,19 @@ +export enum LocatorType { + XPATH = 'xpath', + ID = 'id', + CLASS = 'class', + NAME = 'name', + TAG_NAME = 'tag_name', + CSS_SELECTOR = 'css_selector', + LINK_TEXT = 'link_text', + PARTIAL_LINK_TEXT = 'partial_link_text', + USER_DESCRIPTION = 'user_description', +} + +export function locatorTypeFromName(name: string): LocatorType { + const byValue = Object.values(LocatorType).find((v) => v === name); + if (byValue) { + return byValue; + } + return LocatorType[name as keyof typeof LocatorType]; +} diff --git a/src/commons/grpc/testrigor-grpc-schema.ts b/src/commons/grpc/testrigor-grpc-schema.ts new file mode 100644 index 0000000..0a9aab6 --- /dev/null +++ b/src/commons/grpc/testrigor-grpc-schema.ts @@ -0,0 +1,92 @@ +/** Protobuf package name on the wire (do not rename — server contract). */ +export const TESTRIGOR_GRPC_PROTO_PACKAGE = 'com.testrigor.seleniumextension.grpc.lib'; + +/** Embedded gRPC schema — wire-compatible with the testRigor extension service. */ +export const TESTRIGOR_GRPC_SCHEMA = ` +syntax = "proto3"; + +package com.testrigor.seleniumextension.grpc.lib; + +message ProtobufAny { + string type_url = 1; + bytes value = 2; +} + +message RpcStatus { + int32 code = 1; + string message = 2; + repeated ProtobufAny details = 3; +} + +service TestRigorService { + rpc findElement (stream ClientMessage) returns (stream ServerMessage) {} + rpc executePrompt (stream ClientMessage) returns (stream ServerMessage) {} + rpc executeAction (stream ClientMessage) returns (stream ServerMessage) {} +} + +message DriverCommand { + string sessionId = 1; + DriverCommandPayload payload = 2; +} + +message DriverCommandPayload { + string name = 1; + string parametersJson = 2; +} + +enum ValueEncoding { + VALUE_ENCODING_UNSPECIFIED = 0; + JSON_UTF8 = 1; + GZIP_JSON_UTF8 = 2; +} + +message DriverCommandResponse { + ValueEncoding value_encoding = 1; + bytes value_payload = 2; + string sessionId = 3; + int32 status = 4; + string state = 5; +} + +message Driver { + string sessionId = 1; + optional string capabilitiesJson = 2; + optional string test_id = 3; +} + +message ClientMessagePayload { + optional DriverCommandResponse response = 1; + optional Driver driver = 2; + optional string message = 3; + optional ActionRequest action = 4; +} + +message ClientMessage { + string id = 1; + ClientMessagePayload payload = 3; +} + +message ActionRequest { + string name = 1; + string parametersJson = 2; +} + +message Result { + optional string value = 1 [deprecated = true]; + oneof typedValue { + string elementXpath = 2; + string stringValue = 3; + bool boolValue = 4; + string jsonValue = 5; + } +} + +message ServerMessage { + string id = 1; + oneof message { + DriverCommand command = 2; + Result result = 3; + RpcStatus status = 4; + } +} +`; diff --git a/src/commons/grpc/testrigor-grpc.ts b/src/commons/grpc/testrigor-grpc.ts new file mode 100644 index 0000000..4a7ef7e --- /dev/null +++ b/src/commons/grpc/testrigor-grpc.ts @@ -0,0 +1,139 @@ +import * as grpc from '@grpc/grpc-js'; +import * as protoLoader from '@grpc/proto-loader'; +import protobuf from 'protobufjs'; +import { TESTRIGOR_GRPC_SCHEMA } from './testrigor-grpc-schema.js'; + +const PROTO_LOADER_OPTIONS = { + keepCase: true, + longs: String, + enums: String, + defaults: true, + oneofs: true, +} as const; + +export type ValueEncoding = 'VALUE_ENCODING_UNSPECIFIED' | 'JSON_UTF8' | 'GZIP_JSON_UTF8'; + +export interface DriverCommandPayload { + name?: string; + parametersJson?: string; +} + +export interface DriverCommand { + sessionId?: string; + payload?: DriverCommandPayload; +} + +export interface DriverCommandResponse { + valueEncoding?: ValueEncoding; + valuePayload?: Buffer; + sessionId?: string; + status?: number; + state?: string; +} + +export interface Driver { + sessionId?: string; + capabilitiesJson?: string; + testId?: string; +} + +export interface ActionRequest { + name?: string; + parametersJson?: string; +} + +export interface ClientMessagePayload { + response?: DriverCommandResponse; + driver?: Driver; + message?: string; + action?: ActionRequest; +} + +export interface ClientMessage { + id?: string; + payload?: ClientMessagePayload; +} + +export interface Result { + value?: string; + elementXpath?: string; + stringValue?: string; + boolValue?: boolean; + jsonValue?: string; +} + +export interface RpcStatus { + code?: number; + message?: string; + details?: Array<{ type_url?: string; value?: Buffer }>; +} + +export interface ServerMessage { + id?: string; + command?: DriverCommand; + result?: Result; + status?: RpcStatus; +} + +export type TestRigorServiceClient = grpc.Client & + Pick< + grpc.Client, + | 'close' + | 'getChannel' + | 'waitForReady' + | 'makeUnaryRequest' + | 'makeClientStreamRequest' + | 'makeServerStreamRequest' + | 'makeBidiStreamRequest' + > & { + findElement: ( + metadata?: grpc.Metadata, + options?: Partial, + ) => grpc.ClientDuplexStream; + executePrompt: ( + metadata?: grpc.Metadata, + options?: Partial, + ) => grpc.ClientDuplexStream; + executeAction: ( + metadata?: grpc.Metadata, + options?: Partial, + ) => grpc.ClientDuplexStream; + }; + +interface TestRigorProtoPackage { + com: { + testrigor: { + seleniumextension: { + grpc: { + lib: { + TestRigorService: grpc.ServiceClientConstructor; + }; + }; + }; + }; + }; +} + +function loadPackageDefinition(): protoLoader.PackageDefinition { + const root = protobuf.parse(TESTRIGOR_GRPC_SCHEMA).root; + if (root == null) { + throw new Error('Failed to parse embedded TestRigor gRPC schema'); + } + root.resolveAll(); + return protoLoader.fromJSON(root.toJSON(), PROTO_LOADER_OPTIONS); +} + +const packageDefinition = loadPackageDefinition(); +const protoDescriptor = grpc.loadPackageDefinition( + packageDefinition, +) as unknown as TestRigorProtoPackage; + +const TestRigorService = protoDescriptor.com.testrigor.seleniumextension.grpc.lib.TestRigorService; + +export function createTestRigorServiceClient( + address: string, + credentials: grpc.ChannelCredentials, + options?: grpc.ClientOptions, +): TestRigorServiceClient { + return new TestRigorService(address, credentials, options) as unknown as TestRigorServiceClient; +} diff --git a/src/commons/infrastructure/exceptions/GrpcActionExecutionException.ts b/src/commons/infrastructure/exceptions/GrpcActionExecutionException.ts new file mode 100644 index 0000000..d687f1a --- /dev/null +++ b/src/commons/infrastructure/exceptions/GrpcActionExecutionException.ts @@ -0,0 +1,8 @@ +import { GrpcInternalException } from './GrpcInternalException.js'; + +export class GrpcActionExecutionException extends GrpcInternalException { + constructor(message: string, reason: string, operation: string, messageId: string) { + super(message, reason, operation, messageId); + this.name = 'GrpcActionExecutionException'; + } +} diff --git a/src/commons/infrastructure/exceptions/GrpcClientException.ts b/src/commons/infrastructure/exceptions/GrpcClientException.ts new file mode 100644 index 0000000..c2aa75f --- /dev/null +++ b/src/commons/infrastructure/exceptions/GrpcClientException.ts @@ -0,0 +1,8 @@ +import { TestRigorExtensionException } from './TestRigorExtensionException.js'; + +export class GrpcClientException extends TestRigorExtensionException { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'GrpcClientException'; + } +} diff --git a/src/commons/infrastructure/exceptions/GrpcCode.ts b/src/commons/infrastructure/exceptions/GrpcCode.ts new file mode 100644 index 0000000..a823956 --- /dev/null +++ b/src/commons/infrastructure/exceptions/GrpcCode.ts @@ -0,0 +1,24 @@ +/** Mirrors google.rpc.Code numeric values used by the gRPC server. */ +export enum GrpcCode { + OK = 0, + CANCELLED = 1, + UNKNOWN = 2, + INVALID_ARGUMENT = 3, + NOT_FOUND = 5, + INTERNAL = 13, + UNAVAILABLE = 14, +} + +const CODE_BY_NUMBER: Record = { + 0: GrpcCode.OK, + 1: GrpcCode.CANCELLED, + 2: GrpcCode.UNKNOWN, + 3: GrpcCode.INVALID_ARGUMENT, + 5: GrpcCode.NOT_FOUND, + 13: GrpcCode.INTERNAL, + 14: GrpcCode.UNAVAILABLE, +}; + +export function grpcCodeFromNumber(code: number): GrpcCode { + return CODE_BY_NUMBER[code] ?? GrpcCode.UNKNOWN; +} diff --git a/src/commons/infrastructure/exceptions/GrpcInternalException.ts b/src/commons/infrastructure/exceptions/GrpcInternalException.ts new file mode 100644 index 0000000..d25de60 --- /dev/null +++ b/src/commons/infrastructure/exceptions/GrpcInternalException.ts @@ -0,0 +1,9 @@ +import { GrpcCode } from './GrpcCode.js'; +import { GrpcServerStatusException } from './GrpcServerStatusException.js'; + +export class GrpcInternalException extends GrpcServerStatusException { + constructor(message: string, reason: string, operation: string, messageId: string) { + super(message, GrpcCode.INTERNAL, reason, operation, messageId); + this.name = 'GrpcInternalException'; + } +} diff --git a/src/commons/infrastructure/exceptions/GrpcInvalidArgumentException.ts b/src/commons/infrastructure/exceptions/GrpcInvalidArgumentException.ts new file mode 100644 index 0000000..b99934c --- /dev/null +++ b/src/commons/infrastructure/exceptions/GrpcInvalidArgumentException.ts @@ -0,0 +1,9 @@ +import { GrpcCode } from './GrpcCode.js'; +import { GrpcServerStatusException } from './GrpcServerStatusException.js'; + +export class GrpcInvalidArgumentException extends GrpcServerStatusException { + constructor(message: string, reason: string, operation: string, messageId: string) { + super(message, GrpcCode.INVALID_ARGUMENT, reason, operation, messageId); + this.name = 'GrpcInvalidArgumentException'; + } +} diff --git a/src/commons/infrastructure/exceptions/GrpcIssuesDetectedException.ts b/src/commons/infrastructure/exceptions/GrpcIssuesDetectedException.ts new file mode 100644 index 0000000..f650919 --- /dev/null +++ b/src/commons/infrastructure/exceptions/GrpcIssuesDetectedException.ts @@ -0,0 +1,8 @@ +import { GrpcInternalException } from './GrpcInternalException.js'; + +export class GrpcIssuesDetectedException extends GrpcInternalException { + constructor(message: string, reason: string, operation: string, messageId: string) { + super(message, reason, operation, messageId); + this.name = 'GrpcIssuesDetectedException'; + } +} diff --git a/src/commons/infrastructure/exceptions/GrpcMissingActionPayloadException.ts b/src/commons/infrastructure/exceptions/GrpcMissingActionPayloadException.ts new file mode 100644 index 0000000..da78a8c --- /dev/null +++ b/src/commons/infrastructure/exceptions/GrpcMissingActionPayloadException.ts @@ -0,0 +1,8 @@ +import { GrpcInvalidArgumentException } from './GrpcInvalidArgumentException.js'; + +export class GrpcMissingActionPayloadException extends GrpcInvalidArgumentException { + constructor(message: string, reason: string, operation: string, messageId: string) { + super(message, reason, operation, messageId); + this.name = 'GrpcMissingActionPayloadException'; + } +} diff --git a/src/commons/infrastructure/exceptions/GrpcNonRetryableTransportException.ts b/src/commons/infrastructure/exceptions/GrpcNonRetryableTransportException.ts new file mode 100644 index 0000000..19da8a2 --- /dev/null +++ b/src/commons/infrastructure/exceptions/GrpcNonRetryableTransportException.ts @@ -0,0 +1,15 @@ +import { GrpcTransportException } from './GrpcTransportException.js'; + +export class GrpcNonRetryableTransportException extends GrpcTransportException { + constructor( + message: string, + cause: Error | null | undefined, + operation: string, + messageId: string, + grpcStatusCode: string, + grpcStatusDescription: string, + ) { + super(message, cause, false, operation, messageId, grpcStatusCode, grpcStatusDescription); + this.name = 'GrpcNonRetryableTransportException'; + } +} diff --git a/src/commons/infrastructure/exceptions/GrpcNotFoundException.ts b/src/commons/infrastructure/exceptions/GrpcNotFoundException.ts new file mode 100644 index 0000000..c0395ad --- /dev/null +++ b/src/commons/infrastructure/exceptions/GrpcNotFoundException.ts @@ -0,0 +1,9 @@ +import { GrpcCode } from './GrpcCode.js'; +import { GrpcServerStatusException } from './GrpcServerStatusException.js'; + +export class GrpcNotFoundException extends GrpcServerStatusException { + constructor(message: string, reason: string, operation: string, messageId: string) { + super(message, GrpcCode.NOT_FOUND, reason, operation, messageId); + this.name = 'GrpcNotFoundException'; + } +} diff --git a/src/commons/infrastructure/exceptions/GrpcRetryableTransportException.ts b/src/commons/infrastructure/exceptions/GrpcRetryableTransportException.ts new file mode 100644 index 0000000..c069b8b --- /dev/null +++ b/src/commons/infrastructure/exceptions/GrpcRetryableTransportException.ts @@ -0,0 +1,15 @@ +import { GrpcTransportException } from './GrpcTransportException.js'; + +export class GrpcRetryableTransportException extends GrpcTransportException { + constructor( + message: string, + cause: Error | null | undefined, + operation: string, + messageId: string, + grpcStatusCode: string, + grpcStatusDescription: string, + ) { + super(message, cause, true, operation, messageId, grpcStatusCode, grpcStatusDescription); + this.name = 'GrpcRetryableTransportException'; + } +} diff --git a/src/commons/infrastructure/exceptions/GrpcServerStatusException.ts b/src/commons/infrastructure/exceptions/GrpcServerStatusException.ts new file mode 100644 index 0000000..532311d --- /dev/null +++ b/src/commons/infrastructure/exceptions/GrpcServerStatusException.ts @@ -0,0 +1,24 @@ +import type { GrpcCode } from './GrpcCode.js'; +import { GrpcClientException } from './GrpcClientException.js'; + +export class GrpcServerStatusException extends GrpcClientException { + readonly grpcCode: GrpcCode; + readonly reason: string; + readonly operation: string; + readonly messageId: string; + + constructor( + message: string, + grpcCode: GrpcCode, + reason: string, + operation: string, + messageId: string, + ) { + super(message); + this.name = 'GrpcServerStatusException'; + this.grpcCode = grpcCode; + this.reason = reason; + this.operation = operation; + this.messageId = messageId; + } +} diff --git a/src/commons/infrastructure/exceptions/GrpcStreamClosedException.ts b/src/commons/infrastructure/exceptions/GrpcStreamClosedException.ts new file mode 100644 index 0000000..67e0880 --- /dev/null +++ b/src/commons/infrastructure/exceptions/GrpcStreamClosedException.ts @@ -0,0 +1,8 @@ +import { GrpcNonRetryableTransportException } from './GrpcNonRetryableTransportException.js'; + +export class GrpcStreamClosedException extends GrpcNonRetryableTransportException { + constructor(message: string, operation: string, messageId: string) { + super(message, null, operation, messageId, 'UNKNOWN', 'stream completed'); + this.name = 'GrpcStreamClosedException'; + } +} diff --git a/src/commons/infrastructure/exceptions/GrpcTransportException.ts b/src/commons/infrastructure/exceptions/GrpcTransportException.ts new file mode 100644 index 0000000..273afe7 --- /dev/null +++ b/src/commons/infrastructure/exceptions/GrpcTransportException.ts @@ -0,0 +1,27 @@ +import { GrpcClientException } from './GrpcClientException.js'; + +export class GrpcTransportException extends GrpcClientException { + readonly retryable: boolean; + readonly operation: string; + readonly messageId: string; + readonly grpcStatusCode: string; + readonly grpcStatusDescription: string; + + constructor( + message: string, + cause: Error | null | undefined, + retryable: boolean, + operation: string, + messageId: string, + grpcStatusCode: string, + grpcStatusDescription: string, + ) { + super(message, cause ? { cause } : undefined); + this.name = 'GrpcTransportException'; + this.retryable = retryable; + this.operation = operation; + this.messageId = messageId; + this.grpcStatusCode = grpcStatusCode; + this.grpcStatusDescription = grpcStatusDescription; + } +} diff --git a/src/commons/infrastructure/exceptions/GrpcUnsupportedActionException.ts b/src/commons/infrastructure/exceptions/GrpcUnsupportedActionException.ts new file mode 100644 index 0000000..8ac504b --- /dev/null +++ b/src/commons/infrastructure/exceptions/GrpcUnsupportedActionException.ts @@ -0,0 +1,8 @@ +import { GrpcInvalidArgumentException } from './GrpcInvalidArgumentException.js'; + +export class GrpcUnsupportedActionException extends GrpcInvalidArgumentException { + constructor(message: string, reason: string, operation: string, messageId: string) { + super(message, reason, operation, messageId); + this.name = 'GrpcUnsupportedActionException'; + } +} diff --git a/src/commons/infrastructure/exceptions/TestRigorExtensionException.ts b/src/commons/infrastructure/exceptions/TestRigorExtensionException.ts new file mode 100644 index 0000000..33fbc90 --- /dev/null +++ b/src/commons/infrastructure/exceptions/TestRigorExtensionException.ts @@ -0,0 +1,6 @@ +export class TestRigorExtensionException extends Error { + constructor(message?: string, options?: ErrorOptions) { + super(message, options); + this.name = 'TestRigorExtensionException'; + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..f33b324 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,74 @@ +import { + DEFAULT_GRPC_HOST, + DEFAULT_GRPC_PORT, + type GrpcHoconConfig, +} from './commons/application/grpc/GrpcEndpointConfig.js'; +import type { PlaywrightLaunchConfig } from './session/playwright-session.js'; + +/** + * Plugin configuration replacing Typesafe Config / application.properties. + * Keys mirror testRigor HOCON property names from application.properties. + */ +export interface PlaywrightPluginConfig extends GrpcHoconConfig { + 'playwright.browser'?: string; + 'playwright.headless'?: boolean; +} + +/** Default gRPC endpoint and Playwright launch settings. */ +export const DEFAULT_APPLICATION_PROPERTIES: PlaywrightPluginConfig = { + 'testrigor.grpc.uri': DEFAULT_GRPC_HOST, + 'testrigor.grpc.port': DEFAULT_GRPC_PORT, + 'playwright.browser': 'chromium', + 'playwright.headless': false, +}; + +const ENV_GRPC_URI = 'TESTRIGOR_GRPC_URI'; +const ENV_GRPC_PORT = 'TESTRIGOR_GRPC_PORT'; +const ENV_GRPC_USE_TLS = 'TESTRIGOR_GRPC_USE_TLS'; +const ENV_PLAYWRIGHT_BROWSER = 'TESTRIGOR_PLAYWRIGHT_BROWSER'; +const ENV_PLAYWRIGHT_HEADLESS = 'TESTRIGOR_PLAYWRIGHT_HEADLESS'; + +function loadConfigFromEnv(): Partial { + const config: Partial = {}; + const uri = process.env[ENV_GRPC_URI]; + if (uri != null && uri.trim() !== '') { + config['testrigor.grpc.uri'] = uri.trim(); + } + const port = process.env[ENV_GRPC_PORT]; + if (port != null && port.trim() !== '') { + const parsed = Number.parseInt(port, 10); + if (!Number.isNaN(parsed)) { + config['testrigor.grpc.port'] = parsed; + } + } + const useTls = process.env[ENV_GRPC_USE_TLS]; + if (useTls != null && useTls.trim() !== '') { + config['testrigor.grpc.use-tls'] = useTls.trim().toLowerCase() === 'true'; + } + const browser = process.env[ENV_PLAYWRIGHT_BROWSER]; + if (browser != null && browser.trim() !== '') { + config['playwright.browser'] = browser.trim(); + } + const headless = process.env[ENV_PLAYWRIGHT_HEADLESS]; + if (headless != null && headless.trim() !== '') { + config['playwright.headless'] = headless.trim().toLowerCase() === 'true'; + } + return config; +} + +export function loadDefaultConfig( + overrides?: Partial, +): PlaywrightPluginConfig { + return { + ...DEFAULT_APPLICATION_PROPERTIES, + ...loadConfigFromEnv(), + ...overrides, + }; +} + +export function toLaunchConfig(config: PlaywrightPluginConfig): PlaywrightLaunchConfig { + return { + browser: config['playwright.browser'] ?? 'chromium', + headless: config['playwright.headless'] ?? false, + }; +} diff --git a/src/elements/element-registry.test.ts b/src/elements/element-registry.test.ts new file mode 100644 index 0000000..c36149d --- /dev/null +++ b/src/elements/element-registry.test.ts @@ -0,0 +1,58 @@ +import type { ElementHandle, JSHandle, Locator } from 'playwright'; +import { describe, expect, it, vi } from 'vitest'; +import { ElementRegistry } from './element-registry.js'; +import { PlaywrightScriptResultConverter } from '../protocol/script-result-converter.js'; + +describe('ElementRegistry', () => { + it('resolve accepts legacy id parameter', () => { + const registry = new ElementRegistry(); + const locator = { elementHandle: vi.fn() } as unknown as Locator; + const id = String(registry.register(locator)[ElementRegistry.W3C_ELEMENT_KEY]); + + expect(registry.resolve({ id }).toLocator()).toBe(locator); + }); + + it('resolve accepts W3C element reference', () => { + const registry = new ElementRegistry(); + const locator = { elementHandle: vi.fn() } as unknown as Locator; + const registered = registry.register(locator); + + expect(registry.resolve(registered).toLocator()).toBe(locator); + }); + + it('resolve accepts nested parent reference', () => { + const registry = new ElementRegistry(); + const locator = { elementHandle: vi.fn() } as unknown as Locator; + const id = String(registry.register(locator)[ElementRegistry.W3C_ELEMENT_KEY]); + + const parameters = { + id: { [ElementRegistry.W3C_ELEMENT_KEY]: id }, + using: 'css selector', + value: '.child', + }; + + expect(registry.resolve(parameters).toLocator()).toBe(locator); + }); + + it('register elementHandle can be resolved', async () => { + const registry = new ElementRegistry(); + const handle = {} as ElementHandle; + const id = String(registry.register(handle)[ElementRegistry.W3C_ELEMENT_KEY]); + + await expect(registry.resolve({ id }).toElementHandle()).resolves.toBe(handle); + }); + + it('scriptResultConverter registers returned elements', async () => { + const registry = new ElementRegistry(); + const converter = new PlaywrightScriptResultConverter(registry); + const handle = {} as ElementHandle; + const jsHandle = { + asElement: vi.fn().mockReturnValue(handle), + } as unknown as JSHandle; + + const reference = (await converter.convert(jsHandle)) as Record; + + expect(reference).toHaveProperty(ElementRegistry.W3C_ELEMENT_KEY); + await expect(registry.resolve(reference).toElementHandle()).resolves.toBe(handle); + }); +}); diff --git a/src/elements/element-registry.ts b/src/elements/element-registry.ts new file mode 100644 index 0000000..b257c9a --- /dev/null +++ b/src/elements/element-registry.ts @@ -0,0 +1,69 @@ +import type { ElementHandle, Locator } from 'playwright'; +import { ElementNotFoundException } from '../errors/element-not-found-exception.js'; +import { ResolvedElement } from './resolved-element.js'; + +export class ElementRegistry { + static readonly W3C_ELEMENT_KEY = 'element-6066-11e4-a52e-4f735466cecf'; + private static readonly LEGACY_ELEMENT_KEY = 'ELEMENT'; + private static readonly ELEMENT_ID_PARAMETER = 'id'; + + private counter = 0; + private readonly elements = new Map(); + + register(locator: Locator): Record; + register(handle: ElementHandle): Record; + register(element: ResolvedElement): Record; + register(target: Locator | ElementHandle | ResolvedElement): Record { + if (target instanceof ResolvedElement) { + return this.store(target); + } + if ('elementHandle' in target) { + return this.store(ResolvedElement.fromLocator(target)); + } + return this.store(ResolvedElement.fromHandle(target)); + } + + private store(element: ResolvedElement): Record { + const id = String(++this.counter); + this.elements.set(id, element); + return { [ElementRegistry.W3C_ELEMENT_KEY]: id }; + } + + resolve(parameters: Record): ResolvedElement { + const elementId = ElementRegistry.extractElementId(parameters); + if (elementId == null) { + throw new Error('Missing element reference'); + } + const element = this.elements.get(elementId); + if (element == null) { + throw new ElementNotFoundException(`Element reference not found: ${elementId}`); + } + return element; + } + + static extractElementId(parameters: Record | null | undefined): string | null { + if (parameters == null || Object.keys(parameters).length === 0) { + return null; + } + const w3cId = parameters[ElementRegistry.W3C_ELEMENT_KEY]; + if (w3cId != null) { + return String(w3cId); + } + const legacyId = parameters[ElementRegistry.LEGACY_ELEMENT_KEY]; + if (legacyId != null) { + return String(legacyId); + } + const id = parameters[ElementRegistry.ELEMENT_ID_PARAMETER]; + if (id != null && typeof id === 'object' && !Array.isArray(id)) { + return ElementRegistry.extractElementId(id as Record); + } + if (id != null) { + return String(id); + } + return null; + } + + clear(): void { + this.elements.clear(); + } +} diff --git a/src/elements/playwright-element.ts b/src/elements/playwright-element.ts new file mode 100644 index 0000000..325720f --- /dev/null +++ b/src/elements/playwright-element.ts @@ -0,0 +1,42 @@ +import type { PlaywrightElementFinder } from '../locators/playwright-element-finder.js'; +import { PlaywrightLocator } from '../locators/playwright-locator.js'; +import type { ResolvedElement } from './resolved-element.js'; + +export class PlaywrightElement { + constructor( + private readonly resolved: ResolvedElement, + private readonly finder: PlaywrightElementFinder, + ) {} + + async click(): Promise { + await this.resolved.click(); + } + + async getText(): Promise { + return this.resolved.innerText(); + } + + async getAttribute(name: string): Promise { + return this.resolved.getAttribute(name); + } + + async isDisplayed(): Promise { + return this.resolved.isVisible(); + } + + async isEnabled(): Promise { + return this.resolved.isEnabled(); + } + + async isSelected(): Promise { + return this.resolved.isSelected(); + } + + async findElement(locator: PlaywrightLocator): Promise { + return this.finder.findElement(locator, this.resolved); + } + + async findElements(locator: PlaywrightLocator): Promise { + return this.finder.findElements(locator, this.resolved); + } +} diff --git a/src/elements/resolved-element.test.ts b/src/elements/resolved-element.test.ts new file mode 100644 index 0000000..9715574 --- /dev/null +++ b/src/elements/resolved-element.test.ts @@ -0,0 +1,31 @@ +import type { ElementHandle, Locator } from 'playwright'; +import { describe, expect, it, vi } from 'vitest'; +import { ResolvedElement } from './resolved-element.js'; + +describe('ResolvedElement sendKeys', () => { + it('type delegates to locator', async () => { + const locator = { + pressSequentially: vi.fn().mockResolvedValue(undefined), + } as unknown as Locator; + + await ResolvedElement.fromLocator(locator).type('Playwright extension test'); + + expect(locator.pressSequentially).toHaveBeenCalledWith('Playwright extension test'); + }); + + it('type delegates to element handle', async () => { + const handle = { + focus: vi.fn().mockResolvedValue(undefined), + ownerFrame: vi.fn().mockResolvedValue({ + page: vi.fn().mockReturnValue({ + keyboard: { type: vi.fn().mockResolvedValue(undefined) }, + }), + }), + } as unknown as ElementHandle; + + await ResolvedElement.fromHandle(handle).type('hello'); + + const page = (await handle.ownerFrame())!.page(); + expect(page!.keyboard.type).toHaveBeenCalledWith('hello'); + }); +}); diff --git a/src/elements/resolved-element.ts b/src/elements/resolved-element.ts new file mode 100644 index 0000000..49c3119 --- /dev/null +++ b/src/elements/resolved-element.ts @@ -0,0 +1,156 @@ +import type { ElementHandle, Locator } from 'playwright'; +import { ElementNotFoundException } from '../errors/element-not-found-exception.js'; +import { resolveLocator, toCssSelector } from '../locators/playwright-locator-resolver.js'; +import { + findChildByXpath, + isElementSelected, + readElementProperty, +} from '../protocol/page-eval-functions.js'; + +export class ResolvedElement { + private readonly locator: Locator | null; + private readonly handle: ElementHandle | null; + + private constructor(locator: Locator | null, handle: ElementHandle | null) { + this.locator = locator; + this.handle = handle; + } + + static fromLocator(locator: Locator): ResolvedElement { + return new ResolvedElement(locator, null); + } + + static fromHandle(handle: ElementHandle): ResolvedElement { + return new ResolvedElement(null, handle); + } + + toLocator(): Locator { + if (this.locator != null) { + return this.locator; + } + throw new ElementNotFoundException('Element handle cannot be used as a locator'); + } + + async toElementHandle(): Promise { + if (this.handle != null) { + return this.handle; + } + const elementHandle = await this.locator!.elementHandle(); + if (elementHandle == null) { + throw new ElementNotFoundException('Unable to resolve element handle'); + } + return elementHandle; + } + + async click(): Promise { + if (this.locator != null) { + await this.locator.click(); + return; + } + await this.handle!.click(); + } + + async fill(value: string): Promise { + if (this.locator != null) { + await this.locator.fill(value); + return; + } + await this.handle!.fill(value); + } + + async press(keys: string): Promise { + if (this.locator != null) { + await this.locator.press(keys); + return; + } + await this.handle!.press(keys); + } + + async type(text: string): Promise { + if (this.locator != null) { + await this.locator.pressSequentially(text); + return; + } + await this.handle!.focus(); + const ownerFrame = await this.handle!.ownerFrame(); + const page = ownerFrame?.page(); + if (page == null) { + throw new ElementNotFoundException('Unable to resolve element handle'); + } + await page.keyboard.type(text); + } + + async innerText(): Promise { + if (this.locator != null) { + return this.locator.innerText(); + } + return this.handle!.innerText(); + } + + async getAttribute(name: string): Promise { + const attribute = + this.locator != null + ? await this.locator.getAttribute(name) + : await this.handle!.getAttribute(name); + if (attribute != null) { + return attribute; + } + return this.readDomProperty(name); + } + + async readDomProperty(name: string): Promise { + const handle = await this.toElementHandle(); + const value = await readElementProperty(handle, name); + return value == null ? null : String(value); + } + + async isVisible(): Promise { + if (this.locator != null) { + return this.locator.isVisible(); + } + return this.handle!.isVisible(); + } + + async isEnabled(): Promise { + if (this.locator != null) { + return this.locator.isEnabled(); + } + return this.handle!.isEnabled(); + } + + async isSelected(): Promise { + const handle = await this.toElementHandle(); + return isElementSelected(handle); + } + + async boundingBox(): Promise<{ x: number; y: number; width: number; height: number } | null> { + if (this.locator != null) { + return this.locator.boundingBox(); + } + return this.handle!.boundingBox(); + } + + async findChild(using: string, value: string): Promise { + if (this.locator != null) { + const child = resolveLocator(this.locator, using, value); + if ((await child.count()) === 0) { + throw new ElementNotFoundException('Unable to locate child element'); + } + return ResolvedElement.fromLocator(child.first()); + } + if (using === 'xpath') { + const handle = await this.toElementHandle(); + const childHandle = await findChildByXpath(handle, value); + if (childHandle == null) { + throw new ElementNotFoundException('Unable to locate child element'); + } + return ResolvedElement.fromHandle(childHandle); + } + const selector = toCssSelector(using, value); + const childHandle = await this.handle!.$(selector); + if (childHandle == null) { + throw new ElementNotFoundException('Unable to locate child element'); + } + return ResolvedElement.fromHandle(childHandle); + } +} diff --git a/src/errors/element-not-found-exception.ts b/src/errors/element-not-found-exception.ts new file mode 100644 index 0000000..ff50408 --- /dev/null +++ b/src/errors/element-not-found-exception.ts @@ -0,0 +1,8 @@ +import { PlaywrightDriverException } from './playwright-driver-exception.js'; + +export class ElementNotFoundException extends PlaywrightDriverException { + constructor(message: string) { + super(message); + this.name = 'ElementNotFoundException'; + } +} diff --git a/src/errors/frame-not-found-exception.ts b/src/errors/frame-not-found-exception.ts new file mode 100644 index 0000000..42e33d0 --- /dev/null +++ b/src/errors/frame-not-found-exception.ts @@ -0,0 +1,8 @@ +import { PlaywrightDriverException } from './playwright-driver-exception.js'; + +export class FrameNotFoundException extends PlaywrightDriverException { + constructor(message: string) { + super(message); + this.name = 'FrameNotFoundException'; + } +} diff --git a/src/errors/playwright-driver-exception.ts b/src/errors/playwright-driver-exception.ts new file mode 100644 index 0000000..ef31144 --- /dev/null +++ b/src/errors/playwright-driver-exception.ts @@ -0,0 +1,6 @@ +export class PlaywrightDriverException extends Error { + constructor(message: string, cause?: unknown) { + super(message, { cause }); + this.name = 'PlaywrightDriverException'; + } +} diff --git a/src/errors/shadow-root-not-found-exception.ts b/src/errors/shadow-root-not-found-exception.ts new file mode 100644 index 0000000..e00050e --- /dev/null +++ b/src/errors/shadow-root-not-found-exception.ts @@ -0,0 +1,8 @@ +import { PlaywrightDriverException } from './playwright-driver-exception.js'; + +export class ShadowRootNotFoundException extends PlaywrightDriverException { + constructor(message: string) { + super(message); + this.name = 'ShadowRootNotFoundException'; + } +} diff --git a/src/errors/window-not-found-exception.ts b/src/errors/window-not-found-exception.ts new file mode 100644 index 0000000..633ac7a --- /dev/null +++ b/src/errors/window-not-found-exception.ts @@ -0,0 +1,8 @@ +import { PlaywrightDriverException } from './playwright-driver-exception.js'; + +export class WindowNotFoundException extends PlaywrightDriverException { + constructor(message: string) { + super(message); + this.name = 'WindowNotFoundException'; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..88f5549 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,20 @@ +export { TestRigor } from './testrigor.js'; +export { TestrigorPlaywrightDriver } from './application/playwright-driver.js'; +export { PlaywrightExtensionService } from './application/extension-service.js'; +export { PlaywrightGrpcConnection } from './application/grpc-connection.js'; +export { PlaywrightLocator } from './locators/playwright-locator.js'; +export { PlaywrightElement } from './elements/playwright-element.js'; +export { PlaywrightElementFinder } from './locators/playwright-element-finder.js'; +export { PlaywrightSession } from './session/playwright-session.js'; +export { TestRigorActions } from './commons/application/commands/TestRigorActions.js'; +export { TestRigorValidations } from './commons/application/commands/TestRigorValidations.js'; +export { TestRigorQueries } from './commons/application/commands/TestRigorQueries.js'; +export type { TestRigorCommandDriver } from './commons/application/commands/TestRigorCommandDriver.js'; +export { GrpcEndpointConfig } from './commons/application/grpc/GrpcEndpointConfig.js'; +export { + DEFAULT_APPLICATION_PROPERTIES, + loadDefaultConfig, + toLaunchConfig, + type PlaywrightPluginConfig, +} from './config.js'; +export { TestRigorExtensionException } from './commons/infrastructure/exceptions/TestRigorExtensionException.js'; diff --git a/src/locators/playwright-element-finder.test.ts b/src/locators/playwright-element-finder.test.ts new file mode 100644 index 0000000..355be13 --- /dev/null +++ b/src/locators/playwright-element-finder.test.ts @@ -0,0 +1,104 @@ +import type { Frame, Locator, Page } from 'playwright'; +import { describe, expect, it, vi } from 'vitest'; +import type { PlaywrightExtensionService } from '../application/extension-service.js'; +import { Locator as CommonsLocator } from '../commons/domain/model/Locator.js'; +import { LocatorType } from '../commons/domain/model/LocatorType.js'; +import { PlaywrightElement } from '../elements/playwright-element.js'; +import { ResolvedElement } from '../elements/resolved-element.js'; +import type { PlaywrightCommandExecutor } from '../protocol/command-executor.js'; +import { PlaywrightElementFinder } from './playwright-element-finder.js'; +import { PlaywrightLocator } from './playwright-locator.js'; + +describe('PlaywrightElementFinder', () => { + it('findElement by user description delegates to extension service', async () => { + const service = { + findResolvedByUserDescription: vi.fn(), + saveAction: vi.fn(), + } as unknown as PlaywrightExtensionService; + const executor = {} as PlaywrightCommandExecutor; + const locator = { + innerText: vi.fn().mockResolvedValue('Submit'), + } as unknown as Locator; + const resolved = ResolvedElement.fromLocator(locator); + service.findResolvedByUserDescription = vi.fn().mockResolvedValue(resolved); + + const finder = new PlaywrightElementFinder(service, executor); + const element = await finder.findElement(PlaywrightLocator.byUserDescription('Submit')); + + expect(element).toBeInstanceOf(PlaywrightElement); + expect(await element.getText()).toBe('Submit'); + expect(service.findResolvedByUserDescription).toHaveBeenCalledWith('Submit'); + }); + + it('findElement saves action for resolved locators', async () => { + const service = { + findResolvedByUserDescription: vi.fn(), + saveAction: vi.fn().mockResolvedValue(undefined), + } as unknown as PlaywrightExtensionService; + const first = {} as Locator; + const match = { + count: vi.fn().mockResolvedValue(1), + first: vi.fn().mockReturnValue(first), + } as unknown as Locator; + const frame = { + locator: vi.fn().mockReturnValue(match), + } as unknown as Frame; + const page = { + mainFrame: vi.fn().mockReturnValue(frame), + } as unknown as Page; + const executor = { + getPage: vi.fn().mockReturnValue(page), + } as unknown as PlaywrightCommandExecutor; + + const finder = new PlaywrightElementFinder(service, executor); + await finder.findElement(PlaywrightLocator.id('ok')); + + expect(service.saveAction).toHaveBeenCalledOnce(); + }); + + it('findElement self-heals via extension service when local locator fails', async () => { + const original = new CommonsLocator(LocatorType.ID, 'broken-changer'); + const healed = new CommonsLocator(LocatorType.ID, 'changer'); + const service = { + findResolvedByUserDescription: vi.fn(), + saveAction: vi.fn(), + getHealedLocator: vi.fn().mockResolvedValue(healed), + recordHealedFind: vi.fn().mockResolvedValue(undefined), + } as unknown as PlaywrightExtensionService; + + const healedPlaywrightLocator = {} as Locator; + const healedMatch = { + count: vi.fn().mockResolvedValue(1), + first: vi.fn().mockReturnValue(healedPlaywrightLocator), + }; + const missingMatch = { + count: vi.fn().mockResolvedValue(0), + first: vi.fn(), + }; + const frame = { + locator: vi.fn().mockImplementation((selector: string) => { + if (selector === '#broken-changer') { + return missingMatch; + } + if (selector === '#changer') { + return healedMatch; + } + throw new Error(`Unexpected selector: ${selector}`); + }), + } as unknown as Frame; + const page = { + mainFrame: vi.fn().mockReturnValue(frame), + } as unknown as Page; + const executor = { + getPage: vi.fn().mockReturnValue(page), + } as unknown as PlaywrightCommandExecutor; + + const finder = new PlaywrightElementFinder(service, executor); + const element = await finder.findElement(PlaywrightLocator.id('broken-changer')); + + expect(element).toBeInstanceOf(PlaywrightElement); + expect(service.getHealedLocator).toHaveBeenCalledWith(original); + expect(service.recordHealedFind).toHaveBeenCalledWith(original, healed); + expect(service.saveAction).not.toHaveBeenCalled(); + }); +}); diff --git a/src/locators/playwright-element-finder.ts b/src/locators/playwright-element-finder.ts new file mode 100644 index 0000000..e848a4a --- /dev/null +++ b/src/locators/playwright-element-finder.ts @@ -0,0 +1,101 @@ +import type { Frame } from 'playwright'; +import type { PlaywrightExtensionService } from '../application/extension-service.js'; +import { Action } from '../commons/domain/model/Action.js'; +import { ActionType } from '../commons/domain/model/ActionType.js'; +import { ElementNotFoundException } from '../errors/element-not-found-exception.js'; +import type { PlaywrightCommandExecutor } from '../protocol/command-executor.js'; +import { ResolvedElement } from '../elements/resolved-element.js'; +import { PlaywrightElement } from '../elements/playwright-element.js'; +import { PlaywrightLocator } from './playwright-locator.js'; +import { resolveFrame, resolveLocator } from './playwright-locator-resolver.js'; + +export class PlaywrightElementFinder { + constructor( + private readonly extensionService: PlaywrightExtensionService, + private readonly commandExecutor: PlaywrightCommandExecutor, + ) {} + + async findElement( + playwrightLocator: PlaywrightLocator, + parent?: ResolvedElement, + ): Promise { + const locator = playwrightLocator.toCommonsLocator(); + const action = new Action(ActionType.FIND, locator); + try { + if (playwrightLocator.isUserDescription()) { + return this.wrap( + await this.extensionService.findResolvedByUserDescription(playwrightLocator.value), + ); + } + const resolved = await this.resolveLocal(playwrightLocator, parent); + await this.extensionService.saveAction(action); + return this.wrap(resolved); + } catch (notFound) { + if (!(notFound instanceof ElementNotFoundException)) { + throw notFound; + } + console.info('Trying to self heal with locator %s and value %s', locator.type, locator.value); + const healed = await this.extensionService.getHealedLocator(locator); + if (healed == null) { + throw notFound; + } + if (healed.type != null) { + await this.extensionService.recordHealedFind(locator, healed); + } + return this.wrap(await this.resolveLocal(PlaywrightLocator.from(healed), parent)); + } + } + + async findElements( + playwrightLocator: PlaywrightLocator, + parent?: ResolvedElement, + ): Promise { + if (playwrightLocator.isUserDescription()) { + return [ + this.wrap( + await this.extensionService.findResolvedByUserDescription(playwrightLocator.value), + ), + ]; + } + const matches = await this.resolveMatches(playwrightLocator, parent); + const count = await matches.count(); + const elements: PlaywrightElement[] = []; + for (let index = 0; index < count; index++) { + elements.push(this.wrap(ResolvedElement.fromLocator(matches.nth(index)))); + } + return elements; + } + + wrap(resolved: ResolvedElement): PlaywrightElement { + return new PlaywrightElement(resolved, this); + } + + private async resolveLocal( + playwrightLocator: PlaywrightLocator, + parent?: ResolvedElement, + ): Promise { + if (parent != null) { + return parent.findChild(playwrightLocator.using(), playwrightLocator.value); + } + const locator = resolveFrame( + this.currentFrame(), + playwrightLocator.using(), + playwrightLocator.value, + ); + if ((await locator.count()) === 0) { + throw new ElementNotFoundException(`Unable to locate element using ${playwrightLocator}`); + } + return ResolvedElement.fromLocator(locator.first()); + } + + private async resolveMatches(playwrightLocator: PlaywrightLocator, parent?: ResolvedElement) { + if (parent == null) { + return resolveFrame(this.currentFrame(), playwrightLocator.using(), playwrightLocator.value); + } + return resolveLocator(parent.toLocator(), playwrightLocator.using(), playwrightLocator.value); + } + + private currentFrame(): Frame { + return this.commandExecutor.getPage().mainFrame(); + } +} diff --git a/src/locators/playwright-locator-resolver.test.ts b/src/locators/playwright-locator-resolver.test.ts new file mode 100644 index 0000000..574e629 --- /dev/null +++ b/src/locators/playwright-locator-resolver.test.ts @@ -0,0 +1,43 @@ +import type { Frame } from 'playwright'; +import { describe, expect, it, vi } from 'vitest'; +import { resolveFrame, toCssSelector } from './playwright-locator-resolver.js'; + +function toPlaywrightSelector(using: string, value: string): string { + const frame = { locator: vi.fn() }; + resolveFrame(frame as unknown as Frame, using, value); + return frame.locator.mock.calls[0]![0] as string; +} + +describe('PlaywrightLocatorResolver', () => { + it('toPlaywrightSelector css returns value', () => { + expect(toPlaywrightSelector('css selector', '.btn-primary')).toBe('.btn-primary'); + }); + + it('toPlaywrightSelector xpath adds prefix', () => { + expect(toPlaywrightSelector('xpath', '//button')).toBe('xpath=//button'); + }); + + it('toPlaywrightSelector id escapes special characters', () => { + expect(toPlaywrightSelector('id', 'item:42')).toBe('#item\\:42'); + }); + + it('toPlaywrightSelector name builds attribute selector', () => { + expect(toPlaywrightSelector('name', 'email')).toBe("[name='email']"); + }); + + it('toPlaywrightSelector linkText uses exact match', () => { + expect(toPlaywrightSelector('link text', 'Sign in')).toBe('text="Sign in"'); + }); + + it('toPlaywrightSelector partialLinkText uses substring match', () => { + expect(toPlaywrightSelector('partial link text', 'Sign')).toBe('text=Sign'); + }); + + it('toCssSelector id builds hash selector', () => { + expect(toCssSelector('id', 'login')).toBe('#login'); + }); + + it('toCssSelector xpath is rejected', () => { + expect(() => toCssSelector('xpath', '//button')).toThrow('XPath must be evaluated separately'); + }); +}); diff --git a/src/locators/playwright-locator-resolver.ts b/src/locators/playwright-locator-resolver.ts new file mode 100644 index 0000000..76c2f0d --- /dev/null +++ b/src/locators/playwright-locator-resolver.ts @@ -0,0 +1,79 @@ +import type { Frame, Locator } from 'playwright'; + +export function resolveFrame(frame: Frame, using: string, value: string): Locator { + return frame.locator(toPlaywrightSelector(using, value)); +} + +export function resolveLocator(parent: Locator, using: string, value: string): Locator { + return parent.locator(toPlaywrightSelector(using, value)); +} + +function toPlaywrightSelector(using: string, value: string): string { + if (using == null || value == null) { + throw new Error('Locator strategy and value are required'); + } + switch (using) { + case 'css selector': + return value; + case 'xpath': + return `xpath=${value}`; + case 'id': + return `#${escapeCssId(value)}`; + case 'name': + return `[name='${escapeCssAttribute(value)}']`; + case 'class name': + return `.${escapeCssClass(value)}`; + case 'tag name': + return value; + case 'link text': + return `text="${escapePlaywrightText(value)}"`; + case 'partial link text': + return `text=${escapePlaywrightText(value)}`; + default: + throw new Error(`Unsupported locator strategy: ${using}`); + } +} + +/** + * CSS-only selector for DOM APIs such as querySelector and shadow-root lookups. + */ +export function toCssSelector(using: string, value: string): string { + if (using == null || value == null) { + throw new Error('Locator strategy and value are required'); + } + switch (using) { + case 'css selector': + return value; + case 'id': + return `#${escapeCssId(value)}`; + case 'name': + return `[name='${escapeCssAttribute(value)}']`; + case 'class name': + return `.${escapeCssClass(value)}`; + case 'tag name': + return value; + case 'link text': + case 'partial link text': + throw new Error(`Link text is not supported in CSS selectors: ${using}`); + case 'xpath': + throw new Error('XPath must be evaluated separately from CSS selectors'); + default: + throw new Error(`Unsupported locator strategy: ${using}`); + } +} + +export function escapeCssId(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/:/g, '\\:'); +} + +export function escapeCssAttribute(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); +} + +function escapeCssClass(value: string): string { + return escapeCssId(value); +} + +function escapePlaywrightText(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} diff --git a/src/locators/playwright-locator.test.ts b/src/locators/playwright-locator.test.ts new file mode 100644 index 0000000..9db9cf6 --- /dev/null +++ b/src/locators/playwright-locator.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { LocatorType } from '../commons/domain/model/LocatorType.js'; +import { PlaywrightLocator } from './playwright-locator.js'; + +describe('PlaywrightLocator', () => { + it('byUserDescription maps to commons locator', () => { + const locator = PlaywrightLocator.byUserDescription('Login'); + + expect(locator.isUserDescription()).toBe(true); + expect(locator.toCommonsLocator().type).toBe(LocatorType.USER_DESCRIPTION); + expect(locator.toCommonsLocator().value).toBe('Login'); + }); + + it('id using returns locator strategy name', () => { + expect(PlaywrightLocator.id('x').using()).toBe('id'); + }); +}); diff --git a/src/locators/playwright-locator.ts b/src/locators/playwright-locator.ts new file mode 100644 index 0000000..98ef250 --- /dev/null +++ b/src/locators/playwright-locator.ts @@ -0,0 +1,88 @@ +import { Locator } from '../commons/domain/model/Locator.js'; +import { LocatorType } from '../commons/domain/model/LocatorType.js'; + +export class PlaywrightLocator { + readonly type: LocatorType; + readonly value: string; + + private constructor(type: LocatorType, value: string) { + this.type = type; + this.value = value; + } + + static byUserDescription(description: string): PlaywrightLocator { + return new PlaywrightLocator(LocatorType.USER_DESCRIPTION, description); + } + + static id(id: string): PlaywrightLocator { + return new PlaywrightLocator(LocatorType.ID, id); + } + + static xpath(xpath: string): PlaywrightLocator { + return new PlaywrightLocator(LocatorType.XPATH, xpath); + } + + static cssSelector(selector: string): PlaywrightLocator { + return new PlaywrightLocator(LocatorType.CSS_SELECTOR, selector); + } + + static name(name: string): PlaywrightLocator { + return new PlaywrightLocator(LocatorType.NAME, name); + } + + static className(className: string): PlaywrightLocator { + return new PlaywrightLocator(LocatorType.CLASS, className); + } + + static tagName(tagName: string): PlaywrightLocator { + return new PlaywrightLocator(LocatorType.TAG_NAME, tagName); + } + + static linkText(linkText: string): PlaywrightLocator { + return new PlaywrightLocator(LocatorType.LINK_TEXT, linkText); + } + + static partialLinkText(partialLinkText: string): PlaywrightLocator { + return new PlaywrightLocator(LocatorType.PARTIAL_LINK_TEXT, partialLinkText); + } + + static from(locator: Locator): PlaywrightLocator { + return new PlaywrightLocator(locator.type, locator.value); + } + + toCommonsLocator(): Locator { + return new Locator(this.type, this.value); + } + + isUserDescription(): boolean { + return this.type === LocatorType.USER_DESCRIPTION; + } + + using(): string { + switch (this.type) { + case LocatorType.ID: + return 'id'; + case LocatorType.XPATH: + return 'xpath'; + case LocatorType.CSS_SELECTOR: + return 'css selector'; + case LocatorType.TAG_NAME: + return 'tag name'; + case LocatorType.LINK_TEXT: + return 'link text'; + case LocatorType.NAME: + return 'name'; + case LocatorType.CLASS: + return 'class name'; + case LocatorType.PARTIAL_LINK_TEXT: + return 'partial link text'; + case LocatorType.USER_DESCRIPTION: + default: + throw new Error('USER_DESCRIPTION locators use gRPC find-by-description'); + } + } + + toString(): string { + return `${this.type}: ${this.value}`; + } +} diff --git a/src/protocol/command-executor.get-current-url.test.ts b/src/protocol/command-executor.get-current-url.test.ts new file mode 100644 index 0000000..dd66b36 --- /dev/null +++ b/src/protocol/command-executor.get-current-url.test.ts @@ -0,0 +1,38 @@ +import type { Browser, BrowserContext, Page } from 'playwright'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { PlaywrightCommandExecutor } from './command-executor.js'; +import { DriverCommandNames } from './driver-command-names.js'; + +describe('PlaywrightCommandExecutor getCurrentUrl', () => { + let page: Page; + let executor: PlaywrightCommandExecutor; + + beforeEach(() => { + page = { + url: vi.fn().mockReturnValue('http://r4d4.info/form-button-label'), + mainFrame: vi.fn().mockReturnValue({ + url: vi.fn().mockReturnValue('http://r4d4.info/form-button-label'), + }), + on: vi.fn(), + } as unknown as Page; + + const context = { + pages: vi.fn().mockReturnValue([page]), + } as unknown as BrowserContext; + + executor = new PlaywrightCommandExecutor('session-1', null, {} as Browser, context, page, null); + }); + + it('returns page url for getCurrentUrl', async () => { + const response = await executor.execute(DriverCommandNames.GET_CURRENT_URL, {}); + + expect(response.status).toBe(0); + expect(response.value).toBe('http://r4d4.info/form-button-label'); + }); + + it('supports getUrl alias', async () => { + const response = await executor.execute('getUrl', {}); + + expect(response.value).toBe('http://r4d4.info/form-button-label'); + }); +}); diff --git a/src/protocol/command-executor.navigation.test.ts b/src/protocol/command-executor.navigation.test.ts new file mode 100644 index 0000000..dada73f --- /dev/null +++ b/src/protocol/command-executor.navigation.test.ts @@ -0,0 +1,71 @@ +import type { Browser, BrowserContext, Page } from 'playwright'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { PlaywrightCommandExecutor } from './command-executor.js'; +import { DriverCommandNames } from './driver-command-names.js'; + +const navigationCommands = [ + DriverCommandNames.GO_BACK, + DriverCommandNames.BACK, + DriverCommandNames.GO_FORWARD, + DriverCommandNames.FORWARD, + DriverCommandNames.REFRESH, + DriverCommandNames.RELOAD, +] as const; + +describe('PlaywrightCommandExecutor navigation', () => { + let page: Page; + let executor: PlaywrightCommandExecutor; + + beforeEach(() => { + page = { + goBack: vi.fn().mockResolvedValue(undefined), + goForward: vi.fn().mockResolvedValue(undefined), + reload: vi.fn().mockResolvedValue(undefined), + waitForLoadState: vi.fn().mockResolvedValue(undefined), + on: vi.fn(), + mainFrame: vi.fn(), + } as unknown as Page; + + const context = { + pages: vi.fn().mockReturnValue([page]), + } as unknown as BrowserContext; + + const browser = {} as Browser; + + executor = new PlaywrightCommandExecutor('session-1', null, browser, context, page, null); + }); + + it.each(navigationCommands)( + 'execute supports remote navigation command %s', + async (commandName) => { + const response = await executor.execute(commandName, {}); + + expect(response.status).toBe(0); + expect(page.waitForLoadState).toHaveBeenCalledWith('domcontentloaded'); + }, + ); + + it('execute goBack invokes Playwright goBack', async () => { + await executor.execute(DriverCommandNames.GO_BACK, {}); + + expect(page.goBack).toHaveBeenCalledWith({ waitUntil: 'load' }); + }); + + it('execute goForward invokes Playwright goForward', async () => { + await executor.execute(DriverCommandNames.GO_FORWARD, {}); + + expect(page.goForward).toHaveBeenCalledWith({ waitUntil: 'load' }); + }); + + it('execute reload invokes Playwright reload', async () => { + await executor.execute(DriverCommandNames.RELOAD, {}); + + expect(page.reload).toHaveBeenCalledWith({ waitUntil: 'load' }); + }); + + it('execute refresh command invokes Playwright reload', async () => { + await executor.execute(DriverCommandNames.REFRESH, {}); + + expect(page.reload).toHaveBeenCalledWith({ waitUntil: 'load' }); + }); +}); diff --git a/src/protocol/command-executor.send-keys.test.ts b/src/protocol/command-executor.send-keys.test.ts new file mode 100644 index 0000000..171a75b --- /dev/null +++ b/src/protocol/command-executor.send-keys.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { readSendKeysText } from './send-keys-text.js'; + +describe('readSendKeysText', () => { + it('uses text parameter for W3C payload', () => { + expect(readSendKeysText({ text: 'hello' })).toBe('hello'); + }); + + it('joins value list from wire protocol', () => { + expect(readSendKeysText({ value: ['Playwright extension test'] })).toBe( + 'Playwright extension test', + ); + expect(readSendKeysText({ value: ['Play', 'wright'] })).toBe('Playwright'); + }); +}); diff --git a/src/protocol/command-executor.ts b/src/protocol/command-executor.ts new file mode 100644 index 0000000..9a43e14 --- /dev/null +++ b/src/protocol/command-executor.ts @@ -0,0 +1,756 @@ +import type { Browser, BrowserContext, Dialog, Frame, Page } from 'playwright'; +import { ElementRegistry } from '../elements/element-registry.js'; +import { ResolvedElement } from '../elements/resolved-element.js'; +import { ElementNotFoundException } from '../errors/element-not-found-exception.js'; +import { FrameNotFoundException } from '../errors/frame-not-found-exception.js'; +import { PlaywrightDriverException } from '../errors/playwright-driver-exception.js'; +import { WindowNotFoundException } from '../errors/window-not-found-exception.js'; +import { resolveFrame, resolveLocator } from '../locators/playwright-locator-resolver.js'; +import { ResolvedShadowRoot } from '../shadow/resolved-shadow-root.js'; +import { ShadowRootRegistry } from '../shadow/shadow-root-registry.js'; +import { BROWSER_NAME, PLATFORM_NAME } from '../session/playwright-driver-constants.js'; +import { DriverCommandNames } from './driver-command-names.js'; +import { readSendKeysText } from './send-keys-text.js'; +import { + createDriverCommandResponse, + type DriverCommandResponse, +} from './driver-command-response.js'; +import { + resolveFrameByIndex, + resolveFrameByName, + resolveFrameFromElement, +} from './frame-resolver.js'; +import { PlaywrightScriptResultConverter } from './script-result-converter.js'; +import { runAsyncScriptInFrame, runSyncScriptInFrame } from './remote-script-adapter.js'; + +import { + documentActiveElement, + readElementCssProperty, + readElementProperty, + readElementTagName, + readPageSource, + submitElement, +} from './page-eval-functions.js'; + +const pageIdentity = new WeakMap(); +let pageIdentityCounter = 0; + +type PlaywrightLifecycle = { close(): Promise }; + +function pageHashCode(page: Page): number { + let identity = pageIdentity.get(page); + if (identity == null) { + identity = ++pageIdentityCounter; + pageIdentity.set(page, identity); + } + return identity; +} + +export class PlaywrightCommandExecutor { + private readonly elementRegistry = new ElementRegistry(); + private readonly shadowRootRegistry = new ShadowRootRegistry(); + private readonly scriptResultConverter: PlaywrightScriptResultConverter; + private readonly sessionId: string; + private readonly playwright: PlaywrightLifecycle | null; + private readonly browser: Browser | null; + private readonly context: BrowserContext; + readonly page: Page; + private readonly onQuit: ((runnable: () => void | Promise) => void) | null; + + private activePage: Page; + private pendingDialog: Dialog | null = null; + private activeFrame: Frame | null = null; + + constructor( + sessionId: string, + playwright: PlaywrightLifecycle | null, + browser: Browser | null, + context: BrowserContext, + page: Page, + onQuit: ((runnable: () => void | Promise) => void) | null, + ) { + this.sessionId = sessionId; + this.playwright = playwright; + this.browser = browser; + this.context = context; + this.page = page; + this.activePage = page; + this.activeFrame = null; + this.onQuit = onQuit; + this.scriptResultConverter = new PlaywrightScriptResultConverter(this.elementRegistry); + this.registerDialogHandler(); + } + + getPage(): Page { + return this.page; + } + + async execute( + commandName: string, + parameters: Record = {}, + ): Promise { + try { + return await this.executeInternal(commandName, parameters); + } catch (error) { + if (error instanceof PlaywrightDriverException || isPlaywrightException(error)) { + throw error; + } + if (error instanceof Error) { + throw new PlaywrightDriverException(error.message, error); + } + throw new PlaywrightDriverException(String(error)); + } + } + + private async executeInternal( + name: string, + parameters: Record, + ): Promise { + switch (normalizeCommandName(name)) { + case DriverCommandNames.NEW_SESSION: + return this.success(this.newSession(parameters)); + case DriverCommandNames.QUIT: + this.elementRegistry.clear(); + this.shadowRootRegistry.clear(); + if (this.onQuit != null) { + this.onQuit(() => this.closeResources()); + } else { + await this.closeResources(); + } + return this.success(null); + case DriverCommandNames.CLOSE: + await this.closeCurrentPage(); + return this.success(null); + case DriverCommandNames.GET: + await this.navigate(String(parameters.url)); + return this.success(null); + case DriverCommandNames.GET_CURRENT_URL: + return this.success(this.readCurrentUrl()); + case DriverCommandNames.GET_TITLE: + return this.success(await this.activePage.title()); + case DriverCommandNames.GET_PAGE_SOURCE: + return this.success(await this.readPageSource()); + case DriverCommandNames.REFRESH: + case DriverCommandNames.RELOAD: + await this.reloadPage(); + return this.success(null); + case DriverCommandNames.GO_BACK: + case DriverCommandNames.BACK: + await this.goBack(); + return this.success(null); + case DriverCommandNames.GO_FORWARD: + case DriverCommandNames.FORWARD: + await this.goForward(); + return this.success(null); + case DriverCommandNames.FIND_ELEMENT: + return this.success(await this.findElement(parameters, false)); + case DriverCommandNames.FIND_ELEMENTS: + return this.success(await this.findElements(parameters, false)); + case DriverCommandNames.FIND_CHILD_ELEMENT: + return this.success(await this.findElement(parameters, true)); + case DriverCommandNames.FIND_CHILD_ELEMENTS: + return this.success(await this.findElements(parameters, true)); + case DriverCommandNames.GET_ELEMENT_SHADOW_ROOT: + return this.success(await this.getElementShadowRoot(parameters)); + case DriverCommandNames.FIND_ELEMENT_FROM_SHADOW_ROOT: + return this.success(await this.findElementFromShadowRoot(parameters)); + case DriverCommandNames.FIND_ELEMENTS_FROM_SHADOW_ROOT: + return this.success(await this.findElementsFromShadowRoot(parameters)); + case DriverCommandNames.CLICK: + await this.elementRegistry.resolve(parameters).click(); + return this.success(null); + case DriverCommandNames.CLEAR: + await this.elementRegistry.resolve(parameters).fill(''); + return this.success(null); + case DriverCommandNames.SUBMIT: { + const handle = await this.elementRegistry.resolve(parameters).toElementHandle(); + await submitElement(handle); + return this.success(null); + } + case DriverCommandNames.SEND_KEYS_TO_ELEMENT: + await this.sendKeys(parameters); + return this.success(null); + case DriverCommandNames.GET_ELEMENT_TEXT: + return this.success(await this.elementRegistry.resolve(parameters).innerText()); + case DriverCommandNames.GET_ELEMENT_PROPERTY: + case DriverCommandNames.GET_ELEMENT_DOM_PROPERTY: { + const handle = await this.elementRegistry.resolve(parameters).toElementHandle(); + return this.success(await readElementProperty(handle, readNameParameter(parameters))); + } + case DriverCommandNames.GET_ELEMENT_ATTRIBUTE: + case DriverCommandNames.GET_ELEMENT_DOM_ATTRIBUTE: + return this.success( + await this.elementRegistry + .resolve(parameters) + .getAttribute(readNameParameter(parameters)), + ); + case DriverCommandNames.GET_ELEMENT_VALUE_OF_CSS_PROPERTY: { + const handle = await this.elementRegistry.resolve(parameters).toElementHandle(); + return this.success(await readElementCssProperty(handle, readCssPropertyName(parameters))); + } + case DriverCommandNames.IS_ELEMENT_DISPLAYED: + return this.success(await this.elementRegistry.resolve(parameters).isVisible()); + case DriverCommandNames.IS_ELEMENT_ENABLED: + return this.success(await this.elementRegistry.resolve(parameters).isEnabled()); + case DriverCommandNames.IS_ELEMENT_SELECTED: + return this.success(await this.elementRegistry.resolve(parameters).isSelected()); + case DriverCommandNames.GET_ELEMENT_TAG_NAME: { + const handle = await this.elementRegistry.resolve(parameters).toElementHandle(); + return this.success(await readElementTagName(handle)); + } + case DriverCommandNames.GET_ELEMENT_RECT: + case DriverCommandNames.GET_ELEMENT_LOCATION_ONCE_SCROLLED_INTO_VIEW: + return this.success(await this.toRectMap(this.elementRegistry.resolve(parameters))); + case DriverCommandNames.GET_ELEMENT_LOCATION: + return this.success(await this.toLocationMap(this.elementRegistry.resolve(parameters))); + case DriverCommandNames.GET_ELEMENT_SIZE: + return this.success(await this.toSizeMap(this.elementRegistry.resolve(parameters))); + case DriverCommandNames.ELEMENT_SCREENSHOT: + return this.success(await this.elementScreenshot(this.elementRegistry.resolve(parameters))); + case DriverCommandNames.GET_ACTIVE_ELEMENT: + return this.success(await this.registerActiveElement()); + case DriverCommandNames.EXECUTE_SCRIPT: + return this.success(await this.executeScript(parameters, false)); + case DriverCommandNames.EXECUTE_ASYNC_SCRIPT: + return this.success(await this.executeScript(parameters, true)); + case DriverCommandNames.SCREENSHOT: + return this.success(await this.screenshot()); + case DriverCommandNames.SWITCH_TO_FRAME: + await this.switchToFrame(parameters); + return this.success(null); + case DriverCommandNames.SWITCH_TO_PARENT_FRAME: + this.activeFrame = this.activeFrame == null ? null : this.activeFrame.parentFrame(); + return this.success(null); + case DriverCommandNames.GET_CURRENT_WINDOW_HANDLE: + return this.success( + `${pageHashCode(this.activePage)}:${this.context.pages().indexOf(this.activePage)}`, + ); + case DriverCommandNames.GET_WINDOW_HANDLES: + return this.success(this.collectWindowHandles()); + case DriverCommandNames.SWITCH_TO_WINDOW: + await this.switchToWindow(String(parameters.handle)); + return this.success(null); + case DriverCommandNames.MAXIMIZE_WINDOW: + case DriverCommandNames.FULLSCREEN_WINDOW: + await this.activePage.setViewportSize({ width: 1920, height: 1080 }); + return this.success(null); + case DriverCommandNames.MINIMIZE_WINDOW: + return this.success(null); + case DriverCommandNames.GET_WINDOW_RECT: + case DriverCommandNames.GET_CURRENT_WINDOW_SIZE: { + const viewport = this.activePage.viewportSize(); + return this.success({ + width: viewport?.width ?? 0, + height: viewport?.height ?? 0, + }); + } + case DriverCommandNames.GET_CURRENT_WINDOW_POSITION: + return this.success({ x: 0, y: 0 }); + case DriverCommandNames.SET_WINDOW_RECT: + case DriverCommandNames.SET_CURRENT_WINDOW_SIZE: + await this.activePage.setViewportSize({ + width: Number(parameters.width), + height: Number(parameters.height), + }); + return this.success(null); + case DriverCommandNames.SET_CURRENT_WINDOW_POSITION: + return this.success(null); + case DriverCommandNames.SET_TIMEOUT: + case DriverCommandNames.SET_SCRIPT_TIMEOUT: + case DriverCommandNames.IMPLICITLY_WAIT: + return this.success(null); + case DriverCommandNames.GET_TIMEOUTS: + return this.success({ + implicit: 0, + pageLoad: 0, + script: 0, + }); + case DriverCommandNames.DELETE_ALL_COOKIES: + await this.context.clearCookies(); + return this.success(null); + case DriverCommandNames.GET_ALL_COOKIES: + return this.success(this.toWireProtocolCookies(await this.context.cookies())); + case DriverCommandNames.GET_COOKIE: + return this.success(await this.findCookie(String(parameters.name))); + case DriverCommandNames.DELETE_COOKIE: + await this.deleteCookie(String(parameters.name)); + return this.success(null); + case DriverCommandNames.ADD_COOKIE: + await this.addCookie(parameters); + return this.success(null); + case DriverCommandNames.GET_ALERT_TEXT: + return this.success(this.pendingDialog == null ? '' : this.pendingDialog.message()); + case DriverCommandNames.SET_ALERT_VALUE: + if (this.pendingDialog != null) { + await this.pendingDialog.accept(parameters.text == null ? '' : String(parameters.text)); + } + return this.success(null); + case DriverCommandNames.ACCEPT_ALERT: + if (this.pendingDialog != null) { + await this.pendingDialog.accept(); + this.pendingDialog = null; + } + return this.success(null); + case DriverCommandNames.DISMISS_ALERT: + if (this.pendingDialog != null) { + await this.pendingDialog.dismiss(); + this.pendingDialog = null; + } + return this.success(null); + case DriverCommandNames.GET_AVAILABLE_LOG_TYPES: + return this.success(['browser', 'performance', 'client', 'driver']); + case DriverCommandNames.GET_LOG: + return this.success([]); + case DriverCommandNames.GET_SESSION_LOGS: + return this.success([]); + default: + console.warn(`Unsupported Playwright command: ${name}`); + throw new Error(`Unsupported command: ${name}`); + } + } + + private success(value: unknown): DriverCommandResponse { + return createDriverCommandResponse({ + sessionId: this.sessionId, + status: 0, + state: '', + value, + }); + } + + private newSession(parameters: Record): Record { + const capabilities: Record = { + browserName: BROWSER_NAME, + platformName: PLATFORM_NAME, + acceptInsecureCerts: true, + }; + const requested = parameters.capabilities; + if (requested != null && typeof requested === 'object' && !Array.isArray(requested)) { + Object.assign(capabilities, requested); + } + return capabilities; + } + + async resolveElementByXpath(xpath: string): Promise { + const expression = xpath.startsWith('xpath=') ? xpath.slice('xpath='.length) : xpath; + const locator = this.currentFrame().locator(`xpath=${expression}`); + if ((await locator.count()) === 0) { + throw new ElementNotFoundException(`Unable to locate element: ${xpath}`); + } + const resolved = ResolvedElement.fromLocator(locator.first()); + this.elementRegistry.register(resolved); + return resolved; + } + + private currentFrame(): Frame { + if (this.activeFrame != null) { + return this.activeFrame; + } + return this.activePage.mainFrame(); + } + + private readCurrentUrl(): string { + const url = this.activePage.url(); + if (url != null && url !== '') { + return url; + } + const frameUrl = this.currentFrame().url(); + return frameUrl == null || frameUrl === '' ? 'about:blank' : frameUrl; + } + + private async navigate(url: string): Promise { + await this.activePage.goto(url, { waitUntil: 'load' }); + await this.activePage.waitForLoadState('domcontentloaded'); + } + + private async goBack(): Promise { + await this.activePage.goBack({ waitUntil: 'load' }); + await this.activePage.waitForLoadState('domcontentloaded'); + } + + private async goForward(): Promise { + await this.activePage.goForward({ waitUntil: 'load' }); + await this.activePage.waitForLoadState('domcontentloaded'); + } + + private async reloadPage(): Promise { + await this.activePage.reload({ waitUntil: 'load' }); + await this.activePage.waitForLoadState('domcontentloaded'); + } + + private async findElement( + parameters: Record, + child: boolean, + ): Promise> { + if (child) { + const parent = this.elementRegistry.resolve(parameters); + const using = String(parameters.using); + const value = String(parameters.value); + return this.elementRegistry.register(await parent.findChild(using, value)); + } + const locator = this.resolveSearchLocator(parameters); + if ((await locator.count()) === 0) { + throw new ElementNotFoundException('Unable to locate element'); + } + return this.elementRegistry.register(locator.first()); + } + + private async findElements( + parameters: Record, + child: boolean, + ): Promise>> { + let locator; + if (child) { + const parent = this.elementRegistry.resolve(parameters); + const using = String(parameters.using); + const value = String(parameters.value); + locator = resolveLocator(parent.toLocator(), using, value); + } else { + locator = this.resolveSearchLocator(parameters); + } + return this.registerLocators(locator); + } + + private async registerLocators( + locator: import('playwright').Locator, + ): Promise>> { + const count = await locator.count(); + const elements: Array> = []; + for (let index = 0; index < count; index++) { + elements.push(this.elementRegistry.register(locator.nth(index))); + } + return elements; + } + + private resolveSearchLocator(parameters: Record) { + const using = String(parameters.using); + const value = String(parameters.value); + return resolveFrame(this.currentFrame(), using, value); + } + + private async getElementShadowRoot( + parameters: Record, + ): Promise> { + const element = this.elementRegistry.resolve(parameters); + const shadowRoot = await ResolvedShadowRoot.fromHost(await element.toElementHandle()); + return this.shadowRootRegistry.register(shadowRoot); + } + + private async findElementFromShadowRoot( + parameters: Record, + ): Promise> { + const shadowRoot = this.shadowRootRegistry.resolveFromParameters(parameters); + const using = String(parameters.using); + const value = String(parameters.value); + return this.elementRegistry.register(await shadowRoot.findElement(using, value)); + } + + private async findElementsFromShadowRoot( + parameters: Record, + ): Promise>> { + const shadowRoot = this.shadowRootRegistry.resolveFromParameters(parameters); + const using = String(parameters.using); + const value = String(parameters.value); + const elements = await shadowRoot.findElements(using, value); + const references: Array> = []; + for (const element of elements) { + references.push(this.elementRegistry.register(element)); + } + return references; + } + + private async sendKeys(parameters: Record): Promise { + const element = this.elementRegistry.resolve(parameters); + const text = readSendKeysText(parameters); + if (text.length === 0) { + return; + } + await element.type(text); + } + + private async executeScript( + parameters: Record, + asyncScript: boolean, + ): Promise { + const script = String(parameters.script); + const args = await this.extractScriptArgs(parameters.args); + const handle = asyncScript + ? await runAsyncScriptInFrame(this.currentFrame(), script, args) + : await runSyncScriptInFrame(this.currentFrame(), script, args); + return this.scriptResultConverter.convert(handle); + } + + private async extractScriptArgs(rawArgs: unknown): Promise { + if (!Array.isArray(rawArgs)) { + return []; + } + const resolved: unknown[] = []; + for (const item of rawArgs) { + resolved.push(await this.resolveScriptArg(item)); + } + return resolved; + } + + private async resolveScriptArg(arg: unknown): Promise { + if (arg == null || typeof arg !== 'object' || Array.isArray(arg)) { + return arg; + } + const map = arg as Record; + if (ElementRegistry.extractElementId(map) == null) { + return arg; + } + return this.elementRegistry.resolve(map).toElementHandle(); + } + + private async readPageSource(): Promise { + return readPageSource(this.currentFrame()); + } + + private async registerActiveElement(): Promise> { + const handle = await documentActiveElement(this.currentFrame()); + const converted = await this.scriptResultConverter.convert(handle); + if (converted != null && typeof converted === 'object' && !Array.isArray(converted)) { + return converted as Record; + } + return this.elementRegistry.register(this.currentFrame().locator('html').first()); + } + + private async elementScreenshot(element: ResolvedElement): Promise { + const bytes = await (await element.toElementHandle()).screenshot(); + return Buffer.from(bytes).toString('base64'); + } + + private async screenshot(): Promise { + const bytes = await this.activePage.screenshot({ type: 'png' }); + return Buffer.from(bytes).toString('base64'); + } + + private async switchToFrame(parameters: Record): Promise { + const id = parameters.id; + if (id == null) { + this.activeFrame = null; + return; + } + + let frame: Frame | null; + if (typeof id === 'number') { + frame = await resolveFrameByIndex(this.currentFrame(), id); + } else if (typeof id === 'object' && !Array.isArray(id)) { + frame = await resolveFrameFromElement( + this.activePage, + this.currentFrame(), + await this.elementRegistry.resolve(id as Record).toElementHandle(), + ); + } else { + const frameToken = String(id); + try { + frame = await resolveFrameFromElement( + this.activePage, + this.currentFrame(), + await this.elementRegistry.resolve({ id: frameToken }).toElementHandle(), + ); + } catch (error) { + if ( + !(error instanceof ElementNotFoundException) && + !(error instanceof Error && error.message === 'Missing element reference') + ) { + throw error; + } + frame = await resolveFrameByName(this.activePage, this.currentFrame(), frameToken); + } + } + + if (frame == null) { + throw new FrameNotFoundException('Unable to locate frame'); + } + this.activeFrame = frame; + } + + private collectWindowHandles(): string[] { + const handles: string[] = []; + let index = 0; + for (const contextPage of this.context.pages()) { + handles.push(`${pageHashCode(contextPage)}:${index}`); + index++; + } + return handles; + } + + private async switchToWindow(handle: string): Promise { + const separator = handle.lastIndexOf(':'); + if (separator < 0) { + throw new WindowNotFoundException(`Invalid window handle: ${handle}`); + } + const pageIndex = Number.parseInt(handle.substring(separator + 1), 10); + const pages = this.context.pages(); + if (pageIndex < 0 || pageIndex >= pages.length) { + throw new WindowNotFoundException(`Unable to locate window: ${handle}`); + } + this.activePage = pages[pageIndex]!; + await this.activePage.bringToFront(); + this.activeFrame = null; + } + + private async closeCurrentPage(): Promise { + if (this.context.pages().length <= 1) { + await this.activePage.close(); + return; + } + const closing = this.activePage; + await closing.close(); + this.activePage = this.context.pages()[0]!; + this.activeFrame = null; + } + + private async toRectMap(element: ResolvedElement): Promise> { + return boundingBoxMap(element, true, true); + } + + private async toLocationMap(element: ResolvedElement): Promise> { + return boundingBoxMap(element, false, true); + } + + private async toSizeMap(element: ResolvedElement): Promise> { + return boundingBoxMap(element, true, false); + } + + private async findCookie(name: string): Promise> { + for (const cookie of await this.context.cookies()) { + if (name === cookie.name) { + return this.toWireProtocolCookies([cookie])[0] ?? {}; + } + } + return {}; + } + + private async deleteCookie(name: string): Promise { + const remaining = (await this.context.cookies()).filter((cookie) => name !== cookie.name); + await this.context.clearCookies(); + if (remaining.length > 0) { + await this.context.addCookies(remaining); + } + } + + private async addCookie(parameters: Record): Promise { + const cookie = parameters.cookie as Record; + const playwrightCookie: { name: string; value: string; domain?: string; path?: string } = { + name: String(cookie.name), + value: String(cookie.value), + }; + if (cookie.domain != null) { + playwrightCookie.domain = String(cookie.domain); + } + if (cookie.path != null) { + playwrightCookie.path = String(cookie.path); + } + await this.context.addCookies([playwrightCookie]); + } + + private registerDialogHandler(): void { + this.activePage.on('dialog', (dialog) => { + this.pendingDialog = dialog; + }); + for (const contextPage of this.context.pages()) { + contextPage.on('dialog', (dialog) => { + this.pendingDialog = dialog; + }); + } + } + + private toWireProtocolCookies( + cookies: Array<{ name: string; value: string; domain?: string; path?: string }>, + ): Array> { + return cookies.map((cookie) => { + const wireCookie: Record = { + name: cookie.name, + value: cookie.value, + }; + if (cookie.domain != null) { + wireCookie.domain = cookie.domain; + } + if (cookie.path != null) { + wireCookie.path = cookie.path; + } + return wireCookie; + }); + } + + private async closeResources(): Promise { + try { + if (this.context != null) { + await this.context.close(); + } + } catch (error) { + console.warn( + `Error closing browser context: ${error instanceof Error ? error.message : String(error)}`, + ); + } + try { + if (this.browser != null) { + await this.browser.close(); + } + } catch (error) { + console.warn( + `Error closing browser: ${error instanceof Error ? error.message : String(error)}`, + ); + } + try { + if (this.playwright != null) { + await this.playwright.close(); + } + } catch (error) { + console.warn( + `Error closing Playwright: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} + +function normalizeCommandName(name: string): string { + const trimmed = name.trim(); + const aliases: Record = { + getUrl: DriverCommandNames.GET_CURRENT_URL, + }; + return aliases[trimmed] ?? trimmed; +} + +function readNameParameter(parameters: Record): string { + const name = parameters.name; + return name == null ? '' : String(name); +} + +function readCssPropertyName(parameters: Record): string { + const propertyName = parameters.propertyName; + if (propertyName != null) { + return String(propertyName); + } + return readNameParameter(parameters); +} + +async function boundingBoxMap( + element: ResolvedElement, + includeSize: boolean, + includeLocation: boolean, +): Promise> { + const box = await element.boundingBox(); + if (box == null) { + if (includeSize && includeLocation) { + return { x: 0, y: 0, width: 0, height: 0 }; + } + if (includeLocation) { + return { x: 0, y: 0 }; + } + return { width: 0, height: 0 }; + } + if (includeSize && includeLocation) { + return { x: box.x, y: box.y, width: box.width, height: box.height }; + } + if (includeLocation) { + return { x: box.x, y: box.y }; + } + return { width: box.width, height: box.height }; +} + +function isPlaywrightException(error: unknown): boolean { + return error instanceof Error && error.name === 'TimeoutError'; +} diff --git a/src/protocol/driver-command-names.ts b/src/protocol/driver-command-names.ts new file mode 100644 index 0000000..f6856bb --- /dev/null +++ b/src/protocol/driver-command-names.ts @@ -0,0 +1,77 @@ +export const DriverCommandNames = { + NEW_SESSION: 'newSession', + QUIT: 'quit', + CLOSE: 'close', + GET: 'get', + GET_CURRENT_URL: 'getCurrentUrl', + GET_TITLE: 'getTitle', + GET_PAGE_SOURCE: 'getPageSource', + REFRESH: 'refresh', + RELOAD: 'reload', + GO_BACK: 'goBack', + GO_FORWARD: 'goForward', + /** Legacy JSON wire protocol name. */ + BACK: 'back', + /** Legacy JSON wire protocol name. */ + FORWARD: 'forward', + FIND_ELEMENT: 'findElement', + FIND_ELEMENTS: 'findElements', + FIND_CHILD_ELEMENT: 'findChildElement', + FIND_CHILD_ELEMENTS: 'findChildElements', + CLICK: 'clickElement', + CLEAR: 'clearElement', + SUBMIT: 'submitElement', + SEND_KEYS_TO_ELEMENT: 'sendKeysToElement', + GET_ELEMENT_TEXT: 'getElementText', + GET_ELEMENT_ATTRIBUTE: 'getElementAttribute', + GET_ELEMENT_PROPERTY: 'getElementProperty', + GET_ELEMENT_DOM_PROPERTY: 'getElementDomProperty', + GET_ELEMENT_DOM_ATTRIBUTE: 'getElementDomAttribute', + GET_ELEMENT_VALUE_OF_CSS_PROPERTY: 'getElementValueOfCssProperty', + IS_ELEMENT_DISPLAYED: 'isElementDisplayed', + IS_ELEMENT_ENABLED: 'isElementEnabled', + IS_ELEMENT_SELECTED: 'isElementSelected', + GET_ELEMENT_TAG_NAME: 'getElementTagName', + GET_ELEMENT_RECT: 'getElementRect', + GET_ELEMENT_LOCATION: 'getElementLocation', + GET_ELEMENT_SIZE: 'getElementSize', + GET_ELEMENT_LOCATION_ONCE_SCROLLED_INTO_VIEW: 'getElementLocationOnceScrolledIntoView', + ELEMENT_SCREENSHOT: 'elementScreenshot', + GET_ACTIVE_ELEMENT: 'getActiveElement', + EXECUTE_SCRIPT: 'executeScript', + EXECUTE_ASYNC_SCRIPT: 'executeAsyncScript', + SCREENSHOT: 'screenshot', + SWITCH_TO_FRAME: 'switchToFrame', + SWITCH_TO_PARENT_FRAME: 'switchToParentFrame', + GET_CURRENT_WINDOW_HANDLE: 'getCurrentWindowHandle', + GET_WINDOW_HANDLES: 'getWindowHandles', + SWITCH_TO_WINDOW: 'switchToWindow', + MAXIMIZE_WINDOW: 'maximizeCurrentWindow', + MINIMIZE_WINDOW: 'minimizeCurrentWindow', + FULLSCREEN_WINDOW: 'fullscreenCurrentWindow', + GET_WINDOW_RECT: 'getWindowRect', + GET_CURRENT_WINDOW_SIZE: 'getCurrentWindowSize', + SET_CURRENT_WINDOW_SIZE: 'setCurrentWindowSize', + GET_CURRENT_WINDOW_POSITION: 'getCurrentWindowPosition', + SET_CURRENT_WINDOW_POSITION: 'setCurrentWindowPosition', + SET_WINDOW_RECT: 'setWindowRect', + SET_TIMEOUT: 'setTimeout', + SET_SCRIPT_TIMEOUT: 'setScriptTimeout', + IMPLICITLY_WAIT: 'implicitlyWait', + GET_TIMEOUTS: 'getTimeouts', + DELETE_ALL_COOKIES: 'deleteAllCookies', + GET_ALL_COOKIES: 'getCookies', + GET_COOKIE: 'getCookie', + DELETE_COOKIE: 'deleteCookie', + ADD_COOKIE: 'addCookie', + GET_ALERT_TEXT: 'getAlertText', + SET_ALERT_VALUE: 'setAlertValue', + ACCEPT_ALERT: 'acceptAlert', + DISMISS_ALERT: 'dismissAlert', + GET_AVAILABLE_LOG_TYPES: 'getAvailableLogTypes', + GET_LOG: 'getLog', + GET_SESSION_LOGS: 'getSessionLogs', + GET_ELEMENT_SHADOW_ROOT: 'getElementShadowRoot', + FIND_ELEMENT_FROM_SHADOW_ROOT: 'findElementFromShadowRoot', + FIND_ELEMENTS_FROM_SHADOW_ROOT: 'findElementsFromShadowRoot', +} as const; diff --git a/src/protocol/driver-command-response.ts b/src/protocol/driver-command-response.ts new file mode 100644 index 0000000..ef9579f --- /dev/null +++ b/src/protocol/driver-command-response.ts @@ -0,0 +1,15 @@ +export interface DriverCommandResponse { + sessionId: string; + status: number; + state: string; + value: unknown; +} + +export function createDriverCommandResponse(params: { + sessionId: string; + status: number; + state: string; + value: unknown; +}): DriverCommandResponse { + return params; +} diff --git a/src/protocol/frame-resolver.test.ts b/src/protocol/frame-resolver.test.ts new file mode 100644 index 0000000..2cc255b --- /dev/null +++ b/src/protocol/frame-resolver.test.ts @@ -0,0 +1,103 @@ +import type { ElementHandle, Frame, Locator, Page } from 'playwright'; +import { describe, expect, it, vi } from 'vitest'; +import { + resolveFrameByIndex, + resolveFrameByName, + resolveFrameFromElement, +} from './frame-resolver.js'; + +describe('PlaywrightFrameResolver', () => { + it('resolveFromElement uses contentFrame when available', async () => { + const page = { + waitForTimeout: vi.fn(), + } as unknown as Page; + const searchRoot = {} as Frame; + const childFrame = {} as Frame; + const iframe = { + scrollIntoViewIfNeeded: vi.fn().mockResolvedValue(undefined), + contentFrame: vi.fn().mockResolvedValue(childFrame), + } as unknown as ElementHandle; + + await expect(resolveFrameFromElement(page, searchRoot, iframe)).resolves.toBe(childFrame); + expect(page.waitForTimeout).not.toHaveBeenCalled(); + }); + + it('resolveFromElement falls back to frame element match', async () => { + const page = { + frames: vi.fn().mockReturnValue([]), + waitForTimeout: vi.fn().mockResolvedValue(undefined), + } as unknown as Page; + const searchRoot = {} as Frame; + const matchedFrame = { + frameElement: vi.fn(), + } as unknown as Frame; + const matchedElement = {} as ElementHandle; + const iframe = { + scrollIntoViewIfNeeded: vi.fn().mockResolvedValue(undefined), + contentFrame: vi.fn().mockResolvedValue(null), + getAttribute: vi.fn().mockResolvedValue(null), + evaluate: vi.fn().mockResolvedValue(false), + } as unknown as ElementHandle; + + page.frames = vi.fn().mockReturnValue([matchedFrame]); + matchedFrame.frameElement = vi.fn().mockResolvedValue(matchedElement); + iframe.evaluate = vi.fn().mockImplementation((_script, other) => { + return Promise.resolve(other === matchedElement); + }); + + await expect(resolveFrameFromElement(page, searchRoot, iframe)).resolves.toBe(matchedFrame); + }); + + it('resolveFromElement falls back to frame name', async () => { + const namedFrame = {} as Frame; + const page = { + frame: vi.fn().mockReturnValue(namedFrame), + waitForTimeout: vi.fn().mockResolvedValue(undefined), + } as unknown as Page; + const searchRoot = {} as Frame; + const iframe = { + scrollIntoViewIfNeeded: vi.fn().mockResolvedValue(undefined), + contentFrame: vi.fn().mockResolvedValue(null), + getAttribute: vi.fn().mockImplementation((name: string) => { + return Promise.resolve(name === 'name' ? 'content' : null); + }), + } as unknown as ElementHandle; + + await expect(resolveFrameFromElement(page, searchRoot, iframe)).resolves.toBe(namedFrame); + expect(page.frame).toHaveBeenCalledWith('content'); + }); + + it('resolveByIndex uses nth iframe in current frame', async () => { + const page = { + waitForTimeout: vi.fn().mockResolvedValue(undefined), + } as unknown as Page; + const childFrame = {} as Frame; + const iframe = { + scrollIntoViewIfNeeded: vi.fn().mockResolvedValue(undefined), + contentFrame: vi.fn().mockResolvedValue(childFrame), + } as unknown as ElementHandle; + const nth = { + elementHandle: vi.fn().mockResolvedValue(iframe), + } as unknown as Locator; + const iframes = { + count: vi.fn().mockResolvedValue(2), + nth: vi.fn().mockReturnValue(nth), + } as unknown as Locator; + const searchRoot = { + locator: vi.fn().mockReturnValue(iframes), + page: vi.fn().mockReturnValue(page), + } as unknown as Frame; + + await expect(resolveFrameByIndex(searchRoot, 1)).resolves.toBe(childFrame); + }); + + it('resolveByName uses page frame lookup', async () => { + const namedFrame = {} as Frame; + const page = { + frame: vi.fn().mockReturnValue(namedFrame), + } as unknown as Page; + const searchRoot = {} as Frame; + + await expect(resolveFrameByName(page, searchRoot, 'sidebar')).resolves.toBe(namedFrame); + }); +}); diff --git a/src/protocol/frame-resolver.ts b/src/protocol/frame-resolver.ts new file mode 100644 index 0000000..66401bd --- /dev/null +++ b/src/protocol/frame-resolver.ts @@ -0,0 +1,104 @@ +import type { ElementHandle, Frame, Page } from 'playwright'; +import { escapeCssAttribute, escapeCssId } from '../locators/playwright-locator-resolver.js'; +import { elementsAreSame } from '../protocol/page-eval-functions.js'; + +const CONTENT_FRAME_ATTEMPTS = 20; +const CONTENT_FRAME_POLL_MS = 50; + +export async function resolveFrameFromElement( + page: Page, + _searchRoot: Frame, + iframeElement: ElementHandle, +): Promise { + await iframeElement.scrollIntoViewIfNeeded(); + + let frame = await pollContentFrame(page, iframeElement); + if (frame != null) { + return frame; + } + + frame = await resolveByNameOrId(page, iframeElement); + if (frame != null) { + return frame; + } + + return findMatchingFrame(page, iframeElement); +} + +export async function resolveFrameByIndex(searchRoot: Frame, index: number): Promise { + const iframes = searchRoot.locator('iframe, frame'); + const count = await iframes.count(); + if (index < 0 || index >= count) { + return null; + } + const handle = await iframes.nth(index).elementHandle(); + if (handle == null) { + return null; + } + return resolveFrameFromElement(searchRoot.page(), searchRoot, handle); +} + +export async function resolveFrameByName( + page: Page, + searchRoot: Frame, + nameOrId: string, +): Promise { + const namedFrame = page.frame(nameOrId); + if (namedFrame != null) { + return namedFrame; + } + + const escaped = escapeCssAttribute(nameOrId); + const escapedId = escapeCssId(nameOrId); + const locator = searchRoot.locator( + `frame[name='${escaped}'],iframe[name='${escaped}'],frame#${escapedId},iframe#${escapedId}`, + ); + if ((await locator.count()) === 0) { + return null; + } + const handle = await locator.first().elementHandle(); + if (handle == null) { + return null; + } + return resolveFrameFromElement(page, searchRoot, handle); +} + +async function pollContentFrame(page: Page, iframeElement: ElementHandle): Promise { + for (let attempt = 0; attempt < CONTENT_FRAME_ATTEMPTS; attempt++) { + const frame = await iframeElement.contentFrame(); + if (frame != null) { + return frame; + } + await page.waitForTimeout(CONTENT_FRAME_POLL_MS); + } + return null; +} + +async function resolveByNameOrId(page: Page, iframeElement: ElementHandle): Promise { + const name = await iframeElement.getAttribute('name'); + if (name != null && name.trim() !== '') { + const frame = page.frame(name); + if (frame != null) { + return frame; + } + } + const frameId = await iframeElement.getAttribute('id'); + if (frameId != null && frameId.trim() !== '') { + return page.frame(frameId); + } + return null; +} + +async function findMatchingFrame(page: Page, iframeElement: ElementHandle): Promise { + for (const candidate of page.frames()) { + const candidateElement = await candidate.frameElement(); + if (candidateElement != null && (await sameElement(iframeElement, candidateElement))) { + return candidate; + } + } + return null; +} + +async function sameElement(left: ElementHandle, right: ElementHandle): Promise { + return elementsAreSame(left, right); +} diff --git a/src/protocol/page-eval-functions.ts b/src/protocol/page-eval-functions.ts new file mode 100644 index 0000000..0a2b654 --- /dev/null +++ b/src/protocol/page-eval-functions.ts @@ -0,0 +1,136 @@ +import type { ElementHandle, Frame, JSHandle } from 'playwright'; + +export function readPageSource(frame: Frame): Promise { + return frame.evaluate(() => { + const doc = document; + if (!doc?.documentElement) { + return ''; + } + let source = doc.documentElement.outerHTML; + if (!source) { + source = new XMLSerializer().serializeToString(doc); + } + return source; + }); +} + +export async function documentActiveElement(frame: Frame): Promise { + const handle = await frame.evaluateHandle(() => document.activeElement); + const element = handle.asElement(); + if (element == null) { + throw new Error('No active element'); + } + return element; +} + +export function submitElement(element: ElementHandle): Promise { + return element.evaluate((el) => { + if (typeof (el as HTMLFormElement).submit === 'function') { + (el as HTMLFormElement).submit(); + } + }); +} + +export function readElementProperty(element: ElementHandle, name: string): Promise { + return element.evaluate( + (el, propertyName) => (el as unknown as Record)[propertyName], + name, + ); +} + +export function readElementCssProperty(element: ElementHandle, name: string): Promise { + return element.evaluate( + (el, propertyName) => getComputedStyle(el as Element).getPropertyValue(propertyName), + name, + ); +} + +export function readElementTagName(element: ElementHandle): Promise { + return element.evaluate((el) => (el as Element).tagName.toLowerCase()); +} + +export function isElementSelected(element: ElementHandle): Promise { + return element.evaluate((el) => + 'checked' in el + ? Boolean((el as HTMLInputElement).checked) + : Boolean((el as HTMLOptionElement).selected), + ); +} + +export function findChildByXpath( + element: ElementHandle, + xpath: string, +): Promise { + return element + .evaluateHandle( + (el, expression) => + document.evaluate(expression, el, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null) + .singleNodeValue, + xpath, + ) + .then((handle) => handle.asElement()); +} + +export function elementsAreSame(left: ElementHandle, right: ElementHandle): Promise { + return left.evaluate((self, other) => self === other, right); +} + +export function hostShadowRoot(host: ElementHandle): Promise { + return host.evaluateHandle((el) => (el as Element & { shadowRoot?: ShadowRoot }).shadowRoot); +} + +export function shadowRootQuerySelector(shadowRoot: JSHandle, selector: string): Promise { + return shadowRoot.evaluateHandle( + (root, cssSelector) => (root as ShadowRoot | null)?.querySelector(cssSelector) ?? null, + selector, + ); +} + +export function shadowRootQuerySelectorAll( + shadowRoot: JSHandle, + selector: string, +): Promise { + return shadowRoot.evaluateHandle( + (root, cssSelector) => + Array.from((root as ShadowRoot | null)?.querySelectorAll(cssSelector) ?? []), + selector, + ); +} + +export function shadowRootFindByXpath(shadowRoot: JSHandle, xpath: string): Promise { + return shadowRoot.evaluateHandle((root, expression) => { + if (root == null) { + return null; + } + return document.evaluate( + expression, + root as Node, + null, + XPathResult.FIRST_ORDERED_NODE_TYPE, + null, + ).singleNodeValue; + }, xpath); +} + +export function shadowRootFindAllByXpath(shadowRoot: JSHandle, xpath: string): Promise { + return shadowRoot.evaluateHandle((root, expression) => { + if (root == null) { + return []; + } + const result = document.evaluate( + expression, + root as Node, + null, + XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, + null, + ); + const nodes: Node[] = []; + for (let index = 0; index < result.snapshotLength; index++) { + const item = result.snapshotItem(index); + if (item != null) { + nodes.push(item); + } + } + return nodes; + }, xpath); +} diff --git a/src/protocol/remote-script-adapter.test.ts b/src/protocol/remote-script-adapter.test.ts new file mode 100644 index 0000000..0f301d2 --- /dev/null +++ b/src/protocol/remote-script-adapter.test.ts @@ -0,0 +1,39 @@ +import { chromium, type Browser } from 'playwright'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { runAsyncScriptInFrame, runSyncScriptInFrame } from './remote-script-adapter.js'; + +describe('remote-script-adapter', () => { + let browser: Browser; + + beforeAll(async () => { + browser = await chromium.launch({ headless: true }); + }); + + afterAll(async () => { + await browser.close(); + }); + + it('runSyncScriptInFrame exposes remote arguments array', async () => { + const page = await browser.newPage(); + try { + const handle = await runSyncScriptInFrame(page.mainFrame(), 'return arguments[0];', ['ok']); + await expect(handle.jsonValue()).resolves.toBe('ok'); + } finally { + await page.close(); + } + }); + + it('runAsyncScriptInFrame appends Promise callback', async () => { + const page = await browser.newPage(); + try { + const handle = await runAsyncScriptInFrame( + page.mainFrame(), + "const callback = arguments[arguments.length - 1]; callback('done');", + [], + ); + await expect(handle.jsonValue()).resolves.toBe('done'); + } finally { + await page.close(); + } + }); +}); diff --git a/src/protocol/remote-script-adapter.ts b/src/protocol/remote-script-adapter.ts new file mode 100644 index 0000000..ab47686 --- /dev/null +++ b/src/protocol/remote-script-adapter.ts @@ -0,0 +1,46 @@ +import type { Frame } from 'playwright'; + +interface RemoteScriptPayload { + scriptArgs: unknown[]; + scriptBody: string; +} + +/** + * Run a synchronous remote script in the browser frame. + * Script bodies may reference the `arguments` array (remote driver protocol convention). + */ +export function runSyncScriptInFrame( + frame: Frame, + scriptBody: string, + scriptArgs: unknown[], +): ReturnType { + const payload: RemoteScriptPayload = { scriptArgs, scriptBody }; + return frame.evaluateHandle(({ scriptArgs: remoteArgs, scriptBody: body }) => { + const execute = new Function('arguments', body) as (args: unknown[]) => unknown; + return execute(remoteArgs); + }, payload); +} + +/** + * Run an asynchronous remote script in the browser frame. + * Script bodies may reference `arguments` and invoke the trailing callback. + */ +export function runAsyncScriptInFrame( + frame: Frame, + scriptBody: string, + scriptArgs: unknown[], +): ReturnType { + const payload: RemoteScriptPayload = { scriptArgs, scriptBody }; + return frame.evaluateHandle(({ scriptArgs: remoteArgs, scriptBody: body }) => { + const argsWithCallback = remoteArgs.slice(); + return new Promise((resolve, reject) => { + argsWithCallback.push((value: unknown) => resolve(value)); + try { + const execute = new Function('arguments', body) as (args: unknown[]) => void; + execute(argsWithCallback); + } catch (error) { + reject(error); + } + }); + }, payload); +} diff --git a/src/protocol/script-result-converter.ts b/src/protocol/script-result-converter.ts new file mode 100644 index 0000000..cc47bf9 --- /dev/null +++ b/src/protocol/script-result-converter.ts @@ -0,0 +1,32 @@ +import type { JSHandle } from 'playwright'; +import { ElementRegistry } from '../elements/element-registry.js'; + +export class PlaywrightScriptResultConverter { + constructor(private readonly elementRegistry: ElementRegistry) {} + + async convert(handle: JSHandle | null): Promise { + if (handle == null) { + return null; + } + const elementHandle = handle.asElement(); + if (elementHandle != null) { + return this.elementRegistry.register(elementHandle); + } + return this.convertValue(await handle.jsonValue()); + } + + convertValue(value: unknown): unknown { + if (value != null && typeof value === 'object' && !Array.isArray(value)) { + const map = value as Record; + const converted: Record = {}; + for (const [key, entryValue] of Object.entries(map)) { + converted[key] = this.convertValue(entryValue); + } + return converted; + } + if (Array.isArray(value)) { + return value.map((item) => this.convertValue(item)); + } + return value; + } +} diff --git a/src/protocol/send-keys-text.ts b/src/protocol/send-keys-text.ts new file mode 100644 index 0000000..0d3f3c0 --- /dev/null +++ b/src/protocol/send-keys-text.ts @@ -0,0 +1,20 @@ +export function readSendKeysText(parameters: Record): string { + const textParameter = parameters.text; + if (textParameter != null) { + return String(textParameter); + } + const rawValue = parameters.value; + if (Array.isArray(rawValue)) { + let builder = ''; + for (const item of rawValue) { + if (item != null) { + builder += item; + } + } + return builder; + } + if (rawValue != null) { + return String(rawValue); + } + return ''; +} diff --git a/src/session/playwright-browser-session.ts b/src/session/playwright-browser-session.ts new file mode 100644 index 0000000..496eb9e --- /dev/null +++ b/src/session/playwright-browser-session.ts @@ -0,0 +1,25 @@ +import type { DriverCommandResponse } from '../protocol/driver-command-response.js'; +import type { PlaywrightCommandExecutor } from '../protocol/command-executor.js'; +import { BROWSER_NAME, PLATFORM_NAME } from './playwright-driver-constants.js'; + +export class PlaywrightBrowserSession { + readonly sessionId: string; + readonly capabilities: Record; + readonly commandExecutor: PlaywrightCommandExecutor; + + constructor(sessionId: string, executor: PlaywrightCommandExecutor) { + this.sessionId = sessionId; + this.commandExecutor = executor; + this.capabilities = { + browserName: BROWSER_NAME, + platformName: PLATFORM_NAME, + }; + } + + executeCommand( + commandName: string, + parameters: Record = {}, + ): Promise { + return this.commandExecutor.execute(commandName, parameters); + } +} diff --git a/src/session/playwright-driver-constants.ts b/src/session/playwright-driver-constants.ts new file mode 100644 index 0000000..393b24f --- /dev/null +++ b/src/session/playwright-driver-constants.ts @@ -0,0 +1,2 @@ +export const BROWSER_NAME = 'chromium'; +export const PLATFORM_NAME = 'ANY'; diff --git a/src/session/playwright-session.ts b/src/session/playwright-session.ts new file mode 100644 index 0000000..43f130d --- /dev/null +++ b/src/session/playwright-session.ts @@ -0,0 +1,91 @@ +import type { Browser, BrowserContext, Page } from 'playwright'; +import { randomUUID } from 'node:crypto'; +import { PlaywrightCommandExecutor } from '../protocol/command-executor.js'; +import { PlaywrightBrowserSession } from './playwright-browser-session.js'; + +export interface PlaywrightLaunchConfig { + browser?: string; + headless?: boolean; +} + +export class PlaywrightSession { + readonly page: Page; + readonly browserSession: PlaywrightBrowserSession; + private readonly ownsLifecycle: boolean; + + private constructor( + page: Page, + browserSession: PlaywrightBrowserSession, + ownsLifecycle: boolean, + ) { + this.page = page; + this.browserSession = browserSession; + this.ownsLifecycle = ownsLifecycle; + } + + static async launch(config: PlaywrightLaunchConfig = {}): Promise { + const browserName = config.browser ?? 'chromium'; + const headless = config.headless ?? false; + const browserType = await selectBrowserType(browserName); + const browser = await browserType.launch({ headless }); + const context = await browser.newContext(); + const page = await context.newPage(); + return PlaywrightSession.wrapInternal(null, browser, context, page, true); + } + + static wrap(page: Page): PlaywrightSession { + const context = page.context(); + const browser = context.browser(); + return PlaywrightSession.wrapInternal(null, browser, context, page, false); + } + + private static wrapInternal( + playwright: { close(): Promise } | null, + browser: Browser | null, + context: BrowserContext, + page: Page, + ownsLifecycle: boolean, + ): PlaywrightSession { + const sessionId = randomUUID(); + const executor = new PlaywrightCommandExecutor( + sessionId, + playwright, + browser, + context, + page, + ownsLifecycle + ? (runnable) => { + void runnable(); + } + : null, + ); + const browserSession = new PlaywrightBrowserSession(sessionId, executor); + return new PlaywrightSession(page, browserSession, ownsLifecycle); + } + + async close(): Promise { + if (!this.ownsLifecycle) { + return; + } + try { + await this.browserSession.executeCommand('quit', {}); + } catch (error) { + console.warn( + `Error closing Playwright session: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} + +async function selectBrowserType(browserName: string) { + const { chromium, firefox, webkit } = await import('playwright'); + switch (browserName.toLowerCase()) { + case 'firefox': + return firefox; + case 'webkit': + return webkit; + case 'chromium': + default: + return chromium; + } +} diff --git a/src/shadow/resolved-shadow-root.ts b/src/shadow/resolved-shadow-root.ts new file mode 100644 index 0000000..7211620 --- /dev/null +++ b/src/shadow/resolved-shadow-root.ts @@ -0,0 +1,93 @@ +import type { ElementHandle, JSHandle } from 'playwright'; +import { ElementNotFoundException } from '../errors/element-not-found-exception.js'; +import { ShadowRootNotFoundException } from '../errors/shadow-root-not-found-exception.js'; +import { toCssSelector } from '../locators/playwright-locator-resolver.js'; +import { + hostShadowRoot, + shadowRootFindAllByXpath, + shadowRootFindByXpath, + shadowRootQuerySelector, + shadowRootQuerySelectorAll, +} from '../protocol/page-eval-functions.js'; + +export class ResolvedShadowRoot { + constructor(private readonly shadowRoot: JSHandle) {} + + shadowRootHandle(): JSHandle { + return this.shadowRoot; + } + + static async fromHost(host: ElementHandle): Promise { + const handle = await hostShadowRoot(host); + const value = await handle.evaluate((root) => root); + if (value == null) { + throw new ShadowRootNotFoundException('No shadow root attached to element'); + } + return new ResolvedShadowRoot(handle); + } + + async findElement(using: string, value: string): Promise { + const result = await this.findHandle(using, value, false); + const element = result.asElement(); + if (element == null) { + throw new ElementNotFoundException('Unable to locate element in shadow root'); + } + return element; + } + + async findElements(using: string, value: string): Promise { + const rawResult = await this.findRawResult(using, value, true); + if (!Array.isArray(rawResult)) { + return []; + } + const elements: ElementHandle[] = []; + for (const handle of rawResult) { + if (handle && typeof handle === 'object' && 'asElement' in handle) { + const element = (handle as JSHandle).asElement(); + if (element != null) { + elements.push(element); + } + } + } + return elements; + } + + private async findHandle(using: string, value: string, multiple: boolean): Promise { + const rawResult = await this.findRawResult(using, value, multiple); + if (rawResult == null) { + throw new ElementNotFoundException('Unable to locate element in shadow root'); + } + if (rawResult && typeof rawResult === 'object' && 'asElement' in rawResult) { + return rawResult as JSHandle; + } + throw new ElementNotFoundException('Unable to locate element in shadow root'); + } + + private async findRawResult(using: string, value: string, multiple: boolean): Promise { + if (using === 'xpath') { + if (multiple) { + return this.extractElementHandlesFromArray( + await shadowRootFindAllByXpath(this.shadowRoot, value), + ); + } + return shadowRootFindByXpath(this.shadowRoot, value); + } + const selector = toCssSelector(using, value); + if (multiple) { + return this.extractElementHandlesFromArray( + await shadowRootQuerySelectorAll(this.shadowRoot, selector), + ); + } + return shadowRootQuerySelector(this.shadowRoot, selector); + } + + private async extractElementHandlesFromArray(arrayHandle: JSHandle): Promise { + const properties = await arrayHandle.getProperties(); + const handles: JSHandle[] = []; + for (const propertyHandle of properties.values()) { + handles.push(propertyHandle); + } + await arrayHandle.dispose(); + return handles; + } +} diff --git a/src/shadow/shadow-root-registry.test.ts b/src/shadow/shadow-root-registry.test.ts new file mode 100644 index 0000000..d19d384 --- /dev/null +++ b/src/shadow/shadow-root-registry.test.ts @@ -0,0 +1,142 @@ +import type { ElementHandle, JSHandle } from 'playwright'; +import { describe, expect, it, vi } from 'vitest'; +import { ElementNotFoundException } from '../errors/element-not-found-exception.js'; +import { ShadowRootNotFoundException } from '../errors/shadow-root-not-found-exception.js'; +import { ResolvedShadowRoot } from './resolved-shadow-root.js'; +import { ShadowRootRegistry } from './shadow-root-registry.js'; + +describe('ShadowRootRegistry', () => { + it('register returns W3C shadow root reference', () => { + const registry = new ShadowRootRegistry(); + const handle = {} as JSHandle; + + const reference = registry.register(new ResolvedShadowRoot(handle)); + + expect(reference).toHaveProperty(ShadowRootRegistry.W3C_SHADOW_ROOT_KEY); + expect( + registry.resolve(String(reference[ShadowRootRegistry.W3C_SHADOW_ROOT_KEY])), + ).toBeDefined(); + }); + + it('resolve accepts shadowId parameter', () => { + const registry = new ShadowRootRegistry(); + const handle = {} as JSHandle; + const id = String( + registry.register(new ResolvedShadowRoot(handle))[ShadowRootRegistry.W3C_SHADOW_ROOT_KEY], + ); + + expect(registry.resolve(id).shadowRootHandle()).toBe(handle); + }); + + it('resolve missing reference throws', () => { + const registry = new ShadowRootRegistry(); + + expect(() => registry.resolve('missing')).toThrow(ShadowRootNotFoundException); + }); + + it('resolve accepts nested shadow reference', () => { + const registry = new ShadowRootRegistry(); + const handle = {} as JSHandle; + const id = String( + registry.register(new ResolvedShadowRoot(handle))[ShadowRootRegistry.W3C_SHADOW_ROOT_KEY], + ); + + const parameters = { + shadowId: { [ShadowRootRegistry.W3C_SHADOW_ROOT_KEY]: id }, + using: 'css selector', + value: '.child', + }; + + expect(registry.resolveFromParameters(parameters).shadowRootHandle()).toBe(handle); + }); + + it('clear removes registered shadow roots', () => { + const registry = new ShadowRootRegistry(); + const handle = {} as JSHandle; + const id = String( + registry.register(new ResolvedShadowRoot(handle))[ShadowRootRegistry.W3C_SHADOW_ROOT_KEY], + ); + + registry.clear(); + + expect(() => registry.resolve(id)).toThrow(ShadowRootNotFoundException); + }); +}); + +describe('ResolvedShadowRoot', () => { + it('fromHost returns shadow root when present', async () => { + const host = { + evaluateHandle: vi.fn(), + } as unknown as ElementHandle; + const shadowRoot = { + evaluate: vi.fn().mockResolvedValue({}), + } as unknown as JSHandle; + host.evaluateHandle = vi.fn().mockResolvedValue(shadowRoot); + + const resolved = await ResolvedShadowRoot.fromHost(host); + + expect(resolved.shadowRootHandle()).toBe(shadowRoot); + }); + + it('fromHost throws when shadow root missing', async () => { + const host = { + evaluateHandle: vi.fn(), + } as unknown as ElementHandle; + const shadowRoot = { + evaluate: vi.fn().mockResolvedValue(null), + } as unknown as JSHandle; + host.evaluateHandle = vi.fn().mockResolvedValue(shadowRoot); + + await expect(ResolvedShadowRoot.fromHost(host)).rejects.toThrow(ShadowRootNotFoundException); + }); + + it('findElement uses css selector', async () => { + const element = {} as ElementHandle; + const resultHandle = { + asElement: vi.fn().mockReturnValue(element), + } as unknown as JSHandle; + const shadowRoot = { + evaluateHandle: vi.fn().mockResolvedValue(resultHandle), + } as unknown as JSHandle; + + const found = await new ResolvedShadowRoot(shadowRoot).findElement('css selector', '.item'); + + expect(found).toBe(element); + expect(shadowRoot.evaluateHandle).toHaveBeenCalledWith(expect.any(Function), '.item'); + }); + + it('findElements uses xpath', async () => { + const first = { asElement: vi.fn().mockReturnValue({}) } as unknown as JSHandle; + const second = { asElement: vi.fn().mockReturnValue({}) } as unknown as JSHandle; + const arrayHandle = { + getProperties: vi.fn().mockResolvedValue( + new Map([ + ['0', first], + ['1', second], + ]), + ), + dispose: vi.fn().mockResolvedValue(undefined), + } as unknown as JSHandle; + const shadowRoot = { + evaluateHandle: vi.fn().mockResolvedValue(arrayHandle), + } as unknown as JSHandle; + + const found = await new ResolvedShadowRoot(shadowRoot).findElements('xpath', '//button'); + + expect(found).toHaveLength(2); + expect(shadowRoot.evaluateHandle).toHaveBeenCalledWith(expect.any(Function), '//button'); + }); + + it('findElement throws when not found', async () => { + const resultHandle = { + asElement: vi.fn().mockReturnValue(null), + } as unknown as JSHandle; + const shadowRoot = { + evaluateHandle: vi.fn().mockResolvedValue(resultHandle), + } as unknown as JSHandle; + + await expect( + new ResolvedShadowRoot(shadowRoot).findElement('css selector', '.missing'), + ).rejects.toThrow(ElementNotFoundException); + }); +}); diff --git a/src/shadow/shadow-root-registry.ts b/src/shadow/shadow-root-registry.ts new file mode 100644 index 0000000..ea5ce9e --- /dev/null +++ b/src/shadow/shadow-root-registry.ts @@ -0,0 +1,46 @@ +import { ShadowRootNotFoundException } from '../errors/shadow-root-not-found-exception.js'; +import { ResolvedShadowRoot } from './resolved-shadow-root.js'; + +export class ShadowRootRegistry { + static readonly W3C_SHADOW_ROOT_KEY = 'shadow-6066-11e4-a52e-4f735466cecf'; + private static readonly SHADOW_ID_PARAMETER = 'shadowId'; + + private counter = 0; + private readonly shadowRoots = new Map(); + + register(shadowRoot: ResolvedShadowRoot): Record { + const id = String(++this.counter); + this.shadowRoots.set(id, shadowRoot); + return { [ShadowRootRegistry.W3C_SHADOW_ROOT_KEY]: id }; + } + + resolve(shadowId: string | null | undefined): ResolvedShadowRoot { + if (shadowId == null || shadowId.trim() === '') { + throw new Error('Missing shadow root reference'); + } + const shadowRoot = this.shadowRoots.get(shadowId); + if (shadowRoot == null) { + throw new ShadowRootNotFoundException(`Shadow root reference not found: ${shadowId}`); + } + return shadowRoot; + } + + resolveFromParameters(parameters: Record): ResolvedShadowRoot { + let rawShadowId: unknown = parameters[ShadowRootRegistry.SHADOW_ID_PARAMETER]; + if (rawShadowId == null) { + rawShadowId = parameters[ShadowRootRegistry.W3C_SHADOW_ROOT_KEY]; + } + if (rawShadowId != null && typeof rawShadowId === 'object' && !Array.isArray(rawShadowId)) { + const nested = rawShadowId as Record; + const nestedId = nested[ShadowRootRegistry.W3C_SHADOW_ROOT_KEY]; + if (nestedId != null) { + return this.resolve(String(nestedId)); + } + } + return this.resolve(rawShadowId == null ? null : String(rawShadowId)); + } + + clear(): void { + this.shadowRoots.clear(); + } +} diff --git a/src/testrigor.ts b/src/testrigor.ts new file mode 100644 index 0000000..f225b5c --- /dev/null +++ b/src/testrigor.ts @@ -0,0 +1,95 @@ +import type { Page } from 'playwright'; +import { TestRigorActions } from './commons/application/commands/TestRigorActions.js'; +import { TestRigorQueries } from './commons/application/commands/TestRigorQueries.js'; +import { TestRigorValidations } from './commons/application/commands/TestRigorValidations.js'; +import type { TestRigorCommandDriver } from './commons/application/commands/TestRigorCommandDriver.js'; +import { GrpcEndpointConfig } from './commons/application/grpc/GrpcEndpointConfig.js'; +import { PlaywrightExtensionService } from './application/extension-service.js'; +import { TestrigorPlaywrightDriver } from './application/playwright-driver.js'; +import { loadDefaultConfig, toLaunchConfig, type PlaywrightPluginConfig } from './config.js'; +import { PlaywrightLocator } from './locators/playwright-locator.js'; +import { PlaywrightSession } from './session/playwright-session.js'; + +export class TestRigor { + private constructor() {} + + static extendPage(page: Page, apiToken: string): TestrigorPlaywrightDriver; + static extendPage( + page: Page, + apiToken: string, + grpcEndpoint: GrpcEndpointConfig, + ): TestrigorPlaywrightDriver; + static extendPage( + page: Page, + apiToken: string, + grpcEndpoint?: GrpcEndpointConfig, + ): TestrigorPlaywrightDriver { + const session = PlaywrightSession.wrap(page); + return TestRigor.extendSession( + session, + apiToken, + grpcEndpoint ?? GrpcEndpointConfig.fromConfig(loadDefaultConfig()), + ); + } + + static async createBrowserPage( + config: PlaywrightPluginConfig, + apiToken: string, + ): Promise; + static async createBrowserPage( + config: PlaywrightPluginConfig, + apiToken: string, + grpcEndpoint: GrpcEndpointConfig, + ): Promise; + static async createBrowserPage(apiToken: string): Promise; + static async createBrowserPage( + configOrApiToken: PlaywrightPluginConfig | string, + apiToken?: string, + grpcEndpoint?: GrpcEndpointConfig, + ): Promise { + if (typeof configOrApiToken === 'string') { + return TestRigor.createBrowserPage(loadDefaultConfig(), configOrApiToken); + } + const session = await PlaywrightSession.launch(toLaunchConfig(configOrApiToken)); + return TestRigor.extendSession( + session, + apiToken!, + grpcEndpoint ?? GrpcEndpointConfig.fromConfig(configOrApiToken), + ); + } + + static byUserDescription(description: string): PlaywrightLocator { + return PlaywrightLocator.byUserDescription(description); + } + + static actions(driver: TestRigorCommandDriver): TestRigorActions { + return TestRigorActions.actions(driver); + } + + static validations(driver: TestRigorCommandDriver): TestRigorValidations { + return TestRigorValidations.validations(driver); + } + + static queries(driver: TestRigorCommandDriver): TestRigorQueries { + return TestRigorQueries.queries(driver); + } + + static extendSession(session: PlaywrightSession, apiToken: string): TestrigorPlaywrightDriver; + static extendSession( + session: PlaywrightSession, + apiToken: string, + grpcEndpoint: GrpcEndpointConfig, + ): TestrigorPlaywrightDriver; + static extendSession( + session: PlaywrightSession, + apiToken: string, + grpcEndpoint?: GrpcEndpointConfig, + ): TestrigorPlaywrightDriver { + const extensionService = new PlaywrightExtensionService( + session.browserSession, + apiToken, + grpcEndpoint ?? GrpcEndpointConfig.fromConfig(loadDefaultConfig()), + ); + return new TestrigorPlaywrightDriver(session, extensionService); + } +} diff --git a/tests/manual/manual-driver.ts b/tests/manual/manual-driver.ts new file mode 100644 index 0000000..c61e7fa --- /dev/null +++ b/tests/manual/manual-driver.ts @@ -0,0 +1,35 @@ +import { TestRigor } from '../../src/testrigor.js'; +import type { TestrigorPlaywrightDriver } from '../../src/application/playwright-driver.js'; +import { initManualTestConfig } from './manual-test-support.js'; + +export interface CreateManualDriverOptions { + headless?: boolean; + testContext?: string; +} + +export async function createManualDriver( + options: CreateManualDriverOptions = {}, +): Promise { + const { config, grpcEndpoint, apiToken } = initManualTestConfig(); + if (options.headless != null) { + config['playwright.headless'] = options.headless; + } + const driver = await TestRigor.createBrowserPage(config, apiToken, grpcEndpoint); + if (options.testContext != null && options.testContext.trim() !== '') { + driver.setTestContext(options.testContext); + } + return driver; +} + +export async function closeManualDriver( + driver: TestrigorPlaywrightDriver | undefined, +): Promise { + if (driver == null) { + return; + } + try { + await driver.quit(); + } catch { + // ignore cleanup errors + } +} diff --git a/tests/manual/manual-test-support.ts b/tests/manual/manual-test-support.ts new file mode 100644 index 0000000..7094492 --- /dev/null +++ b/tests/manual/manual-test-support.ts @@ -0,0 +1,152 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { GrpcEndpointConfig } from '../../src/commons/application/grpc/GrpcEndpointConfig.js'; +import { loadDefaultConfig, type PlaywrightPluginConfig } from '../../src/config.js'; + +const APPLICATION_PROPERTIES = 'application.properties'; +const APPLICATION_PROPERTIES_EXAMPLE = 'application.properties.example'; +const DEFAULT_MANUAL_TESTS_RESOURCES = path.resolve(process.cwd(), 'tests/resources'); + +export interface ManualTestConfig { + config: PlaywrightPluginConfig; + grpcEndpoint: GrpcEndpointConfig; + apiToken: string; +} + +function normalizeConfigValue(value: string | undefined): string | undefined { + if (value == null) { + return undefined; + } + const trimmed = value.trim(); + if ( + trimmed.length >= 2 && + ((trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'"))) + ) { + return trimmed.slice(1, -1).trim(); + } + return trimmed; +} + +function parsePropertiesFile(filePath: string): Record { + if (!fs.existsSync(filePath)) { + return {}; + } + const content = fs.readFileSync(filePath, 'utf8'); + const properties: Record = {}; + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed === '' || trimmed.startsWith('#')) { + continue; + } + const separator = trimmed.indexOf('='); + if (separator < 0) { + continue; + } + const key = trimmed.slice(0, separator).trim(); + const value = normalizeConfigValue(trimmed.slice(separator + 1)); + if (value != null) { + properties[key] = value; + } + } + return properties; +} + +function loadPropertiesLayers(): Record { + const resourcesDir = DEFAULT_MANUAL_TESTS_RESOURCES; + const examplePath = path.join(resourcesDir, APPLICATION_PROPERTIES_EXAMPLE); + const applicationPath = path.join(resourcesDir, APPLICATION_PROPERTIES); + + let merged = parsePropertiesFile(examplePath); + merged = { ...merged, ...parsePropertiesFile(applicationPath) }; + + const configFile = process.env.CONFIG_FILE ?? process.env['config.file']; + if (configFile != null && configFile.trim() !== '') { + merged = { ...merged, ...parsePropertiesFile(path.resolve(configFile.trim())) }; + } + + return merged; +} + +function resolveOptional( + properties: Record, + key: string, + envKey: string, +): string | undefined { + const fromProperties = normalizeConfigValue(properties[key]); + if (fromProperties != null && fromProperties !== '') { + return fromProperties; + } + return normalizeConfigValue(process.env[envKey]); +} + +function assignString( + config: PlaywrightPluginConfig, + properties: Record, + key: 'testrigor.grpc.uri' | 'playwright.browser', + envKey: string, +): void { + const value = resolveOptional(properties, key, envKey); + if (value != null) { + config[key] = value; + } +} + +function assignNumber( + config: PlaywrightPluginConfig, + properties: Record, + key: 'testrigor.grpc.port', + envKey: string, +): void { + const value = resolveOptional(properties, key, envKey); + if (value == null) { + return; + } + const parsed = Number.parseInt(value, 10); + if (!Number.isNaN(parsed)) { + config[key] = parsed; + } +} + +function assignBoolean( + config: PlaywrightPluginConfig, + properties: Record, + key: 'testrigor.grpc.use-tls' | 'playwright.headless', + envKey: string, +): void { + const value = resolveOptional(properties, key, envKey); + if (value != null) { + config[key] = value.toLowerCase() === 'true'; + } +} + +function toPluginConfig(properties: Record): PlaywrightPluginConfig { + const config = loadDefaultConfig(); + + assignString(config, properties, 'testrigor.grpc.uri', 'TESTRIGOR_GRPC_URI'); + assignNumber(config, properties, 'testrigor.grpc.port', 'TESTRIGOR_GRPC_PORT'); + assignBoolean(config, properties, 'testrigor.grpc.use-tls', 'TESTRIGOR_GRPC_USE_TLS'); + assignString(config, properties, 'playwright.browser', 'TESTRIGOR_PLAYWRIGHT_BROWSER'); + assignBoolean(config, properties, 'playwright.headless', 'TESTRIGOR_PLAYWRIGHT_HEADLESS'); + + return config; +} + +function getRequiredValue(properties: Record, key: string, envKey: string): string { + const value = resolveOptional(properties, key, envKey); + if (value == null || value === '') { + throw new Error( + `Missing required configuration: ${key} (${APPLICATION_PROPERTIES} / ` + + `${APPLICATION_PROPERTIES_EXAMPLE} / env ${envKey})`, + ); + } + return value; +} + +export function initManualTestConfig(): ManualTestConfig { + const properties = loadPropertiesLayers(); + const config = toPluginConfig(properties); + const grpcEndpoint = GrpcEndpointConfig.fromConfig(config); + const apiToken = getRequiredValue(properties, 'testrigor.apiToken', 'TESTRIGOR_API_TOKEN'); + return { config, grpcEndpoint, apiToken }; +} diff --git a/tests/manual/playwright-grpc.manual.test.ts b/tests/manual/playwright-grpc.manual.test.ts new file mode 100644 index 0000000..cff5f1f --- /dev/null +++ b/tests/manual/playwright-grpc.manual.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { TestrigorPlaywrightDriver } from '../../src/application/playwright-driver.js'; +import { PlaywrightLocator } from '../../src/locators/playwright-locator.js'; +import { TestRigor } from '../../src/testrigor.js'; +import { closeManualDriver, createManualDriver } from './manual-driver.js'; + +const R4D4_URL = 'http://r4d4.info/'; +const LABEL_UPDATER_URL = 'http://r4d4.info/form-button-label'; + +describe.sequential('PlaywrightGrpcTest', () => { + let driver: TestrigorPlaywrightDriver; + + beforeEach(async () => { + driver = await createManualDriver(); + }); + + afterEach(async () => { + await closeManualDriver(driver); + }); + + function beginTest(testContext: string): void { + driver.setTestContext(testContext); + } + + async function openHome(): Promise { + await TestRigor.actions(driver).openUrl(R4D4_URL).waitUntilPageContains('Home Page').execute(); + } + + it('test_find_by_user_description', async () => { + beginTest('test_find_by_user_description'); + await openHome(); + + const button = await driver.findElement(TestRigor.byUserDescription('Empty Page')); + expect(button).not.toBeNull(); + + await TestRigor.validations(driver).checkThatElementIsVisible('Empty Page').execute(); + }); + + it('test_home_page_validations', async () => { + beginTest('test_home_page_validations'); + await openHome(); + + await TestRigor.validations(driver) + .checkPageContains('Home Page') + .and() + .checkPageContains('List of Sections and Shortcuts') + .and() + .checkUrlContains('r4d4.info') + .execute(); + }); + + it('test_navigation_flow_via_actions', async () => { + beginTest('test_navigation_flow_via_actions'); + await openHome(); + + await TestRigor.actions(driver) + .click('Static Pages') + .waitUntilPageContains('List of static pages') + .goBack() + .and() + .click('Empty Page') + .waitUntilPageContains('This is just an empty page') + .execute(); + }); + + it('test_local_playwright_locators', async () => { + beginTest('test_local_playwright_locators'); + + await TestRigor.actions(driver) + .openUrl(LABEL_UPDATER_URL) + .waitUntilPageContains('Label Updater') + .execute(); + + const updateButton = await driver.findElement(PlaywrightLocator.id('changer')); + expect(await updateButton.isDisplayed()).toBe(true); + + await TestRigor.validations(driver) + .checkButtonEnabled('Update Message') + .and() + .checkThatElementIsEnabled('Message') + .and() + .checkThatElementContains('Update Message', 'Update Message') + .execute(); + }); + + it('test_label_updater_via_testrigor_commands', async () => { + beginTest('test_label_updater_via_testrigor_commands'); + const message = 'Playwright extension test'; + + await TestRigor.actions(driver) + .openUrl(LABEL_UPDATER_URL) + .waitUntilPageContains('Label Updater') + .and() + .enter(message) + .into('Message') + .and() + .click('Update Message') + .execute(); + + await TestRigor.validations(driver) + .checkPageContains(message) + .and() + .checkThatInputHasValue('Message', message) + .execute(); + + const grabbed = await TestRigor.queries(driver).grabValue('input', 'Message'); + expect(grabbed).toContain(message); + }); + + it('test_execute_prompt_checks', async () => { + beginTest('test_execute_prompt_checks'); + + await TestRigor.actions(driver) + .openUrl(LABEL_UPDATER_URL) + .waitUntilPageContains('Label Updater') + .execute(); + + await TestRigor.validations(driver) + .checkPageContains('Label Updater') + .and() + .checkPageContains('Update Message') + .and() + .checkUrlContains('form-button-label') + .execute(); + + await driver.executePrompt('check that page contains "Updates this message"'); + }); + + it('test_self_healing_locator', async () => { + beginTest('test_self_healing_locator'); + + await TestRigor.actions(driver) + .openUrl(LABEL_UPDATER_URL) + .waitUntilPageContains('Label Updater') + .execute(); + + const seeded = await driver.findElement(PlaywrightLocator.id('changer')); + expect(await seeded.isDisplayed()).toBe(true); + + await driver.getPage().evaluate(() => { + const button = document.getElementById('changer'); + if (button == null) { + throw new Error('Expected #changer before DOM mutation'); + } + button.id = 'changer-updated'; + }); + + const healed = await driver.findElement(PlaywrightLocator.id('changer')); + expect(await healed.isDisplayed()).toBe(true); + }); +}); diff --git a/tests/resources/application.properties.example b/tests/resources/application.properties.example new file mode 100644 index 0000000..006eee1 --- /dev/null +++ b/tests/resources/application.properties.example @@ -0,0 +1,11 @@ +# optional: direct to different grpc +# testrigor.grpc.uri= +# testrigor.grpc.port=443 +# optional: force TLS on/off; omit to match port (TLS for 443 only) +# testrigor.grpc.use-tls=true + +# Required for manual gRPC integration tests: +testrigor.apiToken=replace-with-your-api-token + +playwright.browser=chromium +playwright.headless=false diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4d38fda --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] +} diff --git a/vitest.config.manual.ts b/vitest.config.manual.ts new file mode 100644 index 0000000..f26a8e5 --- /dev/null +++ b/vitest.config.manual.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: false, + environment: 'node', + include: ['tests/manual/**/*.test.ts'], + testTimeout: 180_000, + hookTimeout: 120_000, + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..6403a58 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: false, + environment: 'node', + include: ['src/**/*.test.ts', 'src/**/__tests__/**/*.ts'], + }, +});