diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1cae92d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,133 @@ +# Contributing + +## Prerequisites + +- Node.js 20+ +- Docker + Docker Compose + +Install dependencies: + +```bash +npm install +``` + +Copy `.env.example` to `.env` and fill in the required values before running. + +--- + +## Starting the application + +**Development** (watch mode, restarts on file changes): + +```bash +npm run start:dev +``` + +**Production build, then start**: + +```bash +npm run build +npm run start:prod +``` + +The application listens on port **3000** by default. + +--- + +## Running tests + +```bash +npm run test +``` + +Run a single spec file: + +```bash +npm run test -- --no-coverage test/scenario.controller.spec.ts +``` + +Watch mode: + +```bash +npm run test:watch +``` + +Enable verbose NestJS log output during tests: + +```bash +npm run test:debug +``` + +--- + +## Formatting code + +[Prettier](https://prettier.io/) is used to format all TypeScript source and test files: + +```bash +npm run format +``` + +This rewrites `src/**/*.ts` and `test/**/*.ts` in place. + +--- + +## Linting + +[ESLint](https://eslint.org/) with `typescript-eslint` and `eslint-config-prettier` is used: + +```bash +# report issues +npm run lint + +# report and auto-fix where possible +npm run lint:fix +``` + +The project targets zero errors. Run lint before committing. + +--- + +## Building the container + +```bash +docker compose build +``` + +To rebuild without the layer cache: + +```bash +docker compose build --no-cache +``` + +--- + +## Running with Docker Compose + +Start (detached): + +```bash +docker compose up -d +``` + +Build and start in one step: + +```bash +docker compose up -d --build +``` + +The application is exposed at **http://localhost:13000**. + +SQLite data is persisted in `./data/` and key files are mounted from `./keys/` — both directories are volume-mounted into the container. + +Stop and remove containers: + +```bash +docker compose down +``` + +View logs: + +```bash +docker compose logs -f +``` diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..ff13b74 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,8 @@ +import tseslint from 'typescript-eslint'; +import eslintConfigPrettier from 'eslint-config-prettier'; + +export default tseslint.config( + { ignores: ['dist/**', 'node_modules/**'] }, + ...tseslint.configs.recommended, + eslintConfigPrettier, +); diff --git a/package-lock.json b/package-lock.json index 2c5ea6f..b35d020 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,10 +43,14 @@ "@types/node": "^25.5.2", "@types/supertest": "^7.2.0", "debug": "^4.4.3", + "eslint": "^10.2.0", + "eslint-config-prettier": "^10.1.8", "jest": "^30.3.0", + "prettier": "^3.8.1", "supertest": "^7.2.2", "ts-jest": "^29.4.9", - "typescript": "^6.0.2" + "typescript": "^6.0.2", + "typescript-eslint": "^8.58.1" } }, "node_modules/@angular-devkit/core": { @@ -932,6 +936,152 @@ "tslib": "^2.4.0" } }, + "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, + "license": "MIT", + "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-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, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "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, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", + "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, "node_modules/@exodus/bytes": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", @@ -961,6 +1111,58 @@ "hono": "^4" } }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "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, + "license": "Apache-2.0", + "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, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@inquirer/ansi": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", @@ -2683,6 +2885,13 @@ "@types/estree": "*" } }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2933,6 +3142,275 @@ "dev": true, "license": "MIT" }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz", + "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/type-utils": "8.58.1", + "@typescript-eslint/utils": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "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.58.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.1.tgz", + "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "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/project-service": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.1.tgz", + "integrity": "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.1", + "@typescript-eslint/types": "^8.58.1", + "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/scope-manager": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.1.tgz", + "integrity": "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.1.tgz", + "integrity": "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw==", + "dev": true, + "license": "MIT", + "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/type-utils": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.1.tgz", + "integrity": "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/utils": "8.58.1", + "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/types": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz", + "integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.1.tgz", + "integrity": "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.1", + "@typescript-eslint/tsconfig-utils": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "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/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.1.tgz", + "integrity": "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1" + }, + "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/visitor-keys": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.1.tgz", + "integrity": "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.1", + "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/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -3422,6 +3900,16 @@ "acorn": "^8.14.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, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/ajv": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", @@ -4557,6 +5045,13 @@ "node": ">=4.0.0" } }, + "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, + "license": "MIT" + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -4869,6 +5364,78 @@ "node": ">=8" } }, + "node_modules/eslint": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.0.tgz", + "integrity": "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.4", + "@eslint/config-helpers": "^0.5.4", + "@eslint/core": "^1.2.0", + "@eslint/plugin-kit": "^0.7.0", + "@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", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.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", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, "node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", @@ -4883,6 +5450,191 @@ "node": ">=8.0.0" } }, + "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, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "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/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, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint/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, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/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, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/eslint/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, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/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, + "license": "MIT" + }, + "node_modules/eslint/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, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/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, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/eslint/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, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -4897,6 +5649,29 @@ "node": ">=4" } }, + "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, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esquery/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, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/esrecurse": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", @@ -4930,6 +5705,16 @@ "node": ">=4.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, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -5112,6 +5897,13 @@ "dev": true, "license": "MIT" }, + "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, + "license": "MIT" + }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", @@ -5144,6 +5936,37 @@ "bser": "2.1.1" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/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, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/file-type": { "version": "21.3.4", "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", @@ -5203,6 +6026,27 @@ "node": ">=8" } }, + "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, + "license": "MIT", + "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, + "license": "ISC" + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -5499,6 +6343,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "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, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/glob-to-regexp": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", @@ -5751,6 +6608,16 @@ ], "license": "BSD-3-Clause" }, + "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, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -5859,6 +6726,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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, + "license": "MIT", + "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", @@ -5878,6 +6755,19 @@ "node": ">=6" } }, + "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, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-interactive": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", @@ -7014,6 +7904,13 @@ "node": ">=6" } }, + "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, + "license": "MIT" + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -7033,6 +7930,13 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "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, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -7066,6 +7970,16 @@ "graceful-fs": "^4.1.6" } }, + "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, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -7076,6 +7990,20 @@ "node": ">=6" } }, + "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, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/libphonenumber-js": { "version": "1.12.41", "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.41.tgz", @@ -7636,6 +8564,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "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/ora": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", @@ -7968,6 +8914,32 @@ "node": ">=10" } }, + "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, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "30.3.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", @@ -9085,6 +10057,36 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/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, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tldts": { "version": "7.0.28", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", @@ -9175,6 +10177,19 @@ "node": ">=20" } }, + "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, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ts-jest": { "version": "29.4.9", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.9.tgz", @@ -9290,6 +10305,19 @@ "node": "*" } }, + "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, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -9566,6 +10594,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.1.tgz", + "integrity": "sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.58.1", + "@typescript-eslint/parser": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/utils": "8.58.1" + }, + "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/uglify-js": { "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", @@ -10011,6 +11063,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", diff --git a/package.json b/package.json index 7dd13a5..dc55b2c 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,9 @@ "start": "nest start", "start:dev": "nest start --watch", "start:prod": "node dist/main", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"", + "lint:fix": "eslint --fix \"src/**/*.ts\" \"test/**/*.ts\"", "test": "jest", "test:debug": "DEBUG=test jest", "test:watch": "jest --watch" @@ -50,9 +53,13 @@ "@types/node": "^25.5.2", "@types/supertest": "^7.2.0", "debug": "^4.4.3", + "eslint": "^10.2.0", + "eslint-config-prettier": "^10.1.8", "jest": "^30.3.0", + "prettier": "^3.8.1", "supertest": "^7.2.2", "ts-jest": "^29.4.9", - "typescript": "^6.0.2" + "typescript": "^6.0.2", + "typescript-eslint": "^8.58.1" } } diff --git a/src/app.module.ts b/src/app.module.ts index 7d64940..e10b820 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,33 +1,42 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { ScheduleModule } from '@nestjs/schedule'; -import { HealthModule } from './health/health.module'; -import { AuthModule } from './auth/auth.module'; -import { BrowserModule } from './browser/browser.module'; -import { SessionEntity } from './session/session.entity'; -import { EnvironmentEntity } from './environment/environment.entity'; -import { EnvironmentModule } from './environment/environment.module'; -import { McpModule } from './mcp/mcp.module'; -import { ScenarioEntity } from './scenario/scenario.entity'; -import { ScenarioStepEntity } from './scenario/scenario-step.entity'; -import { ScenarioRunEntity } from './scenario/scenario-run.entity'; -import { ScenarioRunStepEntity } from './scenario/scenario-run-step.entity'; -import { ScenarioModule } from './scenario/scenario.module'; +import { Module } from "@nestjs/common"; +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { ScheduleModule } from "@nestjs/schedule"; +import { HealthModule } from "./health/health.module"; +import { AuthModule } from "./auth/auth.module"; +import { BrowserModule } from "./browser/browser.module"; +import { SessionEntity } from "./session/session.entity"; +import { EnvironmentEntity } from "./environment/environment.entity"; +import { EnvironmentModule } from "./environment/environment.module"; +import { McpModule } from "./mcp/mcp.module"; +import { ScenarioEntity } from "./scenario/scenario.entity"; +import { ScenarioStepEntity } from "./scenario/scenario-step.entity"; +import { ScenarioRunEntity } from "./scenario/scenario-run.entity"; +import { ScenarioRunStepEntity } from "./scenario/scenario-run-step.entity"; +import { ScenarioRunLogEntity } from "./scenario/scenario-run-log.entity"; +import { ScenarioModule } from "./scenario/scenario.module"; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, - envFilePath: '.env', + envFilePath: ".env", }), ScheduleModule.forRoot(), TypeOrmModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService) => ({ - type: 'better-sqlite3', - database: config.get('DB_PATH', 'data/sessions.db'), - entities: [SessionEntity, EnvironmentEntity, ScenarioEntity, ScenarioStepEntity, ScenarioRunEntity, ScenarioRunStepEntity], + type: "better-sqlite3", + database: config.get("DB_PATH", "data/sessions.db"), + entities: [ + SessionEntity, + EnvironmentEntity, + ScenarioEntity, + ScenarioStepEntity, + ScenarioRunEntity, + ScenarioRunStepEntity, + ScenarioRunLogEntity, + ], synchronize: true, }), }), diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 43a7577..be6f7df 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,26 +1,51 @@ -import { Body, Controller, Get, Post } from '@nestjs/common'; -import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; -import { AuthService } from './auth.service'; -import { LoginDto } from './dto/login.dto'; +import { Body, Controller, Get, Post } from "@nestjs/common"; +import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; +import { AuthService } from "./auth.service"; +import { LoginDto } from "./dto/login.dto"; -@ApiTags('auth') +@ApiTags("auth") @Controller() export class AuthController { constructor(private readonly authService: AuthService) {} - @Get('keys') - @ApiOperation({ summary: 'List available key identifiers from the keys directory' }) - @ApiResponse({ status: 200, description: 'List of key names', schema: { properties: { keys: { type: 'array', items: { type: 'string' } } } } }) + @Get("keys") + @ApiOperation({ + summary: "List available key identifiers from the keys directory", + }) + @ApiResponse({ + status: 200, + description: "List of key names", + schema: { + properties: { keys: { type: "array", items: { type: "string" } } }, + }, + }) listKeys(): { keys: string[] } { return { keys: this.authService.listKeys() }; } - @Post('login') - @ApiOperation({ summary: 'Log in using a file key and return the session token' }) - @ApiResponse({ status: 201, description: 'Login successful', schema: { properties: { token: { type: 'string' }, sessionName: { type: 'string' } } } }) - @ApiResponse({ status: 400, description: 'Key not found or invalid' }) - @ApiResponse({ status: 500, description: 'Automation failed' }) - login(@Body() dto: LoginDto): Promise<{ token: string; sessionName: string }> { - return this.authService.login(dto.key, dto.environmentName, dto.sessionName); + @Post("login") + @ApiOperation({ + summary: "Log in using a file key and return the session token", + }) + @ApiResponse({ + status: 201, + description: "Login successful", + schema: { + properties: { + token: { type: "string" }, + sessionName: { type: "string" }, + }, + }, + }) + @ApiResponse({ status: 400, description: "Key not found or invalid" }) + @ApiResponse({ status: 500, description: "Automation failed" }) + login( + @Body() dto: LoginDto, + ): Promise<{ token: string; sessionName: string }> { + return this.authService.login( + dto.key, + dto.environmentName, + dto.sessionName, + ); } } diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index f276c2b..c253584 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,8 +1,8 @@ -import { Module } from '@nestjs/common'; -import { AuthController } from './auth.controller'; -import { AuthService } from './auth.service'; -import { SessionModule } from '../session/session.module'; -import { EnvironmentModule } from '../environment/environment.module'; +import { Module } from "@nestjs/common"; +import { AuthController } from "./auth.controller"; +import { AuthService } from "./auth.service"; +import { SessionModule } from "../session/session.module"; +import { EnvironmentModule } from "../environment/environment.module"; @Module({ imports: [SessionModule, EnvironmentModule], diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index f1d1705..d750de8 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -3,16 +3,16 @@ import { BadRequestException, InternalServerErrorException, NotFoundException, -} from '@nestjs/common'; -import { TraceLogger } from '../common/trace-logger'; -import { ConfigService } from '@nestjs/config'; -import { chromium } from 'playwright'; -import type { Page } from 'playwright'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as crypto from 'crypto'; -import { SessionService } from '../session/session.service'; -import { EnvironmentService } from '../environment/environment.service'; +} from "@nestjs/common"; +import { TraceLogger } from "../common/trace-logger"; +import { ConfigService } from "@nestjs/config"; +import { chromium } from "playwright"; +import type { Page } from "playwright"; +import * as fs from "fs"; +import * as path from "path"; +import * as crypto from "crypto"; +import { SessionService } from "../session/session.service"; +import { EnvironmentService } from "../environment/environment.service"; interface KeyDescriptor { keyFile?: string; @@ -30,16 +30,15 @@ export class AuthService { private readonly sessionService: SessionService, private readonly environmentService: EnvironmentService, ) { - this.keysDir = path.resolve( - this.config.get('KEYS_DIR', 'keys'), - ); + this.keysDir = path.resolve(this.config.get("KEYS_DIR", "keys")); } listKeys(): string[] { if (!fs.existsSync(this.keysDir)) return []; - return fs.readdirSync(this.keysDir) - .filter(f => f.endsWith('.json')) - .map(f => path.basename(f, '.json')); + return fs + .readdirSync(this.keysDir) + .filter((f) => f.endsWith(".json")) + .map((f) => path.basename(f, ".json")); } private loadKeyDescriptor(keyId: string): KeyDescriptor { @@ -48,51 +47,73 @@ export class AuthService { throw new BadRequestException(`Key not found: ${keyId}`); } try { - return JSON.parse(fs.readFileSync(keyJsonPath, 'utf-8')) as KeyDescriptor; + return JSON.parse(fs.readFileSync(keyJsonPath, "utf-8")) as KeyDescriptor; } catch (err) { - throw new BadRequestException(`Cannot read key descriptor: ${keyId}`, { cause: err }); + throw new BadRequestException(`Cannot read key descriptor: ${keyId}`, { + cause: err, + }); } } - async login(keyId: string, environmentName: string, sessionName?: string): Promise<{ token: string; sessionName: string }> { + async login( + keyId: string, + environmentName: string, + sessionName?: string, + ): Promise<{ token: string; sessionName: string }> { const resolvedSession = sessionName ?? crypto.randomUUID(); - const env = await this.environmentService.findAll().then(({ data }) => data.find(e => e.name === environmentName)); - if (!env) throw new NotFoundException(`Environment "${environmentName}" not found`); + const env = await this.environmentService + .findAll() + .then(({ data }) => data.find((e) => e.name === environmentName)); + if (!env) + throw new NotFoundException(`Environment "${environmentName}" not found`); const loginUrl = env.urls.id_url; const cabinetUrl = env.urls.cabinet_url; - if (!loginUrl) throw new BadRequestException(`Environment "${environmentName}" is missing id_url`); - if (!cabinetUrl) throw new BadRequestException(`Environment "${environmentName}" is missing cabinet_url`); + if (!loginUrl) + throw new BadRequestException( + `Environment "${environmentName}" is missing id_url`, + ); + if (!cabinetUrl) + throw new BadRequestException( + `Environment "${environmentName}" is missing cabinet_url`, + ); const descriptor = this.loadKeyDescriptor(keyId); const useLoginPassword = !!descriptor.login; if (!useLoginPassword) { if (!descriptor.keyFile) { - throw new BadRequestException(`Key descriptor for "${keyId}" must have either "login" or "keyFile"`); + throw new BadRequestException( + `Key descriptor for "${keyId}" must have either "login" or "keyFile"`, + ); } const keyFilePath = path.resolve(this.keysDir, descriptor.keyFile); if (!fs.existsSync(keyFilePath)) { - throw new BadRequestException(`Key file not found: ${descriptor.keyFile}`); + throw new BadRequestException( + `Key file not found: ${descriptor.keyFile}`, + ); } } - const browser = await chromium.launch({ headless: true, executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH }); + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, + }); try { const context = await browser.newContext(); const page = await context.newPage(); this.logger.log(`Navigating to ${loginUrl}`); - await page.goto(loginUrl, { waitUntil: 'networkidle' }); + await page.goto(loginUrl, { waitUntil: "networkidle" }); if (useLoginPassword) { // Click "Логін і пароль" auth method await page.locator('p[aria-label="Логін і пароль"]').click(); // Fill login and password - await page.getByLabel('Електронна пошта').fill(descriptor.login!); - await page.getByLabel('Пароль').fill(descriptor.password); + await page.getByLabel("Електронна пошта").fill(descriptor.login!); + await page.getByLabel("Пароль").fill(descriptor.password); // Click "Увійти" await page.locator('button:has-text("Увійти")').click(); @@ -100,19 +121,21 @@ export class AuthService { const keyFilePath = path.resolve(this.keysDir, descriptor.keyFile!); // Click "Файловий ключ" button - await page.getByText('Файловий ключ').click(); + await page.getByText("Файловий ключ").click(); // Upload key file via hidden file input - const fileInput = page.locator('input[accept=".dat,.pfx,.pk8,.zs2,.jks"][type="file"]'); + const fileInput = page.locator( + 'input[accept=".dat,.pfx,.pk8,.zs2,.jks"][type="file"]', + ); await fileInput.setInputFiles(keyFilePath); // Enter password await page - .locator('#id-app-login-file-key-password') + .locator("#id-app-login-file-key-password") .fill(descriptor.password); // Click "Продовжити" - await page.locator('#id-app-login-file-key-sign-button').click(); + await page.locator("#id-app-login-file-key-sign-button").click(); } // Wait until redirected to cabinet @@ -120,11 +143,11 @@ export class AuthService { await page.waitForURL(cabinetUrl, { timeout: 30000 }); // Extract token from localStorage - const token = await page.evaluate(() => localStorage.getItem('token')); + const token = await page.evaluate(() => localStorage.getItem("token")); if (!token) { throw new InternalServerErrorException( - 'Login succeeded but token was not found in localStorage', + "Login succeeded but token was not found in localStorage", ); } @@ -134,14 +157,21 @@ export class AuthService { const entries: Record = {}; for (let i = 0; i < window.localStorage.length; i++) { const k = window.localStorage.key(i); - if (k !== null) entries[k] = window.localStorage.getItem(k) ?? ''; + if (k !== null) entries[k] = window.localStorage.getItem(k) ?? ""; } return entries; }); - await this.sessionService.upsert(resolvedSession, token, cookies, localStorageData); + await this.sessionService.upsert( + resolvedSession, + token, + cookies, + localStorageData, + ); - this.logger.log(`Login successful for key ${keyId}, session: ${resolvedSession}`); + this.logger.log( + `Login successful for key ${keyId}, session: ${resolvedSession}`, + ); return { token, sessionName: resolvedSession }; } catch (err) { if ( @@ -164,26 +194,40 @@ export class AuthService { const descriptor = this.loadKeyDescriptor(keyId); if (!descriptor.keyFile) { - throw new BadRequestException(`Key descriptor for "${keyId}" must have "keyFile" to sign`); + throw new BadRequestException( + `Key descriptor for "${keyId}" must have "keyFile" to sign`, + ); } const keyFilePath = path.resolve(this.keysDir, descriptor.keyFile); if (!fs.existsSync(keyFilePath)) { - throw new BadRequestException(`Key file not found: ${descriptor.keyFile}`); + throw new BadRequestException( + `Key file not found: ${descriptor.keyFile}`, + ); } this.logger.log(`Signing with key ${keyId} on page: ${page.url()}`); // Open the EDS sign widget - await page.locator('button').filter({ hasText: /підпис|sign/i }).first().click(); + await page + .locator("button") + .filter({ hasText: /підпис|sign/i }) + .first() + .click(); await page.waitForTimeout(500); // Select the file key tab inside the widget - await page.locator('button, [role="tab"], li').filter({ hasText: /файлов|file key/i }).first().click(); + await page + .locator('button, [role="tab"], li') + .filter({ hasText: /файлов|file key/i }) + .first() + .click(); await page.waitForTimeout(300); // Upload the key file - const fileInput = page.locator('input[accept=".dat,.pfx,.pk8,.zs2,.jks"][type="file"]'); + const fileInput = page.locator( + 'input[accept=".dat,.pfx,.pk8,.zs2,.jks"][type="file"]', + ); await fileInput.setInputFiles(keyFilePath); await page.waitForTimeout(300); @@ -192,8 +236,12 @@ export class AuthService { await page.waitForTimeout(200); // Submit - await page.locator('button').filter({ hasText: /підпис|sign|підтвер/i }).last().click(); - await page.waitForLoadState('networkidle'); + await page + .locator("button") + .filter({ hasText: /підпис|sign|підтвер/i }) + .last() + .click(); + await page.waitForLoadState("networkidle"); this.logger.log(`Sign completed for key ${keyId}`); } diff --git a/src/auth/dto/login.dto.ts b/src/auth/dto/login.dto.ts index ade399b..df85e55 100644 --- a/src/auth/dto/login.dto.ts +++ b/src/auth/dto/login.dto.ts @@ -1,26 +1,28 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsNotEmpty, IsOptional, IsString } from "class-validator"; export class LoginDto { @ApiProperty({ - description: 'Key identifier — filename (without extension) from the keys/ directory', - example: '3273334361', + description: + "Key identifier — filename (without extension) from the keys/ directory", + example: "3273334361", }) @IsString() @IsNotEmpty() key: string; @ApiProperty({ - description: 'Environment name to resolve login/cabinet URLs from', - example: 'liquio-diia-stg', + description: "Environment name to resolve login/cabinet URLs from", + example: "liquio-diia-stg", }) @IsString() @IsNotEmpty() environmentName: string; @ApiPropertyOptional({ - description: 'Session name to store credentials under. Auto-generated UUID if omitted.', - example: 'my-test-session', + description: + "Session name to store credentials under. Auto-generated UUID if omitted.", + example: "my-test-session", }) @IsOptional() @IsString() diff --git a/src/browser/browser.controller.ts b/src/browser/browser.controller.ts index 90b0521..7a48d14 100644 --- a/src/browser/browser.controller.ts +++ b/src/browser/browser.controller.ts @@ -1,11 +1,11 @@ -import { Body, Controller, Post } from '@nestjs/common'; -import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; -import { BrowserService, ExecResult, OpenResult } from './browser.service'; -import { CodeExecutorService } from '../code-executor/code-executor.service'; -import { OpenDto } from './dto/open.dto'; -import { ExecDto } from './dto/exec.dto'; +import { Body, Controller, Post } from "@nestjs/common"; +import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; +import { BrowserService, ExecResult, OpenResult } from "./browser.service"; +import { CodeExecutorService } from "../code-executor/code-executor.service"; +import { OpenDto } from "./dto/open.dto"; +import { ExecDto } from "./dto/exec.dto"; -@ApiTags('browser') +@ApiTags("browser") @Controller() export class BrowserController { constructor( @@ -13,39 +13,47 @@ export class BrowserController { private readonly codeExecutor: CodeExecutorService, ) {} - @Post('open') - @ApiOperation({ summary: 'Open a URL with a stored session (cookies + localStorage)' }) + @Post("open") + @ApiOperation({ + summary: "Open a URL with a stored session (cookies + localStorage)", + }) @ApiResponse({ status: 201, - description: 'Page loaded successfully', + description: "Page loaded successfully", schema: { properties: { - url: { type: 'string' }, - title: { type: 'string' }, - content: { type: 'string' }, + url: { type: "string" }, + title: { type: "string" }, + content: { type: "string" }, }, }, }) - @ApiResponse({ status: 400, description: 'Invalid input' }) - @ApiResponse({ status: 404, description: 'Session not found' }) - @ApiResponse({ status: 500, description: 'Browser automation failed' }) + @ApiResponse({ status: 400, description: "Invalid input" }) + @ApiResponse({ status: 404, description: "Session not found" }) + @ApiResponse({ status: 500, description: "Browser automation failed" }) open(@Body() dto: OpenDto): Promise { - return this.browserService.open(dto.sessionName, dto.url, dto.readerMode ?? false, dto.selector); + return this.browserService.open( + dto.sessionName, + dto.url, + dto.readerMode ?? false, + dto.selector, + ); } - @Post('exec') + @Post("exec") @ApiOperation({ - summary: 'Execute custom Playwright JavaScript within a stored session', - description: 'The `code` string is executed as an async function body with `page` (Playwright Page) and `context` (BrowserContext) in scope. The return value is serialised and returned as `result`.', + summary: "Execute custom Playwright JavaScript within a stored session", + description: + "The `code` string is executed as an async function body with `page` (Playwright Page) and `context` (BrowserContext) in scope. The return value is serialised and returned as `result`.", }) @ApiResponse({ status: 201, - description: 'Code executed successfully', + description: "Code executed successfully", schema: { properties: { result: {} } }, }) - @ApiResponse({ status: 400, description: 'Invalid input' }) - @ApiResponse({ status: 404, description: 'Session not found' }) - @ApiResponse({ status: 500, description: 'Execution failed' }) + @ApiResponse({ status: 400, description: "Invalid input" }) + @ApiResponse({ status: 404, description: "Session not found" }) + @ApiResponse({ status: 500, description: "Execution failed" }) exec(@Body() dto: ExecDto): Promise { this.codeExecutor.validate(dto.code); return this.browserService.exec(dto.sessionName, dto.code, dto.url); diff --git a/src/browser/browser.module.ts b/src/browser/browser.module.ts index 9db3ccd..7e1ea3c 100644 --- a/src/browser/browser.module.ts +++ b/src/browser/browser.module.ts @@ -1,8 +1,8 @@ -import { Module } from '@nestjs/common'; -import { BrowserController } from './browser.controller'; -import { BrowserService } from './browser.service'; -import { SessionModule } from '../session/session.module'; -import { CodeExecutorModule } from '../code-executor/code-executor.module'; +import { Module } from "@nestjs/common"; +import { BrowserController } from "./browser.controller"; +import { BrowserService } from "./browser.service"; +import { SessionModule } from "../session/session.module"; +import { CodeExecutorModule } from "../code-executor/code-executor.module"; @Module({ imports: [SessionModule, CodeExecutorModule], diff --git a/src/browser/browser.service.ts b/src/browser/browser.service.ts index 9de2441..14bacf9 100644 --- a/src/browser/browser.service.ts +++ b/src/browser/browser.service.ts @@ -3,17 +3,17 @@ import { HttpException, InternalServerErrorException, NotFoundException, -} from '@nestjs/common'; -import { TraceLogger } from '../common/trace-logger'; -import { chromium } from 'playwright'; -import type { BrowserContext } from 'playwright'; -import { Readability } from '@mozilla/readability'; -import { JSDOM } from 'jsdom'; -import { SessionService } from '../session/session.service'; -import { CodeExecutorService } from '../code-executor/code-executor.service'; -import type { ExecResult } from '../code-executor/code-executor.service'; +} from "@nestjs/common"; +import { TraceLogger } from "../common/trace-logger"; +import { chromium } from "playwright"; +import type { BrowserContext, Cookie } from "playwright"; +import { Readability } from "@mozilla/readability"; +import { JSDOM } from "jsdom"; +import { SessionService } from "../session/session.service"; +import { CodeExecutorService } from "../code-executor/code-executor.service"; +import type { ExecResult } from "../code-executor/code-executor.service"; -export type { ExecResult } from '../code-executor/code-executor.service'; +export type { ExecResult } from "../code-executor/code-executor.service"; export interface OpenResult { url: string; @@ -30,19 +30,25 @@ export class BrowserService { private readonly codeExecutor: CodeExecutorService, ) {} - private async setupSession(context: BrowserContext, sessionName: string | undefined): Promise { + private async setupSession( + context: BrowserContext, + sessionName: string | undefined, + ): Promise { if (!sessionName) return; const session = await this.sessionService.findBySessionName(sessionName); if (!session) { throw new NotFoundException(`Session not found: ${sessionName}`); } - let cookies: any[]; + let cookies: Cookie[]; let localStorageData: Record; try { cookies = JSON.parse(session.cookies); localStorageData = JSON.parse(session.localStorage); } catch (err) { - throw new InternalServerErrorException('Failed to deserialize session data', { cause: err }); + throw new InternalServerErrorException( + "Failed to deserialize session data", + { cause: err }, + ); } await context.addCookies(cookies); await context.addInitScript((entries: Record) => { @@ -62,16 +68,24 @@ export class BrowserService { ); } - async open(sessionName: string | undefined, url: string, readerMode = false, selector?: string): Promise { - const label = sessionName ?? 'anonymous'; - const browser = await chromium.launch({ headless: true, executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH }); + async open( + sessionName: string | undefined, + url: string, + readerMode = false, + selector?: string, + ): Promise { + const label = sessionName ?? "anonymous"; + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, + }); try { const context = await browser.newContext(); await this.setupSession(context, sessionName); const page = await context.newPage(); this.logger.log(`[${label}] Opening ${url}`); - await page.goto(url, { waitUntil: 'networkidle' }); + await page.goto(url, { waitUntil: "networkidle" }); const finalUrl = page.url(); const title = await page.title(); @@ -82,28 +96,39 @@ export class BrowserService { const dom = new JSDOM(rawHtml, { url: finalUrl }); const el = dom.window.document.querySelector(selector); content = readerMode - ? (el?.textContent?.replace(/\s+/g, ' ').trim() ?? '') - : (el?.outerHTML ?? ''); + ? (el?.textContent?.replace(/\s+/g, " ").trim() ?? "") + : (el?.outerHTML ?? ""); } else if (readerMode) { const dom = new JSDOM(rawHtml, { url: finalUrl }); const article = new Readability(dom.window.document).parse(); - content = article ? article.textContent.replace(/\s+/g, ' ').trim() : rawHtml; + content = article + ? article.textContent.replace(/\s+/g, " ").trim() + : rawHtml; } else { content = rawHtml; } - this.logger.log(`[${label}] Loaded: ${finalUrl} — "${title}"${readerMode ? ' (reader mode)' : ''}${selector ? ` (selector: ${selector})` : ''}`); + this.logger.log( + `[${label}] Loaded: ${finalUrl} — "${title}"${readerMode ? " (reader mode)" : ""}${selector ? ` (selector: ${selector})` : ""}`, + ); return { url: finalUrl, title, content }; } catch (err) { - this.rethrow(err, label, 'open'); + this.rethrow(err, label, "open"); } finally { await browser.close(); } } - async exec(sessionName: string | undefined, code: string, url?: string): Promise { - const label = sessionName ?? 'anonymous'; - const browser = await chromium.launch({ headless: true, executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH }); + async exec( + sessionName: string | undefined, + code: string, + url?: string, + ): Promise { + const label = sessionName ?? "anonymous"; + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, + }); try { const context = await browser.newContext(); await this.setupSession(context, sessionName); @@ -111,7 +136,7 @@ export class BrowserService { if (url) { this.logger.log(`[${label}] exec: navigating to ${url}`); - await page.goto(url, { waitUntil: 'networkidle' }); + await page.goto(url, { waitUntil: "networkidle" }); } this.logger.log(`[${label}] exec: running user code`); @@ -120,7 +145,7 @@ export class BrowserService { this.logger.log(`[${label}] exec: done`); return result; } catch (err) { - this.rethrow(err, label, 'exec'); + this.rethrow(err, label, "exec"); } finally { await browser.close(); } diff --git a/src/browser/dto/exec.dto.ts b/src/browser/dto/exec.dto.ts index 36355c0..2109bbc 100644 --- a/src/browser/dto/exec.dto.ts +++ b/src/browser/dto/exec.dto.ts @@ -1,26 +1,29 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsOptional, IsString, IsUrl } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsOptional, IsString, IsUrl } from "class-validator"; export class ExecDto { @ApiPropertyOptional({ - description: 'Session name previously created by POST /login. If omitted, executes without a stored session.', - example: 'test-session-1', + description: + "Session name previously created by POST /login. If omitted, executes without a stored session.", + example: "test-session-1", }) @IsOptional() @IsString() sessionName?: string; @ApiPropertyOptional({ - description: 'URL to navigate to before executing code. Skipped if omitted.', - example: 'https://cabinet-liquio-diia-stg.kitsoft.ua/messages', + description: + "URL to navigate to before executing code. Skipped if omitted.", + example: "https://cabinet-liquio-diia-stg.kitsoft.ua/messages", }) @IsOptional() @IsUrl({ require_tld: true, require_protocol: true }) url?: string; @ApiProperty({ - description: 'JavaScript code to execute. Receives `page` (Playwright Page) and `context` (BrowserContext) as arguments. May be async. Return value is serialised and returned.', - example: 'return await page.title();', + description: + "JavaScript code to execute. Receives `page` (Playwright Page) and `context` (BrowserContext) as arguments. May be async. Return value is serialised and returned.", + example: "return await page.title();", }) @IsString() code: string; diff --git a/src/browser/dto/open.dto.ts b/src/browser/dto/open.dto.ts index 3a013da..e571b06 100644 --- a/src/browser/dto/open.dto.ts +++ b/src/browser/dto/open.dto.ts @@ -1,24 +1,26 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsOptional, IsString, IsUrl } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsBoolean, IsOptional, IsString, IsUrl } from "class-validator"; export class OpenDto { @ApiPropertyOptional({ - description: 'Session name previously created by POST /login. If omitted, opens the URL without a stored session.', - example: 'test-session-1', + description: + "Session name previously created by POST /login. If omitted, opens the URL without a stored session.", + example: "test-session-1", }) @IsOptional() @IsString() sessionName?: string; @ApiProperty({ - description: 'URL to open with the authenticated session', - example: 'https://cabinet-liquio-diia-stg.kitsoft.ua/messages', + description: "URL to open with the authenticated session", + example: "https://cabinet-liquio-diia-stg.kitsoft.ua/messages", }) @IsUrl({ require_tld: true, require_protocol: true }) url: string; @ApiPropertyOptional({ - description: 'When true, return a plain-text reader-mode summary instead of raw HTML', + description: + "When true, return a plain-text reader-mode summary instead of raw HTML", default: false, }) @IsOptional() @@ -26,8 +28,9 @@ export class OpenDto { readerMode?: boolean; @ApiPropertyOptional({ - description: 'CSS selector whose matching element content is returned. When omitted the full page HTML is used.', - example: '#main-content', + description: + "CSS selector whose matching element content is returned. When omitted the full page HTML is used.", + example: "#main-content", }) @IsOptional() @IsString() diff --git a/src/code-executor/code-executor.module.ts b/src/code-executor/code-executor.module.ts index 0b10a60..c438182 100644 --- a/src/code-executor/code-executor.module.ts +++ b/src/code-executor/code-executor.module.ts @@ -1,5 +1,5 @@ -import { Module } from '@nestjs/common'; -import { CodeExecutorService } from './code-executor.service'; +import { Module } from "@nestjs/common"; +import { CodeExecutorService } from "./code-executor.service"; @Module({ providers: [CodeExecutorService], diff --git a/src/code-executor/code-executor.service.ts b/src/code-executor/code-executor.service.ts index a5d8f86..810f3c1 100644 --- a/src/code-executor/code-executor.service.ts +++ b/src/code-executor/code-executor.service.ts @@ -1,14 +1,21 @@ -import { BadRequestException, Injectable, InternalServerErrorException } from '@nestjs/common'; -import { TraceLogger } from '../common/trace-logger'; -import { parse } from 'acorn'; -import type { Page, BrowserContext } from 'playwright'; -import { dumpDom } from './dom-helpers'; +import { + BadRequestException, + Injectable, + InternalServerErrorException, +} from "@nestjs/common"; +import { TraceLogger } from "../common/trace-logger"; +import { parse } from "acorn"; +import type { Page, BrowserContext } from "playwright"; +import { dumpDom } from "./dom-helpers"; export interface ExecResult { result: unknown; } -export type ScriptLogger = (level: 'log' | 'warn' | 'error', message: string) => void; +export type ScriptLogger = ( + level: "log" | "warn" | "error", + message: string, +) => void; @Injectable() export class CodeExecutorService { @@ -23,7 +30,10 @@ export class CodeExecutorService { try { parse(wrapped, { ecmaVersion: 2022 }); } catch (err) { - throw new BadRequestException(`Code parse error: ${(err as Error).message}`, { cause: err }); + throw new BadRequestException( + `Code parse error: ${(err as Error).message}`, + { cause: err }, + ); } } @@ -31,34 +41,51 @@ export class CodeExecutorService { * Executes `code` as an async function body with `page` and `context` in * scope. Always call `validate()` before this method. */ - async execute(page: Page, context: BrowserContext, code: string, log?: ScriptLogger): Promise { - const scriptLog: ScriptLogger = log ?? ((level, msg) => this.logger[level](msg)); - const toStr = (args: unknown[]) => args.map((a) => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' '); + async execute( + page: Page, + context: BrowserContext, + code: string, + log?: ScriptLogger, + ): Promise { + const scriptLog: ScriptLogger = + log ?? ((level, msg) => this.logger[level](msg)); + const toStr = (args: unknown[]) => + args + .map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a))) + .join(" "); try { const pageHelpers = { dumpDom: (selector?: string) => dumpDom(page, selector), - log: (...args: unknown[]) => scriptLog('log', toStr(args)), - warn: (...args: unknown[]) => scriptLog('warn', toStr(args)), - error: (...args: unknown[]) => scriptLog('error', toStr(args)), + log: (...args: unknown[]) => scriptLog("log", toStr(args)), + warn: (...args: unknown[]) => scriptLog("warn", toStr(args)), + error: (...args: unknown[]) => scriptLog("error", toStr(args)), }; const fakeConsole = { - log: (...args: unknown[]) => scriptLog('log', toStr(args)), - warn: (...args: unknown[]) => scriptLog('warn', toStr(args)), - error: (...args: unknown[]) => scriptLog('error', toStr(args)), - info: (...args: unknown[]) => scriptLog('log', toStr(args)), - debug: (...args: unknown[]) => scriptLog('log', toStr(args)), + log: (...args: unknown[]) => scriptLog("log", toStr(args)), + warn: (...args: unknown[]) => scriptLog("warn", toStr(args)), + error: (...args: unknown[]) => scriptLog("error", toStr(args)), + info: (...args: unknown[]) => scriptLog("log", toStr(args)), + debug: (...args: unknown[]) => scriptLog("log", toStr(args)), }; // Passing `console` as a named parameter shadows the global in the script scope. - // eslint-disable-next-line no-new-func - const fn = new Function('page', 'context', 'helpers', 'console', `return (async (page, context, helpers) => { ${code} })(page, context, helpers)`); - this.logger.debug('Executing user code'); + const fn = new Function( + "page", + "context", + "helpers", + "console", + `return (async (page, context, helpers) => { ${code} })(page, context, helpers)`, + ); + this.logger.debug("Executing user code"); const result = await fn(page, context, pageHelpers, fakeConsole); return { result }; } catch (err) { - throw new InternalServerErrorException(`Code execution failed: ${(err as Error).message}`, { cause: err }); + throw new InternalServerErrorException( + `Code execution failed: ${(err as Error).message}`, + { cause: err }, + ); } } } diff --git a/src/code-executor/dom-helpers.ts b/src/code-executor/dom-helpers.ts index f90c4be..da1dcbb 100644 --- a/src/code-executor/dom-helpers.ts +++ b/src/code-executor/dom-helpers.ts @@ -1,4 +1,4 @@ -import type { Page } from 'playwright'; +import type { Page } from "playwright"; export interface DomNode { tag: string; @@ -26,50 +26,99 @@ export interface DomNode { * * Useful for debugging Playwright selectors without screenshotting. */ -export async function dumpDom(page: Page, rootSelector = 'body'): Promise { +export async function dumpDom( + page: Page, + rootSelector = "body", +): Promise { return page.evaluate( ([sel, maxDepth]) => { const root = document.querySelector(sel as string); - if (!root) return { tag: 'ERROR', text: `selector not found: ${sel}`, children: [] }; + if (!root) + return { + tag: "ERROR", + text: `selector not found: ${sel}`, + children: [], + }; const STRUCTURAL_TAGS = new Set([ - 'BODY', 'MAIN', 'HEADER', 'FOOTER', 'NAV', 'ASIDE', 'SECTION', - 'FORM', 'DIALOG', 'DETAILS', 'SUMMARY', 'TABLE', 'THEAD', 'TBODY', - 'TR', 'FIELDSET', 'LEGEND', + "BODY", + "MAIN", + "HEADER", + "FOOTER", + "NAV", + "ASIDE", + "SECTION", + "FORM", + "DIALOG", + "DETAILS", + "SUMMARY", + "TABLE", + "THEAD", + "TBODY", + "TR", + "FIELDSET", + "LEGEND", ]); const INTERACTIVE_TAGS = new Set([ - 'A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'LABEL', - 'TH', 'TD', + "A", + "BUTTON", + "INPUT", + "SELECT", + "TEXTAREA", + "LABEL", + "TH", + "TD", ]); - const IGNORED_TAGS = new Set(['SCRIPT', 'STYLE', 'SVG', 'PATH', 'DEFS', 'USE', 'CIRCLE', 'RECT', 'POLYGON', 'POLYLINE', 'LINE', 'ELLIPSE', 'G', 'CLIPPATH', 'IMAGE']); + const IGNORED_TAGS = new Set([ + "SCRIPT", + "STYLE", + "SVG", + "PATH", + "DEFS", + "USE", + "CIRCLE", + "RECT", + "POLYGON", + "POLYLINE", + "LINE", + "ELLIPSE", + "G", + "CLIPPATH", + "IMAGE", + ]); function trimText(el: Element): string | undefined { - const t = (el as HTMLElement).innerText?.trim() ?? el.textContent?.trim() ?? ''; + const t = + (el as HTMLElement).innerText?.trim() ?? el.textContent?.trim() ?? ""; // Only include if short enough to be meaningful, not a dump of all child text const ownText = Array.from(el.childNodes) - .filter(n => n.nodeType === Node.TEXT_NODE) - .map(n => n.textContent?.trim() ?? '') + .filter((n) => n.nodeType === Node.TEXT_NODE) + .map((n) => n.textContent?.trim() ?? "") .filter(Boolean) - .join(' '); + .join(" "); const candidate = ownText || t; return candidate.length > 0 ? candidate.substring(0, 80) : undefined; } function isVisible(el: Element): boolean { const s = window.getComputedStyle(el); - return s.display !== 'none' && s.visibility !== 'hidden' && (el as HTMLElement).offsetParent !== null; + return ( + s.display !== "none" && + s.visibility !== "hidden" && + (el as HTMLElement).offsetParent !== null + ); } function isSignificant(el: Element): boolean { if (STRUCTURAL_TAGS.has(el.tagName)) return true; if (INTERACTIVE_TAGS.has(el.tagName)) return true; - if (el.getAttribute('role')) return true; - if (el.getAttribute('data-testid')) return true; - if (el.getAttribute('data-qa')) return true; - if (el.getAttribute('data-action')) return true; - if (el.getAttribute('data-element-id')) return true; + if (el.getAttribute("role")) return true; + if (el.getAttribute("data-testid")) return true; + if (el.getAttribute("data-qa")) return true; + if (el.getAttribute("data-action")) return true; + if (el.getAttribute("data-element-id")) return true; return false; } @@ -98,41 +147,44 @@ export async function dumpDom(page: Page, rootSelector = 'body'): Promise 1 child - if (!significant) return { tag: el.tagName.toLowerCase(), children: childResults }; + if (!significant) + return { tag: el.tagName.toLowerCase(), children: childResults }; const node: DomNode = { tag: el.tagName.toLowerCase(), children: childResults, }; - const role = el.getAttribute('role'); + const role = el.getAttribute("role"); if (role) node.role = role; - const testid = el.getAttribute('data-testid'); + const testid = el.getAttribute("data-testid"); if (testid) node.testid = testid; - const qa = el.getAttribute('data-qa'); + const qa = el.getAttribute("data-qa"); if (qa) node.qa = qa; - const action = el.getAttribute('data-action'); + const action = el.getAttribute("data-action"); if (action) node.action = action; - const elementId = el.getAttribute('data-element-id'); + const elementId = el.getAttribute("data-element-id"); if (elementId) node.elementId = elementId; const id = el.id; if (id) node.id = id; const type = (el as HTMLInputElement).type; - if (type && type !== 'submit' && el.tagName !== 'BUTTON') node.type = type; + if (type && type !== "submit" && el.tagName !== "BUTTON") + node.type = type; const name = (el as HTMLInputElement).name; if (name) node.name = name; const href = (el as HTMLAnchorElement).href; - if (href && el.tagName === 'A') node.href = href.replace(window.location.origin, ''); + if (href && el.tagName === "A") + node.href = href.replace(window.location.origin, ""); - if ('checked' in el) node.checked = (el as HTMLInputElement).checked; + if ("checked" in el) node.checked = (el as HTMLInputElement).checked; if ((el as HTMLButtonElement).disabled) node.disabled = true; const text = trimText(el); @@ -142,7 +194,7 @@ export async function dumpDom(page: Page, rootSelector = 'body'): Promise { @ApiPropertyOptional({ example: 1, default: 1 }) @@ -17,15 +17,19 @@ export class PaginationQueryDto { @Min(1) limit?: number = 20; - @ApiPropertyOptional({ example: 'id', default: 'id', description: 'Field to order by' }) + @ApiPropertyOptional({ + example: "id", + default: "id", + description: "Field to order by", + }) @IsOptional() @IsString() orderBy?: TOrderBy; - @ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'ASC' }) + @ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "ASC" }) @IsOptional() - @IsIn(['ASC', 'DESC']) - orderDir?: 'ASC' | 'DESC' = 'ASC'; + @IsIn(["ASC", "DESC"]) + orderDir?: "ASC" | "DESC" = "ASC"; } export interface PaginatedResult { diff --git a/src/common/trace-context.ts b/src/common/trace-context.ts index 25ec747..b794db8 100644 --- a/src/common/trace-context.ts +++ b/src/common/trace-context.ts @@ -1,4 +1,4 @@ -import { AsyncLocalStorage } from 'async_hooks'; +import { AsyncLocalStorage } from "async_hooks"; export interface TraceStore { traceId: string; diff --git a/src/common/trace-logger.ts b/src/common/trace-logger.ts index a356ba9..e27980e 100644 --- a/src/common/trace-logger.ts +++ b/src/common/trace-logger.ts @@ -1,5 +1,5 @@ -import { ConsoleLogger, ConsoleLoggerOptions, LogLevel } from '@nestjs/common'; -import { getTraceId } from './trace-context'; +import { ConsoleLogger, ConsoleLoggerOptions, LogLevel } from "@nestjs/common"; +import { getTraceId } from "./trace-context"; export class TraceLogger extends ConsoleLogger { constructor(context?: string, options: ConsoleLoggerOptions = {}) { diff --git a/src/config/app.config.ts b/src/config/app.config.ts index 32969c9..785d9c1 100644 --- a/src/config/app.config.ts +++ b/src/config/app.config.ts @@ -1,10 +1,10 @@ -import { IsInt, IsString, Min, Max } from 'class-validator'; +import { IsInt, IsString, Min, Max } from "class-validator"; -import pkg from '../../package.json'; +import pkg from "../../package.json"; export class AppConfig { @IsString() - NODE_ENV: string = 'development'; + NODE_ENV: string = "development"; @IsInt() @Min(1) diff --git a/src/environment/dto/create-environment.dto.ts b/src/environment/dto/create-environment.dto.ts index 37915d8..b356001 100644 --- a/src/environment/dto/create-environment.dto.ts +++ b/src/environment/dto/create-environment.dto.ts @@ -1,19 +1,19 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsObject, IsString } from 'class-validator'; -import { EnvironmentUrls } from '../environment.entity'; +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty, IsObject, IsString } from "class-validator"; +import { EnvironmentUrls } from "../environment.entity"; export class CreateEnvironmentDto { - @ApiProperty({ example: 'liquio-diia-stg' }) + @ApiProperty({ example: "liquio-diia-stg" }) @IsString() @IsNotEmpty() name: string; @ApiProperty({ - description: 'Map of URL identifiers to URL strings', + description: "Map of URL identifiers to URL strings", example: { - id_url: 'https://id-liquio-diia-stg.kitsoft.ua/', - cabinet_url: 'https://cabinet-liquio-diia-stg.kitsoft.ua/', - admin_url: 'https://admin-liquio-diia-stg.kitsoft.ua/', + id_url: "https://id-liquio-diia-stg.kitsoft.ua/", + cabinet_url: "https://cabinet-liquio-diia-stg.kitsoft.ua/", + admin_url: "https://admin-liquio-diia-stg.kitsoft.ua/", }, }) @IsObject() diff --git a/src/environment/dto/update-environment.dto.ts b/src/environment/dto/update-environment.dto.ts index 8cab80b..243b877 100644 --- a/src/environment/dto/update-environment.dto.ts +++ b/src/environment/dto/update-environment.dto.ts @@ -1,4 +1,4 @@ -import { PartialType } from '@nestjs/swagger'; -import { CreateEnvironmentDto } from './create-environment.dto'; +import { PartialType } from "@nestjs/swagger"; +import { CreateEnvironmentDto } from "./create-environment.dto"; export class UpdateEnvironmentDto extends PartialType(CreateEnvironmentDto) {} diff --git a/src/environment/environment.controller.ts b/src/environment/environment.controller.ts index 7acfe87..70849fa 100644 --- a/src/environment/environment.controller.ts +++ b/src/environment/environment.controller.ts @@ -9,56 +9,59 @@ import { Patch, Post, Query, -} from '@nestjs/common'; -import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; -import { EnvironmentService } from './environment.service'; -import { CreateEnvironmentDto } from './dto/create-environment.dto'; -import { UpdateEnvironmentDto } from './dto/update-environment.dto'; -import { PaginationQueryDto } from '../common/dto/pagination.dto'; -import { EnvironmentOrderBy } from './environment.service'; +} from "@nestjs/common"; +import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; +import { EnvironmentService } from "./environment.service"; +import { CreateEnvironmentDto } from "./dto/create-environment.dto"; +import { UpdateEnvironmentDto } from "./dto/update-environment.dto"; +import { PaginationQueryDto } from "../common/dto/pagination.dto"; +import { EnvironmentOrderBy } from "./environment.service"; -@ApiTags('environments') -@Controller('environments') +@ApiTags("environments") +@Controller("environments") export class EnvironmentController { constructor(private readonly environmentService: EnvironmentService) {} @Post() - @ApiOperation({ summary: 'Create a new environment' }) - @ApiResponse({ status: 201, description: 'Environment created' }) - @ApiResponse({ status: 409, description: 'Environment name already exists' }) + @ApiOperation({ summary: "Create a new environment" }) + @ApiResponse({ status: 201, description: "Environment created" }) + @ApiResponse({ status: 409, description: "Environment name already exists" }) create(@Body() dto: CreateEnvironmentDto) { return this.environmentService.create(dto); } @Get() - @ApiOperation({ summary: 'List all environments (paginated)' }) - @ApiResponse({ status: 200, description: 'Paginated environments' }) + @ApiOperation({ summary: "List all environments (paginated)" }) + @ApiResponse({ status: 200, description: "Paginated environments" }) findAll(@Query() query: PaginationQueryDto) { return this.environmentService.findAll(query); } - @Get(':id') - @ApiOperation({ summary: 'Get environment by ID' }) - @ApiResponse({ status: 200, description: 'Environment record' }) - @ApiResponse({ status: 404, description: 'Environment not found' }) - findOne(@Param('id', ParseIntPipe) id: number) { + @Get(":id") + @ApiOperation({ summary: "Get environment by ID" }) + @ApiResponse({ status: 200, description: "Environment record" }) + @ApiResponse({ status: 404, description: "Environment not found" }) + findOne(@Param("id", ParseIntPipe) id: number) { return this.environmentService.findOne(id); } - @Patch(':id') - @ApiOperation({ summary: 'Update an environment' }) - @ApiResponse({ status: 200, description: 'Environment updated' }) - @ApiResponse({ status: 404, description: 'Environment not found' }) - update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateEnvironmentDto) { + @Patch(":id") + @ApiOperation({ summary: "Update an environment" }) + @ApiResponse({ status: 200, description: "Environment updated" }) + @ApiResponse({ status: 404, description: "Environment not found" }) + update( + @Param("id", ParseIntPipe) id: number, + @Body() dto: UpdateEnvironmentDto, + ) { return this.environmentService.update(id, dto); } - @Delete(':id') + @Delete(":id") @HttpCode(204) - @ApiOperation({ summary: 'Delete an environment' }) - @ApiResponse({ status: 204, description: 'Environment deleted' }) - @ApiResponse({ status: 404, description: 'Environment not found' }) - remove(@Param('id', ParseIntPipe) id: number) { + @ApiOperation({ summary: "Delete an environment" }) + @ApiResponse({ status: 204, description: "Environment deleted" }) + @ApiResponse({ status: 404, description: "Environment not found" }) + remove(@Param("id", ParseIntPipe) id: number) { return this.environmentService.remove(id); } } diff --git a/src/environment/environment.entity.ts b/src/environment/environment.entity.ts index 224c78f..3ffc86f 100644 --- a/src/environment/environment.entity.ts +++ b/src/environment/environment.entity.ts @@ -4,7 +4,7 @@ import { Column, CreateDateColumn, UpdateDateColumn, -} from 'typeorm'; +} from "typeorm"; export interface EnvironmentUrls { id_url?: string; @@ -13,7 +13,7 @@ export interface EnvironmentUrls { [key: string]: string | undefined; } -@Entity('environments') +@Entity("environments") export class EnvironmentEntity { @PrimaryGeneratedColumn() id: number; @@ -21,7 +21,7 @@ export class EnvironmentEntity { @Column({ unique: true }) name: string; - @Column('simple-json') + @Column("simple-json") urls: EnvironmentUrls; @CreateDateColumn() diff --git a/src/environment/environment.module.ts b/src/environment/environment.module.ts index 6466b42..bb07ed7 100644 --- a/src/environment/environment.module.ts +++ b/src/environment/environment.module.ts @@ -1,8 +1,8 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { EnvironmentEntity } from './environment.entity'; -import { EnvironmentService } from './environment.service'; -import { EnvironmentController } from './environment.controller'; +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { EnvironmentEntity } from "./environment.entity"; +import { EnvironmentService } from "./environment.service"; +import { EnvironmentController } from "./environment.controller"; @Module({ imports: [TypeOrmModule.forFeature([EnvironmentEntity])], diff --git a/src/environment/environment.service.ts b/src/environment/environment.service.ts index 94df33e..152ed06 100644 --- a/src/environment/environment.service.ts +++ b/src/environment/environment.service.ts @@ -1,12 +1,19 @@ -import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { EnvironmentEntity } from './environment.entity'; -import { CreateEnvironmentDto } from './dto/create-environment.dto'; -import { UpdateEnvironmentDto } from './dto/update-environment.dto'; -import { PaginationQueryDto, PaginatedResult } from '../common/dto/pagination.dto'; +import { + ConflictException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { EnvironmentEntity } from "./environment.entity"; +import { CreateEnvironmentDto } from "./dto/create-environment.dto"; +import { UpdateEnvironmentDto } from "./dto/update-environment.dto"; +import { + PaginationQueryDto, + PaginatedResult, +} from "../common/dto/pagination.dto"; -export type EnvironmentOrderBy = 'id' | 'name' | 'createdAt' | 'updatedAt'; +export type EnvironmentOrderBy = "id" | "name" | "createdAt" | "updatedAt"; @Injectable() export class EnvironmentService { @@ -23,11 +30,13 @@ export class EnvironmentService { return this.repo.save(this.repo.create(dto)); } - async findAll(query: PaginationQueryDto = {}): Promise> { + async findAll( + query: PaginationQueryDto = {}, + ): Promise> { const page = query.page ?? 1; const limit = query.limit ?? 20; - const orderBy = query.orderBy ?? 'id'; - const orderDir = query.orderDir ?? 'ASC'; + const orderBy = query.orderBy ?? "id"; + const orderDir = query.orderDir ?? "ASC"; const [data, total] = await this.repo.findAndCount({ order: { [orderBy]: orderDir }, skip: (page - 1) * limit, @@ -42,7 +51,10 @@ export class EnvironmentService { return env; } - async update(id: number, dto: UpdateEnvironmentDto): Promise { + async update( + id: number, + dto: UpdateEnvironmentDto, + ): Promise { const env = await this.findOne(id); Object.assign(env, dto); return this.repo.save(env); diff --git a/src/filters/http-exception.filter.ts b/src/filters/http-exception.filter.ts index ad2a865..81fe3e4 100644 --- a/src/filters/http-exception.filter.ts +++ b/src/filters/http-exception.filter.ts @@ -5,9 +5,9 @@ import { ExceptionFilter, HttpException, InternalServerErrorException, -} from '@nestjs/common'; -import { TraceLogger } from '../common/trace-logger'; -import type { Request, Response } from 'express'; +} from "@nestjs/common"; +import { TraceLogger } from "../common/trace-logger"; +import type { Request, Response } from "express"; @Catch(BadRequestException, InternalServerErrorException) export class HttpExceptionFilter implements ExceptionFilter { @@ -20,8 +20,8 @@ export class HttpExceptionFilter implements ExceptionFilter { const status = exception.getStatus(); const body = exception.getResponse(); - const cause = (exception as any).cause as Error | undefined; - const causeMessage = cause ? ` | cause: ${cause.message}` : ''; + const cause = (exception as unknown as { cause?: Error }).cause; + const causeMessage = cause ? ` | cause: ${cause.message}` : ""; const message = `${exception.message}${causeMessage}`; if (status >= 500) { @@ -30,7 +30,9 @@ export class HttpExceptionFilter implements ExceptionFilter { cause?.stack ?? exception.stack, ); } else { - this.logger.warn(`[${request.method} ${request.url}] ${status} — ${message}`); + this.logger.warn( + `[${request.method} ${request.url}] ${status} — ${message}`, + ); } response.status(status).json(body); diff --git a/src/health/health.controller.ts b/src/health/health.controller.ts index 0d53308..e3eb9c6 100644 --- a/src/health/health.controller.ts +++ b/src/health/health.controller.ts @@ -1,13 +1,13 @@ -import { Controller, Get } from '@nestjs/common'; -import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Controller, Get } from "@nestjs/common"; +import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; -@ApiTags('health') +@ApiTags("health") @Controller() export class HealthController { - @Get('healthz') - @ApiOperation({ summary: 'Health check' }) - @ApiResponse({ status: 200, description: 'Service is healthy' }) + @Get("healthz") + @ApiOperation({ summary: "Health check" }) + @ApiResponse({ status: 200, description: "Service is healthy" }) healthz(): { status: string } { - return { status: 'ok' }; + return { status: "ok" }; } } diff --git a/src/health/health.module.ts b/src/health/health.module.ts index 7476abe..40b7bdf 100644 --- a/src/health/health.module.ts +++ b/src/health/health.module.ts @@ -1,5 +1,5 @@ -import { Module } from '@nestjs/common'; -import { HealthController } from './health.controller'; +import { Module } from "@nestjs/common"; +import { HealthController } from "./health.controller"; @Module({ controllers: [HealthController], diff --git a/src/interceptors/logging.interceptor.ts b/src/interceptors/logging.interceptor.ts index 13d2529..51c3bc6 100644 --- a/src/interceptors/logging.interceptor.ts +++ b/src/interceptors/logging.interceptor.ts @@ -3,10 +3,10 @@ import { ExecutionContext, Injectable, NestInterceptor, -} from '@nestjs/common'; -import { TraceLogger } from '../common/trace-logger'; -import type { Request, Response } from 'express'; -import { Observable, tap } from 'rxjs'; +} from "@nestjs/common"; +import { TraceLogger } from "../common/trace-logger"; +import type { Request, Response } from "express"; +import { Observable, tap } from "rxjs"; @Injectable() export class LoggingInterceptor implements NestInterceptor { @@ -18,16 +18,20 @@ export class LoggingInterceptor implements NestInterceptor { const res = http.getResponse(); const { method, url, body } = req; const start = Date.now(); - const bodyStr = body && Object.keys(body).length ? ` ${JSON.stringify(body)}` : ''; + const bodyStr = + body && Object.keys(body).length ? ` ${JSON.stringify(body)}` : ""; this.logger.debug(`→ ${method} ${url}${bodyStr}`); return next.handle().pipe( tap((responseBody) => { const ms = Date.now() - start; - const len = responseBody != null ? JSON.stringify(responseBody).length : 0; - const lenStr = len > 0 ? ` [${len}b]` : ''; - this.logger.debug(`← ${method} ${url} ${res.statusCode} (${ms}ms)${lenStr}`); + const len = + responseBody != null ? JSON.stringify(responseBody).length : 0; + const lenStr = len > 0 ? ` [${len}b]` : ""; + this.logger.debug( + `← ${method} ${url} ${res.statusCode} (${ms}ms)${lenStr}`, + ); }), ); } diff --git a/src/interceptors/trace.interceptor.ts b/src/interceptors/trace.interceptor.ts index 7c4f3f3..36008b5 100644 --- a/src/interceptors/trace.interceptor.ts +++ b/src/interceptors/trace.interceptor.ts @@ -1,14 +1,20 @@ -import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; -import type { Request } from 'express'; -import * as crypto from 'crypto'; -import { Observable } from 'rxjs'; -import { traceStorage } from '../common/trace-context'; +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from "@nestjs/common"; +import type { Request } from "express"; +import * as crypto from "crypto"; +import { Observable } from "rxjs"; +import { traceStorage } from "../common/trace-context"; @Injectable() export class TraceInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable { const req = context.switchToHttp().getRequest(); - const traceId = (req.headers['x-trace-id'] as string | undefined) ?? crypto.randomUUID(); + const traceId = + (req.headers["x-trace-id"] as string | undefined) ?? crypto.randomUUID(); return new Observable((subscriber) => { traceStorage.run({ traceId }, () => { diff --git a/src/main.ts b/src/main.ts index b1aa61f..7d45338 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,25 +1,27 @@ -import { NestFactory } from '@nestjs/core'; -import { ConfigService } from '@nestjs/config'; -import { ValidationPipe } from '@nestjs/common'; -import { TraceLogger } from './common/trace-logger'; -import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; -import { AppModule } from './app.module'; -import { HttpExceptionFilter } from './filters/http-exception.filter'; -import { TraceInterceptor } from './interceptors/trace.interceptor'; -import { LoggingInterceptor } from './interceptors/logging.interceptor'; -import { name as pkgName, version as pkgVersion } from '../package.json'; +import { NestFactory } from "@nestjs/core"; +import { ConfigService } from "@nestjs/config"; +import { ValidationPipe } from "@nestjs/common"; +import { TraceLogger } from "./common/trace-logger"; +import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; +import { AppModule } from "./app.module"; +import { HttpExceptionFilter } from "./filters/http-exception.filter"; +import { TraceInterceptor } from "./interceptors/trace.interceptor"; +import { LoggingInterceptor } from "./interceptors/logging.interceptor"; +import { name as pkgName, version as pkgVersion } from "../package.json"; async function bootstrap() { - const logger = new TraceLogger('Bootstrap'); - const app = await NestFactory.create(AppModule, { logger: new TraceLogger('Bootstrap', { timestamp: true }) }); + const logger = new TraceLogger("Bootstrap"); + const app = await NestFactory.create(AppModule, { + logger: new TraceLogger("Bootstrap", { timestamp: true }), + }); app.useGlobalPipes(new ValidationPipe({ transform: true })); app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalInterceptors(new TraceInterceptor(), new LoggingInterceptor()); const config = app.get(ConfigService); - const port = config.get('PORT', 3000); - const nodeEnv = config.get('NODE_ENV', 'development'); + const port = config.get("PORT", 3000); + const nodeEnv = config.get("NODE_ENV", "development"); const swaggerConfig = new DocumentBuilder() .setTitle(pkgName) @@ -28,11 +30,13 @@ async function bootstrap() { .build(); const document = SwaggerModule.createDocument(app, swaggerConfig); - SwaggerModule.setup('api', app, document); + SwaggerModule.setup("api", app, document); await app.listen(port); - logger.log(`Application "${pkgName}" v${pkgVersion} running on port ${port} [${nodeEnv}]`); + logger.log( + `Application "${pkgName}" v${pkgVersion} running on port ${port} [${nodeEnv}]`, + ); logger.log(`Swagger UI available at http://localhost:${port}/api`); logger.log(`MCP endpoint available at http://localhost:${port}/mcp`); } diff --git a/src/mcp/mcp.controller.ts b/src/mcp/mcp.controller.ts index 956c239..8adf7b3 100644 --- a/src/mcp/mcp.controller.ts +++ b/src/mcp/mcp.controller.ts @@ -1,52 +1,68 @@ -import { Controller, Delete, Get, Post, Req, Res } from '@nestjs/common'; -import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; -import type { Request, Response } from 'express'; -import { McpService } from './mcp.service'; +import { Controller, Delete, Get, Post, Req, Res } from "@nestjs/common"; +import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; +import type { Request, Response } from "express"; +import { McpService } from "./mcp.service"; -@ApiTags('mcp') -@Controller('mcp') +@ApiTags("mcp") +@Controller("mcp") export class McpController { constructor(private readonly mcpService: McpService) {} @Post() @ApiOperation({ - summary: 'Send JSON-RPC message', + summary: "Send JSON-RPC message", description: - 'Accepts a JSON-RPC request, notification, or response. ' + - 'Returns either `application/json` for a single response or ' + - '`text/event-stream` (SSE) when the server streams multiple messages.', + "Accepts a JSON-RPC request, notification, or response. " + + "Returns either `application/json` for a single response or " + + "`text/event-stream` (SSE) when the server streams multiple messages.", + }) + @ApiResponse({ + status: 200, + description: "JSON-RPC response (application/json or text/event-stream)", + }) + @ApiResponse({ + status: 202, + description: "Accepted — input was a notification or response only", + }) + @ApiResponse({ + status: 400, + description: "Bad Request — malformed JSON-RPC payload", }) - @ApiResponse({ status: 200, description: 'JSON-RPC response (application/json or text/event-stream)' }) - @ApiResponse({ status: 202, description: 'Accepted — input was a notification or response only' }) - @ApiResponse({ status: 400, description: 'Bad Request — malformed JSON-RPC payload' }) post(@Req() req: Request, @Res() res: Response): Promise { return this.mcpService.handle(req, res); } @Get() @ApiOperation({ - summary: 'Open server-sent event stream', + summary: "Open server-sent event stream", description: - 'Opens a persistent SSE stream so the server can push JSON-RPC requests and ' + - 'notifications to the client without a prior POST. ' + - 'Requires `Accept: text/event-stream`. Pass `Last-Event-ID` to resume a broken stream.', + "Opens a persistent SSE stream so the server can push JSON-RPC requests and " + + "notifications to the client without a prior POST. " + + "Requires `Accept: text/event-stream`. Pass `Last-Event-ID` to resume a broken stream.", + }) + @ApiResponse({ status: 200, description: "SSE stream (text/event-stream)" }) + @ApiResponse({ + status: 405, + description: "Method Not Allowed — server does not offer an SSE stream", }) - @ApiResponse({ status: 200, description: 'SSE stream (text/event-stream)' }) - @ApiResponse({ status: 405, description: 'Method Not Allowed — server does not offer an SSE stream' }) get(@Req() req: Request, @Res() res: Response): Promise { return this.mcpService.handle(req, res); } @Delete() @ApiOperation({ - summary: 'Terminate session', + summary: "Terminate session", description: - 'Explicitly terminates a session identified by the `Mcp-Session-Id` header. ' + - 'The server may return 405 if it does not support client-initiated session termination.', + "Explicitly terminates a session identified by the `Mcp-Session-Id` header. " + + "The server may return 405 if it does not support client-initiated session termination.", + }) + @ApiResponse({ status: 200, description: "Session terminated" }) + @ApiResponse({ status: 404, description: "Session not found" }) + @ApiResponse({ + status: 405, + description: + "Method Not Allowed — server does not support session termination", }) - @ApiResponse({ status: 200, description: 'Session terminated' }) - @ApiResponse({ status: 404, description: 'Session not found' }) - @ApiResponse({ status: 405, description: 'Method Not Allowed — server does not support session termination' }) delete(@Req() req: Request, @Res() res: Response): Promise { return this.mcpService.handle(req, res); } diff --git a/src/mcp/mcp.module.ts b/src/mcp/mcp.module.ts index 1d02887..40793cf 100644 --- a/src/mcp/mcp.module.ts +++ b/src/mcp/mcp.module.ts @@ -1,15 +1,22 @@ -import { Module } from '@nestjs/common'; -import { McpController } from './mcp.controller'; -import { McpService } from './mcp.service'; -import { AuthModule } from '../auth/auth.module'; -import { SessionModule } from '../session/session.module'; -import { EnvironmentModule } from '../environment/environment.module'; -import { BrowserModule } from '../browser/browser.module'; -import { CodeExecutorModule } from '../code-executor/code-executor.module'; -import { ScenarioModule } from '../scenario/scenario.module'; +import { Module } from "@nestjs/common"; +import { McpController } from "./mcp.controller"; +import { McpService } from "./mcp.service"; +import { AuthModule } from "../auth/auth.module"; +import { SessionModule } from "../session/session.module"; +import { EnvironmentModule } from "../environment/environment.module"; +import { BrowserModule } from "../browser/browser.module"; +import { CodeExecutorModule } from "../code-executor/code-executor.module"; +import { ScenarioModule } from "../scenario/scenario.module"; @Module({ - imports: [AuthModule, SessionModule, EnvironmentModule, BrowserModule, CodeExecutorModule, ScenarioModule], + imports: [ + AuthModule, + SessionModule, + EnvironmentModule, + BrowserModule, + CodeExecutorModule, + ScenarioModule, + ], controllers: [McpController], providers: [McpService], }) diff --git a/src/mcp/mcp.service.ts b/src/mcp/mcp.service.ts index f8b074c..1fffc9d 100644 --- a/src/mcp/mcp.service.ts +++ b/src/mcp/mcp.service.ts @@ -1,17 +1,17 @@ -import { Injectable } from '@nestjs/common'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import { z } from 'zod'; -import type { Request, Response } from 'express'; -import { AuthService } from '../auth/auth.service'; -import { SessionService } from '../session/session.service'; -import { EnvironmentService } from '../environment/environment.service'; -import type { EnvironmentUrls } from '../environment/environment.entity'; -import { BrowserService } from '../browser/browser.service'; -import { CodeExecutorService } from '../code-executor/code-executor.service'; -import { ScenarioService } from '../scenario/scenario.service'; +import { Injectable } from "@nestjs/common"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { z } from "zod"; +import type { Request, Response } from "express"; +import { AuthService } from "../auth/auth.service"; +import { SessionService } from "../session/session.service"; +import { EnvironmentService } from "../environment/environment.service"; +import type { EnvironmentUrls } from "../environment/environment.entity"; +import { BrowserService } from "../browser/browser.service"; +import { CodeExecutorService } from "../code-executor/code-executor.service"; +import { ScenarioService } from "../scenario/scenario.service"; -import pkg from '../../package.json'; +import pkg from "../../package.json"; @Injectable() export class McpService { @@ -31,161 +31,273 @@ export class McpService { } private registerTools(server: McpServer): void { - // ── Auth ────────────────────────────────────────────────────────────────── server.registerTool( - 'list_keys', - { description: 'List available key identifiers from the keys directory' }, + "list_keys", + { description: "List available key identifiers from the keys directory" }, async () => { const keys = this.authService.listKeys(); - return { content: [{ type: 'text' as const, text: JSON.stringify(keys) }] }; + return { + content: [{ type: "text" as const, text: JSON.stringify(keys) }], + }; }, ); server.registerTool( - 'login', + "login", { - description: 'Log in using a file key against a named environment and store the session', + description: + "Log in using a file key against a named environment and store the session", inputSchema: { - key: z.string().describe('Key identifier (filename without extension from keys/ dir)'), - environmentName: z.string().describe('Environment name to resolve login/cabinet URLs'), - sessionName: z.string().optional().describe('Session name to store credentials under. Auto-UUID if omitted.'), + key: z + .string() + .describe( + "Key identifier (filename without extension from keys/ dir)", + ), + environmentName: z + .string() + .describe("Environment name to resolve login/cabinet URLs"), + sessionName: z + .string() + .optional() + .describe( + "Session name to store credentials under. Auto-UUID if omitted.", + ), }, }, async ({ key, environmentName, sessionName }) => { - const result = await this.authService.login(key, environmentName, sessionName); - return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }; + const result = await this.authService.login( + key, + environmentName, + sessionName, + ); + return { + content: [{ type: "text" as const, text: JSON.stringify(result) }], + }; }, ); // ── Sessions ────────────────────────────────────────────────────────────── server.registerTool( - 'list_sessions', + "list_sessions", { - description: 'List all stored sessions (id, sessionName, createdAt, updatedAt), paginated', + description: + "List all stored sessions (id, sessionName, createdAt, updatedAt), paginated", inputSchema: { - page: z.number().int().min(1).optional().describe('Page number (default 1)'), - limit: z.number().int().min(1).optional().describe('Items per page (default 20)'), - orderBy: z.enum(['id', 'sessionName', 'createdAt', 'updatedAt']).optional().describe('Field to order by (default id)'), - orderDir: z.enum(['ASC', 'DESC']).optional().describe('Sort direction (default ASC)'), + page: z + .number() + .int() + .min(1) + .optional() + .describe("Page number (default 1)"), + limit: z + .number() + .int() + .min(1) + .optional() + .describe("Items per page (default 20)"), + orderBy: z + .enum(["id", "sessionName", "createdAt", "updatedAt"]) + .optional() + .describe("Field to order by (default id)"), + orderDir: z + .enum(["ASC", "DESC"]) + .optional() + .describe("Sort direction (default ASC)"), }, }, async ({ page, limit, orderBy, orderDir }) => { - const sessions = await this.sessionService.findAll({ page, limit, orderBy, orderDir }); - return { content: [{ type: 'text' as const, text: JSON.stringify(sessions) }] }; + const sessions = await this.sessionService.findAll({ + page, + limit, + orderBy, + orderDir, + }); + return { + content: [{ type: "text" as const, text: JSON.stringify(sessions) }], + }; }, ); server.registerTool( - 'delete_session', + "delete_session", { - description: 'Delete a session by numeric ID', + description: "Delete a session by numeric ID", inputSchema: { - id: z.number().int().describe('Session ID to delete'), + id: z.number().int().describe("Session ID to delete"), }, }, async ({ id }) => { const { data } = await this.sessionService.findAll(); - if (!data.find(s => s.id === id)) { - return { isError: true, content: [{ type: 'text' as const, text: `Session ${id} not found` }] }; + if (!data.find((s) => s.id === id)) { + return { + isError: true, + content: [ + { type: "text" as const, text: `Session ${id} not found` }, + ], + }; } await this.sessionService.remove(id); - return { content: [{ type: 'text' as const, text: `Session ${id} deleted` }] }; + return { + content: [{ type: "text" as const, text: `Session ${id} deleted` }], + }; }, ); // ── Environments ────────────────────────────────────────────────────────── server.registerTool( - 'list_environments', + "list_environments", { - description: 'List all environments, paginated', + description: "List all environments, paginated", inputSchema: { - page: z.number().int().min(1).optional().describe('Page number (default 1)'), - limit: z.number().int().min(1).optional().describe('Items per page (default 20)'), - orderBy: z.enum(['id', 'name', 'createdAt', 'updatedAt']).optional().describe('Field to order by (default id)'), - orderDir: z.enum(['ASC', 'DESC']).optional().describe('Sort direction (default ASC)'), + page: z + .number() + .int() + .min(1) + .optional() + .describe("Page number (default 1)"), + limit: z + .number() + .int() + .min(1) + .optional() + .describe("Items per page (default 20)"), + orderBy: z + .enum(["id", "name", "createdAt", "updatedAt"]) + .optional() + .describe("Field to order by (default id)"), + orderDir: z + .enum(["ASC", "DESC"]) + .optional() + .describe("Sort direction (default ASC)"), }, }, async ({ page, limit, orderBy, orderDir }) => { - const envs = await this.environmentService.findAll({ page, limit, orderBy, orderDir }); - return { content: [{ type: 'text' as const, text: JSON.stringify(envs) }] }; + const envs = await this.environmentService.findAll({ + page, + limit, + orderBy, + orderDir, + }); + return { + content: [{ type: "text" as const, text: JSON.stringify(envs) }], + }; }, ); server.registerTool( - 'get_environment', + "get_environment", { - description: 'Get an environment record by ID', + description: "Get an environment record by ID", inputSchema: { - id: z.number().int().describe('Environment ID'), + id: z.number().int().describe("Environment ID"), }, }, async ({ id }) => { try { const env = await this.environmentService.findOne(id); - return { content: [{ type: 'text' as const, text: JSON.stringify(env) }] }; + return { + content: [{ type: "text" as const, text: JSON.stringify(env) }], + }; } catch { - return { isError: true, content: [{ type: 'text' as const, text: `Environment ${id} not found` }] }; + return { + isError: true, + content: [ + { type: "text" as const, text: `Environment ${id} not found` }, + ], + }; } }, ); server.registerTool( - 'create_environment', + "create_environment", { - description: 'Create a new named environment with a set of URLs', + description: "Create a new named environment with a set of URLs", inputSchema: { - name: z.string().describe('Unique environment name, e.g. liquio-diia-stg'), - urls: z.record(z.string(), z.string()).describe('Map of URL keys to URL strings (id_url, cabinet_url, admin_url, …)'), + name: z + .string() + .describe("Unique environment name, e.g. liquio-diia-stg"), + urls: z + .record(z.string(), z.string()) + .describe( + "Map of URL keys to URL strings (id_url, cabinet_url, admin_url, …)", + ), }, }, async ({ name, urls }) => { try { - const env = await this.environmentService.create({ name, urls: urls as EnvironmentUrls }); - return { content: [{ type: 'text' as const, text: JSON.stringify(env) }] }; + const env = await this.environmentService.create({ + name, + urls: urls as EnvironmentUrls, + }); + return { + content: [{ type: "text" as const, text: JSON.stringify(env) }], + }; } catch (err) { - return { isError: true, content: [{ type: 'text' as const, text: (err as Error).message }] }; + return { + isError: true, + content: [{ type: "text" as const, text: (err as Error).message }], + }; } }, ); server.registerTool( - 'update_environment', + "update_environment", { - description: 'Update an existing environment (name and/or urls)', + description: "Update an existing environment (name and/or urls)", inputSchema: { - id: z.number().int().describe('Environment ID to update'), - name: z.string().optional().describe('New name'), - urls: z.record(z.string(), z.string()).optional().describe('New URLs map'), + id: z.number().int().describe("Environment ID to update"), + name: z.string().optional().describe("New name"), + urls: z + .record(z.string(), z.string()) + .optional() + .describe("New URLs map"), }, }, async ({ id, name, urls }) => { try { - const env = await this.environmentService.update(id, { name, urls: urls as EnvironmentUrls | undefined }); - return { content: [{ type: 'text' as const, text: JSON.stringify(env) }] }; + const env = await this.environmentService.update(id, { + name, + urls: urls as EnvironmentUrls | undefined, + }); + return { + content: [{ type: "text" as const, text: JSON.stringify(env) }], + }; } catch (err) { - return { isError: true, content: [{ type: 'text' as const, text: (err as Error).message }] }; + return { + isError: true, + content: [{ type: "text" as const, text: (err as Error).message }], + }; } }, ); server.registerTool( - 'delete_environment', + "delete_environment", { - description: 'Delete an environment by ID', + description: "Delete an environment by ID", inputSchema: { - id: z.number().int().describe('Environment ID to delete'), + id: z.number().int().describe("Environment ID to delete"), }, }, async ({ id }) => { try { await this.environmentService.remove(id); - return { content: [{ type: 'text' as const, text: `Environment ${id} deleted` }] }; + return { + content: [ + { type: "text" as const, text: `Environment ${id} deleted` }, + ], + }; } catch (err) { - return { isError: true, content: [{ type: 'text' as const, text: (err as Error).message }] }; + return { + isError: true, + content: [{ type: "text" as const, text: (err as Error).message }], + }; } }, ); @@ -193,43 +305,86 @@ export class McpService { // ── Browser ─────────────────────────────────────────────────────────────── server.registerTool( - 'open_url', + "open_url", { - description: 'Open a URL using a stored session and return the page title and content', + description: + "Open a URL using a stored session and return the page title and content", inputSchema: { - sessionName: z.string().optional().describe('Session name to restore cookies and localStorage from. Omit to open without a stored session.'), - url: z.string().url().describe('URL to navigate to'), - readerMode: z.boolean().optional().describe('Extract readable plain text instead of raw HTML'), - selector: z.string().optional().describe('CSS selector whose matching element content is returned; applied before readerMode'), + sessionName: z + .string() + .optional() + .describe( + "Session name to restore cookies and localStorage from. Omit to open without a stored session.", + ), + url: z.string().url().describe("URL to navigate to"), + readerMode: z + .boolean() + .optional() + .describe("Extract readable plain text instead of raw HTML"), + selector: z + .string() + .optional() + .describe( + "CSS selector whose matching element content is returned; applied before readerMode", + ), }, }, async ({ sessionName, url, readerMode, selector }) => { try { - const result = await this.browserService.open(sessionName, url, readerMode ?? false, selector); - return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }; + const result = await this.browserService.open( + sessionName, + url, + readerMode ?? false, + selector, + ); + return { + content: [{ type: "text" as const, text: JSON.stringify(result) }], + }; } catch (err) { - return { isError: true, content: [{ type: 'text' as const, text: (err as Error).message }] }; + return { + isError: true, + content: [{ type: "text" as const, text: (err as Error).message }], + }; } }, ); server.registerTool( - 'exec_code', + "exec_code", { - description: 'Execute arbitrary Playwright JavaScript with `page` and `context` in scope', + description: + "Execute arbitrary Playwright JavaScript with `page` and `context` in scope", inputSchema: { - sessionName: z.string().optional().describe('Session name to restore. Omit to run without a stored session.'), - code: z.string().describe('JavaScript code body to execute (async-safe, may use `page` and `context`)'), - url: z.string().url().optional().describe('Optional URL to navigate to before running code'), + sessionName: z + .string() + .optional() + .describe( + "Session name to restore. Omit to run without a stored session.", + ), + code: z + .string() + .describe( + "JavaScript code body to execute (async-safe, may use `page` and `context`)", + ), + url: z + .string() + .url() + .optional() + .describe("Optional URL to navigate to before running code"), }, }, async ({ sessionName, code, url }) => { try { this.codeExecutor.validate(code); const result = await this.browserService.exec(sessionName, code, url); - return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }; + return { + content: [{ type: "text" as const, text: JSON.stringify(result) }], + }; } catch (err) { - return { isError: true, content: [{ type: 'text' as const, text: (err as Error).message }] }; + return { + isError: true, + content: [{ type: "text" as const, text: (err as Error).message }], + }; } }, ); @@ -237,215 +392,341 @@ export class McpService { // ── Scenarios ───────────────────────────────────────────────────────────── server.registerTool( - 'list_scenarios', + "list_scenarios", { - description: 'List all scenarios (paginated)', + description: "List all scenarios (paginated)", inputSchema: { - page: z.number().int().min(1).optional().describe('Page number (default 1)'), - limit: z.number().int().min(1).optional().describe('Items per page (default 20)'), - orderBy: z.enum(['id', 'name', 'createdAt', 'updatedAt']).optional().describe('Field to order by (default id)'), - orderDir: z.enum(['ASC', 'DESC']).optional().describe('Sort direction (default ASC)'), + page: z + .number() + .int() + .min(1) + .optional() + .describe("Page number (default 1)"), + limit: z + .number() + .int() + .min(1) + .optional() + .describe("Items per page (default 20)"), + orderBy: z + .enum(["id", "name", "createdAt", "updatedAt"]) + .optional() + .describe("Field to order by (default id)"), + orderDir: z + .enum(["ASC", "DESC"]) + .optional() + .describe("Sort direction (default ASC)"), }, }, async ({ page, limit, orderBy, orderDir }) => { - const result = await this.scenarioService.findAll({ page, limit, orderBy, orderDir }); - return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }; + const result = await this.scenarioService.findAll({ + page, + limit, + orderBy, + orderDir, + }); + return { + content: [{ type: "text" as const, text: JSON.stringify(result) }], + }; }, ); server.registerTool( - 'get_scenario', + "get_scenario", { - description: 'Get a scenario with its steps by ID', + description: "Get a scenario with its steps by ID", inputSchema: { - id: z.number().int().describe('Scenario ID'), + id: z.number().int().describe("Scenario ID"), }, }, async ({ id }) => { try { const scenario = await this.scenarioService.findOne(id); - return { content: [{ type: 'text' as const, text: JSON.stringify(scenario) }] }; + return { + content: [ + { type: "text" as const, text: JSON.stringify(scenario) }, + ], + }; } catch (err) { - return { isError: true, content: [{ type: 'text' as const, text: (err as Error).message }] }; + return { + isError: true, + content: [{ type: "text" as const, text: (err as Error).message }], + }; } }, ); server.registerTool( - 'create_scenario', + "create_scenario", { - description: 'Create a new scenario', + description: "Create a new scenario", inputSchema: { - name: z.string().describe('Scenario name'), + name: z.string().describe("Scenario name"), }, }, async ({ name }) => { try { const scenario = await this.scenarioService.create({ name }); - return { content: [{ type: 'text' as const, text: JSON.stringify(scenario) }] }; + return { + content: [ + { type: "text" as const, text: JSON.stringify(scenario) }, + ], + }; } catch (err) { - return { isError: true, content: [{ type: 'text' as const, text: (err as Error).message }] }; + return { + isError: true, + content: [{ type: "text" as const, text: (err as Error).message }], + }; } }, ); server.registerTool( - 'update_scenario', + "update_scenario", { - description: 'Update a scenario name', + description: "Update a scenario name", inputSchema: { - id: z.number().int().describe('Scenario ID'), - name: z.string().optional().describe('New name'), + id: z.number().int().describe("Scenario ID"), + name: z.string().optional().describe("New name"), }, }, async ({ id, name }) => { try { const scenario = await this.scenarioService.update(id, { name }); - return { content: [{ type: 'text' as const, text: JSON.stringify(scenario) }] }; + return { + content: [ + { type: "text" as const, text: JSON.stringify(scenario) }, + ], + }; } catch (err) { - return { isError: true, content: [{ type: 'text' as const, text: (err as Error).message }] }; + return { + isError: true, + content: [{ type: "text" as const, text: (err as Error).message }], + }; } }, ); server.registerTool( - 'delete_scenario', + "delete_scenario", { - description: 'Delete a scenario by ID', + description: "Delete a scenario by ID", inputSchema: { - id: z.number().int().describe('Scenario ID'), + id: z.number().int().describe("Scenario ID"), }, }, async ({ id }) => { try { await this.scenarioService.remove(id); - return { content: [{ type: 'text' as const, text: `Scenario ${id} deleted` }] }; + return { + content: [ + { type: "text" as const, text: `Scenario ${id} deleted` }, + ], + }; } catch (err) { - return { isError: true, content: [{ type: 'text' as const, text: (err as Error).message }] }; + return { + isError: true, + content: [{ type: "text" as const, text: (err as Error).message }], + }; } }, ); server.registerTool( - 'create_scenario_step', + "create_scenario_step", { - description: 'Add a step to a scenario', + description: "Add a step to a scenario", inputSchema: { - scenarioId: z.number().int().describe('Parent scenario ID'), - order: z.number().int().min(0).describe('Execution order (ascending)'), - type: z.enum(['login', 'exec', 'sign']).describe('Step type'), - sessionName: z.string().describe('Session name used by this step'), - execCode: z.string().optional().describe('Playwright JS code to execute (exec steps)'), - validateCode: z.string().optional().describe('Validation JS code returning { success, description }'), + scenarioId: z.number().int().describe("Parent scenario ID"), + order: z + .number() + .int() + .min(0) + .describe("Execution order (ascending)"), + type: z.enum(["login", "exec", "sign"]).describe("Step type"), + sessionName: z.string().describe("Session name used by this step"), + execCode: z + .string() + .optional() + .describe("Playwright JS code to execute (exec steps)"), + validateCode: z + .string() + .optional() + .describe("Validation JS code returning { success, description }"), }, }, async ({ scenarioId, ...dto }) => { try { const step = await this.scenarioService.createStep(scenarioId, dto); - return { content: [{ type: 'text' as const, text: JSON.stringify(step) }] }; + return { + content: [{ type: "text" as const, text: JSON.stringify(step) }], + }; } catch (err) { - return { isError: true, content: [{ type: 'text' as const, text: (err as Error).message }] }; + return { + isError: true, + content: [{ type: "text" as const, text: (err as Error).message }], + }; } }, ); server.registerTool( - 'get_scenario_step', + "get_scenario_step", { - description: 'Get a single step of a scenario', + description: "Get a single step of a scenario", inputSchema: { - scenarioId: z.number().int().describe('Scenario ID'), - stepId: z.number().int().describe('Step ID'), + scenarioId: z.number().int().describe("Scenario ID"), + stepId: z.number().int().describe("Step ID"), }, }, async ({ scenarioId, stepId }) => { try { const step = await this.scenarioService.findStep(scenarioId, stepId); - return { content: [{ type: 'text' as const, text: JSON.stringify(step) }] }; + return { + content: [{ type: "text" as const, text: JSON.stringify(step) }], + }; } catch (err) { - return { isError: true, content: [{ type: 'text' as const, text: (err as Error).message }] }; + return { + isError: true, + content: [{ type: "text" as const, text: (err as Error).message }], + }; } }, ); server.registerTool( - 'update_scenario_step', + "update_scenario_step", { - description: 'Update a step within a scenario', + description: "Update a step within a scenario", inputSchema: { - scenarioId: z.number().int().describe('Scenario ID'), - stepId: z.number().int().describe('Step ID'), - order: z.number().int().min(0).optional().describe('New execution order'), - type: z.enum(['login', 'exec', 'sign']).optional().describe('New step type'), - sessionName: z.string().optional().describe('New session name'), - execCode: z.string().optional().describe('New exec code'), - validateCode: z.string().optional().describe('New validation code'), + scenarioId: z.number().int().describe("Scenario ID"), + stepId: z.number().int().describe("Step ID"), + order: z + .number() + .int() + .min(0) + .optional() + .describe("New execution order"), + type: z + .enum(["login", "exec", "sign"]) + .optional() + .describe("New step type"), + sessionName: z.string().optional().describe("New session name"), + execCode: z.string().optional().describe("New exec code"), + validateCode: z.string().optional().describe("New validation code"), }, }, async ({ scenarioId, stepId, ...dto }) => { try { - const step = await this.scenarioService.updateStep(scenarioId, stepId, dto); - return { content: [{ type: 'text' as const, text: JSON.stringify(step) }] }; + const step = await this.scenarioService.updateStep( + scenarioId, + stepId, + dto, + ); + return { + content: [{ type: "text" as const, text: JSON.stringify(step) }], + }; } catch (err) { - return { isError: true, content: [{ type: 'text' as const, text: (err as Error).message }] }; + return { + isError: true, + content: [{ type: "text" as const, text: (err as Error).message }], + }; } }, ); server.registerTool( - 'delete_scenario_step', + "delete_scenario_step", { - description: 'Delete a step from a scenario', + description: "Delete a step from a scenario", inputSchema: { - scenarioId: z.number().int().describe('Scenario ID'), - stepId: z.number().int().describe('Step ID'), + scenarioId: z.number().int().describe("Scenario ID"), + stepId: z.number().int().describe("Step ID"), }, }, async ({ scenarioId, stepId }) => { try { await this.scenarioService.removeStep(scenarioId, stepId); - return { content: [{ type: 'text' as const, text: `Step ${stepId} deleted from scenario ${scenarioId}` }] }; + return { + content: [ + { + type: "text" as const, + text: `Step ${stepId} deleted from scenario ${scenarioId}`, + }, + ], + }; } catch (err) { - return { isError: true, content: [{ type: 'text' as const, text: (err as Error).message }] }; + return { + isError: true, + content: [{ type: "text" as const, text: (err as Error).message }], + }; } }, ); server.registerTool( - 'list_scenario_runs', + "list_scenario_runs", { - description: 'List runs for a scenario (paginated, optionally filtered by status)', + description: + "List runs for a scenario (paginated, optionally filtered by status)", inputSchema: { - scenarioId: z.number().int().describe('Scenario ID'), - status: z.enum(['pending', 'in_progress', 'pass', 'fail']).optional().describe('Filter by run status'), - page: z.number().int().min(1).optional().describe('Page number (default 1)'), - limit: z.number().int().min(1).optional().describe('Items per page (default 20)'), + scenarioId: z.number().int().describe("Scenario ID"), + status: z + .enum(["pending", "in_progress", "pass", "fail"]) + .optional() + .describe("Filter by run status"), + page: z + .number() + .int() + .min(1) + .optional() + .describe("Page number (default 1)"), + limit: z + .number() + .int() + .min(1) + .optional() + .describe("Items per page (default 20)"), }, }, async ({ scenarioId, status, page, limit }) => { try { - const result = await this.scenarioService.findRuns(scenarioId, { status, page, limit }); - return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }; + const result = await this.scenarioService.findRuns(scenarioId, { + status, + page, + limit, + }); + return { + content: [{ type: "text" as const, text: JSON.stringify(result) }], + }; } catch (err) { - return { isError: true, content: [{ type: 'text' as const, text: (err as Error).message }] }; + return { + isError: true, + content: [{ type: "text" as const, text: (err as Error).message }], + }; } }, ); server.registerTool( - 'run_scenario', + "run_scenario", { - description: 'Trigger an immediate run of a scenario by ID', + description: "Trigger an immediate run of a scenario by ID", inputSchema: { - id: z.number().int().describe('Scenario ID to run'), + id: z.number().int().describe("Scenario ID to run"), }, }, async ({ id }) => { try { const run = await this.scenarioService.createRun(id); - return { content: [{ type: 'text' as const, text: JSON.stringify(run) }] }; + return { + content: [{ type: "text" as const, text: JSON.stringify(run) }], + }; } catch (err) { - return { isError: true, content: [{ type: 'text' as const, text: (err as Error).message }] }; + return { + isError: true, + content: [{ type: "text" as const, text: (err as Error).message }], + }; } }, ); @@ -455,7 +736,9 @@ export class McpService { async handle(req: Request, res: Response): Promise { const server = this.createServer(); - const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); await server.connect(transport); try { await transport.handleRequest(req, res, req.body); diff --git a/src/scenario/dto/create-scenario-step.dto.ts b/src/scenario/dto/create-scenario-step.dto.ts index d2da3e3..b8afa1b 100644 --- a/src/scenario/dto/create-scenario-step.dto.ts +++ b/src/scenario/dto/create-scenario-step.dto.ts @@ -1,29 +1,43 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsIn, IsInt, IsNotEmpty, IsOptional, IsString, Min } from 'class-validator'; -import { StepType } from '../scenario-step.entity'; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { + IsIn, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Min, +} from "class-validator"; +import { StepType } from "../scenario-step.entity"; export class CreateScenarioStepDto { - @ApiProperty({ example: 0, description: 'Execution order (ascending)' }) + @ApiProperty({ example: 0, description: "Execution order (ascending)" }) @IsInt() @Min(0) order: number; - @ApiProperty({ enum: ['login', 'exec', 'sign'], example: 'exec' }) - @IsIn(['login', 'exec', 'sign']) + @ApiProperty({ enum: ["login", "exec", "sign"], example: "exec" }) + @IsIn(["login", "exec", "sign"]) type: StepType; - @ApiProperty({ description: 'Session name used by this step. login steps create it; exec steps consume it.', example: 'my-session' }) + @ApiProperty({ + description: + "Session name used by this step. login steps create it; exec steps consume it.", + example: "my-session", + }) @IsString() @IsNotEmpty() sessionName: string; - @ApiPropertyOptional({ example: 'return await page.title();' }) + @ApiPropertyOptional({ example: "return await page.title();" }) @IsOptional() @IsString() @IsNotEmpty() execCode?: string; - @ApiPropertyOptional({ example: 'return { success: result !== null, description: "title present" };' }) + @ApiPropertyOptional({ + example: + 'return { success: result !== null, description: "title present" };', + }) @IsOptional() @IsString() @IsNotEmpty() diff --git a/src/scenario/dto/create-scenario.dto.ts b/src/scenario/dto/create-scenario.dto.ts index 0d3b8f2..b62a60c 100644 --- a/src/scenario/dto/create-scenario.dto.ts +++ b/src/scenario/dto/create-scenario.dto.ts @@ -1,8 +1,8 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsString } from 'class-validator'; +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty, IsString } from "class-validator"; export class CreateScenarioDto { - @ApiProperty({ example: 'Login and verify cabinet' }) + @ApiProperty({ example: "Login and verify cabinet" }) @IsString() @IsNotEmpty() name: string; diff --git a/src/scenario/dto/pagination-query.dto.ts b/src/scenario/dto/pagination-query.dto.ts index 0a5f39c..1445957 100644 --- a/src/scenario/dto/pagination-query.dto.ts +++ b/src/scenario/dto/pagination-query.dto.ts @@ -1 +1 @@ -export { PaginationQueryDto } from '../../common/dto/pagination.dto'; +export { PaginationQueryDto } from "../../common/dto/pagination.dto"; diff --git a/src/scenario/dto/runs-query.dto.ts b/src/scenario/dto/runs-query.dto.ts index 8e2410b..2100afc 100644 --- a/src/scenario/dto/runs-query.dto.ts +++ b/src/scenario/dto/runs-query.dto.ts @@ -1,7 +1,7 @@ -import { ApiPropertyOptional } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; -import { IsIn, IsInt, IsOptional, Min } from 'class-validator'; -import { RunStatus } from '../scenario-run.entity'; +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsIn, IsInt, IsOptional, Min } from "class-validator"; +import { RunStatus } from "../scenario-run.entity"; export class RunsQueryDto { @ApiPropertyOptional({ example: 1, default: 1 }) @@ -19,10 +19,10 @@ export class RunsQueryDto { limit?: number = 20; @ApiPropertyOptional({ - enum: ['pending', 'in_progress', 'pass', 'fail'], - description: 'Filter by run status', + enum: ["pending", "in_progress", "pass", "fail"], + description: "Filter by run status", }) @IsOptional() - @IsIn(['pending', 'in_progress', 'pass', 'fail']) + @IsIn(["pending", "in_progress", "pass", "fail"]) status?: RunStatus; } diff --git a/src/scenario/dto/scenario-export.dto.ts b/src/scenario/dto/scenario-export.dto.ts index a1ebf75..cb43293 100644 --- a/src/scenario/dto/scenario-export.dto.ts +++ b/src/scenario/dto/scenario-export.dto.ts @@ -1,5 +1,5 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; import { IsArray, IsIn, @@ -9,8 +9,8 @@ import { IsString, Min, ValidateNested, -} from 'class-validator'; -import { StepType } from '../scenario-step.entity'; +} from "class-validator"; +import { StepType } from "../scenario-step.entity"; export class ScenarioStepExportDto { @ApiProperty() @@ -18,8 +18,8 @@ export class ScenarioStepExportDto { @Min(0) order: number; - @ApiProperty({ enum: ['login', 'exec', 'sign'] }) - @IsIn(['login', 'exec', 'sign']) + @ApiProperty({ enum: ["login", "exec", "sign"] }) + @IsIn(["login", "exec", "sign"]) type: StepType; @ApiProperty() diff --git a/src/scenario/dto/update-scenario-step.dto.ts b/src/scenario/dto/update-scenario-step.dto.ts index 651afd7..f9e81c5 100644 --- a/src/scenario/dto/update-scenario-step.dto.ts +++ b/src/scenario/dto/update-scenario-step.dto.ts @@ -1,6 +1,13 @@ -import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsIn, IsInt, IsNotEmpty, IsOptional, IsString, Min } from 'class-validator'; -import { StepType } from '../scenario-step.entity'; +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { + IsIn, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Min, +} from "class-validator"; +import { StepType } from "../scenario-step.entity"; export class UpdateScenarioStepDto { @ApiPropertyOptional({ example: 0 }) @@ -9,9 +16,9 @@ export class UpdateScenarioStepDto { @Min(0) order?: number; - @ApiPropertyOptional({ enum: ['login', 'exec', 'sign'] }) + @ApiPropertyOptional({ enum: ["login", "exec", "sign"] }) @IsOptional() - @IsIn(['login', 'exec', 'sign']) + @IsIn(["login", "exec", "sign"]) type?: StepType; @ApiPropertyOptional() diff --git a/src/scenario/dto/update-scenario.dto.ts b/src/scenario/dto/update-scenario.dto.ts index f4d5dbf..6c7f215 100644 --- a/src/scenario/dto/update-scenario.dto.ts +++ b/src/scenario/dto/update-scenario.dto.ts @@ -1,8 +1,8 @@ -import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsOptional, IsString, IsNotEmpty } from 'class-validator'; +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsOptional, IsString, IsNotEmpty } from "class-validator"; export class UpdateScenarioDto { - @ApiPropertyOptional({ example: 'Updated scenario name' }) + @ApiPropertyOptional({ example: "Updated scenario name" }) @IsOptional() @IsString() @IsNotEmpty() diff --git a/src/scenario/scenario-run-log.entity.ts b/src/scenario/scenario-run-log.entity.ts new file mode 100644 index 0000000..c11d5db --- /dev/null +++ b/src/scenario/scenario-run-log.entity.ts @@ -0,0 +1,44 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from "typeorm"; +import { ScenarioRunEntity } from "./scenario-run.entity"; +import { ScenarioRunStepEntity } from "./scenario-run-step.entity"; + +export type LogLevel = "log" | "warn" | "error"; + +@Entity("scenario_run_logs") +export class ScenarioRunLogEntity { + @PrimaryGeneratedColumn() + id: number; + + @Column() + runId: number; + + @ManyToOne(() => ScenarioRunEntity, { onDelete: "CASCADE" }) + @JoinColumn({ name: "runId" }) + run: ScenarioRunEntity; + + @Column({ nullable: true }) + stepRunId: number | null; + + @ManyToOne(() => ScenarioRunStepEntity, { + onDelete: "SET NULL", + nullable: true, + }) + @JoinColumn({ name: "stepRunId" }) + stepRun: ScenarioRunStepEntity | null; + + @Column({ type: "text", default: "log" }) + level: LogLevel; + + @Column({ type: "text" }) + message: string; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/src/scenario/scenario-run-step.entity.ts b/src/scenario/scenario-run-step.entity.ts index e465777..9eacdcc 100644 --- a/src/scenario/scenario-run-step.entity.ts +++ b/src/scenario/scenario-run-step.entity.ts @@ -6,13 +6,19 @@ import { UpdateDateColumn, ManyToOne, JoinColumn, -} from 'typeorm'; -import { ScenarioRunEntity } from './scenario-run.entity'; -import { ScenarioStepEntity } from './scenario-step.entity'; +} from "typeorm"; +import { ScenarioRunEntity } from "./scenario-run.entity"; +import { ScenarioStepEntity } from "./scenario-step.entity"; -export type RunStepStatus = 'waiting' | 'pending' | 'in_progress' | 'pass' | 'fail' | 'cancelled'; +export type RunStepStatus = + | "waiting" + | "pending" + | "in_progress" + | "pass" + | "fail" + | "cancelled"; -@Entity('scenario_run_steps') +@Entity("scenario_run_steps") export class ScenarioRunStepEntity { @PrimaryGeneratedColumn() id: number; @@ -20,24 +26,26 @@ export class ScenarioRunStepEntity { @Column() runId: number; - @ManyToOne(() => ScenarioRunEntity, (run) => run.stepRuns, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'runId' }) + @ManyToOne(() => ScenarioRunEntity, (run) => run.stepRuns, { + onDelete: "CASCADE", + }) + @JoinColumn({ name: "runId" }) run: ScenarioRunEntity; @Column() scenarioStepId: number; - @ManyToOne(() => ScenarioStepEntity, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'scenarioStepId' }) + @ManyToOne(() => ScenarioStepEntity, { onDelete: "CASCADE" }) + @JoinColumn({ name: "scenarioStepId" }) scenarioStep: ScenarioStepEntity; - @Column({ type: 'text', default: 'waiting' }) + @Column({ type: "text", default: "waiting" }) status: RunStepStatus; @Column({ default: 0 }) order: number; - @Column({ type: 'text', nullable: true }) + @Column({ type: "text", nullable: true }) description: string | null; @CreateDateColumn() diff --git a/src/scenario/scenario-run.entity.ts b/src/scenario/scenario-run.entity.ts index 890f8f9..f93dcca 100644 --- a/src/scenario/scenario-run.entity.ts +++ b/src/scenario/scenario-run.entity.ts @@ -7,13 +7,13 @@ import { ManyToOne, OneToMany, JoinColumn, -} from 'typeorm'; -import { ScenarioEntity } from './scenario.entity'; -import { ScenarioRunStepEntity } from './scenario-run-step.entity'; +} from "typeorm"; +import { ScenarioEntity } from "./scenario.entity"; +import { ScenarioRunStepEntity } from "./scenario-run-step.entity"; -export type RunStatus = 'pending' | 'in_progress' | 'pass' | 'fail'; +export type RunStatus = "pending" | "in_progress" | "pass" | "fail"; -@Entity('scenario_runs') +@Entity("scenario_runs") export class ScenarioRunEntity { @PrimaryGeneratedColumn() id: number; @@ -21,11 +21,11 @@ export class ScenarioRunEntity { @Column() scenarioId: number; - @ManyToOne(() => ScenarioEntity, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'scenarioId' }) + @ManyToOne(() => ScenarioEntity, { onDelete: "CASCADE" }) + @JoinColumn({ name: "scenarioId" }) scenario: ScenarioEntity; - @Column({ type: 'text', default: 'pending' }) + @Column({ type: "text", default: "pending" }) status: RunStatus; @OneToMany(() => ScenarioRunStepEntity, (rs) => rs.run, { diff --git a/src/scenario/scenario-scheduler.service.ts b/src/scenario/scenario-scheduler.service.ts index 018b493..6d5af1e 100644 --- a/src/scenario/scenario-scheduler.service.ts +++ b/src/scenario/scenario-scheduler.service.ts @@ -1,19 +1,20 @@ -import { Injectable } from '@nestjs/common'; -import { TraceLogger } from '../common/trace-logger'; -import { traceStorage } from '../common/trace-context'; -import { Interval } from '@nestjs/schedule'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import * as crypto from 'crypto'; -import { chromium } from 'playwright'; -import type { Browser, BrowserContext, Page } from 'playwright'; -import { ScenarioRunEntity } from './scenario-run.entity'; -import { ScenarioRunStepEntity } from './scenario-run-step.entity'; -import { ScenarioStepEntity } from './scenario-step.entity'; -import type { ScriptLogger } from '../code-executor/code-executor.service'; -import { AuthService } from '../auth/auth.service'; -import { CodeExecutorService } from '../code-executor/code-executor.service'; -import { SessionService } from '../session/session.service'; +import { Injectable } from "@nestjs/common"; +import { TraceLogger } from "../common/trace-logger"; +import { traceStorage } from "../common/trace-context"; +import { Interval } from "@nestjs/schedule"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import * as crypto from "crypto"; +import { chromium } from "playwright"; +import type { Browser, BrowserContext, Page } from "playwright"; +import { ScenarioRunEntity } from "./scenario-run.entity"; +import { ScenarioRunStepEntity } from "./scenario-run-step.entity"; +import { ScenarioRunLogEntity } from "./scenario-run-log.entity"; +import { ScenarioStepEntity } from "./scenario-step.entity"; +import type { ScriptLogger } from "../code-executor/code-executor.service"; +import { AuthService } from "../auth/auth.service"; +import { CodeExecutorService } from "../code-executor/code-executor.service"; +import { SessionService } from "../session/session.service"; interface ValidateResult { success: boolean; @@ -37,49 +38,69 @@ export class ScenarioSchedulerService { private readonly runRepo: Repository, @InjectRepository(ScenarioRunStepEntity) private readonly runStepRepo: Repository, + @InjectRepository(ScenarioRunLogEntity) + private readonly runLogRepo: Repository, private readonly authService: AuthService, private readonly codeExecutor: CodeExecutorService, private readonly sessionService: SessionService, ) {} - private stepLogger(stepRunId: number): ScriptLogger { - return (level, msg) => this.logger[level](`StepRun #${stepRunId} script: ${msg}`); + private persistLog( + runId: number, + stepRunId: number | null, + level: "log" | "warn" | "error", + message: string, + ): void { + void this.runLogRepo.save( + this.runLogRepo.create({ runId, stepRunId, level, message }), + ); + } + + private stepLogger(stepRunId: number, runId: number): ScriptLogger { + return (level, msg) => { + this.logger[level](`StepRun #${stepRunId} script: ${msg}`); + this.persistLog(runId, stepRunId, level, msg); + }; } // ── Job: pick up pending runs and process each to completion ───────────── @Interval(1000) async pickUpPendingRuns(): Promise { - const pending = await this.runRepo.find({ where: { status: 'pending' } }); + const pending = await this.runRepo.find({ where: { status: "pending" } }); for (const run of pending) { if (this.activeRuns.has(run.id)) continue; this.activeRuns.add(run.id); - run.status = 'in_progress'; + run.status = "in_progress"; await this.runRepo.save(run); this.logger.log(`Run #${run.id} → in_progress`); const traceId = crypto.randomUUID(); - void traceStorage.run({ traceId }, () => this.processRunToCompletion(run.id)); + void traceStorage.run({ traceId }, () => + this.processRunToCompletion(run.id), + ); } } private async processRunToCompletion(runId: number): Promise { try { let stepRun = await this.runStepRepo.findOne({ - where: { runId, status: 'pending' }, - relations: ['scenarioStep'], - order: { order: 'ASC' }, + where: { runId, status: "pending" }, + relations: ["scenarioStep"], + order: { order: "ASC" }, }); while (stepRun) { await this.executeStepRun(stepRun); stepRun = await this.runStepRepo.findOne({ - where: { runId, status: 'pending' }, - relations: ['scenarioStep'], - order: { order: 'ASC' }, + where: { runId, status: "pending" }, + relations: ["scenarioStep"], + order: { order: "ASC" }, }); } } catch (err) { - this.logger.error(`Run #${runId}: unexpected error: ${(err as Error).message}`); - await this.runRepo.update(runId, { status: 'fail' }); + this.logger.error( + `Run #${runId}: unexpected error: ${(err as Error).message}`, + ); + await this.runRepo.update(runId, { status: "fail" }); } finally { this.activeRuns.delete(runId); } @@ -88,14 +109,16 @@ export class ScenarioSchedulerService { private async executeStepRun(stepRun: ScenarioRunStepEntity): Promise { const step = stepRun.scenarioStep as ScenarioStepEntity; - stepRun.status = 'in_progress'; + stepRun.status = "in_progress"; await this.runStepRepo.save(stepRun); - this.logger.log(`StepRun #${stepRun.id} (run #${stepRun.runId}, step #${step.id} order=${step.order}) → in_progress`); + this.logger.log( + `StepRun #${stepRun.id} (run #${stepRun.runId}, step #${step.id} order=${step.order}) → in_progress`, + ); try { - if (step.type === 'login') { + if (step.type === "login") { await this.executeLoginStep(stepRun, step); - } else if (step.type === 'sign') { + } else if (step.type === "sign") { await this.executeSignStep(stepRun, step); } else { await this.executeExecStep(stepRun, step); @@ -109,24 +132,33 @@ export class ScenarioSchedulerService { // ── Shared browser per run ───────────────────────────────────────────────── - private async getOrCreateBrowserHandle(runId: number, sessionName: string): Promise { + private async getOrCreateBrowserHandle( + runId: number, + sessionName: string, + ): Promise { const existing = this.runBrowsers.get(runId); if (existing) return existing; const session = await this.sessionService.findBySessionName(sessionName); if (!session) throw new Error(`Session not found: ${sessionName}`); - const cookies = JSON.parse(session.cookies) as Parameters[0]; - const localStorageData: Record = JSON.parse(session.localStorage); + const cookies = JSON.parse(session.cookies) as Parameters< + BrowserContext["addCookies"] + >[0]; + const localStorageData: Record = JSON.parse( + session.localStorage, + ); const browser = await chromium.launch({ headless: true, + // TODO: env var move to config executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, }); const context = await browser.newContext(); await context.addCookies(cookies); await context.addInitScript((entries: Record) => { - for (const [k, v] of Object.entries(entries)) window.localStorage.setItem(k, v); + for (const [k, v] of Object.entries(entries)) + window.localStorage.setItem(k, v); }, localStorageData); const page = await context.newPage(); @@ -144,33 +176,56 @@ export class ScenarioSchedulerService { await handle.browser.close(); this.logger.log(`Run #${runId}: browser closed`); } catch (err) { - this.logger.warn(`Run #${runId}: error closing browser: ${(err as Error).message}`); + this.logger.warn( + `Run #${runId}: error closing browser: ${(err as Error).message}`, + ); } } // ── Login step ───────────────────────────────────────────────────────────── - private async executeLoginStep(stepRun: ScenarioRunStepEntity, step: ScenarioStepEntity): Promise { + private async executeLoginStep( + stepRun: ScenarioRunStepEntity, + step: ScenarioStepEntity, + ): Promise { // execCode must be a JSON object: { "keyId": "...", "environmentName": "..." } let params: { keyId: string; environmentName: string }; try { - params = JSON.parse(step.execCode ?? '{}'); + params = JSON.parse(step.execCode ?? "{}"); } catch { - throw new Error('login step execCode must be valid JSON with keyId and environmentName'); + throw new Error( + "login step execCode must be valid JSON with keyId and environmentName", + ); } if (!params.keyId || !params.environmentName) { - throw new Error('login step execCode must include keyId and environmentName'); + throw new Error( + "login step execCode must include keyId and environmentName", + ); } - const loginResult = await this.authService.login(params.keyId, params.environmentName, step.sessionName); - this.logger.log(`StepRun #${stepRun.id}: login OK, session=${loginResult.sessionName}`); + const loginResult = await this.authService.login( + params.keyId, + params.environmentName, + step.sessionName, + ); + this.logger.log( + `StepRun #${stepRun.id}: login OK, session=${loginResult.sessionName}`, + ); if (step.validateCode) { - const { page, context } = await this.getOrCreateBrowserHandle(stepRun.runId, step.sessionName); + const { page, context } = await this.getOrCreateBrowserHandle( + stepRun.runId, + step.sessionName, + ); this.codeExecutor.validate(step.validateCode); - const { result } = await this.codeExecutor.execute(page, context, step.validateCode, this.stepLogger(stepRun.id)); + const { result } = await this.codeExecutor.execute( + page, + context, + step.validateCode, + this.stepLogger(stepRun.id, stepRun.runId), + ); const vr = this.parseValidateResult(result); - if (!vr.success) throw new Error(vr.description ?? 'Validation failed'); + if (!vr.success) throw new Error(vr.description ?? "Validation failed"); } await this.passStepRun(stepRun, null); @@ -178,19 +233,35 @@ export class ScenarioSchedulerService { // ── Exec step ────────────────────────────────────────────────────────────── - private async executeExecStep(stepRun: ScenarioRunStepEntity, step: ScenarioStepEntity): Promise { - if (!step.execCode) throw new Error('exec step has no execCode'); + private async executeExecStep( + stepRun: ScenarioRunStepEntity, + step: ScenarioStepEntity, + ): Promise { + if (!step.execCode) throw new Error("exec step has no execCode"); this.codeExecutor.validate(step.execCode); - const { page, context } = await this.getOrCreateBrowserHandle(stepRun.runId, step.sessionName); - await this.codeExecutor.execute(page, context, step.execCode, this.stepLogger(stepRun.id)); + const { page, context } = await this.getOrCreateBrowserHandle( + stepRun.runId, + step.sessionName, + ); + await this.codeExecutor.execute( + page, + context, + step.execCode, + this.stepLogger(stepRun.id, stepRun.runId), + ); this.logger.log(`StepRun #${stepRun.id}: exec OK`); if (step.validateCode) { this.codeExecutor.validate(step.validateCode); - const { result } = await this.codeExecutor.execute(page, context, step.validateCode, this.stepLogger(stepRun.id)); + const { result } = await this.codeExecutor.execute( + page, + context, + step.validateCode, + this.stepLogger(stepRun.id, stepRun.runId), + ); const vr = this.parseValidateResult(result); - if (!vr.success) throw new Error(vr.description ?? 'Validation failed'); + if (!vr.success) throw new Error(vr.description ?? "Validation failed"); } await this.passStepRun(stepRun, null); @@ -198,25 +269,36 @@ export class ScenarioSchedulerService { // ── Sign step ────────────────────────────────────────────────────────────── - private async executeSignStep(stepRun: ScenarioRunStepEntity, step: ScenarioStepEntity): Promise { + private async executeSignStep( + stepRun: ScenarioRunStepEntity, + step: ScenarioStepEntity, + ): Promise { // execCode must be a JSON object: { "keyId": "..." } let params: { keyId: string }; try { - params = JSON.parse(step.execCode ?? '{}'); + params = JSON.parse(step.execCode ?? "{}"); } catch { - throw new Error('sign step execCode must be valid JSON with keyId'); + throw new Error("sign step execCode must be valid JSON with keyId"); } - if (!params.keyId) throw new Error('sign step execCode must include keyId'); + if (!params.keyId) throw new Error("sign step execCode must include keyId"); - const { page, context } = await this.getOrCreateBrowserHandle(stepRun.runId, step.sessionName); + const { page, context } = await this.getOrCreateBrowserHandle( + stepRun.runId, + step.sessionName, + ); await this.authService.signWithKey(params.keyId, page); this.logger.log(`StepRun #${stepRun.id}: sign OK`); if (step.validateCode) { this.codeExecutor.validate(step.validateCode); - const { result } = await this.codeExecutor.execute(page, context, step.validateCode, this.stepLogger(stepRun.id)); + const { result } = await this.codeExecutor.execute( + page, + context, + step.validateCode, + this.stepLogger(stepRun.id, stepRun.runId), + ); const vr = this.parseValidateResult(result); - if (!vr.success) throw new Error(vr.description ?? 'Validation failed'); + if (!vr.success) throw new Error(vr.description ?? "Validation failed"); } await this.passStepRun(stepRun, null); @@ -225,12 +307,13 @@ export class ScenarioSchedulerService { // ── Validation helper ────────────────────────────────────────────────────── private parseValidateResult(raw: unknown): ValidateResult { - if (typeof raw === 'boolean') return { success: raw }; - if (raw && typeof raw === 'object') { + if (typeof raw === "boolean") return { success: raw }; + if (raw && typeof raw === "object") { const r = raw as Record; return { - success: Boolean(r['success']), - description: r['description'] != null ? String(r['description']) : undefined, + success: Boolean(r["success"]), + description: + r["description"] != null ? String(r["description"]) : undefined, }; } return { success: Boolean(raw) }; @@ -238,40 +321,46 @@ export class ScenarioSchedulerService { // ── Pass / fail helpers ──────────────────────────────────────────────────── - private async passStepRun(stepRun: ScenarioRunStepEntity, description: string | null): Promise { - stepRun.status = 'pass'; + private async passStepRun( + stepRun: ScenarioRunStepEntity, + description: string | null, + ): Promise { + stepRun.status = "pass"; stepRun.description = description; await this.runStepRepo.save(stepRun); this.logger.log(`StepRun #${stepRun.id} → pass`); // Find the next waiting step in this run (next by order) const nextStep = await this.runStepRepo.findOne({ - where: { runId: stepRun.runId, status: 'waiting' }, - order: { order: 'ASC' }, + where: { runId: stepRun.runId, status: "waiting" }, + order: { order: "ASC" }, }); if (nextStep) { - nextStep.status = 'pending'; + nextStep.status = "pending"; await this.runStepRepo.save(nextStep); } else { // No more waiting steps — check if any are still in_progress/pending (shouldn't be, but guard anyway) const remaining = await this.runStepRepo.count({ where: [ - { runId: stepRun.runId, status: 'pending' }, - { runId: stepRun.runId, status: 'in_progress' }, - { runId: stepRun.runId, status: 'waiting' }, + { runId: stepRun.runId, status: "pending" }, + { runId: stepRun.runId, status: "in_progress" }, + { runId: stepRun.runId, status: "waiting" }, ], }); if (remaining === 0) { await this.closeBrowserHandle(stepRun.runId); - await this.runRepo.update(stepRun.runId, { status: 'pass' }); + await this.runRepo.update(stepRun.runId, { status: "pass" }); this.logger.log(`Run #${stepRun.runId} → pass (all steps passed)`); } } } - private async failStepRun(stepRun: ScenarioRunStepEntity, description: string): Promise { - stepRun.status = 'fail'; + private async failStepRun( + stepRun: ScenarioRunStepEntity, + description: string, + ): Promise { + stepRun.status = "fail"; stepRun.description = description; await this.runStepRepo.save(stepRun); this.logger.log(`StepRun #${stepRun.id} → fail: ${description}`); @@ -280,17 +369,17 @@ export class ScenarioSchedulerService { await this.runStepRepo .createQueryBuilder() .update() - .set({ status: 'cancelled' }) - .where('runId = :runId AND status IN (:...statuses)', { + .set({ status: "cancelled" }) + .where("runId = :runId AND status IN (:...statuses)", { runId: stepRun.runId, - statuses: ['waiting', 'pending'], + statuses: ["waiting", "pending"], }) .execute(); this.logger.log(`Run #${stepRun.runId}: remaining steps cancelled`); await this.closeBrowserHandle(stepRun.runId); - await this.runRepo.update(stepRun.runId, { status: 'fail' }); + await this.runRepo.update(stepRun.runId, { status: "fail" }); this.logger.log(`Run #${stepRun.runId} → fail`); } } diff --git a/src/scenario/scenario-step.entity.ts b/src/scenario/scenario-step.entity.ts index d2ca907..736369c 100644 --- a/src/scenario/scenario-step.entity.ts +++ b/src/scenario/scenario-step.entity.ts @@ -6,12 +6,12 @@ import { UpdateDateColumn, ManyToOne, JoinColumn, -} from 'typeorm'; -import { ScenarioEntity } from './scenario.entity'; +} from "typeorm"; +import { ScenarioEntity } from "./scenario.entity"; -export type StepType = 'login' | 'exec' | 'sign'; +export type StepType = "login" | "exec" | "sign"; -@Entity('scenario_steps') +@Entity("scenario_steps") export class ScenarioStepEntity { @PrimaryGeneratedColumn() id: number; @@ -20,24 +20,24 @@ export class ScenarioStepEntity { scenarioId: number; @ManyToOne(() => ScenarioEntity, (scenario) => scenario.steps, { - onDelete: 'CASCADE', + onDelete: "CASCADE", }) - @JoinColumn({ name: 'scenarioId' }) + @JoinColumn({ name: "scenarioId" }) scenario: ScenarioEntity; @Column({ default: 0 }) order: number; - @Column({ type: 'text' }) + @Column({ type: "text" }) type: StepType; - @Column({ type: 'text' }) + @Column({ type: "text" }) sessionName: string; - @Column({ type: 'text', nullable: true }) + @Column({ type: "text", nullable: true }) execCode: string | null; - @Column({ type: 'text', nullable: true }) + @Column({ type: "text", nullable: true }) validateCode: string | null; @CreateDateColumn() diff --git a/src/scenario/scenario.controller.ts b/src/scenario/scenario.controller.ts index 5c3046c..a2e4c41 100644 --- a/src/scenario/scenario.controller.ts +++ b/src/scenario/scenario.controller.ts @@ -9,145 +9,176 @@ import { Patch, Post, Query, -} from '@nestjs/common'; -import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; -import { ScenarioService } from './scenario.service'; -import { CreateScenarioDto } from './dto/create-scenario.dto'; -import { UpdateScenarioDto } from './dto/update-scenario.dto'; -import { CreateScenarioStepDto } from './dto/create-scenario-step.dto'; -import { UpdateScenarioStepDto } from './dto/update-scenario-step.dto'; -import { PaginationQueryDto } from './dto/pagination-query.dto'; -import { ScenarioOrderBy } from './scenario.service'; -import { RunsQueryDto } from './dto/runs-query.dto'; -import { ScenarioExportDto } from './dto/scenario-export.dto'; +} from "@nestjs/common"; +import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; +import { ScenarioService } from "./scenario.service"; +import { CreateScenarioDto } from "./dto/create-scenario.dto"; +import { UpdateScenarioDto } from "./dto/update-scenario.dto"; +import { CreateScenarioStepDto } from "./dto/create-scenario-step.dto"; +import { UpdateScenarioStepDto } from "./dto/update-scenario-step.dto"; +import { PaginationQueryDto } from "./dto/pagination-query.dto"; +import { ScenarioOrderBy } from "./scenario.service"; +import { RunsQueryDto } from "./dto/runs-query.dto"; +import { ScenarioExportDto } from "./dto/scenario-export.dto"; -@ApiTags('scenarios') -@Controller('scenarios') +@ApiTags("scenarios") +@Controller("scenarios") export class ScenarioController { constructor(private readonly scenarioService: ScenarioService) {} // ── Scenarios ───────────────────────────────────────────────────────────── @Post() - @ApiOperation({ summary: 'Create a scenario' }) - @ApiResponse({ status: 201, description: 'Scenario created' }) + @ApiOperation({ summary: "Create a scenario" }) + @ApiResponse({ status: 201, description: "Scenario created" }) create(@Body() dto: CreateScenarioDto) { return this.scenarioService.create(dto); } - @Post('import') - @ApiOperation({ summary: 'Import a scenario from an export payload' }) - @ApiResponse({ status: 201, description: 'Scenario imported' }) + @Post("import") + @ApiOperation({ summary: "Import a scenario from an export payload" }) + @ApiResponse({ status: 201, description: "Scenario imported" }) importScenario(@Body() dto: ScenarioExportDto) { return this.scenarioService.importScenario(dto); } @Get() - @ApiOperation({ summary: 'List all scenarios (paginated)' }) + @ApiOperation({ summary: "List all scenarios (paginated)" }) @ApiResponse({ status: 200 }) findAll(@Query() query: PaginationQueryDto) { return this.scenarioService.findAll(query); } - @Get(':id') - @ApiOperation({ summary: 'Get a scenario with its steps' }) + @Get(":id") + @ApiOperation({ summary: "Get a scenario with its steps" }) @ApiResponse({ status: 200 }) - @ApiResponse({ status: 404, description: 'Scenario not found' }) - findOne(@Param('id', ParseIntPipe) id: number) { + @ApiResponse({ status: 404, description: "Scenario not found" }) + findOne(@Param("id", ParseIntPipe) id: number) { return this.scenarioService.findOne(id); } - @Patch(':id') - @ApiOperation({ summary: 'Update a scenario' }) + @Patch(":id") + @ApiOperation({ summary: "Update a scenario" }) @ApiResponse({ status: 200 }) - @ApiResponse({ status: 404, description: 'Scenario not found' }) - update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateScenarioDto) { + @ApiResponse({ status: 404, description: "Scenario not found" }) + update( + @Param("id", ParseIntPipe) id: number, + @Body() dto: UpdateScenarioDto, + ) { return this.scenarioService.update(id, dto); } - @Delete(':id') + @Delete(":id") @HttpCode(204) - @ApiOperation({ summary: 'Delete a scenario and all its steps' }) + @ApiOperation({ summary: "Delete a scenario and all its steps" }) @ApiResponse({ status: 204 }) - @ApiResponse({ status: 404, description: 'Scenario not found' }) - remove(@Param('id', ParseIntPipe) id: number) { + @ApiResponse({ status: 404, description: "Scenario not found" }) + remove(@Param("id", ParseIntPipe) id: number) { return this.scenarioService.remove(id); } - @Get(':id/export') - @ApiOperation({ summary: 'Export a scenario as a portable JSON payload' }) + @Get(":id/export") + @ApiOperation({ summary: "Export a scenario as a portable JSON payload" }) @ApiResponse({ status: 200 }) - @ApiResponse({ status: 404, description: 'Scenario not found' }) - exportScenario(@Param('id', ParseIntPipe) id: number) { + @ApiResponse({ status: 404, description: "Scenario not found" }) + exportScenario(@Param("id", ParseIntPipe) id: number) { return this.scenarioService.exportScenario(id); } // ── Steps ───────────────────────────────────────────────────────────────── - @Post(':id/steps') - @ApiOperation({ summary: 'Add a step to a scenario' }) - @ApiResponse({ status: 201, description: 'Step created' }) - @ApiResponse({ status: 404, description: 'Scenario not found' }) + @Post(":id/steps") + @ApiOperation({ summary: "Add a step to a scenario" }) + @ApiResponse({ status: 201, description: "Step created" }) + @ApiResponse({ status: 404, description: "Scenario not found" }) createStep( - @Param('id', ParseIntPipe) id: number, + @Param("id", ParseIntPipe) id: number, @Body() dto: CreateScenarioStepDto, ) { return this.scenarioService.createStep(id, dto); } - @Get(':id/steps/:stepId') - @ApiOperation({ summary: 'Get a single step' }) + @Get(":id/steps/:stepId") + @ApiOperation({ summary: "Get a single step" }) @ApiResponse({ status: 200 }) - @ApiResponse({ status: 404, description: 'Scenario or step not found' }) + @ApiResponse({ status: 404, description: "Scenario or step not found" }) findStep( - @Param('id', ParseIntPipe) id: number, - @Param('stepId', ParseIntPipe) stepId: number, + @Param("id", ParseIntPipe) id: number, + @Param("stepId", ParseIntPipe) stepId: number, ) { return this.scenarioService.findStep(id, stepId); } - @Patch(':id/steps/:stepId') - @ApiOperation({ summary: 'Update a step' }) + @Patch(":id/steps/:stepId") + @ApiOperation({ summary: "Update a step" }) @ApiResponse({ status: 200 }) - @ApiResponse({ status: 404, description: 'Scenario or step not found' }) + @ApiResponse({ status: 404, description: "Scenario or step not found" }) updateStep( - @Param('id', ParseIntPipe) id: number, - @Param('stepId', ParseIntPipe) stepId: number, + @Param("id", ParseIntPipe) id: number, + @Param("stepId", ParseIntPipe) stepId: number, @Body() dto: UpdateScenarioStepDto, ) { return this.scenarioService.updateStep(id, stepId, dto); } - @Delete(':id/steps/:stepId') + @Delete(":id/steps/:stepId") @HttpCode(204) - @ApiOperation({ summary: 'Delete a step' }) + @ApiOperation({ summary: "Delete a step" }) @ApiResponse({ status: 204 }) - @ApiResponse({ status: 404, description: 'Scenario or step not found' }) + @ApiResponse({ status: 404, description: "Scenario or step not found" }) removeStep( - @Param('id', ParseIntPipe) id: number, - @Param('stepId', ParseIntPipe) stepId: number, + @Param("id", ParseIntPipe) id: number, + @Param("stepId", ParseIntPipe) stepId: number, ) { return this.scenarioService.removeStep(id, stepId); } // ── Runs ────────────────────────────────────────────────────────────────── - @Get(':id/runs') - @ApiOperation({ summary: 'List runs for a scenario (paginated, filterable by status)' }) + @Get(":id/runs") + @ApiOperation({ + summary: "List runs for a scenario (paginated, filterable by status)", + }) @ApiResponse({ status: 200 }) - @ApiResponse({ status: 404, description: 'Scenario not found' }) + @ApiResponse({ status: 404, description: "Scenario not found" }) findRuns( - @Param('id', ParseIntPipe) id: number, + @Param("id", ParseIntPipe) id: number, @Query() query: RunsQueryDto, ) { return this.scenarioService.findRuns(id, query); } - @Post(':id/run') - @ApiOperation({ summary: 'Create a new run for a scenario' }) - @ApiResponse({ status: 201, description: 'Run created with step runs' }) - @ApiResponse({ status: 404, description: 'Scenario not found' }) - createRun(@Param('id', ParseIntPipe) id: number) { + @Post(":id/run") + @ApiOperation({ summary: "Create a new run for a scenario" }) + @ApiResponse({ status: 201, description: "Run created with step runs" }) + @ApiResponse({ status: 404, description: "Scenario not found" }) + createRun(@Param("id", ParseIntPipe) id: number) { return this.scenarioService.createRun(id); } + + @Get(":id/run/:runId") + @ApiOperation({ summary: "Get a specific run with step runs and logs" }) + @ApiResponse({ status: 200 }) + @ApiResponse({ status: 404, description: "Scenario or run not found" }) + findRun( + @Param("id", ParseIntPipe) id: number, + @Param("runId", ParseIntPipe) runId: number, + ) { + return this.scenarioService.findRun(id, runId); + } + + @Post(":id/run/:runId/wait") + @HttpCode(200) + @ApiOperation({ + summary: + "Block until the run reaches pass or fail (max 5 min), then return run with logs", + }) + @ApiResponse({ status: 200 }) + @ApiResponse({ status: 404, description: "Scenario or run not found" }) + waitForRun( + @Param("id", ParseIntPipe) id: number, + @Param("runId", ParseIntPipe) runId: number, + ) { + return this.scenarioService.waitForRun(id, runId); + } } diff --git a/src/scenario/scenario.entity.ts b/src/scenario/scenario.entity.ts index 84defbe..deb9e90 100644 --- a/src/scenario/scenario.entity.ts +++ b/src/scenario/scenario.entity.ts @@ -5,10 +5,10 @@ import { CreateDateColumn, UpdateDateColumn, OneToMany, -} from 'typeorm'; -import { ScenarioStepEntity } from './scenario-step.entity'; +} from "typeorm"; +import { ScenarioStepEntity } from "./scenario-step.entity"; -@Entity('scenarios') +@Entity("scenarios") export class ScenarioEntity { @PrimaryGeneratedColumn() id: number; diff --git a/src/scenario/scenario.module.ts b/src/scenario/scenario.module.ts index fa8240d..353f56d 100644 --- a/src/scenario/scenario.module.ts +++ b/src/scenario/scenario.module.ts @@ -1,19 +1,26 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { ScenarioEntity } from './scenario.entity'; -import { ScenarioStepEntity } from './scenario-step.entity'; -import { ScenarioRunEntity } from './scenario-run.entity'; -import { ScenarioRunStepEntity } from './scenario-run-step.entity'; -import { ScenarioService } from './scenario.service'; -import { ScenarioController } from './scenario.controller'; -import { ScenarioSchedulerService } from './scenario-scheduler.service'; -import { AuthModule } from '../auth/auth.module'; -import { CodeExecutorModule } from '../code-executor/code-executor.module'; -import { SessionModule } from '../session/session.module'; +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { ScenarioEntity } from "./scenario.entity"; +import { ScenarioStepEntity } from "./scenario-step.entity"; +import { ScenarioRunEntity } from "./scenario-run.entity"; +import { ScenarioRunStepEntity } from "./scenario-run-step.entity"; +import { ScenarioRunLogEntity } from "./scenario-run-log.entity"; +import { ScenarioService } from "./scenario.service"; +import { ScenarioController } from "./scenario.controller"; +import { ScenarioSchedulerService } from "./scenario-scheduler.service"; +import { AuthModule } from "../auth/auth.module"; +import { CodeExecutorModule } from "../code-executor/code-executor.module"; +import { SessionModule } from "../session/session.module"; @Module({ imports: [ - TypeOrmModule.forFeature([ScenarioEntity, ScenarioStepEntity, ScenarioRunEntity, ScenarioRunStepEntity]), + TypeOrmModule.forFeature([ + ScenarioEntity, + ScenarioStepEntity, + ScenarioRunEntity, + ScenarioRunStepEntity, + ScenarioRunLogEntity, + ]), AuthModule, CodeExecutorModule, SessionModule, diff --git a/src/scenario/scenario.service.ts b/src/scenario/scenario.service.ts index 4b72884..cc5f827 100644 --- a/src/scenario/scenario.service.ts +++ b/src/scenario/scenario.service.ts @@ -1,20 +1,24 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { ScenarioEntity } from './scenario.entity'; -import { ScenarioStepEntity } from './scenario-step.entity'; -import { ScenarioRunEntity } from './scenario-run.entity'; -import { ScenarioRunStepEntity } from './scenario-run-step.entity'; -import { CreateScenarioDto } from './dto/create-scenario.dto'; -import { UpdateScenarioDto } from './dto/update-scenario.dto'; -import { CreateScenarioStepDto } from './dto/create-scenario-step.dto'; -import { UpdateScenarioStepDto } from './dto/update-scenario-step.dto'; -import { PaginationQueryDto, PaginatedResult } from '../common/dto/pagination.dto'; -import { RunsQueryDto } from './dto/runs-query.dto'; -import { ScenarioExportDto } from './dto/scenario-export.dto'; +import { Injectable, NotFoundException } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { ScenarioEntity } from "./scenario.entity"; +import { ScenarioStepEntity } from "./scenario-step.entity"; +import { ScenarioRunEntity } from "./scenario-run.entity"; +import { ScenarioRunStepEntity } from "./scenario-run-step.entity"; +import { ScenarioRunLogEntity } from "./scenario-run-log.entity"; +import { CreateScenarioDto } from "./dto/create-scenario.dto"; +import { UpdateScenarioDto } from "./dto/update-scenario.dto"; +import { CreateScenarioStepDto } from "./dto/create-scenario-step.dto"; +import { UpdateScenarioStepDto } from "./dto/update-scenario-step.dto"; +import { + PaginationQueryDto, + PaginatedResult, +} from "../common/dto/pagination.dto"; +import { RunsQueryDto } from "./dto/runs-query.dto"; +import { ScenarioExportDto } from "./dto/scenario-export.dto"; -export { PaginatedResult } from '../common/dto/pagination.dto'; -export type ScenarioOrderBy = 'id' | 'name' | 'createdAt' | 'updatedAt'; +export { PaginatedResult } from "../common/dto/pagination.dto"; +export type ScenarioOrderBy = "id" | "name" | "createdAt" | "updatedAt"; @Injectable() export class ScenarioService { @@ -27,6 +31,8 @@ export class ScenarioService { private readonly runRepo: Repository, @InjectRepository(ScenarioRunStepEntity) private readonly runStepRepo: Repository, + @InjectRepository(ScenarioRunLogEntity) + private readonly runLogRepo: Repository, ) {} // ── Scenarios ───────────────────────────────────────────────────────────── @@ -35,11 +41,13 @@ export class ScenarioService { return this.scenarioRepo.save(this.scenarioRepo.create(dto)); } - async findAll(query: PaginationQueryDto): Promise> { + async findAll( + query: PaginationQueryDto, + ): Promise> { const page = query.page ?? 1; const limit = query.limit ?? 20; - const orderBy = query.orderBy ?? 'id'; - const orderDir = query.orderDir ?? 'ASC'; + const orderBy = query.orderBy ?? "id"; + const orderDir = query.orderDir ?? "ASC"; const [data, total] = await this.scenarioRepo.findAndCount({ order: { [orderBy]: orderDir }, skip: (page - 1) * limit, @@ -51,8 +59,8 @@ export class ScenarioService { async findOne(id: number): Promise { const scenario = await this.scenarioRepo.findOne({ where: { id }, - relations: ['steps'], - order: { steps: { order: 'ASC' } }, + relations: ["steps"], + order: { steps: { order: "ASC" } }, }); if (!scenario) throw new NotFoundException(`Scenario ${id} not found`); return scenario; @@ -71,7 +79,10 @@ export class ScenarioService { // ── Steps ───────────────────────────────────────────────────────────────── - async createStep(scenarioId: number, dto: CreateScenarioStepDto): Promise { + async createStep( + scenarioId: number, + dto: CreateScenarioStepDto, + ): Promise { await this.findOne(scenarioId); return this.stepRepo.save( this.stepRepo.create({ @@ -83,13 +94,23 @@ export class ScenarioService { ); } - async findStep(scenarioId: number, stepId: number): Promise { + async findStep( + scenarioId: number, + stepId: number, + ): Promise { const step = await this.stepRepo.findOneBy({ id: stepId, scenarioId }); - if (!step) throw new NotFoundException(`Step ${stepId} not found in scenario ${scenarioId}`); + if (!step) + throw new NotFoundException( + `Step ${stepId} not found in scenario ${scenarioId}`, + ); return step; } - async updateStep(scenarioId: number, stepId: number, dto: UpdateScenarioStepDto): Promise { + async updateStep( + scenarioId: number, + stepId: number, + dto: UpdateScenarioStepDto, + ): Promise { const step = await this.findStep(scenarioId, stepId); Object.assign(step, dto); return this.stepRepo.save(step); @@ -100,26 +121,71 @@ export class ScenarioService { await this.stepRepo.delete(stepId); } - async findRuns(scenarioId: number, query: RunsQueryDto): Promise> { + async findRuns( + scenarioId: number, + query: RunsQueryDto, + ): Promise> { await this.findOne(scenarioId); // 404 guard const page = query.page ?? 1; const limit = query.limit ?? 20; const where: Record = { scenarioId }; - if (query.status) where['status'] = query.status; + if (query.status) where["status"] = query.status; const [data, total] = await this.runRepo.findAndCount({ where, - relations: ['stepRuns'], - order: { id: 'DESC', stepRuns: { order: 'ASC' } }, + relations: ["stepRuns"], + order: { id: "DESC", stepRuns: { order: "ASC" } }, skip: (page - 1) * limit, take: limit, }); return { data, total, page, limit }; } - async createRun(scenarioId: number): Promise { const scenario = await this.findOne(scenarioId); + async findRun( + scenarioId: number, + runId: number, + ): Promise { + await this.findOne(scenarioId); // 404 guard + const run = await this.runRepo.findOne({ + where: { id: runId, scenarioId }, + relations: ["stepRuns", "stepRuns.scenarioStep"], + order: { stepRuns: { order: "ASC" } }, + }); + if (!run) + throw new NotFoundException( + `Run ${runId} not found in scenario ${scenarioId}`, + ); + const logs = await this.runLogRepo.find({ + where: { runId }, + order: { createdAt: "ASC" }, + }); + return Object.assign(run, { logs }); + } + + async waitForRun( + scenarioId: number, + runId: number, + timeoutMs = 300_000, + ): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const run = await this.runRepo.findOneBy({ id: runId, scenarioId }); + if (!run) + throw new NotFoundException( + `Run ${runId} not found in scenario ${scenarioId}`, + ); + if (run.status === "pass" || run.status === "fail") { + return this.findRun(scenarioId, runId); + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + return this.findRun(scenarioId, runId); + } + + async createRun(scenarioId: number): Promise { + const scenario = await this.findOne(scenarioId); const run = await this.runRepo.save( - this.runRepo.create({ scenarioId, status: 'pending' }), + this.runRepo.create({ scenarioId, status: "pending" }), ); const stepRuns = scenario.steps.map((step, index) => @@ -127,7 +193,7 @@ export class ScenarioService { runId: run.id, scenarioStepId: step.id, order: step.order, - status: index === 0 ? 'pending' : 'waiting', + status: index === 0 ? "pending" : "waiting", description: null, }), ); @@ -136,8 +202,8 @@ export class ScenarioService { return this.runRepo.findOne({ where: { id: run.id }, - relations: ['stepRuns'], - order: { stepRuns: { order: 'ASC' } }, + relations: ["stepRuns"], + order: { stepRuns: { order: "ASC" } }, }) as Promise; } @@ -177,4 +243,3 @@ export class ScenarioService { return this.findOne(scenario.id); } } - diff --git a/src/session/session.controller.ts b/src/session/session.controller.ts index 653c230..abe3c0e 100644 --- a/src/session/session.controller.ts +++ b/src/session/session.controller.ts @@ -1,28 +1,36 @@ -import { Controller, Delete, Get, NotFoundException, Param, ParseIntPipe, Query } from '@nestjs/common'; -import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; -import { SessionService } from './session.service'; -import { PaginationQueryDto } from '../common/dto/pagination.dto'; -import { SessionOrderBy } from './session.service'; +import { + Controller, + Delete, + Get, + NotFoundException, + Param, + ParseIntPipe, + Query, +} from "@nestjs/common"; +import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; +import { SessionService } from "./session.service"; +import { PaginationQueryDto } from "../common/dto/pagination.dto"; +import { SessionOrderBy } from "./session.service"; -@ApiTags('sessions') -@Controller('sessions') +@ApiTags("sessions") +@Controller("sessions") export class SessionController { constructor(private readonly sessionService: SessionService) {} @Get() - @ApiOperation({ summary: 'List all stored sessions (paginated)' }) - @ApiResponse({ status: 200, description: 'Paginated sessions' }) + @ApiOperation({ summary: "List all stored sessions (paginated)" }) + @ApiResponse({ status: 200, description: "Paginated sessions" }) findAll(@Query() query: PaginationQueryDto) { return this.sessionService.findAll(query); } - @Delete(':id') - @ApiOperation({ summary: 'Delete a session by ID' }) - @ApiResponse({ status: 200, description: 'Session deleted' }) - @ApiResponse({ status: 404, description: 'Session not found' }) - async remove(@Param('id', ParseIntPipe) id: number): Promise { + @Delete(":id") + @ApiOperation({ summary: "Delete a session by ID" }) + @ApiResponse({ status: 200, description: "Session deleted" }) + @ApiResponse({ status: 404, description: "Session not found" }) + async remove(@Param("id", ParseIntPipe) id: number): Promise { const sessions = await this.sessionService.findAll(); - if (!sessions.data.find(s => s.id === id)) { + if (!sessions.data.find((s) => s.id === id)) { throw new NotFoundException(`Session ${id} not found`); } await this.sessionService.remove(id); diff --git a/src/session/session.entity.ts b/src/session/session.entity.ts index 8d9ceb2..7f9bcfc 100644 --- a/src/session/session.entity.ts +++ b/src/session/session.entity.ts @@ -4,9 +4,9 @@ import { Column, CreateDateColumn, UpdateDateColumn, -} from 'typeorm'; +} from "typeorm"; -@Entity('sessions') +@Entity("sessions") export class SessionEntity { @PrimaryGeneratedColumn() id: number; @@ -14,13 +14,13 @@ export class SessionEntity { @Column({ unique: true }) sessionName: string; - @Column('text') + @Column("text") token: string; - @Column('text') + @Column("text") cookies: string; // JSON-serialised Cookie[] from Playwright - @Column('text', { default: '{}' }) + @Column("text", { default: "{}" }) localStorage: string; // JSON-serialised Record from Playwright @CreateDateColumn() diff --git a/src/session/session.module.ts b/src/session/session.module.ts index c5bcaa0..3c2cbda 100644 --- a/src/session/session.module.ts +++ b/src/session/session.module.ts @@ -1,8 +1,8 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { SessionEntity } from './session.entity'; -import { SessionService } from './session.service'; -import { SessionController } from './session.controller'; +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { SessionEntity } from "./session.entity"; +import { SessionService } from "./session.service"; +import { SessionController } from "./session.controller"; @Module({ imports: [TypeOrmModule.forFeature([SessionEntity])], diff --git a/src/session/session.service.ts b/src/session/session.service.ts index 8e8e42b..e156847 100644 --- a/src/session/session.service.ts +++ b/src/session/session.service.ts @@ -1,11 +1,14 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { SessionEntity } from './session.entity'; -import type { Cookie } from 'playwright'; -import { PaginationQueryDto, PaginatedResult } from '../common/dto/pagination.dto'; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { SessionEntity } from "./session.entity"; +import type { Cookie } from "playwright"; +import { + PaginationQueryDto, + PaginatedResult, +} from "../common/dto/pagination.dto"; -export type SessionOrderBy = 'id' | 'sessionName' | 'createdAt' | 'updatedAt'; +export type SessionOrderBy = "id" | "sessionName" | "createdAt" | "updatedAt"; @Injectable() export class SessionService { @@ -43,13 +46,19 @@ export class SessionService { return this.repo.findOneBy({ sessionName }); } - async findAll(query: PaginationQueryDto = {}): Promise>> { + async findAll( + query: PaginationQueryDto = {}, + ): Promise< + PaginatedResult< + Pick + > + > { const page = query.page ?? 1; const limit = query.limit ?? 20; - const orderBy = query.orderBy ?? 'id'; - const orderDir = query.orderDir ?? 'ASC'; + const orderBy = query.orderBy ?? "id"; + const orderDir = query.orderDir ?? "ASC"; const [data, total] = await this.repo.findAndCount({ - select: ['id', 'sessionName', 'createdAt', 'updatedAt'], + select: ["id", "sessionName", "createdAt", "updatedAt"], order: { [orderBy]: orderDir }, skip: (page - 1) * limit, take: limit, diff --git a/test/__mocks__/jsdom.ts b/test/__mocks__/jsdom.ts index 4e81fd6..58eeb1f 100644 --- a/test/__mocks__/jsdom.ts +++ b/test/__mocks__/jsdom.ts @@ -1,14 +1,17 @@ const mockElement = { outerHTML: '
mock content
', - textContent: 'mock content', + textContent: "mock content", }; export class JSDOM { - constructor(public html: string, public options?: any) {} + constructor( + public html: string, + public options?: Record, + ) {} get window() { return { document: { - querySelector: (_selector: string) => mockElement, + querySelector: () => mockElement, }, }; } diff --git a/test/__mocks__/playwright.ts b/test/__mocks__/playwright.ts index 37273ce..f61d08d 100644 --- a/test/__mocks__/playwright.ts +++ b/test/__mocks__/playwright.ts @@ -3,9 +3,9 @@ export const chromium = { newContext: jest.fn().mockResolvedValue({ newPage: jest.fn().mockResolvedValue({ goto: jest.fn(), - content: jest.fn().mockResolvedValue(''), - title: jest.fn().mockReturnValue(''), - url: jest.fn().mockReturnValue(''), + content: jest.fn().mockResolvedValue(""), + title: jest.fn().mockReturnValue(""), + url: jest.fn().mockReturnValue(""), evaluate: jest.fn(), close: jest.fn(), }), diff --git a/test/__mocks__/readability.ts b/test/__mocks__/readability.ts index 1bbb19e..aec5c81 100644 --- a/test/__mocks__/readability.ts +++ b/test/__mocks__/readability.ts @@ -1,6 +1,6 @@ export class Readability { - constructor(private doc: any) {} + constructor(private doc: unknown) {} parse() { - return { textContent: '' }; + return { textContent: "" }; } } diff --git a/test/app.harness.ts b/test/app.harness.ts index 4afb85a..4816451 100644 --- a/test/app.harness.ts +++ b/test/app.harness.ts @@ -1,23 +1,27 @@ -import { INestApplication, ValidationPipe } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { ConfigModule } from '@nestjs/config'; -import { ScheduleModule } from '@nestjs/schedule'; -import debug from 'debug'; +import { INestApplication, ValidationPipe } from "@nestjs/common"; +import { Test, TestingModule } from "@nestjs/testing"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { ConfigModule } from "@nestjs/config"; +import { ScheduleModule } from "@nestjs/schedule"; +jest.mock("@nestjs/common", () => { + const actual = jest.requireActual("@nestjs/common"); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const log = require("debug")("test"); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { Logger } = require("@nestjs/common/services/logger.service"); -jest.mock('@nestjs/common', () => { - const actual = jest.requireActual('@nestjs/common'); - const log = require('debug')('test'); - const { Logger } = require('@nestjs/common/services/logger.service'); - - Logger.prototype.error = function (message: unknown, stack?: string, context?: string) { - const ctx = context ?? this.context ?? 'App'; - log(`[${ctx}]`, 'error', message, ...(stack ? [stack] : [])); + Logger.prototype.error = function ( + message: unknown, + stack?: string, + context?: string, + ) { + const ctx = context ?? this.context ?? "App"; + log(`[${ctx}]`, "error", message, ...(stack ? [stack] : [])); }; - for (const level of ['log', 'warn', 'debug', 'verbose', 'fatal'] as const) { + for (const level of ["log", "warn", "debug", "verbose", "fatal"] as const) { Logger.prototype[level] = function (message: unknown, context?: string) { - const ctx = context ?? this.context ?? 'App'; + const ctx = context ?? this.context ?? "App"; log(`[${ctx}]`, level, message); }; } @@ -25,29 +29,30 @@ jest.mock('@nestjs/common', () => { return actual; }); -import { AuthModule } from '../src/auth/auth.module'; -import { BrowserModule } from '../src/browser/browser.module'; -import { SessionModule } from '../src/session/session.module'; -import { EnvironmentModule } from '../src/environment/environment.module'; -import { ScenarioModule } from '../src/scenario/scenario.module'; -import { McpModule } from '../src/mcp/mcp.module'; -import { SessionEntity } from '../src/session/session.entity'; -import { EnvironmentEntity } from '../src/environment/environment.entity'; -import { ScenarioEntity } from '../src/scenario/scenario.entity'; -import { ScenarioStepEntity } from '../src/scenario/scenario-step.entity'; -import { ScenarioRunEntity } from '../src/scenario/scenario-run.entity'; -import { ScenarioRunStepEntity } from '../src/scenario/scenario-run-step.entity'; -import { HealthController } from '../src/health/health.controller'; -import { HttpExceptionFilter } from '../src/filters/http-exception.filter'; -import { LoggingInterceptor } from '../src/interceptors/logging.interceptor'; +import { AuthModule } from "../src/auth/auth.module"; +import { BrowserModule } from "../src/browser/browser.module"; +import { SessionModule } from "../src/session/session.module"; +import { EnvironmentModule } from "../src/environment/environment.module"; +import { ScenarioModule } from "../src/scenario/scenario.module"; +import { McpModule } from "../src/mcp/mcp.module"; +import { SessionEntity } from "../src/session/session.entity"; +import { EnvironmentEntity } from "../src/environment/environment.entity"; +import { ScenarioEntity } from "../src/scenario/scenario.entity"; +import { ScenarioStepEntity } from "../src/scenario/scenario-step.entity"; +import { ScenarioRunEntity } from "../src/scenario/scenario-run.entity"; +import { ScenarioRunStepEntity } from "../src/scenario/scenario-run-step.entity"; +import { ScenarioRunLogEntity } from "../src/scenario/scenario-run-log.entity"; +import { HealthController } from "../src/health/health.controller"; +import { HttpExceptionFilter } from "../src/filters/http-exception.filter"; +import { LoggingInterceptor } from "../src/interceptors/logging.interceptor"; export async function buildTestApp(): Promise { const module: TestingModule = await Test.createTestingModule({ imports: [ ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true }), TypeOrmModule.forRoot({ - type: 'better-sqlite3', - database: ':memory:', + type: "better-sqlite3", + database: ":memory:", entities: [ SessionEntity, EnvironmentEntity, @@ -55,6 +60,7 @@ export async function buildTestApp(): Promise { ScenarioStepEntity, ScenarioRunEntity, ScenarioRunStepEntity, + ScenarioRunLogEntity, ], synchronize: true, }), diff --git a/test/auth.controller.spec.ts b/test/auth.controller.spec.ts index 2031630..c477542 100644 --- a/test/auth.controller.spec.ts +++ b/test/auth.controller.spec.ts @@ -1,6 +1,6 @@ -import { INestApplication } from '@nestjs/common'; -import request from 'supertest'; -import { buildTestApp } from './app.harness'; +import { INestApplication } from "@nestjs/common"; +import request from "supertest"; +import { buildTestApp } from "./app.harness"; /** * Auth controller integration tests. @@ -9,7 +9,7 @@ import { buildTestApp } from './app.harness'; * exercised here (those belong to e2e tests with real credentials). * We cover the parts that can be tested without external dependencies. */ -describe('AuthController', () => { +describe("AuthController", () => { let app: INestApplication; beforeAll(async () => { @@ -22,39 +22,39 @@ describe('AuthController', () => { // ── GET /keys ────────────────────────────────────────────────────────────── - describe('GET /keys', () => { - it('returns 200 with a keys array', async () => { - const res = await request(app.getHttpServer()).get('/keys').expect(200); - expect(res.body).toHaveProperty('keys'); + describe("GET /keys", () => { + it("returns 200 with a keys array", async () => { + const res = await request(app.getHttpServer()).get("/keys").expect(200); + expect(res.body).toHaveProperty("keys"); expect(Array.isArray(res.body.keys)).toBe(true); }); }); // ── POST /login ──────────────────────────────────────────────────────────── - describe('POST /login', () => { - it('returns 400 when body is empty', async () => { - await request(app.getHttpServer()).post('/login').send({}).expect(400); + describe("POST /login", () => { + it("returns 400 when body is empty", async () => { + await request(app.getHttpServer()).post("/login").send({}).expect(400); }); - it('returns 400 when key is missing', async () => { + it("returns 400 when key is missing", async () => { await request(app.getHttpServer()) - .post('/login') - .send({ environmentName: 'test-env' }) + .post("/login") + .send({ environmentName: "test-env" }) .expect(400); }); - it('returns 400 when environmentName is missing', async () => { + it("returns 400 when environmentName is missing", async () => { await request(app.getHttpServer()) - .post('/login') - .send({ key: 'some-key' }) + .post("/login") + .send({ key: "some-key" }) .expect(400); }); - it('returns 400 when key file does not exist', async () => { + it("returns 400 when key file does not exist", async () => { await request(app.getHttpServer()) - .post('/login') - .send({ key: 'nonexistent-key', environmentName: 'test-env' }) + .post("/login") + .send({ key: "nonexistent-key", environmentName: "test-env" }) .expect(404); // NotFoundException for missing environment }); }); diff --git a/test/browser.controller.spec.ts b/test/browser.controller.spec.ts index dad3c41..6cf32d7 100644 --- a/test/browser.controller.spec.ts +++ b/test/browser.controller.spec.ts @@ -1,9 +1,9 @@ -import { INestApplication } from '@nestjs/common'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import request from 'supertest'; -import { buildTestApp } from './app.harness'; -import { SessionEntity } from '../src/session/session.entity'; -import { Repository } from 'typeorm'; +import { INestApplication } from "@nestjs/common"; +import { getRepositoryToken } from "@nestjs/typeorm"; +import request from "supertest"; +import { buildTestApp } from "./app.harness"; +import { SessionEntity } from "../src/session/session.entity"; +import { Repository } from "typeorm"; /** * Browser controller integration tests. @@ -12,24 +12,26 @@ import { Repository } from 'typeorm'; * validation rejections (no browser launched) and session-not-found paths, * which are safe to run in a headless CI environment. */ -describe('BrowserController', () => { +describe("BrowserController", () => { let app: INestApplication; let sessionRepo: Repository; - const FAKE_SESSION = 'test-browser-session'; + const FAKE_SESSION = "test-browser-session"; beforeAll(async () => { app = await buildTestApp(); - sessionRepo = app.get>(getRepositoryToken(SessionEntity)); + sessionRepo = app.get>( + getRepositoryToken(SessionEntity), + ); // Seed a session with minimal but valid JSON so the browser code can // deserialise it (it will still fail to open a real page, tested separately) await sessionRepo.save( sessionRepo.create({ sessionName: FAKE_SESSION, - token: 'fake-token', - cookies: '[]', - localStorage: '{}', + token: "fake-token", + cookies: "[]", + localStorage: "{}", }), ); }); @@ -40,29 +42,29 @@ describe('BrowserController', () => { // ── POST /open ───────────────────────────────────────────────────────────── - describe('POST /open', () => { - it('returns 400 when body is empty', async () => { - await request(app.getHttpServer()).post('/open').send({}).expect(400); + describe("POST /open", () => { + it("returns 400 when body is empty", async () => { + await request(app.getHttpServer()).post("/open").send({}).expect(400); }); - it('returns 400 when url is missing', async () => { + it("returns 400 when url is missing", async () => { await request(app.getHttpServer()) - .post('/open') + .post("/open") .send({ sessionName: FAKE_SESSION }) .expect(400); }); - it('returns 404 when session does not exist', async () => { + it("returns 404 when session does not exist", async () => { await request(app.getHttpServer()) - .post('/open') - .send({ sessionName: 'no-such-session', url: 'https://example.com' }) + .post("/open") + .send({ sessionName: "no-such-session", url: "https://example.com" }) .expect(404); }); - it('succeeds without a session (sessionless open)', async () => { + it("succeeds without a session (sessionless open)", async () => { const res = await request(app.getHttpServer()) - .post('/open') - .send({ url: 'https://example.com' }) + .post("/open") + .send({ url: "https://example.com" }) .expect(201); expect(res.body).toMatchObject({ url: expect.any(String), @@ -71,55 +73,59 @@ describe('BrowserController', () => { }); }); - it('returns only selector content when selector is provided', async () => { + it("returns only selector content when selector is provided", async () => { const res = await request(app.getHttpServer()) - .post('/open') - .send({ url: 'https://example.com', selector: '#mock' }) + .post("/open") + .send({ url: "https://example.com", selector: "#mock" }) .expect(201); expect(res.body.content).toBe('
mock content
'); }); - it('returns selector text content in reader mode', async () => { + it("returns selector text content in reader mode", async () => { const res = await request(app.getHttpServer()) - .post('/open') - .send({ url: 'https://example.com', selector: '#mock', readerMode: true }) + .post("/open") + .send({ + url: "https://example.com", + selector: "#mock", + readerMode: true, + }) .expect(201); - expect(res.body.content).toBe('mock content'); + expect(res.body.content).toBe("mock content"); }); }); // ── POST /exec ───────────────────────────────────────────────────────────── - describe('POST /exec', () => { - it('returns 400 when body is empty', async () => { - await request(app.getHttpServer()).post('/exec').send({}).expect(400); + describe("POST /exec", () => { + it("returns 400 when body is empty", async () => { + await request(app.getHttpServer()).post("/exec").send({}).expect(400); }); - it('returns 400 when code is missing', async () => { + it("returns 400 when code is missing", async () => { await request(app.getHttpServer()) - .post('/exec') + .post("/exec") .send({ sessionName: FAKE_SESSION }) .expect(400); }); - it('returns 400 when code has a syntax error', async () => { + it("returns 400 when code has a syntax error", async () => { await request(app.getHttpServer()) - .post('/exec') - .send({ sessionName: FAKE_SESSION, code: 'this is not valid {{{' }) + .post("/exec") + .send({ sessionName: FAKE_SESSION, code: "this is not valid {{{" }) .expect(400); }); - it('returns 404 when session does not exist', async () => { + it("returns 404 when session does not exist", async () => { await request(app.getHttpServer()) - .post('/exec') - .send({ sessionName: 'no-such-session', code: 'return 1;' }) + .post("/exec") + .send({ sessionName: "no-such-session", code: "return 1;" }) .expect(404); }); - it('succeeds without a session (sessionless exec)', async () => { + it("succeeds without a session (sessionless exec)", async () => { const res = await request(app.getHttpServer()) - .post('/exec') - .send({ code: 'return 42;' }) + .post("/exec") + .send({ code: "return 42;" }) .expect(201); expect(res.body).toEqual({ result: 42 }); }); diff --git a/test/dom-helpers.spec.ts b/test/dom-helpers.spec.ts index f6a5c58..dce6384 100644 --- a/test/dom-helpers.spec.ts +++ b/test/dom-helpers.spec.ts @@ -8,7 +8,7 @@ * fake globals injected as named parameters. */ -import { dumpDom, DomNode } from '../src/code-executor/dom-helpers'; +import { dumpDom, DomNode } from "../src/code-executor/dom-helpers"; // --------------------------------------------------------------------------- // Fake DOM builder @@ -39,10 +39,10 @@ interface FakeEl { type ElAttrs = Partial<{ role: string; - 'data-testid': string; - 'data-qa': string; - 'data-action': string; - 'data-element-id': string; + "data-testid": string; + "data-qa": string; + "data-action": string; + "data-element-id": string; id: string; type: string; name: string; @@ -52,19 +52,31 @@ type ElAttrs = Partial<{ style: string; }>; -function el(tag: string, attrs: ElAttrs = {}, ...children: (FakeEl | string)[]): FakeEl { +function el( + tag: string, + attrs: ElAttrs = {}, + ...children: (FakeEl | string)[] +): FakeEl { const ownTextNodes: FakeText[] = children - .filter((c): c is string => typeof c === 'string') - .map(t => ({ nodeType: 3, textContent: t })); - const childEls = children.filter((c): c is FakeEl => typeof c !== 'string'); - const deepText = children.map(c => (typeof c === 'string' ? c : c.innerText)).join(''); + .filter((c): c is string => typeof c === "string") + .map((t) => ({ nodeType: 3, textContent: t })); + const childEls = children.filter((c): c is FakeEl => typeof c !== "string"); + const deepText = children + .map((c) => (typeof c === "string" ? c : c.innerText)) + .join(""); - const style = attrs.style ?? ''; - const display = /display\s*:\s*none/.test(style) ? 'none' : ''; - const visibility = /visibility\s*:\s*hidden/.test(style) ? 'hidden' : ''; + const style = attrs.style ?? ""; + const display = /display\s*:\s*none/.test(style) ? "none" : ""; + const visibility = /visibility\s*:\s*hidden/.test(style) ? "hidden" : ""; const attrMap: Record = {}; - for (const key of ['role', 'data-testid', 'data-qa', 'data-action', 'data-element-id'] as const) { + for (const key of [ + "role", + "data-testid", + "data-qa", + "data-action", + "data-element-id", + ] as const) { if (attrs[key] != null) attrMap[key] = attrs[key] as string; } @@ -73,26 +85,28 @@ function el(tag: string, attrs: ElAttrs = {}, ...children: (FakeEl | string)[]): children: childEls, childNodes: ownTextNodes, offsetParent: display || visibility ? null : {}, - id: attrs.id ?? '', - type: attrs.type ?? '', - name: attrs.name ?? '', + id: attrs.id ?? "", + type: attrs.type ?? "", + name: attrs.name ?? "", href: attrs.href - ? attrs.href.startsWith('http') + ? attrs.href.startsWith("http") ? attrs.href : `https://example.com${attrs.href}` - : '', + : "", checked: !!attrs.checked, disabled: !!attrs.disabled, innerText: deepText, textContent: deepText, - getAttribute(name: string) { return attrMap[name] ?? null; }, + getAttribute(name: string) { + return attrMap[name] ?? null; + }, _display: display, _visibility: visibility, }; } function body(...children: (FakeEl | string)[]): FakeEl { - return el('body', {}, ...children); + return el("body", {}, ...children); } // ── Fake page ────────────────────────────────────────────────────────────── @@ -109,26 +123,34 @@ function findByTag(root: FakeEl, tag: string): FakeEl | null { function makePage(rootEl: FakeEl) { const fakeDocument = { querySelector(sel: string): FakeEl | null { - if (sel.startsWith('#') || sel.startsWith('[') || sel.startsWith('.')) return null; + if (sel.startsWith("#") || sel.startsWith("[") || sel.startsWith(".")) + return null; return findByTag(rootEl, sel); }, }; const fakeWindow = { - getComputedStyle: (e: FakeEl) => ({ display: e._display, visibility: e._visibility }), - location: { origin: 'https://example.com' }, + getComputedStyle: (e: FakeEl) => ({ + display: e._display, + visibility: e._visibility, + }), + location: { origin: "https://example.com" }, }; const fakeNode = { TEXT_NODE: 3 }; - const evaluate = jest.fn().mockImplementation((fn: Function, args: unknown) => { - // eslint-disable-next-line no-new-func - const exec = new Function( - 'document', 'window', 'Node', '__args__', - `return (${fn.toString()})(__args__)`, - ); - return Promise.resolve(exec(fakeDocument, fakeWindow, fakeNode, args)); - }); + const evaluate = jest + .fn() + .mockImplementation((fn: (...args: unknown[]) => unknown, args: unknown) => { + const exec = new Function( + "document", + "window", + "Node", + "__args__", + `return (${fn.toString()})(__args__)`, + ); + return Promise.resolve(exec(fakeDocument, fakeWindow, fakeNode, args)); + }); - return { evaluate } as unknown as import('playwright').Page; + return { evaluate } as unknown as import("playwright").Page; } const dump = (rootEl: FakeEl, sel?: string): Promise => @@ -138,205 +160,262 @@ const dump = (rootEl: FakeEl, sel?: string): Promise => // Tests // --------------------------------------------------------------------------- -describe('dumpDom', () => { +describe("dumpDom", () => { // ── Error handling ──────────────────────────────────────────────────────── - it('returns an ERROR node when the root selector is not found', async () => { - const result = await dump(body(el('p', {}, 'hello')), '#does-not-exist'); - expect(result.tag).toBe('ERROR'); - expect(result.text).toContain('#does-not-exist'); + it("returns an ERROR node when the root selector is not found", async () => { + const result = await dump(body(el("p", {}, "hello")), "#does-not-exist"); + expect(result.tag).toBe("ERROR"); + expect(result.text).toContain("#does-not-exist"); }); // ── Scope & defaults ────────────────────────────────────────────────────── - it('defaults to body scope', async () => { - const result = await dump(body(el('button', {}, 'Go'))); - expect(result.tag).toBe('body'); + it("defaults to body scope", async () => { + const result = await dump(body(el("button", {}, "Go"))); + expect(result.tag).toBe("body"); }); - it('scopes to an arbitrary sub-selector', async () => { + it("scopes to an arbitrary sub-selector", async () => { const root = body( - el('header', {}, el('a', { href: '/nav' }, 'Nav')), - el('main', {}, el('button', {}, 'Action')), + el("header", {}, el("a", { href: "/nav" }, "Nav")), + el("main", {}, el("button", {}, "Action")), ); - const result = await dump(root, 'main'); - expect(result.tag).toBe('main'); - expect(result.children.find(c => c.tag === 'header')).toBeUndefined(); + const result = await dump(root, "main"); + expect(result.tag).toBe("main"); + expect(result.children.find((c) => c.tag === "header")).toBeUndefined(); }); // ── Visibility filtering ────────────────────────────────────────────────── - it('skips elements with display:none', async () => { + it("skips elements with display:none", async () => { const root = body( - el('button', { style: 'display:none' }, 'Hidden'), - el('button', {}, 'Visible'), + el("button", { style: "display:none" }, "Hidden"), + el("button", {}, "Visible"), ); const result = await dump(root); - const btns = result.children.filter(c => c.tag === 'button'); + const btns = result.children.filter((c) => c.tag === "button"); expect(btns).toHaveLength(1); - expect(btns[0].text).toBe('Visible'); + expect(btns[0].text).toBe("Visible"); }); - it('skips elements with visibility:hidden', async () => { + it("skips elements with visibility:hidden", async () => { const root = body( - el('button', { style: 'visibility:hidden' }, 'Hidden'), - el('button', {}, 'Visible'), + el("button", { style: "visibility:hidden" }, "Hidden"), + el("button", {}, "Visible"), ); const result = await dump(root); - const btns = result.children.filter(c => c.tag === 'button'); + const btns = result.children.filter((c) => c.tag === "button"); expect(btns).toHaveLength(1); - expect(btns[0].text).toBe('Visible'); + expect(btns[0].text).toBe("Visible"); }); // ── Ignored tag types ───────────────────────────────────────────────────── - it('skips SVG elements', async () => { - const svgEl = el('svg', {}, el('path', {})); - const result = await dump(body(el('button', {}, svgEl, 'Click'))); - const btn = result.children.find(c => c.tag === 'button'); + it("skips SVG elements", async () => { + const svgEl = el("svg", {}, el("path", {})); + const result = await dump(body(el("button", {}, svgEl, "Click"))); + const btn = result.children.find((c) => c.tag === "button"); expect(btn).toBeDefined(); - expect(btn!.children.some(c => c.tag === 'svg')).toBe(false); + expect(btn!.children.some((c) => c.tag === "svg")).toBe(false); }); - it('skips SCRIPT elements', async () => { - const result = await dump(body(el('script', {}, 'alert(1)'), el('button', {}, 'OK'))); - expect(result.children.some(c => c.tag === 'script')).toBe(false); + it("skips SCRIPT elements", async () => { + const result = await dump( + body(el("script", {}, "alert(1)"), el("button", {}, "OK")), + ); + expect(result.children.some((c) => c.tag === "script")).toBe(false); }); // ── Semantic attributes ─────────────────────────────────────────────────── - it('captures data-testid', async () => { - const result = await dump(body(el('button', { 'data-testid': 'save-btn' }, 'Save'))); - expect(result.children.find(c => c.tag === 'button')?.testid).toBe('save-btn'); + it("captures data-testid", async () => { + const result = await dump( + body(el("button", { "data-testid": "save-btn" }, "Save")), + ); + expect(result.children.find((c) => c.tag === "button")?.testid).toBe( + "save-btn", + ); }); - it('captures data-qa', async () => { - const result = await dump(body(el('div', { 'data-qa': 'process-name' }, el('input', { type: 'text' })))); - expect(result.children.find(c => c.qa === 'process-name')).toBeDefined(); + it("captures data-qa", async () => { + const result = await dump( + body( + el("div", { "data-qa": "process-name" }, el("input", { type: "text" })), + ), + ); + expect(result.children.find((c) => c.qa === "process-name")).toBeDefined(); }); - it('captures data-action', async () => { - const result = await dump(body(el('div', { 'data-action': 'append.append-task' }))); - expect(result.children.find(c => c.action === 'append.append-task')).toBeDefined(); + it("captures data-action", async () => { + const result = await dump( + body(el("div", { "data-action": "append.append-task" })), + ); + expect( + result.children.find((c) => c.action === "append.append-task"), + ).toBeDefined(); }); - it('captures data-element-id', async () => { - const result = await dump(body(el('div', { 'data-element-id': 'Activity_1abc' }))); - expect(result.children.find(c => c.elementId === 'Activity_1abc')).toBeDefined(); + it("captures data-element-id", async () => { + const result = await dump( + body(el("div", { "data-element-id": "Activity_1abc" })), + ); + expect( + result.children.find((c) => c.elementId === "Activity_1abc"), + ).toBeDefined(); }); - it('captures role attribute', async () => { - const result = await dump(body(el('div', { role: 'dialog' }, el('button', {}, 'OK')))); - const dialog = result.children.find(c => c.role === 'dialog'); + it("captures role attribute", async () => { + const result = await dump( + body(el("div", { role: "dialog" }, el("button", {}, "OK"))), + ); + const dialog = result.children.find((c) => c.role === "dialog"); expect(dialog).toBeDefined(); - expect(dialog!.tag).toBe('div'); + expect(dialog!.tag).toBe("div"); }); // ── Interactive element attributes ──────────────────────────────────────── - it('captures input id, type, and name', async () => { - const result = await dump(body(el('input', { id: 'email', type: 'email', name: 'userEmail' }))); - const input = result.children.find(c => c.tag === 'input'); - expect(input?.id).toBe('email'); - expect(input?.type).toBe('email'); - expect(input?.name).toBe('userEmail'); + it("captures input id, type, and name", async () => { + const result = await dump( + body(el("input", { id: "email", type: "email", name: "userEmail" })), + ); + const input = result.children.find((c) => c.tag === "input"); + expect(input?.id).toBe("email"); + expect(input?.type).toBe("email"); + expect(input?.name).toBe("userEmail"); }); - it('captures checked:true on a checked checkbox', async () => { - const result = await dump(body(el('input', { type: 'checkbox', checked: true }))); - expect(result.children.find(c => c.tag === 'input')?.checked).toBe(true); + it("captures checked:true on a checked checkbox", async () => { + const result = await dump( + body(el("input", { type: "checkbox", checked: true })), + ); + expect(result.children.find((c) => c.tag === "input")?.checked).toBe(true); }); - it('captures checked:false on an unchecked checkbox', async () => { - const result = await dump(body(el('input', { type: 'checkbox' }))); - expect(result.children.find(c => c.tag === 'input')?.checked).toBe(false); + it("captures checked:false on an unchecked checkbox", async () => { + const result = await dump(body(el("input", { type: "checkbox" }))); + expect(result.children.find((c) => c.tag === "input")?.checked).toBe(false); }); - it('captures disabled:true on a disabled button', async () => { - const result = await dump(body(el('button', { disabled: true }, 'Nope'))); - expect(result.children.find(c => c.tag === 'button')?.disabled).toBe(true); + it("captures disabled:true on a disabled button", async () => { + const result = await dump(body(el("button", { disabled: true }, "Nope"))); + expect(result.children.find((c) => c.tag === "button")?.disabled).toBe( + true, + ); }); - it('does not set disabled for a non-disabled button', async () => { - const result = await dump(body(el('button', {}, 'OK'))); - expect(result.children.find(c => c.tag === 'button')?.disabled).toBeUndefined(); + it("does not set disabled for a non-disabled button", async () => { + const result = await dump(body(el("button", {}, "OK"))); + expect( + result.children.find((c) => c.tag === "button")?.disabled, + ).toBeUndefined(); }); - it('does not capture type for button elements', async () => { - const result = await dump(body(el('button', { type: 'submit' }, 'Go'))); - expect(result.children.find(c => c.tag === 'button')?.type).toBeUndefined(); + it("does not capture type for button elements", async () => { + const result = await dump(body(el("button", { type: "submit" }, "Go"))); + expect( + result.children.find((c) => c.tag === "button")?.type, + ).toBeUndefined(); }); - it('relativizes same-origin anchor href', async () => { - const result = await dump(body(el('a', { href: '/workflow/123' }, 'Link'))); - expect(result.children.find(c => c.tag === 'a')?.href).toBe('/workflow/123'); + it("relativizes same-origin anchor href", async () => { + const result = await dump(body(el("a", { href: "/workflow/123" }, "Link"))); + expect(result.children.find((c) => c.tag === "a")?.href).toBe( + "/workflow/123", + ); }); - it('keeps full href for cross-origin anchors', async () => { - const result = await dump(body(el('a', { href: 'https://other.com/page' }, 'Ext'))); - expect(result.children.find(c => c.tag === 'a')?.href).toContain('other.com'); + it("keeps full href for cross-origin anchors", async () => { + const result = await dump( + body(el("a", { href: "https://other.com/page" }, "Ext")), + ); + expect(result.children.find((c) => c.tag === "a")?.href).toContain( + "other.com", + ); }); // ── Text content ────────────────────────────────────────────────────────── - it('captures own text content of a button', async () => { - const result = await dump(body(el('button', {}, 'Save'))); - expect(result.children.find(c => c.tag === 'button')?.text).toBe('Save'); + it("captures own text content of a button", async () => { + const result = await dump(body(el("button", {}, "Save"))); + expect(result.children.find((c) => c.tag === "button")?.text).toBe("Save"); }); - it('truncates text to 80 characters', async () => { - const long = 'x'.repeat(100); - const result = await dump(body(el('button', {}, long))); - expect(result.children.find(c => c.tag === 'button')?.text?.length).toBe(80); + it("truncates text to 80 characters", async () => { + const long = "x".repeat(100); + const result = await dump(body(el("button", {}, long))); + expect(result.children.find((c) => c.tag === "button")?.text?.length).toBe( + 80, + ); }); - it('falls back to innerText when element has no direct text nodes', async () => { + it("falls back to innerText when element has no direct text nodes", async () => { // button wraps a span — no direct text node on button, innerText = span text - const result = await dump(body(el('button', {}, el('span', {}, 'Nested')))); - expect(result.children.find(c => c.tag === 'button')?.text).toBe('Nested'); + const result = await dump(body(el("button", {}, el("span", {}, "Nested")))); + expect(result.children.find((c) => c.tag === "button")?.text).toBe( + "Nested", + ); }); // ── Tree pruning / unwrapping ───────────────────────────────────────────── - it('unwraps a non-significant div that has exactly one significant child', async () => { - const result = await dump(body(el('div', {}, el('button', {}, 'Click')))); - const btn = result.children.find(c => c.tag === 'button'); + it("unwraps a non-significant div that has exactly one significant child", async () => { + const result = await dump(body(el("div", {}, el("button", {}, "Click")))); + const btn = result.children.find((c) => c.tag === "button"); expect(btn).toBeDefined(); - expect(result.children.some(c => c.tag === 'div' && !c.role && !c.testid && !c.qa)).toBe(false); + expect( + result.children.some( + (c) => c.tag === "div" && !c.role && !c.testid && !c.qa, + ), + ).toBe(false); }); - it('keeps a non-significant div that has more than one significant child', async () => { - const result = await dump(body(el('div', {}, el('button', {}, 'A'), el('button', {}, 'B')))); - const wrapper = result.children.find(c => c.tag === 'div'); + it("keeps a non-significant div that has more than one significant child", async () => { + const result = await dump( + body(el("div", {}, el("button", {}, "A"), el("button", {}, "B"))), + ); + const wrapper = result.children.find((c) => c.tag === "div"); expect(wrapper).toBeDefined(); expect(wrapper!.children).toHaveLength(2); }); - it('discards non-significant childless elements', async () => { - const result = await dump(body(el('div', {}), el('button', {}, 'Keep'))); - expect(result.children.find(c => c.tag === 'div' && c.children.length === 0)).toBeUndefined(); - expect(result.children.some(c => c.tag === 'button')).toBe(true); + it("discards non-significant childless elements", async () => { + const result = await dump(body(el("div", {}), el("button", {}, "Keep"))); + expect( + result.children.find((c) => c.tag === "div" && c.children.length === 0), + ).toBeUndefined(); + expect(result.children.some((c) => c.tag === "button")).toBe(true); }); // ── Structural tags ─────────────────────────────────────────────────────── - it('preserves nested structure inside a form', async () => { + it("preserves nested structure inside a form", async () => { const result = await dump( - body(el('form', {}, el('input', { id: 'n', type: 'text', name: 'name' }), el('button', {}, 'Send'))), + body( + el( + "form", + {}, + el("input", { id: "n", type: "text", name: "name" }), + el("button", {}, "Send"), + ), + ), ); - const form = result.children.find(c => c.tag === 'form'); + const form = result.children.find((c) => c.tag === "form"); expect(form).toBeDefined(); - expect(form!.children.find(c => c.tag === 'input')).toBeDefined(); - expect(form!.children.find(c => c.tag === 'button')).toBeDefined(); + expect(form!.children.find((c) => c.tag === "input")).toBeDefined(); + expect(form!.children.find((c) => c.tag === "button")).toBeDefined(); }); - it('preserves dialog element', async () => { - const result = await dump(body(el('dialog', { role: 'dialog' }, el('button', {}, 'Close')))); - expect(result.children.find(c => c.tag === 'dialog')).toBeDefined(); + it("preserves dialog element", async () => { + const result = await dump( + body(el("dialog", { role: "dialog" }, el("button", {}, "Close"))), + ); + expect(result.children.find((c) => c.tag === "dialog")).toBeDefined(); }); - it('returns empty node when root has no visible significant content', async () => { - const result = await dumpDom(makePage(el('div', {})), 'div'); - expect(result.tag).toBe('empty'); + it("returns empty node when root has no visible significant content", async () => { + const result = await dumpDom(makePage(el("div", {})), "div"); + expect(result.tag).toBe("empty"); }); }); diff --git a/test/environment.controller.spec.ts b/test/environment.controller.spec.ts index 33ce963..98ce50e 100644 --- a/test/environment.controller.spec.ts +++ b/test/environment.controller.spec.ts @@ -1,8 +1,8 @@ -import { INestApplication } from '@nestjs/common'; -import request from 'supertest'; -import { buildTestApp } from './app.harness'; +import { INestApplication } from "@nestjs/common"; +import request from "supertest"; +import { buildTestApp } from "./app.harness"; -describe('EnvironmentController', () => { +describe("EnvironmentController", () => { let app: INestApplication; beforeAll(async () => { @@ -15,160 +15,187 @@ describe('EnvironmentController', () => { // ── POST /environments ───────────────────────────────────────────────────── - describe('POST /environments', () => { - it('creates an environment and returns 201', async () => { + describe("POST /environments", () => { + it("creates an environment and returns 201", async () => { const res = await request(app.getHttpServer()) - .post('/environments') - .send({ name: 'env-a', urls: { id_url: 'https://id.example.com' } }) + .post("/environments") + .send({ name: "env-a", urls: { id_url: "https://id.example.com" } }) .expect(201); expect(res.body.id).toBeDefined(); - expect(res.body.name).toBe('env-a'); - expect(res.body.urls.id_url).toBe('https://id.example.com'); + expect(res.body.name).toBe("env-a"); + expect(res.body.urls.id_url).toBe("https://id.example.com"); }); - it('returns 400 when name is missing', async () => { + it("returns 400 when name is missing", async () => { await request(app.getHttpServer()) - .post('/environments') - .send({ urls: { id_url: 'https://id.example.com' } }) + .post("/environments") + .send({ urls: { id_url: "https://id.example.com" } }) .expect(400); }); - it('returns 400 when urls is missing', async () => { + it("returns 400 when urls is missing", async () => { await request(app.getHttpServer()) - .post('/environments') - .send({ name: 'env-no-urls' }) + .post("/environments") + .send({ name: "env-no-urls" }) .expect(400); }); - it('returns 400 when urls is not an object', async () => { + it("returns 400 when urls is not an object", async () => { await request(app.getHttpServer()) - .post('/environments') - .send({ name: 'env-bad-urls', urls: 'not-an-object' }) + .post("/environments") + .send({ name: "env-bad-urls", urls: "not-an-object" }) .expect(400); }); - it('returns 409 when name already exists', async () => { + it("returns 409 when name already exists", async () => { await request(app.getHttpServer()) - .post('/environments') - .send({ name: 'env-duplicate', urls: {} }) + .post("/environments") + .send({ name: "env-duplicate", urls: {} }) .expect(201); await request(app.getHttpServer()) - .post('/environments') - .send({ name: 'env-duplicate', urls: {} }) + .post("/environments") + .send({ name: "env-duplicate", urls: {} }) .expect(409); }); }); // ── GET /environments ────────────────────────────────────────────────────── - describe('GET /environments', () => { - it('returns 200 with a paginated result', async () => { - const res = await request(app.getHttpServer()).get('/environments').expect(200); + describe("GET /environments", () => { + it("returns 200 with a paginated result", async () => { + const res = await request(app.getHttpServer()) + .get("/environments") + .expect(200); expect(Array.isArray(res.body.data)).toBe(true); - expect(typeof res.body.total).toBe('number'); - expect(res.body).toHaveProperty('page'); - expect(res.body).toHaveProperty('limit'); + expect(typeof res.body.total).toBe("number"); + expect(res.body).toHaveProperty("page"); + expect(res.body).toHaveProperty("limit"); }); - it('respects page and limit params', async () => { + it("respects page and limit params", async () => { // seed two extra environments - await request(app.getHttpServer()).post('/environments').send({ name: 'env-page-1', urls: {} }).expect(201); - await request(app.getHttpServer()).post('/environments').send({ name: 'env-page-2', urls: {} }).expect(201); + await request(app.getHttpServer()) + .post("/environments") + .send({ name: "env-page-1", urls: {} }) + .expect(201); + await request(app.getHttpServer()) + .post("/environments") + .send({ name: "env-page-2", urls: {} }) + .expect(201); - const res = await request(app.getHttpServer()).get('/environments?page=1&limit=1').expect(200); + const res = await request(app.getHttpServer()) + .get("/environments?page=1&limit=1") + .expect(200); expect(res.body.data).toHaveLength(1); expect(res.body.page).toBe(1); expect(res.body.limit).toBe(1); }); - it('returns empty data array for out-of-range page', async () => { - const res = await request(app.getHttpServer()).get('/environments?page=9999&limit=20').expect(200); + it("returns empty data array for out-of-range page", async () => { + const res = await request(app.getHttpServer()) + .get("/environments?page=9999&limit=20") + .expect(200); expect(res.body.data).toHaveLength(0); }); - it('returns 400 for invalid page param', async () => { - await request(app.getHttpServer()).get('/environments?page=0').expect(400); + it("returns 400 for invalid page param", async () => { + await request(app.getHttpServer()) + .get("/environments?page=0") + .expect(400); }); - it('orders by name ASC', async () => { - await request(app.getHttpServer()).post('/environments').send({ name: 'zzz-env', urls: {} }); - await request(app.getHttpServer()).post('/environments').send({ name: 'aaa-env', urls: {} }); + it("orders by name ASC", async () => { + await request(app.getHttpServer()) + .post("/environments") + .send({ name: "zzz-env", urls: {} }); + await request(app.getHttpServer()) + .post("/environments") + .send({ name: "aaa-env", urls: {} }); - const res = await request(app.getHttpServer()).get('/environments?orderBy=name&orderDir=ASC').expect(200); - const names: string[] = res.body.data.map((e: any) => e.name); + const res = await request(app.getHttpServer()) + .get("/environments?orderBy=name&orderDir=ASC") + .expect(200); + const names: string[] = res.body.data.map((e: { name: string }) => e.name); expect(names).toEqual([...names].sort()); }); - it('orders by name DESC', async () => { - const res = await request(app.getHttpServer()).get('/environments?orderBy=name&orderDir=DESC').expect(200); - const names: string[] = res.body.data.map((e: any) => e.name); + it("orders by name DESC", async () => { + const res = await request(app.getHttpServer()) + .get("/environments?orderBy=name&orderDir=DESC") + .expect(200); + const names: string[] = res.body.data.map((e: { name: string }) => e.name); expect(names).toEqual([...names].sort().reverse()); }); - it('returns 400 for invalid orderDir', async () => { - await request(app.getHttpServer()).get('/environments?orderDir=SIDEWAYS').expect(400); + it("returns 400 for invalid orderDir", async () => { + await request(app.getHttpServer()) + .get("/environments?orderDir=SIDEWAYS") + .expect(400); }); }); // ── GET /environments/:id ────────────────────────────────────────────────── - describe('GET /environments/:id', () => { - it('returns the created environment', async () => { + describe("GET /environments/:id", () => { + it("returns the created environment", async () => { const created = await request(app.getHttpServer()) - .post('/environments') - .send({ name: 'env-get-one', urls: { cabinet_url: 'https://cabinet.example.com' } }) + .post("/environments") + .send({ + name: "env-get-one", + urls: { cabinet_url: "https://cabinet.example.com" }, + }) .expect(201); const res = await request(app.getHttpServer()) .get(`/environments/${created.body.id}`) .expect(200); - expect(res.body.name).toBe('env-get-one'); + expect(res.body.name).toBe("env-get-one"); }); - it('returns 404 for unknown id', async () => { - await request(app.getHttpServer()).get('/environments/99999').expect(404); + it("returns 404 for unknown id", async () => { + await request(app.getHttpServer()).get("/environments/99999").expect(404); }); - it('returns 400 for non-numeric id', async () => { - await request(app.getHttpServer()).get('/environments/abc').expect(400); + it("returns 400 for non-numeric id", async () => { + await request(app.getHttpServer()).get("/environments/abc").expect(400); }); }); // ── PATCH /environments/:id ──────────────────────────────────────────────── - describe('PATCH /environments/:id', () => { - it('updates name and returns 200', async () => { + describe("PATCH /environments/:id", () => { + it("updates name and returns 200", async () => { const created = await request(app.getHttpServer()) - .post('/environments') - .send({ name: 'env-patch-me', urls: {} }) + .post("/environments") + .send({ name: "env-patch-me", urls: {} }) .expect(201); const res = await request(app.getHttpServer()) .patch(`/environments/${created.body.id}`) - .send({ name: 'env-patched' }) + .send({ name: "env-patched" }) .expect(200); - expect(res.body.name).toBe('env-patched'); + expect(res.body.name).toBe("env-patched"); }); - it('returns 404 for unknown id', async () => { + it("returns 404 for unknown id", async () => { await request(app.getHttpServer()) - .patch('/environments/99999') - .send({ name: 'x' }) + .patch("/environments/99999") + .send({ name: "x" }) .expect(404); }); }); // ── DELETE /environments/:id ─────────────────────────────────────────────── - describe('DELETE /environments/:id', () => { - it('deletes and returns 204', async () => { + describe("DELETE /environments/:id", () => { + it("deletes and returns 204", async () => { const created = await request(app.getHttpServer()) - .post('/environments') - .send({ name: 'env-delete-me', urls: {} }) + .post("/environments") + .send({ name: "env-delete-me", urls: {} }) .expect(201); await request(app.getHttpServer()) @@ -180,8 +207,10 @@ describe('EnvironmentController', () => { .expect(404); }); - it('returns 404 for unknown id', async () => { - await request(app.getHttpServer()).delete('/environments/99999').expect(404); + it("returns 404 for unknown id", async () => { + await request(app.getHttpServer()) + .delete("/environments/99999") + .expect(404); }); }); }); diff --git a/test/mcp.controller.spec.ts b/test/mcp.controller.spec.ts index aba10be..bc58d90 100644 --- a/test/mcp.controller.spec.ts +++ b/test/mcp.controller.spec.ts @@ -1,6 +1,6 @@ -import { INestApplication } from '@nestjs/common'; -import request from 'supertest'; -import { buildTestApp } from './app.harness'; +import { INestApplication } from "@nestjs/common"; +import request from "supertest"; +import { buildTestApp } from "./app.harness"; /** * MCP controller integration tests. @@ -14,7 +14,7 @@ import { buildTestApp } from './app.harness'; * Browser-dependent tools (open_url, exec_code) require a live Playwright * session and are not covered here. */ -describe('McpController', () => { +describe("McpController", () => { let app: INestApplication; beforeAll(async () => { @@ -35,13 +35,13 @@ describe('McpController', () => { /** Send a single MCP tool call and return the parsed response body. */ async function mcpCall(toolName: string, args: Record = {}) { const res = await request(app.getHttpServer()) - .post('/mcp') - .set('Content-Type', 'application/json') - .set('Accept', 'application/json, text/event-stream') + .post("/mcp") + .set("Content-Type", "application/json") + .set("Accept", "application/json, text/event-stream") .send({ - jsonrpc: '2.0', + jsonrpc: "2.0", id: 1, - method: 'tools/call', + method: "tools/call", params: { name: toolName, arguments: args }, }); return { status: res.status, rpc: parseSse(res.text) }; @@ -49,20 +49,20 @@ describe('McpController', () => { // ── Connectivity ─────────────────────────────────────────────────────────── - describe('POST /mcp — connectivity', () => { - it('is reachable and returns a non-5xx status', async () => { + describe("POST /mcp — connectivity", () => { + it("is reachable and returns a non-5xx status", async () => { const res = await request(app.getHttpServer()) - .post('/mcp') - .set('Content-Type', 'application/json') - .set('Accept', 'application/json, text/event-stream') + .post("/mcp") + .set("Content-Type", "application/json") + .set("Accept", "application/json, text/event-stream") .send({ - jsonrpc: '2.0', + jsonrpc: "2.0", id: 1, - method: 'initialize', + method: "initialize", params: { - protocolVersion: '2024-11-05', + protocolVersion: "2024-11-05", capabilities: {}, - clientInfo: { name: 'test', version: '0' }, + clientInfo: { name: "test", version: "0" }, }, }); expect(res.status).toBe(200); @@ -71,9 +71,9 @@ describe('McpController', () => { // ── list_keys tool ───────────────────────────────────────────────────────── - describe('list_keys', () => { - it('returns a result with text content containing a JSON array', async () => { - const { status, rpc } = await mcpCall('list_keys'); + describe("list_keys", () => { + it("returns a result with text content containing a JSON array", async () => { + const { status, rpc } = await mcpCall("list_keys"); expect(status).toBe(200); const result = rpc.result as { content: { text: string }[] }; expect(Array.isArray(JSON.parse(result.content[0].text))).toBe(true); @@ -82,50 +82,56 @@ describe('McpController', () => { // ── list_sessions tool ───────────────────────────────────────────────────── - describe('list_sessions', () => { - it('returns a paginated result with a data array', async () => { - const { status, rpc } = await mcpCall('list_sessions'); + describe("list_sessions", () => { + it("returns a paginated result with a data array", async () => { + const { status, rpc } = await mcpCall("list_sessions"); expect(status).toBe(200); const result = rpc.result as { content: { text: string }[] }; - const body = JSON.parse(result.content[0].text) as { data: unknown[]; total: number }; + const body = JSON.parse(result.content[0].text) as { + data: unknown[]; + total: number; + }; expect(Array.isArray(body.data)).toBe(true); - expect(typeof body.total).toBe('number'); + expect(typeof body.total).toBe("number"); }); }); // ── list_environments tool ───────────────────────────────────────────────── - describe('list_environments', () => { - it('returns a paginated result with a data array', async () => { - const { status, rpc } = await mcpCall('list_environments'); + describe("list_environments", () => { + it("returns a paginated result with a data array", async () => { + const { status, rpc } = await mcpCall("list_environments"); expect(status).toBe(200); const result = rpc.result as { content: { text: string }[] }; - const body = JSON.parse(result.content[0].text) as { data: unknown[]; total: number }; + const body = JSON.parse(result.content[0].text) as { + data: unknown[]; + total: number; + }; expect(Array.isArray(body.data)).toBe(true); - expect(typeof body.total).toBe('number'); + expect(typeof body.total).toBe("number"); }); }); // ── create_environment tool ──────────────────────────────────────────────── - describe('create_environment', () => { - it('creates an environment via MCP', async () => { - const { status, rpc } = await mcpCall('create_environment', { - name: 'mcp-test-env', - urls: { id_url: 'https://id.example.com' }, + describe("create_environment", () => { + it("creates an environment via MCP", async () => { + const { status, rpc } = await mcpCall("create_environment", { + name: "mcp-test-env", + urls: { id_url: "https://id.example.com" }, }); expect(status).toBe(200); const result = rpc.result as { content: { text: string }[] }; const created = JSON.parse(result.content[0].text) as { name: string }; - expect(created.name).toBe('mcp-test-env'); + expect(created.name).toBe("mcp-test-env"); }); }); // ── delete_session tool with unknown id ──────────────────────────────────── - describe('delete_session', () => { - it('returns an MCP error result for a non-existent session id', async () => { - const { status, rpc } = await mcpCall('delete_session', { id: 999999 }); + describe("delete_session", () => { + it("returns an MCP error result for a non-existent session id", async () => { + const { status, rpc } = await mcpCall("delete_session", { id: 999999 }); expect(status).toBe(200); // MCP wraps service errors as isError:true content, not HTTP errors const result = rpc.result as { isError: boolean }; diff --git a/test/scenario.controller.spec.ts b/test/scenario.controller.spec.ts index ba8d3cf..53c8b3b 100644 --- a/test/scenario.controller.spec.ts +++ b/test/scenario.controller.spec.ts @@ -1,8 +1,9 @@ -import { INestApplication } from '@nestjs/common'; -import request from 'supertest'; -import { buildTestApp } from './app.harness'; +import { INestApplication } from "@nestjs/common"; +import request from "supertest"; +import { DataSource } from "typeorm"; +import { buildTestApp } from "./app.harness"; -describe('ScenarioController', () => { +describe("ScenarioController", () => { let app: INestApplication; beforeAll(async () => { @@ -15,22 +16,25 @@ describe('ScenarioController', () => { // ── helpers ──────────────────────────────────────────────────────────────── - async function createScenario(name = 'test scenario') { + async function createScenario(name = "test scenario") { const res = await request(app.getHttpServer()) - .post('/scenarios') + .post("/scenarios") .send({ name }) .expect(201); return res.body as { id: number; name: string }; } - async function createStep(scenarioId: number, overrides: Record = {}) { + async function createStep( + scenarioId: number, + overrides: Record = {}, + ) { const res = await request(app.getHttpServer()) .post(`/scenarios/${scenarioId}/steps`) .send({ order: 0, - type: 'exec', - sessionName: 'test-session', - execCode: 'return 1;', + type: "exec", + sessionName: "test-session", + execCode: "return 1;", ...overrides, }) .expect(201); @@ -39,80 +43,93 @@ describe('ScenarioController', () => { // ── POST /scenarios ──────────────────────────────────────────────────────── - describe('POST /scenarios', () => { - it('creates a scenario and returns 201', async () => { + describe("POST /scenarios", () => { + it("creates a scenario and returns 201", async () => { const res = await request(app.getHttpServer()) - .post('/scenarios') - .send({ name: 'my scenario' }) + .post("/scenarios") + .send({ name: "my scenario" }) .expect(201); expect(res.body.id).toBeDefined(); - expect(res.body.name).toBe('my scenario'); + expect(res.body.name).toBe("my scenario"); }); - it('returns 400 when name is missing', async () => { - await request(app.getHttpServer()).post('/scenarios').send({}).expect(400); + it("returns 400 when name is missing", async () => { + await request(app.getHttpServer()) + .post("/scenarios") + .send({}) + .expect(400); }); }); // ── GET /scenarios ───────────────────────────────────────────────────────── - describe('GET /scenarios', () => { - it('returns paginated result', async () => { - const res = await request(app.getHttpServer()).get('/scenarios').expect(200); - expect(res.body).toHaveProperty('data'); - expect(res.body).toHaveProperty('total'); - expect(res.body).toHaveProperty('page'); - expect(res.body).toHaveProperty('limit'); + describe("GET /scenarios", () => { + it("returns paginated result", async () => { + const res = await request(app.getHttpServer()) + .get("/scenarios") + .expect(200); + expect(res.body).toHaveProperty("data"); + expect(res.body).toHaveProperty("total"); + expect(res.body).toHaveProperty("page"); + expect(res.body).toHaveProperty("limit"); expect(Array.isArray(res.body.data)).toBe(true); }); - it('respects page and limit params', async () => { - await createScenario('paged-sc-a'); - await createScenario('paged-sc-b'); + it("respects page and limit params", async () => { + await createScenario("paged-sc-a"); + await createScenario("paged-sc-b"); const res = await request(app.getHttpServer()) - .get('/scenarios?page=1&limit=1') + .get("/scenarios?page=1&limit=1") .expect(200); expect(res.body.data).toHaveLength(1); expect(res.body.limit).toBe(1); expect(res.body.page).toBe(1); }); - it('returns empty data for out-of-range page', async () => { - const res = await request(app.getHttpServer()).get('/scenarios?page=9999&limit=20').expect(200); + it("returns empty data for out-of-range page", async () => { + const res = await request(app.getHttpServer()) + .get("/scenarios?page=9999&limit=20") + .expect(200); expect(res.body.data).toHaveLength(0); }); - it('returns 400 for invalid pagination params', async () => { - await request(app.getHttpServer()).get('/scenarios?page=0').expect(400); + it("returns 400 for invalid pagination params", async () => { + await request(app.getHttpServer()).get("/scenarios?page=0").expect(400); }); - it('orders by name ASC', async () => { - await createScenario('zzz-order-sc'); - await createScenario('aaa-order-sc'); + it("orders by name ASC", async () => { + await createScenario("zzz-order-sc"); + await createScenario("aaa-order-sc"); - const res = await request(app.getHttpServer()).get('/scenarios?orderBy=name&orderDir=ASC').expect(200); - const names: string[] = res.body.data.map((s: any) => s.name); + const res = await request(app.getHttpServer()) + .get("/scenarios?orderBy=name&orderDir=ASC") + .expect(200); + const names: string[] = res.body.data.map((s: { name: string }) => s.name); expect(names).toEqual([...names].sort()); }); - it('orders by name DESC', async () => { - const res = await request(app.getHttpServer()).get('/scenarios?orderBy=name&orderDir=DESC').expect(200); - const names: string[] = res.body.data.map((s: any) => s.name); + it("orders by name DESC", async () => { + const res = await request(app.getHttpServer()) + .get("/scenarios?orderBy=name&orderDir=DESC") + .expect(200); + const names: string[] = res.body.data.map((s: { name: string }) => s.name); expect(names).toEqual([...names].sort().reverse()); }); - it('returns 400 for invalid orderDir', async () => { - await request(app.getHttpServer()).get('/scenarios?orderDir=SIDEWAYS').expect(400); + it("returns 400 for invalid orderDir", async () => { + await request(app.getHttpServer()) + .get("/scenarios?orderDir=SIDEWAYS") + .expect(400); }); }); // ── GET /scenarios/:id ───────────────────────────────────────────────────── - describe('GET /scenarios/:id', () => { - it('returns the scenario with steps array', async () => { - const sc = await createScenario('scenario-get-one'); + describe("GET /scenarios/:id", () => { + it("returns the scenario with steps array", async () => { + const sc = await createScenario("scenario-get-one"); const res = await request(app.getHttpServer()) .get(`/scenarios/${sc.id}`) .expect(200); @@ -120,121 +137,128 @@ describe('ScenarioController', () => { expect(Array.isArray(res.body.steps)).toBe(true); }); - it('returns 404 for unknown id', async () => { - await request(app.getHttpServer()).get('/scenarios/99999').expect(404); + it("returns 404 for unknown id", async () => { + await request(app.getHttpServer()).get("/scenarios/99999").expect(404); }); }); // ── PATCH /scenarios/:id ─────────────────────────────────────────────────── - describe('PATCH /scenarios/:id', () => { - it('updates scenario name', async () => { - const sc = await createScenario('patch-me'); + describe("PATCH /scenarios/:id", () => { + it("updates scenario name", async () => { + const sc = await createScenario("patch-me"); const res = await request(app.getHttpServer()) .patch(`/scenarios/${sc.id}`) - .send({ name: 'patched' }) + .send({ name: "patched" }) .expect(200); - expect(res.body.name).toBe('patched'); + expect(res.body.name).toBe("patched"); }); - it('returns 404 for unknown id', async () => { + it("returns 404 for unknown id", async () => { await request(app.getHttpServer()) - .patch('/scenarios/99999') - .send({ name: 'x' }) + .patch("/scenarios/99999") + .send({ name: "x" }) .expect(404); }); }); // ── DELETE /scenarios/:id ────────────────────────────────────────────────── - describe('DELETE /scenarios/:id', () => { - it('deletes and returns 204', async () => { - const sc = await createScenario('delete-me'); - await request(app.getHttpServer()).delete(`/scenarios/${sc.id}`).expect(204); + describe("DELETE /scenarios/:id", () => { + it("deletes and returns 204", async () => { + const sc = await createScenario("delete-me"); + await request(app.getHttpServer()) + .delete(`/scenarios/${sc.id}`) + .expect(204); await request(app.getHttpServer()).get(`/scenarios/${sc.id}`).expect(404); }); - it('returns 404 for unknown id', async () => { - await request(app.getHttpServer()).delete('/scenarios/99999').expect(404); + it("returns 404 for unknown id", async () => { + await request(app.getHttpServer()).delete("/scenarios/99999").expect(404); }); }); // ── POST /scenarios/:id/steps ────────────────────────────────────────────── - describe('POST /scenarios/:id/steps', () => { - it('creates a step with required fields', async () => { + describe("POST /scenarios/:id/steps", () => { + it("creates a step with required fields", async () => { const sc = await createScenario(); const res = await request(app.getHttpServer()) .post(`/scenarios/${sc.id}/steps`) - .send({ order: 0, type: 'exec', sessionName: 'my-session', execCode: 'return 1;' }) + .send({ + order: 0, + type: "exec", + sessionName: "my-session", + execCode: "return 1;", + }) .expect(201); expect(res.body.id).toBeDefined(); expect(res.body.order).toBe(0); - expect(res.body.type).toBe('exec'); - expect(res.body.sessionName).toBe('my-session'); + expect(res.body.type).toBe("exec"); + expect(res.body.sessionName).toBe("my-session"); }); - it('creates a login step', async () => { + it("creates a login step", async () => { const sc = await createScenario(); const res = await request(app.getHttpServer()) .post(`/scenarios/${sc.id}/steps`) - .send({ order: 0, type: 'login', sessionName: 'session-x' }) + .send({ order: 0, type: "login", sessionName: "session-x" }) .expect(201); - expect(res.body.type).toBe('login'); + expect(res.body.type).toBe("login"); }); - it('returns 400 when order is missing', async () => { + it("returns 400 when order is missing", async () => { const sc = await createScenario(); await request(app.getHttpServer()) .post(`/scenarios/${sc.id}/steps`) - .send({ type: 'exec', sessionName: 'x' }) + .send({ type: "exec", sessionName: "x" }) .expect(400); }); - it('returns 400 when type is invalid', async () => { + it("returns 400 when type is invalid", async () => { const sc = await createScenario(); await request(app.getHttpServer()) .post(`/scenarios/${sc.id}/steps`) - .send({ order: 0, type: 'unknown', sessionName: 'x' }) + .send({ order: 0, type: "unknown", sessionName: "x" }) .expect(400); }); - it('returns 400 when sessionName is missing', async () => { + it("returns 400 when sessionName is missing", async () => { const sc = await createScenario(); await request(app.getHttpServer()) .post(`/scenarios/${sc.id}/steps`) - .send({ order: 0, type: 'exec' }) + .send({ order: 0, type: "exec" }) .expect(400); }); - it('returns 404 for unknown scenario', async () => { + it("returns 404 for unknown scenario", async () => { await request(app.getHttpServer()) - .post('/scenarios/99999/steps') - .send({ order: 0, type: 'exec', sessionName: 'x' }) + .post("/scenarios/99999/steps") + .send({ order: 0, type: "exec", sessionName: "x" }) .expect(404); }); - it('returns steps ordered by order field', async () => { + it("returns steps ordered by order field", async () => { const sc = await createScenario(); - await createStep(sc.id, { order: 2, type: 'exec', sessionName: 's' }); - await createStep(sc.id, { order: 0, type: 'exec', sessionName: 's' }); - await createStep(sc.id, { order: 1, type: 'exec', sessionName: 's' }); + await createStep(sc.id, { order: 2, type: "exec", sessionName: "s" }); + await createStep(sc.id, { order: 0, type: "exec", sessionName: "s" }); + await createStep(sc.id, { order: 1, type: "exec", sessionName: "s" }); const res = await request(app.getHttpServer()) .get(`/scenarios/${sc.id}`) .expect(200); - const orders = res.body.steps.map((s: any) => s.order); + const orders = res.body.steps.map((s: { order: number }) => s.order); expect(orders).toEqual([0, 1, 2]); }); }); // ── GET /scenarios/:id/steps/:stepId ────────────────────────────────────── - describe('GET /scenarios/:id/steps/:stepId', () => { - it('returns the step', async () => { + describe("GET /scenarios/:id/steps/:stepId", () => { + it("returns the step", async () => { const sc = await createScenario(); const step = await createStep(sc.id); @@ -245,7 +269,7 @@ describe('ScenarioController', () => { expect(res.body.id).toBe(step.id); }); - it('returns 404 for unknown step', async () => { + it("returns 404 for unknown step", async () => { const sc = await createScenario(); await request(app.getHttpServer()) .get(`/scenarios/${sc.id}/steps/99999`) @@ -255,21 +279,21 @@ describe('ScenarioController', () => { // ── PATCH /scenarios/:id/steps/:stepId ──────────────────────────────────── - describe('PATCH /scenarios/:id/steps/:stepId', () => { - it('updates step fields', async () => { + describe("PATCH /scenarios/:id/steps/:stepId", () => { + it("updates step fields", async () => { const sc = await createScenario(); - const step = await createStep(sc.id, { order: 0, execCode: 'return 1;' }); + const step = await createStep(sc.id, { order: 0, execCode: "return 1;" }); const res = await request(app.getHttpServer()) .patch(`/scenarios/${sc.id}/steps/${step.id}`) - .send({ order: 5, execCode: 'return 99;' }) + .send({ order: 5, execCode: "return 99;" }) .expect(200); expect(res.body.order).toBe(5); - expect(res.body.execCode).toBe('return 99;'); + expect(res.body.execCode).toBe("return 99;"); }); - it('returns 404 for unknown step', async () => { + it("returns 404 for unknown step", async () => { const sc = await createScenario(); await request(app.getHttpServer()) .patch(`/scenarios/${sc.id}/steps/99999`) @@ -280,8 +304,8 @@ describe('ScenarioController', () => { // ── DELETE /scenarios/:id/steps/:stepId ─────────────────────────────────── - describe('DELETE /scenarios/:id/steps/:stepId', () => { - it('deletes the step and returns 204', async () => { + describe("DELETE /scenarios/:id/steps/:stepId", () => { + it("deletes the step and returns 204", async () => { const sc = await createScenario(); const step = await createStep(sc.id); @@ -297,8 +321,8 @@ describe('ScenarioController', () => { // ── POST /scenarios/:id/run ──────────────────────────────────────────────── - describe('POST /scenarios/:id/run', () => { - it('creates a run with stepRuns in correct initial states', async () => { + describe("POST /scenarios/:id/run", () => { + it("creates a run with stepRuns in correct initial states", async () => { const sc = await createScenario(); await createStep(sc.id, { order: 0 }); await createStep(sc.id, { order: 1 }); @@ -308,30 +332,32 @@ describe('ScenarioController', () => { .post(`/scenarios/${sc.id}/run`) .expect(201); - expect(res.body.status).toBe('pending'); + expect(res.body.status).toBe("pending"); expect(Array.isArray(res.body.stepRuns)).toBe(true); expect(res.body.stepRuns).toHaveLength(3); - const statuses = res.body.stepRuns.map((s: any) => s.status); - expect(statuses[0]).toBe('pending'); - expect(statuses[1]).toBe('waiting'); - expect(statuses[2]).toBe('waiting'); + const statuses = res.body.stepRuns.map((s: { status: string }) => s.status); + expect(statuses[0]).toBe("pending"); + expect(statuses[1]).toBe("waiting"); + expect(statuses[2]).toBe("waiting"); }); - it('returns 404 for unknown scenario', async () => { + it("returns 404 for unknown scenario", async () => { await request(app.getHttpServer()) - .post('/scenarios/99999/run') + .post("/scenarios/99999/run") .expect(404); }); }); // ── GET /scenarios/:id/runs ──────────────────────────────────────────────── - describe('GET /scenarios/:id/runs', () => { - it('returns paginated runs with stepRuns embedded', async () => { + describe("GET /scenarios/:id/runs", () => { + it("returns paginated runs with stepRuns embedded", async () => { const sc = await createScenario(); await createStep(sc.id, { order: 0 }); - await request(app.getHttpServer()).post(`/scenarios/${sc.id}/run`).expect(201); + await request(app.getHttpServer()) + .post(`/scenarios/${sc.id}/run`) + .expect(201); const res = await request(app.getHttpServer()) .get(`/scenarios/${sc.id}/runs`) @@ -341,17 +367,21 @@ describe('ScenarioController', () => { expect(Array.isArray(res.body.data[0].stepRuns)).toBe(true); }); - it('filters by status', async () => { + it("filters by status", async () => { const sc = await createScenario(); await createStep(sc.id, { order: 0 }); - await request(app.getHttpServer()).post(`/scenarios/${sc.id}/run`).expect(201); + await request(app.getHttpServer()) + .post(`/scenarios/${sc.id}/run`) + .expect(201); const pendingRes = await request(app.getHttpServer()) .get(`/scenarios/${sc.id}/runs?status=pending`) .expect(200); expect(pendingRes.body.total).toBeGreaterThanOrEqual(1); - pendingRes.body.data.forEach((r: any) => expect(r.status).toBe('pending')); + pendingRes.body.data.forEach((r: { status: string }) => + expect(r.status).toBe("pending"), + ); const passRes = await request(app.getHttpServer()) .get(`/scenarios/${sc.id}/runs?status=pass`) @@ -360,67 +390,84 @@ describe('ScenarioController', () => { expect(passRes.body.total).toBe(0); }); - it('returns 400 for invalid status filter', async () => { + it("returns 400 for invalid status filter", async () => { const sc = await createScenario(); await request(app.getHttpServer()) .get(`/scenarios/${sc.id}/runs?status=invalid`) .expect(400); }); - it('returns 404 for unknown scenario', async () => { - await request(app.getHttpServer()).get('/scenarios/99999/runs').expect(404); + it("returns 404 for unknown scenario", async () => { + await request(app.getHttpServer()) + .get("/scenarios/99999/runs") + .expect(404); }); }); // ── GET /scenarios/:id/export ───────────────────────────────────────────── - describe('GET /scenarios/:id/export', () => { - it('returns name and steps array', async () => { - const sc = await createScenario('export-me'); - await createStep(sc.id, { order: 0, type: 'login', sessionName: 's', execCode: '{"keyId":"k","environmentName":"e"}' }); - await createStep(sc.id, { order: 1, type: 'exec', sessionName: 's', execCode: 'return 1;', validateCode: 'return true;' }); + describe("GET /scenarios/:id/export", () => { + it("returns name and steps array", async () => { + const sc = await createScenario("export-me"); + await createStep(sc.id, { + order: 0, + type: "login", + sessionName: "s", + execCode: '{"keyId":"k","environmentName":"e"}', + }); + await createStep(sc.id, { + order: 1, + type: "exec", + sessionName: "s", + execCode: "return 1;", + validateCode: "return true;", + }); const res = await request(app.getHttpServer()) .get(`/scenarios/${sc.id}/export`) .expect(200); - expect(res.body.name).toBe('export-me'); + expect(res.body.name).toBe("export-me"); expect(Array.isArray(res.body.steps)).toBe(true); expect(res.body.steps).toHaveLength(2); }); - it('exports steps ordered by order field', async () => { - const sc = await createScenario('export-order'); - await createStep(sc.id, { order: 2, sessionName: 's' }); - await createStep(sc.id, { order: 0, sessionName: 's' }); - await createStep(sc.id, { order: 1, sessionName: 's' }); + it("exports steps ordered by order field", async () => { + const sc = await createScenario("export-order"); + await createStep(sc.id, { order: 2, sessionName: "s" }); + await createStep(sc.id, { order: 0, sessionName: "s" }); + await createStep(sc.id, { order: 1, sessionName: "s" }); const res = await request(app.getHttpServer()) .get(`/scenarios/${sc.id}/export`) .expect(200); - const orders = res.body.steps.map((s: any) => s.order); + const orders = res.body.steps.map((s: { order: number }) => s.order); expect(orders).toEqual([0, 1, 2]); }); - it('omits internal fields (id, scenarioId, timestamps)', async () => { - const sc = await createScenario('export-shape'); - await createStep(sc.id, { order: 0, sessionName: 's' }); + it("omits internal fields (id, scenarioId, timestamps)", async () => { + const sc = await createScenario("export-shape"); + await createStep(sc.id, { order: 0, sessionName: "s" }); const res = await request(app.getHttpServer()) .get(`/scenarios/${sc.id}/export`) .expect(200); const step = res.body.steps[0]; - expect(step).not.toHaveProperty('id'); - expect(step).not.toHaveProperty('scenarioId'); - expect(step).not.toHaveProperty('createdAt'); - expect(step).not.toHaveProperty('updatedAt'); + expect(step).not.toHaveProperty("id"); + expect(step).not.toHaveProperty("scenarioId"); + expect(step).not.toHaveProperty("createdAt"); + expect(step).not.toHaveProperty("updatedAt"); }); - it('exports null validateCode as null', async () => { - const sc = await createScenario('export-null-validate'); - await createStep(sc.id, { order: 0, sessionName: 's', execCode: 'return 1;' }); + it("exports null validateCode as null", async () => { + const sc = await createScenario("export-null-validate"); + await createStep(sc.id, { + order: 0, + sessionName: "s", + execCode: "return 1;", + }); const res = await request(app.getHttpServer()) .get(`/scenarios/${sc.id}/export`) @@ -429,99 +476,261 @@ describe('ScenarioController', () => { expect(res.body.steps[0].validateCode).toBeNull(); }); - it('returns 404 for unknown scenario', async () => { - await request(app.getHttpServer()).get('/scenarios/99999/export').expect(404); + it("returns 404 for unknown scenario", async () => { + await request(app.getHttpServer()) + .get("/scenarios/99999/export") + .expect(404); }); }); // ── POST /scenarios/import ──────────────────────────────────────────────── - describe('POST /scenarios/import', () => { - it('creates a new scenario with all steps', async () => { + describe("POST /scenarios/import", () => { + it("creates a new scenario with all steps", async () => { const payload = { - name: 'imported scenario', + name: "imported scenario", steps: [ - { order: 0, type: 'login', sessionName: 's', execCode: '{"keyId":"k","environmentName":"e"}', validateCode: null }, - { order: 1, type: 'exec', sessionName: 's', execCode: 'return 1;', validateCode: 'return true;' }, - { order: 2, type: 'sign', sessionName: 's', execCode: '{"keyId":"k"}', validateCode: null }, + { + order: 0, + type: "login", + sessionName: "s", + execCode: '{"keyId":"k","environmentName":"e"}', + validateCode: null, + }, + { + order: 1, + type: "exec", + sessionName: "s", + execCode: "return 1;", + validateCode: "return true;", + }, + { + order: 2, + type: "sign", + sessionName: "s", + execCode: '{"keyId":"k"}', + validateCode: null, + }, ], }; const res = await request(app.getHttpServer()) - .post('/scenarios/import') + .post("/scenarios/import") .send(payload) .expect(201); expect(res.body.id).toBeDefined(); - expect(res.body.name).toBe('imported scenario'); + expect(res.body.name).toBe("imported scenario"); expect(res.body.steps).toHaveLength(3); - expect(res.body.steps[0].type).toBe('login'); - expect(res.body.steps[1].type).toBe('exec'); - expect(res.body.steps[2].type).toBe('sign'); + expect(res.body.steps[0].type).toBe("login"); + expect(res.body.steps[1].type).toBe("exec"); + expect(res.body.steps[2].type).toBe("sign"); }); - it('assigns a new id (does not collide with source)', async () => { - const sc = await createScenario('original'); + it("assigns a new id (does not collide with source)", async () => { + const sc = await createScenario("original"); const exportRes = await request(app.getHttpServer()) .get(`/scenarios/${sc.id}/export`) .expect(200); const importRes = await request(app.getHttpServer()) - .post('/scenarios/import') + .post("/scenarios/import") .send(exportRes.body) .expect(201); expect(importRes.body.id).not.toBe(sc.id); }); - it('round-trips a scenario faithfully', async () => { - const sc = await createScenario('roundtrip'); - await createStep(sc.id, { order: 0, type: 'exec', sessionName: 'rs', execCode: 'return 42;', validateCode: 'return true;' }); + it("round-trips a scenario faithfully", async () => { + const sc = await createScenario("roundtrip"); + await createStep(sc.id, { + order: 0, + type: "exec", + sessionName: "rs", + execCode: "return 42;", + validateCode: "return true;", + }); const exportRes = await request(app.getHttpServer()) .get(`/scenarios/${sc.id}/export`) .expect(200); const importRes = await request(app.getHttpServer()) - .post('/scenarios/import') + .post("/scenarios/import") .send(exportRes.body) .expect(201); - expect(importRes.body.name).toBe('roundtrip'); + expect(importRes.body.name).toBe("roundtrip"); expect(importRes.body.steps).toHaveLength(1); - expect(importRes.body.steps[0].execCode).toBe(exportRes.body.steps[0].execCode); - expect(importRes.body.steps[0].validateCode).toBe(exportRes.body.steps[0].validateCode); + expect(importRes.body.steps[0].execCode).toBe( + exportRes.body.steps[0].execCode, + ); + expect(importRes.body.steps[0].validateCode).toBe( + exportRes.body.steps[0].validateCode, + ); }); - it('imports with empty steps array', async () => { + it("imports with empty steps array", async () => { const res = await request(app.getHttpServer()) - .post('/scenarios/import') - .send({ name: 'empty-import', steps: [] }) + .post("/scenarios/import") + .send({ name: "empty-import", steps: [] }) .expect(201); - expect(res.body.name).toBe('empty-import'); + expect(res.body.name).toBe("empty-import"); expect(res.body.steps).toHaveLength(0); }); - it('returns 400 when name is missing', async () => { + it("returns 400 when name is missing", async () => { await request(app.getHttpServer()) - .post('/scenarios/import') + .post("/scenarios/import") .send({ steps: [] }) .expect(400); }); - it('returns 400 when steps is not an array', async () => { + it("returns 400 when steps is not an array", async () => { await request(app.getHttpServer()) - .post('/scenarios/import') - .send({ name: 'bad', steps: 'oops' }) + .post("/scenarios/import") + .send({ name: "bad", steps: "oops" }) .expect(400); }); - it('returns 400 when a step has an invalid type', async () => { + it("returns 400 when a step has an invalid type", async () => { await request(app.getHttpServer()) - .post('/scenarios/import') - .send({ name: 'bad-type', steps: [{ order: 0, type: 'unknown', sessionName: 's' }] }) + .post("/scenarios/import") + .send({ + name: "bad-type", + steps: [{ order: 0, type: "unknown", sessionName: "s" }], + }) .expect(400); }); }); + + // ── GET /scenarios/:id/run/:runId ───────────────────────────────────────── + + describe("GET /scenarios/:id/run/:runId", () => { + it("returns run with stepRuns (with scenarioStep) and logs array", async () => { + const sc = await createScenario(); + await createStep(sc.id, { order: 0 }); + const runRes = await request(app.getHttpServer()) + .post(`/scenarios/${sc.id}/run`) + .expect(201); + const runId = runRes.body.id; + + const res = await request(app.getHttpServer()) + .get(`/scenarios/${sc.id}/run/${runId}`) + .expect(200); + + expect(res.body.id).toBe(runId); + expect(res.body.status).toBe("pending"); + expect(Array.isArray(res.body.stepRuns)).toBe(true); + expect(res.body.stepRuns[0]).toHaveProperty("scenarioStep"); + expect(Array.isArray(res.body.logs)).toBe(true); + }); + + it("stepRuns are ordered by order ASC", async () => { + const sc = await createScenario(); + await createStep(sc.id, { order: 0 }); + await createStep(sc.id, { order: 1 }); + await createStep(sc.id, { order: 2 }); + const runRes = await request(app.getHttpServer()) + .post(`/scenarios/${sc.id}/run`) + .expect(201); + const runId = runRes.body.id; + + const res = await request(app.getHttpServer()) + .get(`/scenarios/${sc.id}/run/${runId}`) + .expect(200); + + const orders = res.body.stepRuns.map((s: { order: number }) => s.order); + expect(orders).toEqual([0, 1, 2]); + }); + + it("returns 404 for unknown run", async () => { + const sc = await createScenario(); + await request(app.getHttpServer()) + .get(`/scenarios/${sc.id}/run/99999`) + .expect(404); + }); + + it("returns 404 when run belongs to a different scenario", async () => { + const sc1 = await createScenario(); + const sc2 = await createScenario(); + await createStep(sc1.id, { order: 0 }); + const runRes = await request(app.getHttpServer()) + .post(`/scenarios/${sc1.id}/run`) + .expect(201); + const runId = runRes.body.id; + + await request(app.getHttpServer()) + .get(`/scenarios/${sc2.id}/run/${runId}`) + .expect(404); + }); + + it("returns 404 for unknown scenario", async () => { + await request(app.getHttpServer()) + .get("/scenarios/99999/run/1") + .expect(404); + }); + }); + + // ── POST /scenarios/:id/run/:runId/wait ─────────────────────────────────── + + describe("POST /scenarios/:id/run/:runId/wait", () => { + it("returns 200 with run data immediately when run is already terminal", async () => { + const sc = await createScenario(); + await createStep(sc.id, { order: 0 }); + const runRes = await request(app.getHttpServer()) + .post(`/scenarios/${sc.id}/run`) + .expect(201); + const runId = runRes.body.id; + + // Manually mark run as pass so wait resolves immediately + const dataSource = app.get(DataSource); + await dataSource.query( + `UPDATE scenario_runs SET status='pass' WHERE id=${runId}`, + ); + + const res = await request(app.getHttpServer()) + .post(`/scenarios/${sc.id}/run/${runId}/wait`) + .expect(200); + + expect(res.body.id).toBe(runId); + expect(res.body.status).toBe("pass"); + expect(Array.isArray(res.body.logs)).toBe(true); + expect(Array.isArray(res.body.stepRuns)).toBe(true); + }); + + it("returns the run in fail state when it has failed", async () => { + const sc = await createScenario(); + await createStep(sc.id, { order: 0 }); + const runRes = await request(app.getHttpServer()) + .post(`/scenarios/${sc.id}/run`) + .expect(201); + const runId = runRes.body.id; + + const dataSource = app.get(DataSource); + await dataSource.query( + `UPDATE scenario_runs SET status='fail' WHERE id=${runId}`, + ); + + const res = await request(app.getHttpServer()) + .post(`/scenarios/${sc.id}/run/${runId}/wait`) + .expect(200); + + expect(res.body.status).toBe("fail"); + }); + + it("returns 404 for unknown run", async () => { + const sc = await createScenario(); + await request(app.getHttpServer()) + .post(`/scenarios/${sc.id}/run/99999/wait`) + .expect(404); + }); + + it("returns 404 for unknown scenario", async () => { + await request(app.getHttpServer()) + .post("/scenarios/99999/run/1/wait") + .expect(404); + }); + }); }); diff --git a/test/session.controller.spec.ts b/test/session.controller.spec.ts index 3f3c457..f8f0a68 100644 --- a/test/session.controller.spec.ts +++ b/test/session.controller.spec.ts @@ -1,17 +1,19 @@ -import { INestApplication } from '@nestjs/common'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import request from 'supertest'; -import { buildTestApp } from './app.harness'; -import { SessionEntity } from '../src/session/session.entity'; +import { INestApplication } from "@nestjs/common"; +import { getRepositoryToken } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import request from "supertest"; +import { buildTestApp } from "./app.harness"; +import { SessionEntity } from "../src/session/session.entity"; -describe('SessionController', () => { +describe("SessionController", () => { let app: INestApplication; let repo: Repository; beforeAll(async () => { app = await buildTestApp(); - repo = app.get>(getRepositoryToken(SessionEntity)); + repo = app.get>( + getRepositoryToken(SessionEntity), + ); }); afterAll(async () => { @@ -22,98 +24,120 @@ describe('SessionController', () => { return repo.save( repo.create({ sessionName: name, - token: 'tok', - cookies: '[]', - localStorage: '{}', + token: "tok", + cookies: "[]", + localStorage: "{}", }), ); } // ── GET /sessions ────────────────────────────────────────────────────────── - describe('GET /sessions', () => { - it('returns 200 with a paginated result', async () => { - const res = await request(app.getHttpServer()).get('/sessions').expect(200); + describe("GET /sessions", () => { + it("returns 200 with a paginated result", async () => { + const res = await request(app.getHttpServer()) + .get("/sessions") + .expect(200); expect(Array.isArray(res.body.data)).toBe(true); - expect(typeof res.body.total).toBe('number'); - expect(res.body).toHaveProperty('page'); - expect(res.body).toHaveProperty('limit'); + expect(typeof res.body.total).toBe("number"); + expect(res.body).toHaveProperty("page"); + expect(res.body).toHaveProperty("limit"); }); - it('includes seeded sessions', async () => { - await seedSession('visible-session'); - const res = await request(app.getHttpServer()).get('/sessions').expect(200); - const names = res.body.data.map((s: any) => s.sessionName); - expect(names).toContain('visible-session'); + it("includes seeded sessions", async () => { + await seedSession("visible-session"); + const res = await request(app.getHttpServer()) + .get("/sessions") + .expect(200); + const names = res.body.data.map((s: { sessionName: string }) => s.sessionName); + expect(names).toContain("visible-session"); }); - it('does not expose token, cookies or localStorage fields', async () => { - await seedSession('private-session'); - const res = await request(app.getHttpServer()).get('/sessions').expect(200); - const item = res.body.data.find((s: any) => s.sessionName === 'private-session'); + it("does not expose token, cookies or localStorage fields", async () => { + await seedSession("private-session"); + const res = await request(app.getHttpServer()) + .get("/sessions") + .expect(200); + const item = res.body.data.find( + (s: { sessionName: string }) => s.sessionName === "private-session", + ) as Record; expect(item).toBeDefined(); expect(item.token).toBeUndefined(); expect(item.cookies).toBeUndefined(); expect(item.localStorage).toBeUndefined(); }); - it('respects page and limit params', async () => { - await seedSession('paged-session-a'); - await seedSession('paged-session-b'); + it("respects page and limit params", async () => { + await seedSession("paged-session-a"); + await seedSession("paged-session-b"); - const res = await request(app.getHttpServer()).get('/sessions?page=1&limit=1').expect(200); + const res = await request(app.getHttpServer()) + .get("/sessions?page=1&limit=1") + .expect(200); expect(res.body.data).toHaveLength(1); expect(res.body.page).toBe(1); expect(res.body.limit).toBe(1); }); - it('returns empty data array for out-of-range page', async () => { - const res = await request(app.getHttpServer()).get('/sessions?page=9999&limit=20').expect(200); + it("returns empty data array for out-of-range page", async () => { + const res = await request(app.getHttpServer()) + .get("/sessions?page=9999&limit=20") + .expect(200); expect(res.body.data).toHaveLength(0); }); - it('returns 400 for invalid page param', async () => { - await request(app.getHttpServer()).get('/sessions?page=0').expect(400); + it("returns 400 for invalid page param", async () => { + await request(app.getHttpServer()).get("/sessions?page=0").expect(400); }); - it('orders by sessionName ASC', async () => { - await seedSession('zzz-sort-session'); - await seedSession('aaa-sort-session'); + it("orders by sessionName ASC", async () => { + await seedSession("zzz-sort-session"); + await seedSession("aaa-sort-session"); - const res = await request(app.getHttpServer()).get('/sessions?orderBy=sessionName&orderDir=ASC').expect(200); - const names: string[] = res.body.data.map((s: any) => s.sessionName); + const res = await request(app.getHttpServer()) + .get("/sessions?orderBy=sessionName&orderDir=ASC") + .expect(200); + const names: string[] = res.body.data.map((s: { sessionName: string }) => s.sessionName); expect(names).toEqual([...names].sort()); }); - it('orders by sessionName DESC', async () => { - const res = await request(app.getHttpServer()).get('/sessions?orderBy=sessionName&orderDir=DESC').expect(200); - const names: string[] = res.body.data.map((s: any) => s.sessionName); + it("orders by sessionName DESC", async () => { + const res = await request(app.getHttpServer()) + .get("/sessions?orderBy=sessionName&orderDir=DESC") + .expect(200); + const names: string[] = res.body.data.map((s: { sessionName: string }) => s.sessionName); expect(names).toEqual([...names].sort().reverse()); }); - it('returns 400 for invalid orderDir', async () => { - await request(app.getHttpServer()).get('/sessions?orderDir=SIDEWAYS').expect(400); + it("returns 400 for invalid orderDir", async () => { + await request(app.getHttpServer()) + .get("/sessions?orderDir=SIDEWAYS") + .expect(400); }); }); // ── DELETE /sessions/:id ─────────────────────────────────────────────────── - describe('DELETE /sessions/:id', () => { - it('deletes an existing session and returns 200', async () => { - const s = await seedSession('delete-me-session'); - await request(app.getHttpServer()).delete(`/sessions/${s.id}`).expect(200); + describe("DELETE /sessions/:id", () => { + it("deletes an existing session and returns 200", async () => { + const s = await seedSession("delete-me-session"); + await request(app.getHttpServer()) + .delete(`/sessions/${s.id}`) + .expect(200); - const res = await request(app.getHttpServer()).get('/sessions').expect(200); - const names = res.body.data.map((sess: any) => sess.sessionName); - expect(names).not.toContain('delete-me-session'); + const res = await request(app.getHttpServer()) + .get("/sessions") + .expect(200); + const names = res.body.data.map((sess: { sessionName: string }) => sess.sessionName); + expect(names).not.toContain("delete-me-session"); }); - it('returns 404 for unknown id', async () => { - await request(app.getHttpServer()).delete('/sessions/99999').expect(404); + it("returns 404 for unknown id", async () => { + await request(app.getHttpServer()).delete("/sessions/99999").expect(404); }); - it('returns 400 for non-numeric id', async () => { - await request(app.getHttpServer()).delete('/sessions/abc').expect(400); + it("returns 400 for non-numeric id", async () => { + await request(app.getHttpServer()).delete("/sessions/abc").expect(400); }); }); });