feat(scenario): run logs, lint/format tooling, CONTRIBUTING

- add ScenarioRunLogEntity to persist step script output to DB
- stepLogger dual-writes to NestJS logger and DB (fire-and-forget)
- add GET /scenarios/:id/run/:runId returning run, stepRuns and logs
- add POST /scenarios/:id/run/:runId/wait (polls until terminal state)
- 9 new integration tests for the two endpoints (136 total)
- add eslint with typescript-eslint and eslint-config-prettier
- add npm scripts: format, lint, lint:fix
- resolve all lint errors across src and test (no any types)
- add CONTRIBUTING.md covering dev workflow
This commit is contained in:
2026-04-08 18:10:42 +03:00
parent b0c02a1cdc
commit 9a9ccbfe37
67 changed files with 3809 additions and 1412 deletions
+133
View File
@@ -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
```
+8
View File
@@ -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,
);
+1063 -1
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -8,6 +8,9 @@
"start": "nest start", "start": "nest start",
"start:dev": "nest start --watch", "start:dev": "nest start --watch",
"start:prod": "node dist/main", "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": "jest",
"test:debug": "DEBUG=test jest", "test:debug": "DEBUG=test jest",
"test:watch": "jest --watch" "test:watch": "jest --watch"
@@ -50,9 +53,13 @@
"@types/node": "^25.5.2", "@types/node": "^25.5.2",
"@types/supertest": "^7.2.0", "@types/supertest": "^7.2.0",
"debug": "^4.4.3", "debug": "^4.4.3",
"eslint": "^10.2.0",
"eslint-config-prettier": "^10.1.8",
"jest": "^30.3.0", "jest": "^30.3.0",
"prettier": "^3.8.1",
"supertest": "^7.2.2", "supertest": "^7.2.2",
"ts-jest": "^29.4.9", "ts-jest": "^29.4.9",
"typescript": "^6.0.2" "typescript": "^6.0.2",
"typescript-eslint": "^8.58.1"
} }
} }
+29 -20
View File
@@ -1,33 +1,42 @@
import { Module } from '@nestjs/common'; import { Module } from "@nestjs/common";
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from "@nestjs/config";
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from "@nestjs/typeorm";
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from "@nestjs/schedule";
import { HealthModule } from './health/health.module'; import { HealthModule } from "./health/health.module";
import { AuthModule } from './auth/auth.module'; import { AuthModule } from "./auth/auth.module";
import { BrowserModule } from './browser/browser.module'; import { BrowserModule } from "./browser/browser.module";
import { SessionEntity } from './session/session.entity'; import { SessionEntity } from "./session/session.entity";
import { EnvironmentEntity } from './environment/environment.entity'; import { EnvironmentEntity } from "./environment/environment.entity";
import { EnvironmentModule } from './environment/environment.module'; import { EnvironmentModule } from "./environment/environment.module";
import { McpModule } from './mcp/mcp.module'; import { McpModule } from "./mcp/mcp.module";
import { ScenarioEntity } from './scenario/scenario.entity'; import { ScenarioEntity } from "./scenario/scenario.entity";
import { ScenarioStepEntity } from './scenario/scenario-step.entity'; import { ScenarioStepEntity } from "./scenario/scenario-step.entity";
import { ScenarioRunEntity } from './scenario/scenario-run.entity'; import { ScenarioRunEntity } from "./scenario/scenario-run.entity";
import { ScenarioRunStepEntity } from './scenario/scenario-run-step.entity'; import { ScenarioRunStepEntity } from "./scenario/scenario-run-step.entity";
import { ScenarioModule } from './scenario/scenario.module'; import { ScenarioRunLogEntity } from "./scenario/scenario-run-log.entity";
import { ScenarioModule } from "./scenario/scenario.module";
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ ConfigModule.forRoot({
isGlobal: true, isGlobal: true,
envFilePath: '.env', envFilePath: ".env",
}), }),
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
TypeOrmModule.forRootAsync({ TypeOrmModule.forRootAsync({
inject: [ConfigService], inject: [ConfigService],
useFactory: (config: ConfigService) => ({ useFactory: (config: ConfigService) => ({
type: 'better-sqlite3', type: "better-sqlite3",
database: config.get<string>('DB_PATH', 'data/sessions.db'), database: config.get<string>("DB_PATH", "data/sessions.db"),
entities: [SessionEntity, EnvironmentEntity, ScenarioEntity, ScenarioStepEntity, ScenarioRunEntity, ScenarioRunStepEntity], entities: [
SessionEntity,
EnvironmentEntity,
ScenarioEntity,
ScenarioStepEntity,
ScenarioRunEntity,
ScenarioRunStepEntity,
ScenarioRunLogEntity,
],
synchronize: true, synchronize: true,
}), }),
}), }),
+40 -15
View File
@@ -1,26 +1,51 @@
import { Body, Controller, Get, Post } from '@nestjs/common'; import { Body, Controller, Get, Post } from "@nestjs/common";
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { AuthService } from './auth.service'; import { AuthService } from "./auth.service";
import { LoginDto } from './dto/login.dto'; import { LoginDto } from "./dto/login.dto";
@ApiTags('auth') @ApiTags("auth")
@Controller() @Controller()
export class AuthController { export class AuthController {
constructor(private readonly authService: AuthService) {} constructor(private readonly authService: AuthService) {}
@Get('keys') @Get("keys")
@ApiOperation({ summary: 'List available key identifiers from the keys directory' }) @ApiOperation({
@ApiResponse({ status: 200, description: 'List of key names', schema: { properties: { keys: { type: 'array', items: { type: 'string' } } } } }) 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[] } { listKeys(): { keys: string[] } {
return { keys: this.authService.listKeys() }; return { keys: this.authService.listKeys() };
} }
@Post('login') @Post("login")
@ApiOperation({ summary: 'Log in using a file key and return the session token' }) @ApiOperation({
@ApiResponse({ status: 201, description: 'Login successful', schema: { properties: { token: { type: 'string' }, sessionName: { type: 'string' } } } }) summary: "Log in using a file key and return the session token",
@ApiResponse({ status: 400, description: 'Key not found or invalid' }) })
@ApiResponse({ status: 500, description: 'Automation failed' }) @ApiResponse({
login(@Body() dto: LoginDto): Promise<{ token: string; sessionName: string }> { status: 201,
return this.authService.login(dto.key, dto.environmentName, dto.sessionName); 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,
);
} }
} }
+5 -5
View File
@@ -1,8 +1,8 @@
import { Module } from '@nestjs/common'; import { Module } from "@nestjs/common";
import { AuthController } from './auth.controller'; import { AuthController } from "./auth.controller";
import { AuthService } from './auth.service'; import { AuthService } from "./auth.service";
import { SessionModule } from '../session/session.module'; import { SessionModule } from "../session/session.module";
import { EnvironmentModule } from '../environment/environment.module'; import { EnvironmentModule } from "../environment/environment.module";
@Module({ @Module({
imports: [SessionModule, EnvironmentModule], imports: [SessionModule, EnvironmentModule],
+93 -45
View File
@@ -3,16 +3,16 @@ import {
BadRequestException, BadRequestException,
InternalServerErrorException, InternalServerErrorException,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from "@nestjs/common";
import { TraceLogger } from '../common/trace-logger'; import { TraceLogger } from "../common/trace-logger";
import { ConfigService } from '@nestjs/config'; import { ConfigService } from "@nestjs/config";
import { chromium } from 'playwright'; import { chromium } from "playwright";
import type { Page } from 'playwright'; import type { Page } from "playwright";
import * as fs from 'fs'; import * as fs from "fs";
import * as path from 'path'; import * as path from "path";
import * as crypto from 'crypto'; import * as crypto from "crypto";
import { SessionService } from '../session/session.service'; import { SessionService } from "../session/session.service";
import { EnvironmentService } from '../environment/environment.service'; import { EnvironmentService } from "../environment/environment.service";
interface KeyDescriptor { interface KeyDescriptor {
keyFile?: string; keyFile?: string;
@@ -30,16 +30,15 @@ export class AuthService {
private readonly sessionService: SessionService, private readonly sessionService: SessionService,
private readonly environmentService: EnvironmentService, private readonly environmentService: EnvironmentService,
) { ) {
this.keysDir = path.resolve( this.keysDir = path.resolve(this.config.get<string>("KEYS_DIR", "keys"));
this.config.get<string>('KEYS_DIR', 'keys'),
);
} }
listKeys(): string[] { listKeys(): string[] {
if (!fs.existsSync(this.keysDir)) return []; if (!fs.existsSync(this.keysDir)) return [];
return fs.readdirSync(this.keysDir) return fs
.filter(f => f.endsWith('.json')) .readdirSync(this.keysDir)
.map(f => path.basename(f, '.json')); .filter((f) => f.endsWith(".json"))
.map((f) => path.basename(f, ".json"));
} }
private loadKeyDescriptor(keyId: string): KeyDescriptor { private loadKeyDescriptor(keyId: string): KeyDescriptor {
@@ -48,51 +47,73 @@ export class AuthService {
throw new BadRequestException(`Key not found: ${keyId}`); throw new BadRequestException(`Key not found: ${keyId}`);
} }
try { try {
return JSON.parse(fs.readFileSync(keyJsonPath, 'utf-8')) as KeyDescriptor; return JSON.parse(fs.readFileSync(keyJsonPath, "utf-8")) as KeyDescriptor;
} catch (err) { } 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 resolvedSession = sessionName ?? crypto.randomUUID();
const env = await this.environmentService.findAll().then(({ data }) => data.find(e => e.name === environmentName)); const env = await this.environmentService
if (!env) throw new NotFoundException(`Environment "${environmentName}" not found`); .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 loginUrl = env.urls.id_url;
const cabinetUrl = env.urls.cabinet_url; const cabinetUrl = env.urls.cabinet_url;
if (!loginUrl) throw new BadRequestException(`Environment "${environmentName}" is missing id_url`); if (!loginUrl)
if (!cabinetUrl) throw new BadRequestException(`Environment "${environmentName}" is missing cabinet_url`); 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 descriptor = this.loadKeyDescriptor(keyId);
const useLoginPassword = !!descriptor.login; const useLoginPassword = !!descriptor.login;
if (!useLoginPassword) { if (!useLoginPassword) {
if (!descriptor.keyFile) { 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); const keyFilePath = path.resolve(this.keysDir, descriptor.keyFile);
if (!fs.existsSync(keyFilePath)) { 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 { try {
const context = await browser.newContext(); const context = await browser.newContext();
const page = await context.newPage(); const page = await context.newPage();
this.logger.log(`Navigating to ${loginUrl}`); this.logger.log(`Navigating to ${loginUrl}`);
await page.goto(loginUrl, { waitUntil: 'networkidle' }); await page.goto(loginUrl, { waitUntil: "networkidle" });
if (useLoginPassword) { if (useLoginPassword) {
// Click "Логін і пароль" auth method // Click "Логін і пароль" auth method
await page.locator('p[aria-label="Логін і пароль"]').click(); await page.locator('p[aria-label="Логін і пароль"]').click();
// Fill login and password // Fill login and password
await page.getByLabel('Електронна пошта').fill(descriptor.login!); await page.getByLabel("Електронна пошта").fill(descriptor.login!);
await page.getByLabel('Пароль').fill(descriptor.password); await page.getByLabel("Пароль").fill(descriptor.password);
// Click "Увійти" // Click "Увійти"
await page.locator('button:has-text("Увійти")').click(); await page.locator('button:has-text("Увійти")').click();
@@ -100,19 +121,21 @@ export class AuthService {
const keyFilePath = path.resolve(this.keysDir, descriptor.keyFile!); const keyFilePath = path.resolve(this.keysDir, descriptor.keyFile!);
// Click "Файловий ключ" button // Click "Файловий ключ" button
await page.getByText('Файловий ключ').click(); await page.getByText("Файловий ключ").click();
// Upload key file via hidden file input // 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); await fileInput.setInputFiles(keyFilePath);
// Enter password // Enter password
await page await page
.locator('#id-app-login-file-key-password') .locator("#id-app-login-file-key-password")
.fill(descriptor.password); .fill(descriptor.password);
// Click "Продовжити" // 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 // Wait until redirected to cabinet
@@ -120,11 +143,11 @@ export class AuthService {
await page.waitForURL(cabinetUrl, { timeout: 30000 }); await page.waitForURL(cabinetUrl, { timeout: 30000 });
// Extract token from localStorage // Extract token from localStorage
const token = await page.evaluate(() => localStorage.getItem('token')); const token = await page.evaluate(() => localStorage.getItem("token"));
if (!token) { if (!token) {
throw new InternalServerErrorException( 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<string, string> = {}; const entries: Record<string, string> = {};
for (let i = 0; i < window.localStorage.length; i++) { for (let i = 0; i < window.localStorage.length; i++) {
const k = window.localStorage.key(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; 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 }; return { token, sessionName: resolvedSession };
} catch (err) { } catch (err) {
if ( if (
@@ -164,26 +194,40 @@ export class AuthService {
const descriptor = this.loadKeyDescriptor(keyId); const descriptor = this.loadKeyDescriptor(keyId);
if (!descriptor.keyFile) { 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); const keyFilePath = path.resolve(this.keysDir, descriptor.keyFile);
if (!fs.existsSync(keyFilePath)) { 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()}`); this.logger.log(`Signing with key ${keyId} on page: ${page.url()}`);
// Open the EDS sign widget // 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); await page.waitForTimeout(500);
// Select the file key tab inside the widget // 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); await page.waitForTimeout(300);
// Upload the key file // 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 fileInput.setInputFiles(keyFilePath);
await page.waitForTimeout(300); await page.waitForTimeout(300);
@@ -192,8 +236,12 @@ export class AuthService {
await page.waitForTimeout(200); await page.waitForTimeout(200);
// Submit // Submit
await page.locator('button').filter({ hasText: /підпис|sign|підтвер/i }).last().click(); await page
await page.waitForLoadState('networkidle'); .locator("button")
.filter({ hasText: /підпис|sign|підтвер/i })
.last()
.click();
await page.waitForLoadState("networkidle");
this.logger.log(`Sign completed for key ${keyId}`); this.logger.log(`Sign completed for key ${keyId}`);
} }
+10 -8
View File
@@ -1,26 +1,28 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { IsNotEmpty, IsOptional, IsString } from "class-validator";
export class LoginDto { export class LoginDto {
@ApiProperty({ @ApiProperty({
description: 'Key identifier — filename (without extension) from the keys/ directory', description:
example: '3273334361', "Key identifier — filename (without extension) from the keys/ directory",
example: "3273334361",
}) })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
key: string; key: string;
@ApiProperty({ @ApiProperty({
description: 'Environment name to resolve login/cabinet URLs from', description: "Environment name to resolve login/cabinet URLs from",
example: 'liquio-diia-stg', example: "liquio-diia-stg",
}) })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
environmentName: string; environmentName: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Session name to store credentials under. Auto-generated UUID if omitted.', description:
example: 'my-test-session', "Session name to store credentials under. Auto-generated UUID if omitted.",
example: "my-test-session",
}) })
@IsOptional() @IsOptional()
@IsString() @IsString()
+32 -24
View File
@@ -1,11 +1,11 @@
import { Body, Controller, Post } from '@nestjs/common'; import { Body, Controller, Post } from "@nestjs/common";
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { BrowserService, ExecResult, OpenResult } from './browser.service'; import { BrowserService, ExecResult, OpenResult } from "./browser.service";
import { CodeExecutorService } from '../code-executor/code-executor.service'; import { CodeExecutorService } from "../code-executor/code-executor.service";
import { OpenDto } from './dto/open.dto'; import { OpenDto } from "./dto/open.dto";
import { ExecDto } from './dto/exec.dto'; import { ExecDto } from "./dto/exec.dto";
@ApiTags('browser') @ApiTags("browser")
@Controller() @Controller()
export class BrowserController { export class BrowserController {
constructor( constructor(
@@ -13,39 +13,47 @@ export class BrowserController {
private readonly codeExecutor: CodeExecutorService, private readonly codeExecutor: CodeExecutorService,
) {} ) {}
@Post('open') @Post("open")
@ApiOperation({ summary: 'Open a URL with a stored session (cookies + localStorage)' }) @ApiOperation({
summary: "Open a URL with a stored session (cookies + localStorage)",
})
@ApiResponse({ @ApiResponse({
status: 201, status: 201,
description: 'Page loaded successfully', description: "Page loaded successfully",
schema: { schema: {
properties: { properties: {
url: { type: 'string' }, url: { type: "string" },
title: { type: 'string' }, title: { type: "string" },
content: { type: 'string' }, content: { type: "string" },
}, },
}, },
}) })
@ApiResponse({ status: 400, description: 'Invalid input' }) @ApiResponse({ status: 400, description: "Invalid input" })
@ApiResponse({ status: 404, description: 'Session not found' }) @ApiResponse({ status: 404, description: "Session not found" })
@ApiResponse({ status: 500, description: 'Browser automation failed' }) @ApiResponse({ status: 500, description: "Browser automation failed" })
open(@Body() dto: OpenDto): Promise<OpenResult> { open(@Body() dto: OpenDto): Promise<OpenResult> {
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({ @ApiOperation({
summary: 'Execute custom Playwright JavaScript within a stored session', 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`.', 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({ @ApiResponse({
status: 201, status: 201,
description: 'Code executed successfully', description: "Code executed successfully",
schema: { properties: { result: {} } }, schema: { properties: { result: {} } },
}) })
@ApiResponse({ status: 400, description: 'Invalid input' }) @ApiResponse({ status: 400, description: "Invalid input" })
@ApiResponse({ status: 404, description: 'Session not found' }) @ApiResponse({ status: 404, description: "Session not found" })
@ApiResponse({ status: 500, description: 'Execution failed' }) @ApiResponse({ status: 500, description: "Execution failed" })
exec(@Body() dto: ExecDto): Promise<ExecResult> { exec(@Body() dto: ExecDto): Promise<ExecResult> {
this.codeExecutor.validate(dto.code); this.codeExecutor.validate(dto.code);
return this.browserService.exec(dto.sessionName, dto.code, dto.url); return this.browserService.exec(dto.sessionName, dto.code, dto.url);
+5 -5
View File
@@ -1,8 +1,8 @@
import { Module } from '@nestjs/common'; import { Module } from "@nestjs/common";
import { BrowserController } from './browser.controller'; import { BrowserController } from "./browser.controller";
import { BrowserService } from './browser.service'; import { BrowserService } from "./browser.service";
import { SessionModule } from '../session/session.module'; import { SessionModule } from "../session/session.module";
import { CodeExecutorModule } from '../code-executor/code-executor.module'; import { CodeExecutorModule } from "../code-executor/code-executor.module";
@Module({ @Module({
imports: [SessionModule, CodeExecutorModule], imports: [SessionModule, CodeExecutorModule],
+52 -27
View File
@@ -3,17 +3,17 @@ import {
HttpException, HttpException,
InternalServerErrorException, InternalServerErrorException,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from "@nestjs/common";
import { TraceLogger } from '../common/trace-logger'; import { TraceLogger } from "../common/trace-logger";
import { chromium } from 'playwright'; import { chromium } from "playwright";
import type { BrowserContext } from 'playwright'; import type { BrowserContext, Cookie } from "playwright";
import { Readability } from '@mozilla/readability'; import { Readability } from "@mozilla/readability";
import { JSDOM } from 'jsdom'; import { JSDOM } from "jsdom";
import { SessionService } from '../session/session.service'; import { SessionService } from "../session/session.service";
import { CodeExecutorService } from '../code-executor/code-executor.service'; import { CodeExecutorService } from "../code-executor/code-executor.service";
import type { ExecResult } 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 { export interface OpenResult {
url: string; url: string;
@@ -30,19 +30,25 @@ export class BrowserService {
private readonly codeExecutor: CodeExecutorService, private readonly codeExecutor: CodeExecutorService,
) {} ) {}
private async setupSession(context: BrowserContext, sessionName: string | undefined): Promise<void> { private async setupSession(
context: BrowserContext,
sessionName: string | undefined,
): Promise<void> {
if (!sessionName) return; if (!sessionName) return;
const session = await this.sessionService.findBySessionName(sessionName); const session = await this.sessionService.findBySessionName(sessionName);
if (!session) { if (!session) {
throw new NotFoundException(`Session not found: ${sessionName}`); throw new NotFoundException(`Session not found: ${sessionName}`);
} }
let cookies: any[]; let cookies: Cookie[];
let localStorageData: Record<string, string>; let localStorageData: Record<string, string>;
try { try {
cookies = JSON.parse(session.cookies); cookies = JSON.parse(session.cookies);
localStorageData = JSON.parse(session.localStorage); localStorageData = JSON.parse(session.localStorage);
} catch (err) { } 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.addCookies(cookies);
await context.addInitScript((entries: Record<string, string>) => { await context.addInitScript((entries: Record<string, string>) => {
@@ -62,16 +68,24 @@ export class BrowserService {
); );
} }
async open(sessionName: string | undefined, url: string, readerMode = false, selector?: string): Promise<OpenResult> { async open(
const label = sessionName ?? 'anonymous'; sessionName: string | undefined,
const browser = await chromium.launch({ headless: true, executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH }); url: string,
readerMode = false,
selector?: string,
): Promise<OpenResult> {
const label = sessionName ?? "anonymous";
const browser = await chromium.launch({
headless: true,
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
});
try { try {
const context = await browser.newContext(); const context = await browser.newContext();
await this.setupSession(context, sessionName); await this.setupSession(context, sessionName);
const page = await context.newPage(); const page = await context.newPage();
this.logger.log(`[${label}] Opening ${url}`); this.logger.log(`[${label}] Opening ${url}`);
await page.goto(url, { waitUntil: 'networkidle' }); await page.goto(url, { waitUntil: "networkidle" });
const finalUrl = page.url(); const finalUrl = page.url();
const title = await page.title(); const title = await page.title();
@@ -82,28 +96,39 @@ export class BrowserService {
const dom = new JSDOM(rawHtml, { url: finalUrl }); const dom = new JSDOM(rawHtml, { url: finalUrl });
const el = dom.window.document.querySelector(selector); const el = dom.window.document.querySelector(selector);
content = readerMode content = readerMode
? (el?.textContent?.replace(/\s+/g, ' ').trim() ?? '') ? (el?.textContent?.replace(/\s+/g, " ").trim() ?? "")
: (el?.outerHTML ?? ''); : (el?.outerHTML ?? "");
} else if (readerMode) { } else if (readerMode) {
const dom = new JSDOM(rawHtml, { url: finalUrl }); const dom = new JSDOM(rawHtml, { url: finalUrl });
const article = new Readability(dom.window.document).parse(); 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 { } else {
content = rawHtml; 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 }; return { url: finalUrl, title, content };
} catch (err) { } catch (err) {
this.rethrow(err, label, 'open'); this.rethrow(err, label, "open");
} finally { } finally {
await browser.close(); await browser.close();
} }
} }
async exec(sessionName: string | undefined, code: string, url?: string): Promise<ExecResult> { async exec(
const label = sessionName ?? 'anonymous'; sessionName: string | undefined,
const browser = await chromium.launch({ headless: true, executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH }); code: string,
url?: string,
): Promise<ExecResult> {
const label = sessionName ?? "anonymous";
const browser = await chromium.launch({
headless: true,
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
});
try { try {
const context = await browser.newContext(); const context = await browser.newContext();
await this.setupSession(context, sessionName); await this.setupSession(context, sessionName);
@@ -111,7 +136,7 @@ export class BrowserService {
if (url) { if (url) {
this.logger.log(`[${label}] exec: navigating to ${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`); this.logger.log(`[${label}] exec: running user code`);
@@ -120,7 +145,7 @@ export class BrowserService {
this.logger.log(`[${label}] exec: done`); this.logger.log(`[${label}] exec: done`);
return result; return result;
} catch (err) { } catch (err) {
this.rethrow(err, label, 'exec'); this.rethrow(err, label, "exec");
} finally { } finally {
await browser.close(); await browser.close();
} }
+11 -8
View File
@@ -1,26 +1,29 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsOptional, IsString, IsUrl } from 'class-validator'; import { IsOptional, IsString, IsUrl } from "class-validator";
export class ExecDto { export class ExecDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Session name previously created by POST /login. If omitted, executes without a stored session.', description:
example: 'test-session-1', "Session name previously created by POST /login. If omitted, executes without a stored session.",
example: "test-session-1",
}) })
@IsOptional() @IsOptional()
@IsString() @IsString()
sessionName?: string; sessionName?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'URL to navigate to before executing code. Skipped if omitted.', description:
example: 'https://cabinet-liquio-diia-stg.kitsoft.ua/messages', "URL to navigate to before executing code. Skipped if omitted.",
example: "https://cabinet-liquio-diia-stg.kitsoft.ua/messages",
}) })
@IsOptional() @IsOptional()
@IsUrl({ require_tld: true, require_protocol: true }) @IsUrl({ require_tld: true, require_protocol: true })
url?: string; url?: string;
@ApiProperty({ @ApiProperty({
description: 'JavaScript code to execute. Receives `page` (Playwright Page) and `context` (BrowserContext) as arguments. May be async. Return value is serialised and returned.', description:
example: 'return await page.title();', "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() @IsString()
code: string; code: string;
+12 -9
View File
@@ -1,24 +1,26 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsBoolean, IsOptional, IsString, IsUrl } from 'class-validator'; import { IsBoolean, IsOptional, IsString, IsUrl } from "class-validator";
export class OpenDto { export class OpenDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Session name previously created by POST /login. If omitted, opens the URL without a stored session.', description:
example: 'test-session-1', "Session name previously created by POST /login. If omitted, opens the URL without a stored session.",
example: "test-session-1",
}) })
@IsOptional() @IsOptional()
@IsString() @IsString()
sessionName?: string; sessionName?: string;
@ApiProperty({ @ApiProperty({
description: 'URL to open with the authenticated session', description: "URL to open with the authenticated session",
example: 'https://cabinet-liquio-diia-stg.kitsoft.ua/messages', example: "https://cabinet-liquio-diia-stg.kitsoft.ua/messages",
}) })
@IsUrl({ require_tld: true, require_protocol: true }) @IsUrl({ require_tld: true, require_protocol: true })
url: string; url: string;
@ApiPropertyOptional({ @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, default: false,
}) })
@IsOptional() @IsOptional()
@@ -26,8 +28,9 @@ export class OpenDto {
readerMode?: boolean; readerMode?: boolean;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'CSS selector whose matching element content is returned. When omitted the full page HTML is used.', description:
example: '#main-content', "CSS selector whose matching element content is returned. When omitted the full page HTML is used.",
example: "#main-content",
}) })
@IsOptional() @IsOptional()
@IsString() @IsString()
+2 -2
View File
@@ -1,5 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from "@nestjs/common";
import { CodeExecutorService } from './code-executor.service'; import { CodeExecutorService } from "./code-executor.service";
@Module({ @Module({
providers: [CodeExecutorService], providers: [CodeExecutorService],
+49 -22
View File
@@ -1,14 +1,21 @@
import { BadRequestException, Injectable, InternalServerErrorException } from '@nestjs/common'; import {
import { TraceLogger } from '../common/trace-logger'; BadRequestException,
import { parse } from 'acorn'; Injectable,
import type { Page, BrowserContext } from 'playwright'; InternalServerErrorException,
import { dumpDom } from './dom-helpers'; } 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 { export interface ExecResult {
result: unknown; result: unknown;
} }
export type ScriptLogger = (level: 'log' | 'warn' | 'error', message: string) => void; export type ScriptLogger = (
level: "log" | "warn" | "error",
message: string,
) => void;
@Injectable() @Injectable()
export class CodeExecutorService { export class CodeExecutorService {
@@ -23,7 +30,10 @@ export class CodeExecutorService {
try { try {
parse(wrapped, { ecmaVersion: 2022 }); parse(wrapped, { ecmaVersion: 2022 });
} catch (err) { } 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 * Executes `code` as an async function body with `page` and `context` in
* scope. Always call `validate()` before this method. * scope. Always call `validate()` before this method.
*/ */
async execute(page: Page, context: BrowserContext, code: string, log?: ScriptLogger): Promise<ExecResult> { async execute(
const scriptLog: ScriptLogger = log ?? ((level, msg) => this.logger[level](msg)); page: Page,
const toStr = (args: unknown[]) => args.map((a) => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' '); context: BrowserContext,
code: string,
log?: ScriptLogger,
): Promise<ExecResult> {
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 { try {
const pageHelpers = { const pageHelpers = {
dumpDom: (selector?: string) => dumpDom(page, selector), dumpDom: (selector?: string) => dumpDom(page, selector),
log: (...args: unknown[]) => scriptLog('log', toStr(args)), log: (...args: unknown[]) => scriptLog("log", toStr(args)),
warn: (...args: unknown[]) => scriptLog('warn', toStr(args)), warn: (...args: unknown[]) => scriptLog("warn", toStr(args)),
error: (...args: unknown[]) => scriptLog('error', toStr(args)), error: (...args: unknown[]) => scriptLog("error", toStr(args)),
}; };
const fakeConsole = { const fakeConsole = {
log: (...args: unknown[]) => scriptLog('log', toStr(args)), log: (...args: unknown[]) => scriptLog("log", toStr(args)),
warn: (...args: unknown[]) => scriptLog('warn', toStr(args)), warn: (...args: unknown[]) => scriptLog("warn", toStr(args)),
error: (...args: unknown[]) => scriptLog('error', toStr(args)), error: (...args: unknown[]) => scriptLog("error", toStr(args)),
info: (...args: unknown[]) => scriptLog('log', toStr(args)), info: (...args: unknown[]) => scriptLog("log", toStr(args)),
debug: (...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. // Passing `console` as a named parameter shadows the global in the script scope.
// eslint-disable-next-line no-new-func const fn = new Function(
const fn = new Function('page', 'context', 'helpers', 'console', `return (async (page, context, helpers) => { ${code} })(page, context, helpers)`); "page",
this.logger.debug('Executing user code'); "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); const result = await fn(page, context, pageHelpers, fakeConsole);
return { result }; return { result };
} catch (err) { } 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 },
);
} }
} }
} }
+81 -29
View File
@@ -1,4 +1,4 @@
import type { Page } from 'playwright'; import type { Page } from "playwright";
export interface DomNode { export interface DomNode {
tag: string; tag: string;
@@ -26,50 +26,99 @@ export interface DomNode {
* *
* Useful for debugging Playwright selectors without screenshotting. * Useful for debugging Playwright selectors without screenshotting.
*/ */
export async function dumpDom(page: Page, rootSelector = 'body'): Promise<DomNode> { export async function dumpDom(
page: Page,
rootSelector = "body",
): Promise<DomNode> {
return page.evaluate( return page.evaluate(
([sel, maxDepth]) => { ([sel, maxDepth]) => {
const root = document.querySelector(sel as string); 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([ const STRUCTURAL_TAGS = new Set([
'BODY', 'MAIN', 'HEADER', 'FOOTER', 'NAV', 'ASIDE', 'SECTION', "BODY",
'FORM', 'DIALOG', 'DETAILS', 'SUMMARY', 'TABLE', 'THEAD', 'TBODY', "MAIN",
'TR', 'FIELDSET', 'LEGEND', "HEADER",
"FOOTER",
"NAV",
"ASIDE",
"SECTION",
"FORM",
"DIALOG",
"DETAILS",
"SUMMARY",
"TABLE",
"THEAD",
"TBODY",
"TR",
"FIELDSET",
"LEGEND",
]); ]);
const INTERACTIVE_TAGS = new Set([ const INTERACTIVE_TAGS = new Set([
'A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'LABEL', "A",
'TH', 'TD', "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 { 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 // Only include if short enough to be meaningful, not a dump of all child text
const ownText = Array.from(el.childNodes) const ownText = Array.from(el.childNodes)
.filter(n => n.nodeType === Node.TEXT_NODE) .filter((n) => n.nodeType === Node.TEXT_NODE)
.map(n => n.textContent?.trim() ?? '') .map((n) => n.textContent?.trim() ?? "")
.filter(Boolean) .filter(Boolean)
.join(' '); .join(" ");
const candidate = ownText || t; const candidate = ownText || t;
return candidate.length > 0 ? candidate.substring(0, 80) : undefined; return candidate.length > 0 ? candidate.substring(0, 80) : undefined;
} }
function isVisible(el: Element): boolean { function isVisible(el: Element): boolean {
const s = window.getComputedStyle(el); 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 { function isSignificant(el: Element): boolean {
if (STRUCTURAL_TAGS.has(el.tagName)) return true; if (STRUCTURAL_TAGS.has(el.tagName)) return true;
if (INTERACTIVE_TAGS.has(el.tagName)) return true; if (INTERACTIVE_TAGS.has(el.tagName)) return true;
if (el.getAttribute('role')) return true; if (el.getAttribute("role")) return true;
if (el.getAttribute('data-testid')) return true; if (el.getAttribute("data-testid")) return true;
if (el.getAttribute('data-qa')) return true; if (el.getAttribute("data-qa")) return true;
if (el.getAttribute('data-action')) return true; if (el.getAttribute("data-action")) return true;
if (el.getAttribute('data-element-id')) return true; if (el.getAttribute("data-element-id")) return true;
return false; return false;
} }
@@ -98,41 +147,44 @@ export async function dumpDom(page: Page, rootSelector = 'body'): Promise<DomNod
if (!significant && childResults.length === 1) return childResults[0]; if (!significant && childResults.length === 1) return childResults[0];
// If not significant but has children, keep as anonymous group only if > 1 child // If not significant but has children, keep as anonymous group only if > 1 child
if (!significant) return { tag: el.tagName.toLowerCase(), children: childResults }; if (!significant)
return { tag: el.tagName.toLowerCase(), children: childResults };
const node: DomNode = { const node: DomNode = {
tag: el.tagName.toLowerCase(), tag: el.tagName.toLowerCase(),
children: childResults, children: childResults,
}; };
const role = el.getAttribute('role'); const role = el.getAttribute("role");
if (role) node.role = role; if (role) node.role = role;
const testid = el.getAttribute('data-testid'); const testid = el.getAttribute("data-testid");
if (testid) node.testid = testid; if (testid) node.testid = testid;
const qa = el.getAttribute('data-qa'); const qa = el.getAttribute("data-qa");
if (qa) node.qa = qa; if (qa) node.qa = qa;
const action = el.getAttribute('data-action'); const action = el.getAttribute("data-action");
if (action) node.action = 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; if (elementId) node.elementId = elementId;
const id = el.id; const id = el.id;
if (id) node.id = id; if (id) node.id = id;
const type = (el as HTMLInputElement).type; 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; const name = (el as HTMLInputElement).name;
if (name) node.name = name; if (name) node.name = name;
const href = (el as HTMLAnchorElement).href; 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; if ((el as HTMLButtonElement).disabled) node.disabled = true;
const text = trimText(el); const text = trimText(el);
@@ -142,7 +194,7 @@ export async function dumpDom(page: Page, rootSelector = 'body'): Promise<DomNod
} }
const result = build(root, 0); const result = build(root, 0);
return result ?? { tag: 'empty', children: [] }; return result ?? { tag: "empty", children: [] };
}, },
[rootSelector, 12] as [string, number], [rootSelector, 12] as [string, number],
); );
+11 -7
View File
@@ -1,6 +1,6 @@
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from 'class-transformer'; import { Type } from "class-transformer";
import { IsIn, IsInt, IsOptional, IsString, Min } from 'class-validator'; import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
export class PaginationQueryDto<TOrderBy extends string = string> { export class PaginationQueryDto<TOrderBy extends string = string> {
@ApiPropertyOptional({ example: 1, default: 1 }) @ApiPropertyOptional({ example: 1, default: 1 })
@@ -17,15 +17,19 @@ export class PaginationQueryDto<TOrderBy extends string = string> {
@Min(1) @Min(1)
limit?: number = 20; limit?: number = 20;
@ApiPropertyOptional({ example: 'id', default: 'id', description: 'Field to order by' }) @ApiPropertyOptional({
example: "id",
default: "id",
description: "Field to order by",
})
@IsOptional() @IsOptional()
@IsString() @IsString()
orderBy?: TOrderBy; orderBy?: TOrderBy;
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'ASC' }) @ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "ASC" })
@IsOptional() @IsOptional()
@IsIn(['ASC', 'DESC']) @IsIn(["ASC", "DESC"])
orderDir?: 'ASC' | 'DESC' = 'ASC'; orderDir?: "ASC" | "DESC" = "ASC";
} }
export interface PaginatedResult<T> { export interface PaginatedResult<T> {
+1 -1
View File
@@ -1,4 +1,4 @@
import { AsyncLocalStorage } from 'async_hooks'; import { AsyncLocalStorage } from "async_hooks";
export interface TraceStore { export interface TraceStore {
traceId: string; traceId: string;
+2 -2
View File
@@ -1,5 +1,5 @@
import { ConsoleLogger, ConsoleLoggerOptions, LogLevel } from '@nestjs/common'; import { ConsoleLogger, ConsoleLoggerOptions, LogLevel } from "@nestjs/common";
import { getTraceId } from './trace-context'; import { getTraceId } from "./trace-context";
export class TraceLogger extends ConsoleLogger { export class TraceLogger extends ConsoleLogger {
constructor(context?: string, options: ConsoleLoggerOptions = {}) { constructor(context?: string, options: ConsoleLoggerOptions = {}) {
+3 -3
View File
@@ -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 { export class AppConfig {
@IsString() @IsString()
NODE_ENV: string = 'development'; NODE_ENV: string = "development";
@IsInt() @IsInt()
@Min(1) @Min(1)
@@ -1,19 +1,19 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from "@nestjs/swagger";
import { IsNotEmpty, IsObject, IsString } from 'class-validator'; import { IsNotEmpty, IsObject, IsString } from "class-validator";
import { EnvironmentUrls } from '../environment.entity'; import { EnvironmentUrls } from "../environment.entity";
export class CreateEnvironmentDto { export class CreateEnvironmentDto {
@ApiProperty({ example: 'liquio-diia-stg' }) @ApiProperty({ example: "liquio-diia-stg" })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
name: string; name: string;
@ApiProperty({ @ApiProperty({
description: 'Map of URL identifiers to URL strings', description: "Map of URL identifiers to URL strings",
example: { example: {
id_url: 'https://id-liquio-diia-stg.kitsoft.ua/', id_url: "https://id-liquio-diia-stg.kitsoft.ua/",
cabinet_url: 'https://cabinet-liquio-diia-stg.kitsoft.ua/', cabinet_url: "https://cabinet-liquio-diia-stg.kitsoft.ua/",
admin_url: 'https://admin-liquio-diia-stg.kitsoft.ua/', admin_url: "https://admin-liquio-diia-stg.kitsoft.ua/",
}, },
}) })
@IsObject() @IsObject()
@@ -1,4 +1,4 @@
import { PartialType } from '@nestjs/swagger'; import { PartialType } from "@nestjs/swagger";
import { CreateEnvironmentDto } from './create-environment.dto'; import { CreateEnvironmentDto } from "./create-environment.dto";
export class UpdateEnvironmentDto extends PartialType(CreateEnvironmentDto) {} export class UpdateEnvironmentDto extends PartialType(CreateEnvironmentDto) {}
+32 -29
View File
@@ -9,56 +9,59 @@ import {
Patch, Patch,
Post, Post,
Query, Query,
} from '@nestjs/common'; } from "@nestjs/common";
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { EnvironmentService } from './environment.service'; import { EnvironmentService } from "./environment.service";
import { CreateEnvironmentDto } from './dto/create-environment.dto'; import { CreateEnvironmentDto } from "./dto/create-environment.dto";
import { UpdateEnvironmentDto } from './dto/update-environment.dto'; import { UpdateEnvironmentDto } from "./dto/update-environment.dto";
import { PaginationQueryDto } from '../common/dto/pagination.dto'; import { PaginationQueryDto } from "../common/dto/pagination.dto";
import { EnvironmentOrderBy } from './environment.service'; import { EnvironmentOrderBy } from "./environment.service";
@ApiTags('environments') @ApiTags("environments")
@Controller('environments') @Controller("environments")
export class EnvironmentController { export class EnvironmentController {
constructor(private readonly environmentService: EnvironmentService) {} constructor(private readonly environmentService: EnvironmentService) {}
@Post() @Post()
@ApiOperation({ summary: 'Create a new environment' }) @ApiOperation({ summary: "Create a new environment" })
@ApiResponse({ status: 201, description: 'Environment created' }) @ApiResponse({ status: 201, description: "Environment created" })
@ApiResponse({ status: 409, description: 'Environment name already exists' }) @ApiResponse({ status: 409, description: "Environment name already exists" })
create(@Body() dto: CreateEnvironmentDto) { create(@Body() dto: CreateEnvironmentDto) {
return this.environmentService.create(dto); return this.environmentService.create(dto);
} }
@Get() @Get()
@ApiOperation({ summary: 'List all environments (paginated)' }) @ApiOperation({ summary: "List all environments (paginated)" })
@ApiResponse({ status: 200, description: 'Paginated environments' }) @ApiResponse({ status: 200, description: "Paginated environments" })
findAll(@Query() query: PaginationQueryDto<EnvironmentOrderBy>) { findAll(@Query() query: PaginationQueryDto<EnvironmentOrderBy>) {
return this.environmentService.findAll(query); return this.environmentService.findAll(query);
} }
@Get(':id') @Get(":id")
@ApiOperation({ summary: 'Get environment by ID' }) @ApiOperation({ summary: "Get environment by ID" })
@ApiResponse({ status: 200, description: 'Environment record' }) @ApiResponse({ status: 200, description: "Environment record" })
@ApiResponse({ status: 404, description: 'Environment not found' }) @ApiResponse({ status: 404, description: "Environment not found" })
findOne(@Param('id', ParseIntPipe) id: number) { findOne(@Param("id", ParseIntPipe) id: number) {
return this.environmentService.findOne(id); return this.environmentService.findOne(id);
} }
@Patch(':id') @Patch(":id")
@ApiOperation({ summary: 'Update an environment' }) @ApiOperation({ summary: "Update an environment" })
@ApiResponse({ status: 200, description: 'Environment updated' }) @ApiResponse({ status: 200, description: "Environment updated" })
@ApiResponse({ status: 404, description: 'Environment not found' }) @ApiResponse({ status: 404, description: "Environment not found" })
update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateEnvironmentDto) { update(
@Param("id", ParseIntPipe) id: number,
@Body() dto: UpdateEnvironmentDto,
) {
return this.environmentService.update(id, dto); return this.environmentService.update(id, dto);
} }
@Delete(':id') @Delete(":id")
@HttpCode(204) @HttpCode(204)
@ApiOperation({ summary: 'Delete an environment' }) @ApiOperation({ summary: "Delete an environment" })
@ApiResponse({ status: 204, description: 'Environment deleted' }) @ApiResponse({ status: 204, description: "Environment deleted" })
@ApiResponse({ status: 404, description: 'Environment not found' }) @ApiResponse({ status: 404, description: "Environment not found" })
remove(@Param('id', ParseIntPipe) id: number) { remove(@Param("id", ParseIntPipe) id: number) {
return this.environmentService.remove(id); return this.environmentService.remove(id);
} }
} }
+3 -3
View File
@@ -4,7 +4,7 @@ import {
Column, Column,
CreateDateColumn, CreateDateColumn,
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } from "typeorm";
export interface EnvironmentUrls { export interface EnvironmentUrls {
id_url?: string; id_url?: string;
@@ -13,7 +13,7 @@ export interface EnvironmentUrls {
[key: string]: string | undefined; [key: string]: string | undefined;
} }
@Entity('environments') @Entity("environments")
export class EnvironmentEntity { export class EnvironmentEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id: number; id: number;
@@ -21,7 +21,7 @@ export class EnvironmentEntity {
@Column({ unique: true }) @Column({ unique: true })
name: string; name: string;
@Column('simple-json') @Column("simple-json")
urls: EnvironmentUrls; urls: EnvironmentUrls;
@CreateDateColumn() @CreateDateColumn()
+5 -5
View File
@@ -1,8 +1,8 @@
import { Module } from '@nestjs/common'; import { Module } from "@nestjs/common";
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from "@nestjs/typeorm";
import { EnvironmentEntity } from './environment.entity'; import { EnvironmentEntity } from "./environment.entity";
import { EnvironmentService } from './environment.service'; import { EnvironmentService } from "./environment.service";
import { EnvironmentController } from './environment.controller'; import { EnvironmentController } from "./environment.controller";
@Module({ @Module({
imports: [TypeOrmModule.forFeature([EnvironmentEntity])], imports: [TypeOrmModule.forFeature([EnvironmentEntity])],
+24 -12
View File
@@ -1,12 +1,19 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import {
import { InjectRepository } from '@nestjs/typeorm'; ConflictException,
import { Repository } from 'typeorm'; Injectable,
import { EnvironmentEntity } from './environment.entity'; NotFoundException,
import { CreateEnvironmentDto } from './dto/create-environment.dto'; } from "@nestjs/common";
import { UpdateEnvironmentDto } from './dto/update-environment.dto'; import { InjectRepository } from "@nestjs/typeorm";
import { PaginationQueryDto, PaginatedResult } from '../common/dto/pagination.dto'; 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() @Injectable()
export class EnvironmentService { export class EnvironmentService {
@@ -23,11 +30,13 @@ export class EnvironmentService {
return this.repo.save(this.repo.create(dto)); return this.repo.save(this.repo.create(dto));
} }
async findAll(query: PaginationQueryDto<EnvironmentOrderBy> = {}): Promise<PaginatedResult<EnvironmentEntity>> { async findAll(
query: PaginationQueryDto<EnvironmentOrderBy> = {},
): Promise<PaginatedResult<EnvironmentEntity>> {
const page = query.page ?? 1; const page = query.page ?? 1;
const limit = query.limit ?? 20; const limit = query.limit ?? 20;
const orderBy = query.orderBy ?? 'id'; const orderBy = query.orderBy ?? "id";
const orderDir = query.orderDir ?? 'ASC'; const orderDir = query.orderDir ?? "ASC";
const [data, total] = await this.repo.findAndCount({ const [data, total] = await this.repo.findAndCount({
order: { [orderBy]: orderDir }, order: { [orderBy]: orderDir },
skip: (page - 1) * limit, skip: (page - 1) * limit,
@@ -42,7 +51,10 @@ export class EnvironmentService {
return env; return env;
} }
async update(id: number, dto: UpdateEnvironmentDto): Promise<EnvironmentEntity> { async update(
id: number,
dto: UpdateEnvironmentDto,
): Promise<EnvironmentEntity> {
const env = await this.findOne(id); const env = await this.findOne(id);
Object.assign(env, dto); Object.assign(env, dto);
return this.repo.save(env); return this.repo.save(env);
+8 -6
View File
@@ -5,9 +5,9 @@ import {
ExceptionFilter, ExceptionFilter,
HttpException, HttpException,
InternalServerErrorException, InternalServerErrorException,
} from '@nestjs/common'; } from "@nestjs/common";
import { TraceLogger } from '../common/trace-logger'; import { TraceLogger } from "../common/trace-logger";
import type { Request, Response } from 'express'; import type { Request, Response } from "express";
@Catch(BadRequestException, InternalServerErrorException) @Catch(BadRequestException, InternalServerErrorException)
export class HttpExceptionFilter implements ExceptionFilter { export class HttpExceptionFilter implements ExceptionFilter {
@@ -20,8 +20,8 @@ export class HttpExceptionFilter implements ExceptionFilter {
const status = exception.getStatus(); const status = exception.getStatus();
const body = exception.getResponse(); const body = exception.getResponse();
const cause = (exception as any).cause as Error | undefined; const cause = (exception as unknown as { cause?: Error }).cause;
const causeMessage = cause ? ` | cause: ${cause.message}` : ''; const causeMessage = cause ? ` | cause: ${cause.message}` : "";
const message = `${exception.message}${causeMessage}`; const message = `${exception.message}${causeMessage}`;
if (status >= 500) { if (status >= 500) {
@@ -30,7 +30,9 @@ export class HttpExceptionFilter implements ExceptionFilter {
cause?.stack ?? exception.stack, cause?.stack ?? exception.stack,
); );
} else { } else {
this.logger.warn(`[${request.method} ${request.url}] ${status}${message}`); this.logger.warn(
`[${request.method} ${request.url}] ${status}${message}`,
);
} }
response.status(status).json(body); response.status(status).json(body);
+7 -7
View File
@@ -1,13 +1,13 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get } from "@nestjs/common";
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
@ApiTags('health') @ApiTags("health")
@Controller() @Controller()
export class HealthController { export class HealthController {
@Get('healthz') @Get("healthz")
@ApiOperation({ summary: 'Health check' }) @ApiOperation({ summary: "Health check" })
@ApiResponse({ status: 200, description: 'Service is healthy' }) @ApiResponse({ status: 200, description: "Service is healthy" })
healthz(): { status: string } { healthz(): { status: string } {
return { status: 'ok' }; return { status: "ok" };
} }
} }
+2 -2
View File
@@ -1,5 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from "@nestjs/common";
import { HealthController } from './health.controller'; import { HealthController } from "./health.controller";
@Module({ @Module({
controllers: [HealthController], controllers: [HealthController],
+12 -8
View File
@@ -3,10 +3,10 @@ import {
ExecutionContext, ExecutionContext,
Injectable, Injectable,
NestInterceptor, NestInterceptor,
} from '@nestjs/common'; } from "@nestjs/common";
import { TraceLogger } from '../common/trace-logger'; import { TraceLogger } from "../common/trace-logger";
import type { Request, Response } from 'express'; import type { Request, Response } from "express";
import { Observable, tap } from 'rxjs'; import { Observable, tap } from "rxjs";
@Injectable() @Injectable()
export class LoggingInterceptor implements NestInterceptor { export class LoggingInterceptor implements NestInterceptor {
@@ -18,16 +18,20 @@ export class LoggingInterceptor implements NestInterceptor {
const res = http.getResponse<Response>(); const res = http.getResponse<Response>();
const { method, url, body } = req; const { method, url, body } = req;
const start = Date.now(); 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}`); this.logger.debug(`${method} ${url}${bodyStr}`);
return next.handle().pipe( return next.handle().pipe(
tap((responseBody) => { tap((responseBody) => {
const ms = Date.now() - start; const ms = Date.now() - start;
const len = responseBody != null ? JSON.stringify(responseBody).length : 0; const len =
const lenStr = len > 0 ? ` [${len}b]` : ''; responseBody != null ? JSON.stringify(responseBody).length : 0;
this.logger.debug(`${method} ${url} ${res.statusCode} (${ms}ms)${lenStr}`); const lenStr = len > 0 ? ` [${len}b]` : "";
this.logger.debug(
`${method} ${url} ${res.statusCode} (${ms}ms)${lenStr}`,
);
}), }),
); );
} }
+12 -6
View File
@@ -1,14 +1,20 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; import {
import type { Request } from 'express'; CallHandler,
import * as crypto from 'crypto'; ExecutionContext,
import { Observable } from 'rxjs'; Injectable,
import { traceStorage } from '../common/trace-context'; 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() @Injectable()
export class TraceInterceptor implements NestInterceptor { export class TraceInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> { intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const req = context.switchToHttp().getRequest<Request>(); const req = context.switchToHttp().getRequest<Request>();
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) => { return new Observable((subscriber) => {
traceStorage.run({ traceId }, () => { traceStorage.run({ traceId }, () => {
+20 -16
View File
@@ -1,25 +1,27 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from "@nestjs/core";
import { ConfigService } from '@nestjs/config'; import { ConfigService } from "@nestjs/config";
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from "@nestjs/common";
import { TraceLogger } from './common/trace-logger'; import { TraceLogger } from "./common/trace-logger";
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import { AppModule } from './app.module'; import { AppModule } from "./app.module";
import { HttpExceptionFilter } from './filters/http-exception.filter'; import { HttpExceptionFilter } from "./filters/http-exception.filter";
import { TraceInterceptor } from './interceptors/trace.interceptor'; import { TraceInterceptor } from "./interceptors/trace.interceptor";
import { LoggingInterceptor } from './interceptors/logging.interceptor'; import { LoggingInterceptor } from "./interceptors/logging.interceptor";
import { name as pkgName, version as pkgVersion } from '../package.json'; import { name as pkgName, version as pkgVersion } from "../package.json";
async function bootstrap() { async function bootstrap() {
const logger = new TraceLogger('Bootstrap'); const logger = new TraceLogger("Bootstrap");
const app = await NestFactory.create(AppModule, { logger: new TraceLogger('Bootstrap', { timestamp: true }) }); const app = await NestFactory.create(AppModule, {
logger: new TraceLogger("Bootstrap", { timestamp: true }),
});
app.useGlobalPipes(new ValidationPipe({ transform: true })); app.useGlobalPipes(new ValidationPipe({ transform: true }));
app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new TraceInterceptor(), new LoggingInterceptor()); app.useGlobalInterceptors(new TraceInterceptor(), new LoggingInterceptor());
const config = app.get(ConfigService); const config = app.get(ConfigService);
const port = config.get<number>('PORT', 3000); const port = config.get<number>("PORT", 3000);
const nodeEnv = config.get<string>('NODE_ENV', 'development'); const nodeEnv = config.get<string>("NODE_ENV", "development");
const swaggerConfig = new DocumentBuilder() const swaggerConfig = new DocumentBuilder()
.setTitle(pkgName) .setTitle(pkgName)
@@ -28,11 +30,13 @@ async function bootstrap() {
.build(); .build();
const document = SwaggerModule.createDocument(app, swaggerConfig); const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('api', app, document); SwaggerModule.setup("api", app, document);
await app.listen(port); 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(`Swagger UI available at http://localhost:${port}/api`);
logger.log(`MCP endpoint available at http://localhost:${port}/mcp`); logger.log(`MCP endpoint available at http://localhost:${port}/mcp`);
} }
+41 -25
View File
@@ -1,52 +1,68 @@
import { Controller, Delete, Get, Post, Req, Res } from '@nestjs/common'; import { Controller, Delete, Get, Post, Req, Res } from "@nestjs/common";
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import type { Request, Response } from 'express'; import type { Request, Response } from "express";
import { McpService } from './mcp.service'; import { McpService } from "./mcp.service";
@ApiTags('mcp') @ApiTags("mcp")
@Controller('mcp') @Controller("mcp")
export class McpController { export class McpController {
constructor(private readonly mcpService: McpService) {} constructor(private readonly mcpService: McpService) {}
@Post() @Post()
@ApiOperation({ @ApiOperation({
summary: 'Send JSON-RPC message', summary: "Send JSON-RPC message",
description: description:
'Accepts a JSON-RPC request, notification, or response. ' + "Accepts a JSON-RPC request, notification, or response. " +
'Returns either `application/json` for a single response or ' + "Returns either `application/json` for a single response or " +
'`text/event-stream` (SSE) when the server streams multiple messages.', "`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<void> { post(@Req() req: Request, @Res() res: Response): Promise<void> {
return this.mcpService.handle(req, res); return this.mcpService.handle(req, res);
} }
@Get() @Get()
@ApiOperation({ @ApiOperation({
summary: 'Open server-sent event stream', summary: "Open server-sent event stream",
description: description:
'Opens a persistent SSE stream so the server can push JSON-RPC requests and ' + "Opens a persistent SSE stream so the server can push JSON-RPC requests and " +
'notifications to the client without a prior POST. ' + "notifications to the client without a prior POST. " +
'Requires `Accept: text/event-stream`. Pass `Last-Event-ID` to resume a broken stream.', "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<void> { get(@Req() req: Request, @Res() res: Response): Promise<void> {
return this.mcpService.handle(req, res); return this.mcpService.handle(req, res);
} }
@Delete() @Delete()
@ApiOperation({ @ApiOperation({
summary: 'Terminate session', summary: "Terminate session",
description: description:
'Explicitly terminates a session identified by the `Mcp-Session-Id` header. ' + "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.', "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<void> { delete(@Req() req: Request, @Res() res: Response): Promise<void> {
return this.mcpService.handle(req, res); return this.mcpService.handle(req, res);
} }
+17 -10
View File
@@ -1,15 +1,22 @@
import { Module } from '@nestjs/common'; import { Module } from "@nestjs/common";
import { McpController } from './mcp.controller'; import { McpController } from "./mcp.controller";
import { McpService } from './mcp.service'; import { McpService } from "./mcp.service";
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from "../auth/auth.module";
import { SessionModule } from '../session/session.module'; import { SessionModule } from "../session/session.module";
import { EnvironmentModule } from '../environment/environment.module'; import { EnvironmentModule } from "../environment/environment.module";
import { BrowserModule } from '../browser/browser.module'; import { BrowserModule } from "../browser/browser.module";
import { CodeExecutorModule } from '../code-executor/code-executor.module'; import { CodeExecutorModule } from "../code-executor/code-executor.module";
import { ScenarioModule } from '../scenario/scenario.module'; import { ScenarioModule } from "../scenario/scenario.module";
@Module({ @Module({
imports: [AuthModule, SessionModule, EnvironmentModule, BrowserModule, CodeExecutorModule, ScenarioModule], imports: [
AuthModule,
SessionModule,
EnvironmentModule,
BrowserModule,
CodeExecutorModule,
ScenarioModule,
],
controllers: [McpController], controllers: [McpController],
providers: [McpService], providers: [McpService],
}) })
+448 -165
View File
@@ -1,17 +1,17 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from "@nestjs/common";
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from 'zod'; import { z } from "zod";
import type { Request, Response } from 'express'; import type { Request, Response } from "express";
import { AuthService } from '../auth/auth.service'; import { AuthService } from "../auth/auth.service";
import { SessionService } from '../session/session.service'; import { SessionService } from "../session/session.service";
import { EnvironmentService } from '../environment/environment.service'; import { EnvironmentService } from "../environment/environment.service";
import type { EnvironmentUrls } from '../environment/environment.entity'; import type { EnvironmentUrls } from "../environment/environment.entity";
import { BrowserService } from '../browser/browser.service'; import { BrowserService } from "../browser/browser.service";
import { CodeExecutorService } from '../code-executor/code-executor.service'; import { CodeExecutorService } from "../code-executor/code-executor.service";
import { ScenarioService } from '../scenario/scenario.service'; import { ScenarioService } from "../scenario/scenario.service";
import pkg from '../../package.json'; import pkg from "../../package.json";
@Injectable() @Injectable()
export class McpService { export class McpService {
@@ -31,161 +31,273 @@ export class McpService {
} }
private registerTools(server: McpServer): void { private registerTools(server: McpServer): void {
// ── Auth ────────────────────────────────────────────────────────────────── // ── Auth ──────────────────────────────────────────────────────────────────
server.registerTool( server.registerTool(
'list_keys', "list_keys",
{ description: 'List available key identifiers from the keys directory' }, { description: "List available key identifiers from the keys directory" },
async () => { async () => {
const keys = this.authService.listKeys(); 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( 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: { inputSchema: {
key: z.string().describe('Key identifier (filename without extension from keys/ dir)'), key: z
environmentName: z.string().describe('Environment name to resolve login/cabinet URLs'), .string()
sessionName: z.string().optional().describe('Session name to store credentials under. Auto-UUID if omitted.'), .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 }) => { async ({ key, environmentName, sessionName }) => {
const result = await this.authService.login(key, environmentName, sessionName); const result = await this.authService.login(
return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }; key,
environmentName,
sessionName,
);
return {
content: [{ type: "text" as const, text: JSON.stringify(result) }],
};
}, },
); );
// ── Sessions ────────────────────────────────────────────────────────────── // ── Sessions ──────────────────────────────────────────────────────────────
server.registerTool( 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: { inputSchema: {
page: z.number().int().min(1).optional().describe('Page number (default 1)'), page: z
limit: z.number().int().min(1).optional().describe('Items per page (default 20)'), .number()
orderBy: z.enum(['id', 'sessionName', 'createdAt', 'updatedAt']).optional().describe('Field to order by (default id)'), .int()
orderDir: z.enum(['ASC', 'DESC']).optional().describe('Sort direction (default ASC)'), .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 }) => { async ({ page, limit, orderBy, orderDir }) => {
const sessions = await this.sessionService.findAll({ page, limit, orderBy, orderDir }); const sessions = await this.sessionService.findAll({
return { content: [{ type: 'text' as const, text: JSON.stringify(sessions) }] }; page,
limit,
orderBy,
orderDir,
});
return {
content: [{ type: "text" as const, text: JSON.stringify(sessions) }],
};
}, },
); );
server.registerTool( server.registerTool(
'delete_session', "delete_session",
{ {
description: 'Delete a session by numeric ID', description: "Delete a session by numeric ID",
inputSchema: { inputSchema: {
id: z.number().int().describe('Session ID to delete'), id: z.number().int().describe("Session ID to delete"),
}, },
}, },
async ({ id }) => { async ({ id }) => {
const { data } = await this.sessionService.findAll(); const { data } = await this.sessionService.findAll();
if (!data.find(s => s.id === id)) { if (!data.find((s) => s.id === id)) {
return { isError: true, content: [{ type: 'text' as const, text: `Session ${id} not found` }] }; return {
isError: true,
content: [
{ type: "text" as const, text: `Session ${id} not found` },
],
};
} }
await this.sessionService.remove(id); 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 ────────────────────────────────────────────────────────── // ── Environments ──────────────────────────────────────────────────────────
server.registerTool( server.registerTool(
'list_environments', "list_environments",
{ {
description: 'List all environments, paginated', description: "List all environments, paginated",
inputSchema: { inputSchema: {
page: z.number().int().min(1).optional().describe('Page number (default 1)'), page: z
limit: z.number().int().min(1).optional().describe('Items per page (default 20)'), .number()
orderBy: z.enum(['id', 'name', 'createdAt', 'updatedAt']).optional().describe('Field to order by (default id)'), .int()
orderDir: z.enum(['ASC', 'DESC']).optional().describe('Sort direction (default ASC)'), .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 }) => { async ({ page, limit, orderBy, orderDir }) => {
const envs = await this.environmentService.findAll({ page, limit, orderBy, orderDir }); const envs = await this.environmentService.findAll({
return { content: [{ type: 'text' as const, text: JSON.stringify(envs) }] }; page,
limit,
orderBy,
orderDir,
});
return {
content: [{ type: "text" as const, text: JSON.stringify(envs) }],
};
}, },
); );
server.registerTool( server.registerTool(
'get_environment', "get_environment",
{ {
description: 'Get an environment record by ID', description: "Get an environment record by ID",
inputSchema: { inputSchema: {
id: z.number().int().describe('Environment ID'), id: z.number().int().describe("Environment ID"),
}, },
}, },
async ({ id }) => { async ({ id }) => {
try { try {
const env = await this.environmentService.findOne(id); 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 { } 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( 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: { inputSchema: {
name: z.string().describe('Unique environment name, e.g. liquio-diia-stg'), name: z
urls: z.record(z.string(), z.string()).describe('Map of URL keys to URL strings (id_url, cabinet_url, admin_url, …)'), .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 }) => { async ({ name, urls }) => {
try { try {
const env = await this.environmentService.create({ name, urls: urls as EnvironmentUrls }); const env = await this.environmentService.create({
return { content: [{ type: 'text' as const, text: JSON.stringify(env) }] }; name,
urls: urls as EnvironmentUrls,
});
return {
content: [{ type: "text" as const, text: JSON.stringify(env) }],
};
} catch (err) { } 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( 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: { inputSchema: {
id: z.number().int().describe('Environment ID to update'), id: z.number().int().describe("Environment ID to update"),
name: z.string().optional().describe('New name'), name: z.string().optional().describe("New name"),
urls: z.record(z.string(), z.string()).optional().describe('New URLs map'), urls: z
.record(z.string(), z.string())
.optional()
.describe("New URLs map"),
}, },
}, },
async ({ id, name, urls }) => { async ({ id, name, urls }) => {
try { try {
const env = await this.environmentService.update(id, { name, urls: urls as EnvironmentUrls | undefined }); const env = await this.environmentService.update(id, {
return { content: [{ type: 'text' as const, text: JSON.stringify(env) }] }; name,
urls: urls as EnvironmentUrls | undefined,
});
return {
content: [{ type: "text" as const, text: JSON.stringify(env) }],
};
} catch (err) { } 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( server.registerTool(
'delete_environment', "delete_environment",
{ {
description: 'Delete an environment by ID', description: "Delete an environment by ID",
inputSchema: { inputSchema: {
id: z.number().int().describe('Environment ID to delete'), id: z.number().int().describe("Environment ID to delete"),
}, },
}, },
async ({ id }) => { async ({ id }) => {
try { try {
await this.environmentService.remove(id); 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) { } 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 ─────────────────────────────────────────────────────────────── // ── Browser ───────────────────────────────────────────────────────────────
server.registerTool( 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: { inputSchema: {
sessionName: z.string().optional().describe('Session name to restore cookies and localStorage from. Omit to open without a stored session.'), sessionName: z
url: z.string().url().describe('URL to navigate to'), .string()
readerMode: z.boolean().optional().describe('Extract readable plain text instead of raw HTML'), .optional()
selector: z.string().optional().describe('CSS selector whose matching element content is returned; applied before readerMode'), .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 }) => { async ({ sessionName, url, readerMode, selector }) => {
try { try {
const result = await this.browserService.open(sessionName, url, readerMode ?? false, selector); const result = await this.browserService.open(
return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }; sessionName,
url,
readerMode ?? false,
selector,
);
return {
content: [{ type: "text" as const, text: JSON.stringify(result) }],
};
} catch (err) { } 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( 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: { inputSchema: {
sessionName: z.string().optional().describe('Session name to restore. Omit to run without a stored session.'), sessionName: z
code: z.string().describe('JavaScript code body to execute (async-safe, may use `page` and `context`)'), .string()
url: z.string().url().optional().describe('Optional URL to navigate to before running code'), .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 }) => { async ({ sessionName, code, url }) => {
try { try {
this.codeExecutor.validate(code); this.codeExecutor.validate(code);
const result = await this.browserService.exec(sessionName, code, url); 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) { } 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 ───────────────────────────────────────────────────────────── // ── Scenarios ─────────────────────────────────────────────────────────────
server.registerTool( server.registerTool(
'list_scenarios', "list_scenarios",
{ {
description: 'List all scenarios (paginated)', description: "List all scenarios (paginated)",
inputSchema: { inputSchema: {
page: z.number().int().min(1).optional().describe('Page number (default 1)'), page: z
limit: z.number().int().min(1).optional().describe('Items per page (default 20)'), .number()
orderBy: z.enum(['id', 'name', 'createdAt', 'updatedAt']).optional().describe('Field to order by (default id)'), .int()
orderDir: z.enum(['ASC', 'DESC']).optional().describe('Sort direction (default ASC)'), .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 }) => { async ({ page, limit, orderBy, orderDir }) => {
const result = await this.scenarioService.findAll({ page, limit, orderBy, orderDir }); const result = await this.scenarioService.findAll({
return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }; page,
limit,
orderBy,
orderDir,
});
return {
content: [{ type: "text" as const, text: JSON.stringify(result) }],
};
}, },
); );
server.registerTool( 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: { inputSchema: {
id: z.number().int().describe('Scenario ID'), id: z.number().int().describe("Scenario ID"),
}, },
}, },
async ({ id }) => { async ({ id }) => {
try { try {
const scenario = await this.scenarioService.findOne(id); 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) { } 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( server.registerTool(
'create_scenario', "create_scenario",
{ {
description: 'Create a new scenario', description: "Create a new scenario",
inputSchema: { inputSchema: {
name: z.string().describe('Scenario name'), name: z.string().describe("Scenario name"),
}, },
}, },
async ({ name }) => { async ({ name }) => {
try { try {
const scenario = await this.scenarioService.create({ name }); 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) { } 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( server.registerTool(
'update_scenario', "update_scenario",
{ {
description: 'Update a scenario name', description: "Update a scenario name",
inputSchema: { inputSchema: {
id: z.number().int().describe('Scenario ID'), id: z.number().int().describe("Scenario ID"),
name: z.string().optional().describe('New name'), name: z.string().optional().describe("New name"),
}, },
}, },
async ({ id, name }) => { async ({ id, name }) => {
try { try {
const scenario = await this.scenarioService.update(id, { name }); 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) { } 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( server.registerTool(
'delete_scenario', "delete_scenario",
{ {
description: 'Delete a scenario by ID', description: "Delete a scenario by ID",
inputSchema: { inputSchema: {
id: z.number().int().describe('Scenario ID'), id: z.number().int().describe("Scenario ID"),
}, },
}, },
async ({ id }) => { async ({ id }) => {
try { try {
await this.scenarioService.remove(id); 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) { } 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( server.registerTool(
'create_scenario_step', "create_scenario_step",
{ {
description: 'Add a step to a scenario', description: "Add a step to a scenario",
inputSchema: { inputSchema: {
scenarioId: z.number().int().describe('Parent scenario ID'), scenarioId: z.number().int().describe("Parent scenario ID"),
order: z.number().int().min(0).describe('Execution order (ascending)'), order: z
type: z.enum(['login', 'exec', 'sign']).describe('Step type'), .number()
sessionName: z.string().describe('Session name used by this step'), .int()
execCode: z.string().optional().describe('Playwright JS code to execute (exec steps)'), .min(0)
validateCode: z.string().optional().describe('Validation JS code returning { success, description }'), .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 }) => { async ({ scenarioId, ...dto }) => {
try { try {
const step = await this.scenarioService.createStep(scenarioId, dto); 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) { } 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( 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: { inputSchema: {
scenarioId: z.number().int().describe('Scenario ID'), scenarioId: z.number().int().describe("Scenario ID"),
stepId: z.number().int().describe('Step ID'), stepId: z.number().int().describe("Step ID"),
}, },
}, },
async ({ scenarioId, stepId }) => { async ({ scenarioId, stepId }) => {
try { try {
const step = await this.scenarioService.findStep(scenarioId, stepId); 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) { } 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( server.registerTool(
'update_scenario_step', "update_scenario_step",
{ {
description: 'Update a step within a scenario', description: "Update a step within a scenario",
inputSchema: { inputSchema: {
scenarioId: z.number().int().describe('Scenario ID'), scenarioId: z.number().int().describe("Scenario ID"),
stepId: z.number().int().describe('Step ID'), stepId: z.number().int().describe("Step ID"),
order: z.number().int().min(0).optional().describe('New execution order'), order: z
type: z.enum(['login', 'exec', 'sign']).optional().describe('New step type'), .number()
sessionName: z.string().optional().describe('New session name'), .int()
execCode: z.string().optional().describe('New exec code'), .min(0)
validateCode: z.string().optional().describe('New validation code'), .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 }) => { async ({ scenarioId, stepId, ...dto }) => {
try { try {
const step = await this.scenarioService.updateStep(scenarioId, stepId, dto); const step = await this.scenarioService.updateStep(
return { content: [{ type: 'text' as const, text: JSON.stringify(step) }] }; scenarioId,
stepId,
dto,
);
return {
content: [{ type: "text" as const, text: JSON.stringify(step) }],
};
} catch (err) { } 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( server.registerTool(
'delete_scenario_step', "delete_scenario_step",
{ {
description: 'Delete a step from a scenario', description: "Delete a step from a scenario",
inputSchema: { inputSchema: {
scenarioId: z.number().int().describe('Scenario ID'), scenarioId: z.number().int().describe("Scenario ID"),
stepId: z.number().int().describe('Step ID'), stepId: z.number().int().describe("Step ID"),
}, },
}, },
async ({ scenarioId, stepId }) => { async ({ scenarioId, stepId }) => {
try { try {
await this.scenarioService.removeStep(scenarioId, stepId); 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) { } 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( 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: { inputSchema: {
scenarioId: z.number().int().describe('Scenario ID'), scenarioId: z.number().int().describe("Scenario ID"),
status: z.enum(['pending', 'in_progress', 'pass', 'fail']).optional().describe('Filter by run status'), status: z
page: z.number().int().min(1).optional().describe('Page number (default 1)'), .enum(["pending", "in_progress", "pass", "fail"])
limit: z.number().int().min(1).optional().describe('Items per page (default 20)'), .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 }) => { async ({ scenarioId, status, page, limit }) => {
try { try {
const result = await this.scenarioService.findRuns(scenarioId, { status, page, limit }); const result = await this.scenarioService.findRuns(scenarioId, {
return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }; status,
page,
limit,
});
return {
content: [{ type: "text" as const, text: JSON.stringify(result) }],
};
} catch (err) { } 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( 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: { inputSchema: {
id: z.number().int().describe('Scenario ID to run'), id: z.number().int().describe("Scenario ID to run"),
}, },
}, },
async ({ id }) => { async ({ id }) => {
try { try {
const run = await this.scenarioService.createRun(id); 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) { } 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<void> { async handle(req: Request, res: Response): Promise<void> {
const server = this.createServer(); const server = this.createServer();
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
await server.connect(transport); await server.connect(transport);
try { try {
await transport.handleRequest(req, res, req.body); await transport.handleRequest(req, res, req.body);
+23 -9
View File
@@ -1,29 +1,43 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsInt, IsNotEmpty, IsOptional, IsString, Min } from 'class-validator'; import {
import { StepType } from '../scenario-step.entity'; IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Min,
} from "class-validator";
import { StepType } from "../scenario-step.entity";
export class CreateScenarioStepDto { export class CreateScenarioStepDto {
@ApiProperty({ example: 0, description: 'Execution order (ascending)' }) @ApiProperty({ example: 0, description: "Execution order (ascending)" })
@IsInt() @IsInt()
@Min(0) @Min(0)
order: number; order: number;
@ApiProperty({ enum: ['login', 'exec', 'sign'], example: 'exec' }) @ApiProperty({ enum: ["login", "exec", "sign"], example: "exec" })
@IsIn(['login', 'exec', 'sign']) @IsIn(["login", "exec", "sign"])
type: StepType; 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() @IsString()
@IsNotEmpty() @IsNotEmpty()
sessionName: string; sessionName: string;
@ApiPropertyOptional({ example: 'return await page.title();' }) @ApiPropertyOptional({ example: "return await page.title();" })
@IsOptional() @IsOptional()
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
execCode?: string; execCode?: string;
@ApiPropertyOptional({ example: 'return { success: result !== null, description: "title present" };' }) @ApiPropertyOptional({
example:
'return { success: result !== null, description: "title present" };',
})
@IsOptional() @IsOptional()
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
+3 -3
View File
@@ -1,8 +1,8 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from "@nestjs/swagger";
import { IsNotEmpty, IsString } from 'class-validator'; import { IsNotEmpty, IsString } from "class-validator";
export class CreateScenarioDto { export class CreateScenarioDto {
@ApiProperty({ example: 'Login and verify cabinet' }) @ApiProperty({ example: "Login and verify cabinet" })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
name: string; name: string;
+1 -1
View File
@@ -1 +1 @@
export { PaginationQueryDto } from '../../common/dto/pagination.dto'; export { PaginationQueryDto } from "../../common/dto/pagination.dto";
+7 -7
View File
@@ -1,7 +1,7 @@
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from 'class-transformer'; import { Type } from "class-transformer";
import { IsIn, IsInt, IsOptional, Min } from 'class-validator'; import { IsIn, IsInt, IsOptional, Min } from "class-validator";
import { RunStatus } from '../scenario-run.entity'; import { RunStatus } from "../scenario-run.entity";
export class RunsQueryDto { export class RunsQueryDto {
@ApiPropertyOptional({ example: 1, default: 1 }) @ApiPropertyOptional({ example: 1, default: 1 })
@@ -19,10 +19,10 @@ export class RunsQueryDto {
limit?: number = 20; limit?: number = 20;
@ApiPropertyOptional({ @ApiPropertyOptional({
enum: ['pending', 'in_progress', 'pass', 'fail'], enum: ["pending", "in_progress", "pass", "fail"],
description: 'Filter by run status', description: "Filter by run status",
}) })
@IsOptional() @IsOptional()
@IsIn(['pending', 'in_progress', 'pass', 'fail']) @IsIn(["pending", "in_progress", "pass", "fail"])
status?: RunStatus; status?: RunStatus;
} }
+6 -6
View File
@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from 'class-transformer'; import { Type } from "class-transformer";
import { import {
IsArray, IsArray,
IsIn, IsIn,
@@ -9,8 +9,8 @@ import {
IsString, IsString,
Min, Min,
ValidateNested, ValidateNested,
} from 'class-validator'; } from "class-validator";
import { StepType } from '../scenario-step.entity'; import { StepType } from "../scenario-step.entity";
export class ScenarioStepExportDto { export class ScenarioStepExportDto {
@ApiProperty() @ApiProperty()
@@ -18,8 +18,8 @@ export class ScenarioStepExportDto {
@Min(0) @Min(0)
order: number; order: number;
@ApiProperty({ enum: ['login', 'exec', 'sign'] }) @ApiProperty({ enum: ["login", "exec", "sign"] })
@IsIn(['login', 'exec', 'sign']) @IsIn(["login", "exec", "sign"])
type: StepType; type: StepType;
@ApiProperty() @ApiProperty()
+12 -5
View File
@@ -1,6 +1,13 @@
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsInt, IsNotEmpty, IsOptional, IsString, Min } from 'class-validator'; import {
import { StepType } from '../scenario-step.entity'; IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Min,
} from "class-validator";
import { StepType } from "../scenario-step.entity";
export class UpdateScenarioStepDto { export class UpdateScenarioStepDto {
@ApiPropertyOptional({ example: 0 }) @ApiPropertyOptional({ example: 0 })
@@ -9,9 +16,9 @@ export class UpdateScenarioStepDto {
@Min(0) @Min(0)
order?: number; order?: number;
@ApiPropertyOptional({ enum: ['login', 'exec', 'sign'] }) @ApiPropertyOptional({ enum: ["login", "exec", "sign"] })
@IsOptional() @IsOptional()
@IsIn(['login', 'exec', 'sign']) @IsIn(["login", "exec", "sign"])
type?: StepType; type?: StepType;
@ApiPropertyOptional() @ApiPropertyOptional()
+3 -3
View File
@@ -1,8 +1,8 @@
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsOptional, IsString, IsNotEmpty } from 'class-validator'; import { IsOptional, IsString, IsNotEmpty } from "class-validator";
export class UpdateScenarioDto { export class UpdateScenarioDto {
@ApiPropertyOptional({ example: 'Updated scenario name' }) @ApiPropertyOptional({ example: "Updated scenario name" })
@IsOptional() @IsOptional()
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
+44
View File
@@ -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;
}
+19 -11
View File
@@ -6,13 +6,19 @@ import {
UpdateDateColumn, UpdateDateColumn,
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
} from 'typeorm'; } from "typeorm";
import { ScenarioRunEntity } from './scenario-run.entity'; import { ScenarioRunEntity } from "./scenario-run.entity";
import { ScenarioStepEntity } from './scenario-step.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 { export class ScenarioRunStepEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id: number; id: number;
@@ -20,24 +26,26 @@ export class ScenarioRunStepEntity {
@Column() @Column()
runId: number; runId: number;
@ManyToOne(() => ScenarioRunEntity, (run) => run.stepRuns, { onDelete: 'CASCADE' }) @ManyToOne(() => ScenarioRunEntity, (run) => run.stepRuns, {
@JoinColumn({ name: 'runId' }) onDelete: "CASCADE",
})
@JoinColumn({ name: "runId" })
run: ScenarioRunEntity; run: ScenarioRunEntity;
@Column() @Column()
scenarioStepId: number; scenarioStepId: number;
@ManyToOne(() => ScenarioStepEntity, { onDelete: 'CASCADE' }) @ManyToOne(() => ScenarioStepEntity, { onDelete: "CASCADE" })
@JoinColumn({ name: 'scenarioStepId' }) @JoinColumn({ name: "scenarioStepId" })
scenarioStep: ScenarioStepEntity; scenarioStep: ScenarioStepEntity;
@Column({ type: 'text', default: 'waiting' }) @Column({ type: "text", default: "waiting" })
status: RunStepStatus; status: RunStepStatus;
@Column({ default: 0 }) @Column({ default: 0 })
order: number; order: number;
@Column({ type: 'text', nullable: true }) @Column({ type: "text", nullable: true })
description: string | null; description: string | null;
@CreateDateColumn() @CreateDateColumn()
+8 -8
View File
@@ -7,13 +7,13 @@ import {
ManyToOne, ManyToOne,
OneToMany, OneToMany,
JoinColumn, JoinColumn,
} from 'typeorm'; } from "typeorm";
import { ScenarioEntity } from './scenario.entity'; import { ScenarioEntity } from "./scenario.entity";
import { ScenarioRunStepEntity } from './scenario-run-step.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 { export class ScenarioRunEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id: number; id: number;
@@ -21,11 +21,11 @@ export class ScenarioRunEntity {
@Column() @Column()
scenarioId: number; scenarioId: number;
@ManyToOne(() => ScenarioEntity, { onDelete: 'CASCADE' }) @ManyToOne(() => ScenarioEntity, { onDelete: "CASCADE" })
@JoinColumn({ name: 'scenarioId' }) @JoinColumn({ name: "scenarioId" })
scenario: ScenarioEntity; scenario: ScenarioEntity;
@Column({ type: 'text', default: 'pending' }) @Column({ type: "text", default: "pending" })
status: RunStatus; status: RunStatus;
@OneToMany(() => ScenarioRunStepEntity, (rs) => rs.run, { @OneToMany(() => ScenarioRunStepEntity, (rs) => rs.run, {
+168 -79
View File
@@ -1,19 +1,20 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from "@nestjs/common";
import { TraceLogger } from '../common/trace-logger'; import { TraceLogger } from "../common/trace-logger";
import { traceStorage } from '../common/trace-context'; import { traceStorage } from "../common/trace-context";
import { Interval } from '@nestjs/schedule'; import { Interval } from "@nestjs/schedule";
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from 'typeorm'; import { Repository } from "typeorm";
import * as crypto from 'crypto'; import * as crypto from "crypto";
import { chromium } from 'playwright'; import { chromium } from "playwright";
import type { Browser, BrowserContext, Page } from 'playwright'; import type { Browser, BrowserContext, Page } from "playwright";
import { ScenarioRunEntity } from './scenario-run.entity'; import { ScenarioRunEntity } from "./scenario-run.entity";
import { ScenarioRunStepEntity } from './scenario-run-step.entity'; import { ScenarioRunStepEntity } from "./scenario-run-step.entity";
import { ScenarioStepEntity } from './scenario-step.entity'; import { ScenarioRunLogEntity } from "./scenario-run-log.entity";
import type { ScriptLogger } from '../code-executor/code-executor.service'; import { ScenarioStepEntity } from "./scenario-step.entity";
import { AuthService } from '../auth/auth.service'; import type { ScriptLogger } from "../code-executor/code-executor.service";
import { CodeExecutorService } from '../code-executor/code-executor.service'; import { AuthService } from "../auth/auth.service";
import { SessionService } from '../session/session.service'; import { CodeExecutorService } from "../code-executor/code-executor.service";
import { SessionService } from "../session/session.service";
interface ValidateResult { interface ValidateResult {
success: boolean; success: boolean;
@@ -37,49 +38,69 @@ export class ScenarioSchedulerService {
private readonly runRepo: Repository<ScenarioRunEntity>, private readonly runRepo: Repository<ScenarioRunEntity>,
@InjectRepository(ScenarioRunStepEntity) @InjectRepository(ScenarioRunStepEntity)
private readonly runStepRepo: Repository<ScenarioRunStepEntity>, private readonly runStepRepo: Repository<ScenarioRunStepEntity>,
@InjectRepository(ScenarioRunLogEntity)
private readonly runLogRepo: Repository<ScenarioRunLogEntity>,
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly codeExecutor: CodeExecutorService, private readonly codeExecutor: CodeExecutorService,
private readonly sessionService: SessionService, private readonly sessionService: SessionService,
) {} ) {}
private stepLogger(stepRunId: number): ScriptLogger { private persistLog(
return (level, msg) => this.logger[level](`StepRun #${stepRunId} script: ${msg}`); 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 ───────────── // ── Job: pick up pending runs and process each to completion ─────────────
@Interval(1000) @Interval(1000)
async pickUpPendingRuns(): Promise<void> { async pickUpPendingRuns(): Promise<void> {
const pending = await this.runRepo.find({ where: { status: 'pending' } }); const pending = await this.runRepo.find({ where: { status: "pending" } });
for (const run of pending) { for (const run of pending) {
if (this.activeRuns.has(run.id)) continue; if (this.activeRuns.has(run.id)) continue;
this.activeRuns.add(run.id); this.activeRuns.add(run.id);
run.status = 'in_progress'; run.status = "in_progress";
await this.runRepo.save(run); await this.runRepo.save(run);
this.logger.log(`Run #${run.id} → in_progress`); this.logger.log(`Run #${run.id} → in_progress`);
const traceId = crypto.randomUUID(); 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<void> { private async processRunToCompletion(runId: number): Promise<void> {
try { try {
let stepRun = await this.runStepRepo.findOne({ let stepRun = await this.runStepRepo.findOne({
where: { runId, status: 'pending' }, where: { runId, status: "pending" },
relations: ['scenarioStep'], relations: ["scenarioStep"],
order: { order: 'ASC' }, order: { order: "ASC" },
}); });
while (stepRun) { while (stepRun) {
await this.executeStepRun(stepRun); await this.executeStepRun(stepRun);
stepRun = await this.runStepRepo.findOne({ stepRun = await this.runStepRepo.findOne({
where: { runId, status: 'pending' }, where: { runId, status: "pending" },
relations: ['scenarioStep'], relations: ["scenarioStep"],
order: { order: 'ASC' }, order: { order: "ASC" },
}); });
} }
} catch (err) { } catch (err) {
this.logger.error(`Run #${runId}: unexpected error: ${(err as Error).message}`); this.logger.error(
await this.runRepo.update(runId, { status: 'fail' }); `Run #${runId}: unexpected error: ${(err as Error).message}`,
);
await this.runRepo.update(runId, { status: "fail" });
} finally { } finally {
this.activeRuns.delete(runId); this.activeRuns.delete(runId);
} }
@@ -88,14 +109,16 @@ export class ScenarioSchedulerService {
private async executeStepRun(stepRun: ScenarioRunStepEntity): Promise<void> { private async executeStepRun(stepRun: ScenarioRunStepEntity): Promise<void> {
const step = stepRun.scenarioStep as ScenarioStepEntity; const step = stepRun.scenarioStep as ScenarioStepEntity;
stepRun.status = 'in_progress'; stepRun.status = "in_progress";
await this.runStepRepo.save(stepRun); 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 { try {
if (step.type === 'login') { if (step.type === "login") {
await this.executeLoginStep(stepRun, step); await this.executeLoginStep(stepRun, step);
} else if (step.type === 'sign') { } else if (step.type === "sign") {
await this.executeSignStep(stepRun, step); await this.executeSignStep(stepRun, step);
} else { } else {
await this.executeExecStep(stepRun, step); await this.executeExecStep(stepRun, step);
@@ -109,24 +132,33 @@ export class ScenarioSchedulerService {
// ── Shared browser per run ───────────────────────────────────────────────── // ── Shared browser per run ─────────────────────────────────────────────────
private async getOrCreateBrowserHandle(runId: number, sessionName: string): Promise<BrowserHandle> { private async getOrCreateBrowserHandle(
runId: number,
sessionName: string,
): Promise<BrowserHandle> {
const existing = this.runBrowsers.get(runId); const existing = this.runBrowsers.get(runId);
if (existing) return existing; if (existing) return existing;
const session = await this.sessionService.findBySessionName(sessionName); const session = await this.sessionService.findBySessionName(sessionName);
if (!session) throw new Error(`Session not found: ${sessionName}`); if (!session) throw new Error(`Session not found: ${sessionName}`);
const cookies = JSON.parse(session.cookies) as Parameters<BrowserContext['addCookies']>[0]; const cookies = JSON.parse(session.cookies) as Parameters<
const localStorageData: Record<string, string> = JSON.parse(session.localStorage); BrowserContext["addCookies"]
>[0];
const localStorageData: Record<string, string> = JSON.parse(
session.localStorage,
);
const browser = await chromium.launch({ const browser = await chromium.launch({
headless: true, headless: true,
// TODO: env var move to config
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
}); });
const context = await browser.newContext(); const context = await browser.newContext();
await context.addCookies(cookies); await context.addCookies(cookies);
await context.addInitScript((entries: Record<string, string>) => { await context.addInitScript((entries: Record<string, string>) => {
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); }, localStorageData);
const page = await context.newPage(); const page = await context.newPage();
@@ -144,33 +176,56 @@ export class ScenarioSchedulerService {
await handle.browser.close(); await handle.browser.close();
this.logger.log(`Run #${runId}: browser closed`); this.logger.log(`Run #${runId}: browser closed`);
} catch (err) { } 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 ───────────────────────────────────────────────────────────── // ── Login step ─────────────────────────────────────────────────────────────
private async executeLoginStep(stepRun: ScenarioRunStepEntity, step: ScenarioStepEntity): Promise<void> { private async executeLoginStep(
stepRun: ScenarioRunStepEntity,
step: ScenarioStepEntity,
): Promise<void> {
// execCode must be a JSON object: { "keyId": "...", "environmentName": "..." } // execCode must be a JSON object: { "keyId": "...", "environmentName": "..." }
let params: { keyId: string; environmentName: string }; let params: { keyId: string; environmentName: string };
try { try {
params = JSON.parse(step.execCode ?? '{}'); params = JSON.parse(step.execCode ?? "{}");
} catch { } 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) { 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); const loginResult = await this.authService.login(
this.logger.log(`StepRun #${stepRun.id}: login OK, session=${loginResult.sessionName}`); params.keyId,
params.environmentName,
step.sessionName,
);
this.logger.log(
`StepRun #${stepRun.id}: login OK, session=${loginResult.sessionName}`,
);
if (step.validateCode) { 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); 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); 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); await this.passStepRun(stepRun, null);
@@ -178,19 +233,35 @@ export class ScenarioSchedulerService {
// ── Exec step ────────────────────────────────────────────────────────────── // ── Exec step ──────────────────────────────────────────────────────────────
private async executeExecStep(stepRun: ScenarioRunStepEntity, step: ScenarioStepEntity): Promise<void> { private async executeExecStep(
if (!step.execCode) throw new Error('exec step has no execCode'); stepRun: ScenarioRunStepEntity,
step: ScenarioStepEntity,
): Promise<void> {
if (!step.execCode) throw new Error("exec step has no execCode");
this.codeExecutor.validate(step.execCode); this.codeExecutor.validate(step.execCode);
const { page, context } = await this.getOrCreateBrowserHandle(stepRun.runId, step.sessionName); const { page, context } = await this.getOrCreateBrowserHandle(
await this.codeExecutor.execute(page, context, step.execCode, this.stepLogger(stepRun.id)); 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`); this.logger.log(`StepRun #${stepRun.id}: exec OK`);
if (step.validateCode) { if (step.validateCode) {
this.codeExecutor.validate(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); 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); await this.passStepRun(stepRun, null);
@@ -198,25 +269,36 @@ export class ScenarioSchedulerService {
// ── Sign step ────────────────────────────────────────────────────────────── // ── Sign step ──────────────────────────────────────────────────────────────
private async executeSignStep(stepRun: ScenarioRunStepEntity, step: ScenarioStepEntity): Promise<void> { private async executeSignStep(
stepRun: ScenarioRunStepEntity,
step: ScenarioStepEntity,
): Promise<void> {
// execCode must be a JSON object: { "keyId": "..." } // execCode must be a JSON object: { "keyId": "..." }
let params: { keyId: string }; let params: { keyId: string };
try { try {
params = JSON.parse(step.execCode ?? '{}'); params = JSON.parse(step.execCode ?? "{}");
} catch { } 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); await this.authService.signWithKey(params.keyId, page);
this.logger.log(`StepRun #${stepRun.id}: sign OK`); this.logger.log(`StepRun #${stepRun.id}: sign OK`);
if (step.validateCode) { if (step.validateCode) {
this.codeExecutor.validate(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); 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); await this.passStepRun(stepRun, null);
@@ -225,12 +307,13 @@ export class ScenarioSchedulerService {
// ── Validation helper ────────────────────────────────────────────────────── // ── Validation helper ──────────────────────────────────────────────────────
private parseValidateResult(raw: unknown): ValidateResult { private parseValidateResult(raw: unknown): ValidateResult {
if (typeof raw === 'boolean') return { success: raw }; if (typeof raw === "boolean") return { success: raw };
if (raw && typeof raw === 'object') { if (raw && typeof raw === "object") {
const r = raw as Record<string, unknown>; const r = raw as Record<string, unknown>;
return { return {
success: Boolean(r['success']), success: Boolean(r["success"]),
description: r['description'] != null ? String(r['description']) : undefined, description:
r["description"] != null ? String(r["description"]) : undefined,
}; };
} }
return { success: Boolean(raw) }; return { success: Boolean(raw) };
@@ -238,40 +321,46 @@ export class ScenarioSchedulerService {
// ── Pass / fail helpers ──────────────────────────────────────────────────── // ── Pass / fail helpers ────────────────────────────────────────────────────
private async passStepRun(stepRun: ScenarioRunStepEntity, description: string | null): Promise<void> { private async passStepRun(
stepRun.status = 'pass'; stepRun: ScenarioRunStepEntity,
description: string | null,
): Promise<void> {
stepRun.status = "pass";
stepRun.description = description; stepRun.description = description;
await this.runStepRepo.save(stepRun); await this.runStepRepo.save(stepRun);
this.logger.log(`StepRun #${stepRun.id} → pass`); this.logger.log(`StepRun #${stepRun.id} → pass`);
// Find the next waiting step in this run (next by order) // Find the next waiting step in this run (next by order)
const nextStep = await this.runStepRepo.findOne({ const nextStep = await this.runStepRepo.findOne({
where: { runId: stepRun.runId, status: 'waiting' }, where: { runId: stepRun.runId, status: "waiting" },
order: { order: 'ASC' }, order: { order: "ASC" },
}); });
if (nextStep) { if (nextStep) {
nextStep.status = 'pending'; nextStep.status = "pending";
await this.runStepRepo.save(nextStep); await this.runStepRepo.save(nextStep);
} else { } else {
// No more waiting steps — check if any are still in_progress/pending (shouldn't be, but guard anyway) // No more waiting steps — check if any are still in_progress/pending (shouldn't be, but guard anyway)
const remaining = await this.runStepRepo.count({ const remaining = await this.runStepRepo.count({
where: [ where: [
{ runId: stepRun.runId, status: 'pending' }, { runId: stepRun.runId, status: "pending" },
{ runId: stepRun.runId, status: 'in_progress' }, { runId: stepRun.runId, status: "in_progress" },
{ runId: stepRun.runId, status: 'waiting' }, { runId: stepRun.runId, status: "waiting" },
], ],
}); });
if (remaining === 0) { if (remaining === 0) {
await this.closeBrowserHandle(stepRun.runId); 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)`); this.logger.log(`Run #${stepRun.runId} → pass (all steps passed)`);
} }
} }
} }
private async failStepRun(stepRun: ScenarioRunStepEntity, description: string): Promise<void> { private async failStepRun(
stepRun.status = 'fail'; stepRun: ScenarioRunStepEntity,
description: string,
): Promise<void> {
stepRun.status = "fail";
stepRun.description = description; stepRun.description = description;
await this.runStepRepo.save(stepRun); await this.runStepRepo.save(stepRun);
this.logger.log(`StepRun #${stepRun.id} → fail: ${description}`); this.logger.log(`StepRun #${stepRun.id} → fail: ${description}`);
@@ -280,17 +369,17 @@ export class ScenarioSchedulerService {
await this.runStepRepo await this.runStepRepo
.createQueryBuilder() .createQueryBuilder()
.update() .update()
.set({ status: 'cancelled' }) .set({ status: "cancelled" })
.where('runId = :runId AND status IN (:...statuses)', { .where("runId = :runId AND status IN (:...statuses)", {
runId: stepRun.runId, runId: stepRun.runId,
statuses: ['waiting', 'pending'], statuses: ["waiting", "pending"],
}) })
.execute(); .execute();
this.logger.log(`Run #${stepRun.runId}: remaining steps cancelled`); this.logger.log(`Run #${stepRun.runId}: remaining steps cancelled`);
await this.closeBrowserHandle(stepRun.runId); 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`); this.logger.log(`Run #${stepRun.runId} → fail`);
} }
} }
+10 -10
View File
@@ -6,12 +6,12 @@ import {
UpdateDateColumn, UpdateDateColumn,
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
} from 'typeorm'; } from "typeorm";
import { ScenarioEntity } from './scenario.entity'; 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 { export class ScenarioStepEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id: number; id: number;
@@ -20,24 +20,24 @@ export class ScenarioStepEntity {
scenarioId: number; scenarioId: number;
@ManyToOne(() => ScenarioEntity, (scenario) => scenario.steps, { @ManyToOne(() => ScenarioEntity, (scenario) => scenario.steps, {
onDelete: 'CASCADE', onDelete: "CASCADE",
}) })
@JoinColumn({ name: 'scenarioId' }) @JoinColumn({ name: "scenarioId" })
scenario: ScenarioEntity; scenario: ScenarioEntity;
@Column({ default: 0 }) @Column({ default: 0 })
order: number; order: number;
@Column({ type: 'text' }) @Column({ type: "text" })
type: StepType; type: StepType;
@Column({ type: 'text' }) @Column({ type: "text" })
sessionName: string; sessionName: string;
@Column({ type: 'text', nullable: true }) @Column({ type: "text", nullable: true })
execCode: string | null; execCode: string | null;
@Column({ type: 'text', nullable: true }) @Column({ type: "text", nullable: true })
validateCode: string | null; validateCode: string | null;
@CreateDateColumn() @CreateDateColumn()
+95 -64
View File
@@ -9,145 +9,176 @@ import {
Patch, Patch,
Post, Post,
Query, Query,
} from '@nestjs/common'; } from "@nestjs/common";
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { ScenarioService } from './scenario.service'; import { ScenarioService } from "./scenario.service";
import { CreateScenarioDto } from './dto/create-scenario.dto'; import { CreateScenarioDto } from "./dto/create-scenario.dto";
import { UpdateScenarioDto } from './dto/update-scenario.dto'; import { UpdateScenarioDto } from "./dto/update-scenario.dto";
import { CreateScenarioStepDto } from './dto/create-scenario-step.dto'; import { CreateScenarioStepDto } from "./dto/create-scenario-step.dto";
import { UpdateScenarioStepDto } from './dto/update-scenario-step.dto'; import { UpdateScenarioStepDto } from "./dto/update-scenario-step.dto";
import { PaginationQueryDto } from './dto/pagination-query.dto'; import { PaginationQueryDto } from "./dto/pagination-query.dto";
import { ScenarioOrderBy } from './scenario.service'; import { ScenarioOrderBy } from "./scenario.service";
import { RunsQueryDto } from './dto/runs-query.dto'; import { RunsQueryDto } from "./dto/runs-query.dto";
import { ScenarioExportDto } from './dto/scenario-export.dto'; import { ScenarioExportDto } from "./dto/scenario-export.dto";
@ApiTags('scenarios') @ApiTags("scenarios")
@Controller('scenarios') @Controller("scenarios")
export class ScenarioController { export class ScenarioController {
constructor(private readonly scenarioService: ScenarioService) {} constructor(private readonly scenarioService: ScenarioService) {}
// ── Scenarios ───────────────────────────────────────────────────────────── // ── Scenarios ─────────────────────────────────────────────────────────────
@Post() @Post()
@ApiOperation({ summary: 'Create a scenario' }) @ApiOperation({ summary: "Create a scenario" })
@ApiResponse({ status: 201, description: 'Scenario created' }) @ApiResponse({ status: 201, description: "Scenario created" })
create(@Body() dto: CreateScenarioDto) { create(@Body() dto: CreateScenarioDto) {
return this.scenarioService.create(dto); return this.scenarioService.create(dto);
} }
@Post('import') @Post("import")
@ApiOperation({ summary: 'Import a scenario from an export payload' }) @ApiOperation({ summary: "Import a scenario from an export payload" })
@ApiResponse({ status: 201, description: 'Scenario imported' }) @ApiResponse({ status: 201, description: "Scenario imported" })
importScenario(@Body() dto: ScenarioExportDto) { importScenario(@Body() dto: ScenarioExportDto) {
return this.scenarioService.importScenario(dto); return this.scenarioService.importScenario(dto);
} }
@Get() @Get()
@ApiOperation({ summary: 'List all scenarios (paginated)' }) @ApiOperation({ summary: "List all scenarios (paginated)" })
@ApiResponse({ status: 200 }) @ApiResponse({ status: 200 })
findAll(@Query() query: PaginationQueryDto<ScenarioOrderBy>) { findAll(@Query() query: PaginationQueryDto<ScenarioOrderBy>) {
return this.scenarioService.findAll(query); return this.scenarioService.findAll(query);
} }
@Get(':id') @Get(":id")
@ApiOperation({ summary: 'Get a scenario with its steps' }) @ApiOperation({ summary: "Get a scenario with its steps" })
@ApiResponse({ status: 200 }) @ApiResponse({ status: 200 })
@ApiResponse({ status: 404, description: 'Scenario not found' }) @ApiResponse({ status: 404, description: "Scenario not found" })
findOne(@Param('id', ParseIntPipe) id: number) { findOne(@Param("id", ParseIntPipe) id: number) {
return this.scenarioService.findOne(id); return this.scenarioService.findOne(id);
} }
@Patch(':id') @Patch(":id")
@ApiOperation({ summary: 'Update a scenario' }) @ApiOperation({ summary: "Update a scenario" })
@ApiResponse({ status: 200 }) @ApiResponse({ status: 200 })
@ApiResponse({ status: 404, description: 'Scenario not found' }) @ApiResponse({ status: 404, description: "Scenario not found" })
update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateScenarioDto) { update(
@Param("id", ParseIntPipe) id: number,
@Body() dto: UpdateScenarioDto,
) {
return this.scenarioService.update(id, dto); return this.scenarioService.update(id, dto);
} }
@Delete(':id') @Delete(":id")
@HttpCode(204) @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: 204 })
@ApiResponse({ status: 404, description: 'Scenario not found' }) @ApiResponse({ status: 404, description: "Scenario not found" })
remove(@Param('id', ParseIntPipe) id: number) { remove(@Param("id", ParseIntPipe) id: number) {
return this.scenarioService.remove(id); return this.scenarioService.remove(id);
} }
@Get(':id/export') @Get(":id/export")
@ApiOperation({ summary: 'Export a scenario as a portable JSON payload' }) @ApiOperation({ summary: "Export a scenario as a portable JSON payload" })
@ApiResponse({ status: 200 }) @ApiResponse({ status: 200 })
@ApiResponse({ status: 404, description: 'Scenario not found' }) @ApiResponse({ status: 404, description: "Scenario not found" })
exportScenario(@Param('id', ParseIntPipe) id: number) { exportScenario(@Param("id", ParseIntPipe) id: number) {
return this.scenarioService.exportScenario(id); return this.scenarioService.exportScenario(id);
} }
// ── Steps ───────────────────────────────────────────────────────────────── // ── Steps ─────────────────────────────────────────────────────────────────
@Post(':id/steps') @Post(":id/steps")
@ApiOperation({ summary: 'Add a step to a scenario' }) @ApiOperation({ summary: "Add a step to a scenario" })
@ApiResponse({ status: 201, description: 'Step created' }) @ApiResponse({ status: 201, description: "Step created" })
@ApiResponse({ status: 404, description: 'Scenario not found' }) @ApiResponse({ status: 404, description: "Scenario not found" })
createStep( createStep(
@Param('id', ParseIntPipe) id: number, @Param("id", ParseIntPipe) id: number,
@Body() dto: CreateScenarioStepDto, @Body() dto: CreateScenarioStepDto,
) { ) {
return this.scenarioService.createStep(id, dto); return this.scenarioService.createStep(id, dto);
} }
@Get(':id/steps/:stepId') @Get(":id/steps/:stepId")
@ApiOperation({ summary: 'Get a single step' }) @ApiOperation({ summary: "Get a single step" })
@ApiResponse({ status: 200 }) @ApiResponse({ status: 200 })
@ApiResponse({ status: 404, description: 'Scenario or step not found' }) @ApiResponse({ status: 404, description: "Scenario or step not found" })
findStep( findStep(
@Param('id', ParseIntPipe) id: number, @Param("id", ParseIntPipe) id: number,
@Param('stepId', ParseIntPipe) stepId: number, @Param("stepId", ParseIntPipe) stepId: number,
) { ) {
return this.scenarioService.findStep(id, stepId); return this.scenarioService.findStep(id, stepId);
} }
@Patch(':id/steps/:stepId') @Patch(":id/steps/:stepId")
@ApiOperation({ summary: 'Update a step' }) @ApiOperation({ summary: "Update a step" })
@ApiResponse({ status: 200 }) @ApiResponse({ status: 200 })
@ApiResponse({ status: 404, description: 'Scenario or step not found' }) @ApiResponse({ status: 404, description: "Scenario or step not found" })
updateStep( updateStep(
@Param('id', ParseIntPipe) id: number, @Param("id", ParseIntPipe) id: number,
@Param('stepId', ParseIntPipe) stepId: number, @Param("stepId", ParseIntPipe) stepId: number,
@Body() dto: UpdateScenarioStepDto, @Body() dto: UpdateScenarioStepDto,
) { ) {
return this.scenarioService.updateStep(id, stepId, dto); return this.scenarioService.updateStep(id, stepId, dto);
} }
@Delete(':id/steps/:stepId') @Delete(":id/steps/:stepId")
@HttpCode(204) @HttpCode(204)
@ApiOperation({ summary: 'Delete a step' }) @ApiOperation({ summary: "Delete a step" })
@ApiResponse({ status: 204 }) @ApiResponse({ status: 204 })
@ApiResponse({ status: 404, description: 'Scenario or step not found' }) @ApiResponse({ status: 404, description: "Scenario or step not found" })
removeStep( removeStep(
@Param('id', ParseIntPipe) id: number, @Param("id", ParseIntPipe) id: number,
@Param('stepId', ParseIntPipe) stepId: number, @Param("stepId", ParseIntPipe) stepId: number,
) { ) {
return this.scenarioService.removeStep(id, stepId); return this.scenarioService.removeStep(id, stepId);
} }
// ── Runs ────────────────────────────────────────────────────────────────── // ── Runs ──────────────────────────────────────────────────────────────────
@Get(':id/runs') @Get(":id/runs")
@ApiOperation({ summary: 'List runs for a scenario (paginated, filterable by status)' }) @ApiOperation({
summary: "List runs for a scenario (paginated, filterable by status)",
})
@ApiResponse({ status: 200 }) @ApiResponse({ status: 200 })
@ApiResponse({ status: 404, description: 'Scenario not found' }) @ApiResponse({ status: 404, description: "Scenario not found" })
findRuns( findRuns(
@Param('id', ParseIntPipe) id: number, @Param("id", ParseIntPipe) id: number,
@Query() query: RunsQueryDto, @Query() query: RunsQueryDto,
) { ) {
return this.scenarioService.findRuns(id, query); return this.scenarioService.findRuns(id, query);
} }
@Post(':id/run') @Post(":id/run")
@ApiOperation({ summary: 'Create a new run for a scenario' }) @ApiOperation({ summary: "Create a new run for a scenario" })
@ApiResponse({ status: 201, description: 'Run created with step runs' }) @ApiResponse({ status: 201, description: "Run created with step runs" })
@ApiResponse({ status: 404, description: 'Scenario not found' }) @ApiResponse({ status: 404, description: "Scenario not found" })
createRun(@Param('id', ParseIntPipe) id: number) { createRun(@Param("id", ParseIntPipe) id: number) {
return this.scenarioService.createRun(id); 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);
}
} }
+3 -3
View File
@@ -5,10 +5,10 @@ import {
CreateDateColumn, CreateDateColumn,
UpdateDateColumn, UpdateDateColumn,
OneToMany, OneToMany,
} from 'typeorm'; } from "typeorm";
import { ScenarioStepEntity } from './scenario-step.entity'; import { ScenarioStepEntity } from "./scenario-step.entity";
@Entity('scenarios') @Entity("scenarios")
export class ScenarioEntity { export class ScenarioEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id: number; id: number;
+20 -13
View File
@@ -1,19 +1,26 @@
import { Module } from '@nestjs/common'; import { Module } from "@nestjs/common";
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from "@nestjs/typeorm";
import { ScenarioEntity } from './scenario.entity'; import { ScenarioEntity } from "./scenario.entity";
import { ScenarioStepEntity } from './scenario-step.entity'; import { ScenarioStepEntity } from "./scenario-step.entity";
import { ScenarioRunEntity } from './scenario-run.entity'; import { ScenarioRunEntity } from "./scenario-run.entity";
import { ScenarioRunStepEntity } from './scenario-run-step.entity'; import { ScenarioRunStepEntity } from "./scenario-run-step.entity";
import { ScenarioService } from './scenario.service'; import { ScenarioRunLogEntity } from "./scenario-run-log.entity";
import { ScenarioController } from './scenario.controller'; import { ScenarioService } from "./scenario.service";
import { ScenarioSchedulerService } from './scenario-scheduler.service'; import { ScenarioController } from "./scenario.controller";
import { AuthModule } from '../auth/auth.module'; import { ScenarioSchedulerService } from "./scenario-scheduler.service";
import { CodeExecutorModule } from '../code-executor/code-executor.module'; import { AuthModule } from "../auth/auth.module";
import { SessionModule } from '../session/session.module'; import { CodeExecutorModule } from "../code-executor/code-executor.module";
import { SessionModule } from "../session/session.module";
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([ScenarioEntity, ScenarioStepEntity, ScenarioRunEntity, ScenarioRunStepEntity]), TypeOrmModule.forFeature([
ScenarioEntity,
ScenarioStepEntity,
ScenarioRunEntity,
ScenarioRunStepEntity,
ScenarioRunLogEntity,
]),
AuthModule, AuthModule,
CodeExecutorModule, CodeExecutorModule,
SessionModule, SessionModule,
+100 -35
View File
@@ -1,20 +1,24 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from "@nestjs/common";
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from 'typeorm'; import { Repository } from "typeorm";
import { ScenarioEntity } from './scenario.entity'; import { ScenarioEntity } from "./scenario.entity";
import { ScenarioStepEntity } from './scenario-step.entity'; import { ScenarioStepEntity } from "./scenario-step.entity";
import { ScenarioRunEntity } from './scenario-run.entity'; import { ScenarioRunEntity } from "./scenario-run.entity";
import { ScenarioRunStepEntity } from './scenario-run-step.entity'; import { ScenarioRunStepEntity } from "./scenario-run-step.entity";
import { CreateScenarioDto } from './dto/create-scenario.dto'; import { ScenarioRunLogEntity } from "./scenario-run-log.entity";
import { UpdateScenarioDto } from './dto/update-scenario.dto'; import { CreateScenarioDto } from "./dto/create-scenario.dto";
import { CreateScenarioStepDto } from './dto/create-scenario-step.dto'; import { UpdateScenarioDto } from "./dto/update-scenario.dto";
import { UpdateScenarioStepDto } from './dto/update-scenario-step.dto'; import { CreateScenarioStepDto } from "./dto/create-scenario-step.dto";
import { PaginationQueryDto, PaginatedResult } from '../common/dto/pagination.dto'; import { UpdateScenarioStepDto } from "./dto/update-scenario-step.dto";
import { RunsQueryDto } from './dto/runs-query.dto'; import {
import { ScenarioExportDto } from './dto/scenario-export.dto'; 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 { PaginatedResult } from "../common/dto/pagination.dto";
export type ScenarioOrderBy = 'id' | 'name' | 'createdAt' | 'updatedAt'; export type ScenarioOrderBy = "id" | "name" | "createdAt" | "updatedAt";
@Injectable() @Injectable()
export class ScenarioService { export class ScenarioService {
@@ -27,6 +31,8 @@ export class ScenarioService {
private readonly runRepo: Repository<ScenarioRunEntity>, private readonly runRepo: Repository<ScenarioRunEntity>,
@InjectRepository(ScenarioRunStepEntity) @InjectRepository(ScenarioRunStepEntity)
private readonly runStepRepo: Repository<ScenarioRunStepEntity>, private readonly runStepRepo: Repository<ScenarioRunStepEntity>,
@InjectRepository(ScenarioRunLogEntity)
private readonly runLogRepo: Repository<ScenarioRunLogEntity>,
) {} ) {}
// ── Scenarios ───────────────────────────────────────────────────────────── // ── Scenarios ─────────────────────────────────────────────────────────────
@@ -35,11 +41,13 @@ export class ScenarioService {
return this.scenarioRepo.save(this.scenarioRepo.create(dto)); return this.scenarioRepo.save(this.scenarioRepo.create(dto));
} }
async findAll(query: PaginationQueryDto<ScenarioOrderBy>): Promise<PaginatedResult<ScenarioEntity>> { async findAll(
query: PaginationQueryDto<ScenarioOrderBy>,
): Promise<PaginatedResult<ScenarioEntity>> {
const page = query.page ?? 1; const page = query.page ?? 1;
const limit = query.limit ?? 20; const limit = query.limit ?? 20;
const orderBy = query.orderBy ?? 'id'; const orderBy = query.orderBy ?? "id";
const orderDir = query.orderDir ?? 'ASC'; const orderDir = query.orderDir ?? "ASC";
const [data, total] = await this.scenarioRepo.findAndCount({ const [data, total] = await this.scenarioRepo.findAndCount({
order: { [orderBy]: orderDir }, order: { [orderBy]: orderDir },
skip: (page - 1) * limit, skip: (page - 1) * limit,
@@ -51,8 +59,8 @@ export class ScenarioService {
async findOne(id: number): Promise<ScenarioEntity> { async findOne(id: number): Promise<ScenarioEntity> {
const scenario = await this.scenarioRepo.findOne({ const scenario = await this.scenarioRepo.findOne({
where: { id }, where: { id },
relations: ['steps'], relations: ["steps"],
order: { steps: { order: 'ASC' } }, order: { steps: { order: "ASC" } },
}); });
if (!scenario) throw new NotFoundException(`Scenario ${id} not found`); if (!scenario) throw new NotFoundException(`Scenario ${id} not found`);
return scenario; return scenario;
@@ -71,7 +79,10 @@ export class ScenarioService {
// ── Steps ───────────────────────────────────────────────────────────────── // ── Steps ─────────────────────────────────────────────────────────────────
async createStep(scenarioId: number, dto: CreateScenarioStepDto): Promise<ScenarioStepEntity> { async createStep(
scenarioId: number,
dto: CreateScenarioStepDto,
): Promise<ScenarioStepEntity> {
await this.findOne(scenarioId); await this.findOne(scenarioId);
return this.stepRepo.save( return this.stepRepo.save(
this.stepRepo.create({ this.stepRepo.create({
@@ -83,13 +94,23 @@ export class ScenarioService {
); );
} }
async findStep(scenarioId: number, stepId: number): Promise<ScenarioStepEntity> { async findStep(
scenarioId: number,
stepId: number,
): Promise<ScenarioStepEntity> {
const step = await this.stepRepo.findOneBy({ id: stepId, scenarioId }); 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; return step;
} }
async updateStep(scenarioId: number, stepId: number, dto: UpdateScenarioStepDto): Promise<ScenarioStepEntity> { async updateStep(
scenarioId: number,
stepId: number,
dto: UpdateScenarioStepDto,
): Promise<ScenarioStepEntity> {
const step = await this.findStep(scenarioId, stepId); const step = await this.findStep(scenarioId, stepId);
Object.assign(step, dto); Object.assign(step, dto);
return this.stepRepo.save(step); return this.stepRepo.save(step);
@@ -100,26 +121,71 @@ export class ScenarioService {
await this.stepRepo.delete(stepId); await this.stepRepo.delete(stepId);
} }
async findRuns(scenarioId: number, query: RunsQueryDto): Promise<PaginatedResult<ScenarioRunEntity>> { async findRuns(
scenarioId: number,
query: RunsQueryDto,
): Promise<PaginatedResult<ScenarioRunEntity>> {
await this.findOne(scenarioId); // 404 guard await this.findOne(scenarioId); // 404 guard
const page = query.page ?? 1; const page = query.page ?? 1;
const limit = query.limit ?? 20; const limit = query.limit ?? 20;
const where: Record<string, unknown> = { scenarioId }; const where: Record<string, unknown> = { scenarioId };
if (query.status) where['status'] = query.status; if (query.status) where["status"] = query.status;
const [data, total] = await this.runRepo.findAndCount({ const [data, total] = await this.runRepo.findAndCount({
where, where,
relations: ['stepRuns'], relations: ["stepRuns"],
order: { id: 'DESC', stepRuns: { order: 'ASC' } }, order: { id: "DESC", stepRuns: { order: "ASC" } },
skip: (page - 1) * limit, skip: (page - 1) * limit,
take: limit, take: limit,
}); });
return { data, total, page, limit }; return { data, total, page, limit };
} }
async createRun(scenarioId: number): Promise<ScenarioRunEntity> { const scenario = await this.findOne(scenarioId); async findRun(
scenarioId: number,
runId: number,
): Promise<ScenarioRunEntity & { logs: ScenarioRunLogEntity[] }> {
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<ScenarioRunEntity & { logs: ScenarioRunLogEntity[] }> {
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<void>((resolve) => setTimeout(resolve, 500));
}
return this.findRun(scenarioId, runId);
}
async createRun(scenarioId: number): Promise<ScenarioRunEntity> {
const scenario = await this.findOne(scenarioId);
const run = await this.runRepo.save( 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) => const stepRuns = scenario.steps.map((step, index) =>
@@ -127,7 +193,7 @@ export class ScenarioService {
runId: run.id, runId: run.id,
scenarioStepId: step.id, scenarioStepId: step.id,
order: step.order, order: step.order,
status: index === 0 ? 'pending' : 'waiting', status: index === 0 ? "pending" : "waiting",
description: null, description: null,
}), }),
); );
@@ -136,8 +202,8 @@ export class ScenarioService {
return this.runRepo.findOne({ return this.runRepo.findOne({
where: { id: run.id }, where: { id: run.id },
relations: ['stepRuns'], relations: ["stepRuns"],
order: { stepRuns: { order: 'ASC' } }, order: { stepRuns: { order: "ASC" } },
}) as Promise<ScenarioRunEntity>; }) as Promise<ScenarioRunEntity>;
} }
@@ -177,4 +243,3 @@ export class ScenarioService {
return this.findOne(scenario.id); return this.findOne(scenario.id);
} }
} }
+23 -15
View File
@@ -1,28 +1,36 @@
import { Controller, Delete, Get, NotFoundException, Param, ParseIntPipe, Query } from '@nestjs/common'; import {
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; Controller,
import { SessionService } from './session.service'; Delete,
import { PaginationQueryDto } from '../common/dto/pagination.dto'; Get,
import { SessionOrderBy } from './session.service'; 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') @ApiTags("sessions")
@Controller('sessions') @Controller("sessions")
export class SessionController { export class SessionController {
constructor(private readonly sessionService: SessionService) {} constructor(private readonly sessionService: SessionService) {}
@Get() @Get()
@ApiOperation({ summary: 'List all stored sessions (paginated)' }) @ApiOperation({ summary: "List all stored sessions (paginated)" })
@ApiResponse({ status: 200, description: 'Paginated sessions' }) @ApiResponse({ status: 200, description: "Paginated sessions" })
findAll(@Query() query: PaginationQueryDto<SessionOrderBy>) { findAll(@Query() query: PaginationQueryDto<SessionOrderBy>) {
return this.sessionService.findAll(query); return this.sessionService.findAll(query);
} }
@Delete(':id') @Delete(":id")
@ApiOperation({ summary: 'Delete a session by ID' }) @ApiOperation({ summary: "Delete a session by ID" })
@ApiResponse({ status: 200, description: 'Session deleted' }) @ApiResponse({ status: 200, description: "Session deleted" })
@ApiResponse({ status: 404, description: 'Session not found' }) @ApiResponse({ status: 404, description: "Session not found" })
async remove(@Param('id', ParseIntPipe) id: number): Promise<void> { async remove(@Param("id", ParseIntPipe) id: number): Promise<void> {
const sessions = await this.sessionService.findAll(); 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`); throw new NotFoundException(`Session ${id} not found`);
} }
await this.sessionService.remove(id); await this.sessionService.remove(id);
+5 -5
View File
@@ -4,9 +4,9 @@ import {
Column, Column,
CreateDateColumn, CreateDateColumn,
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } from "typeorm";
@Entity('sessions') @Entity("sessions")
export class SessionEntity { export class SessionEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id: number; id: number;
@@ -14,13 +14,13 @@ export class SessionEntity {
@Column({ unique: true }) @Column({ unique: true })
sessionName: string; sessionName: string;
@Column('text') @Column("text")
token: string; token: string;
@Column('text') @Column("text")
cookies: string; // JSON-serialised Cookie[] from Playwright cookies: string; // JSON-serialised Cookie[] from Playwright
@Column('text', { default: '{}' }) @Column("text", { default: "{}" })
localStorage: string; // JSON-serialised Record<string, string> from Playwright localStorage: string; // JSON-serialised Record<string, string> from Playwright
@CreateDateColumn() @CreateDateColumn()
+5 -5
View File
@@ -1,8 +1,8 @@
import { Module } from '@nestjs/common'; import { Module } from "@nestjs/common";
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from "@nestjs/typeorm";
import { SessionEntity } from './session.entity'; import { SessionEntity } from "./session.entity";
import { SessionService } from './session.service'; import { SessionService } from "./session.service";
import { SessionController } from './session.controller'; import { SessionController } from "./session.controller";
@Module({ @Module({
imports: [TypeOrmModule.forFeature([SessionEntity])], imports: [TypeOrmModule.forFeature([SessionEntity])],
+20 -11
View File
@@ -1,11 +1,14 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from "@nestjs/common";
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from 'typeorm'; import { Repository } from "typeorm";
import { SessionEntity } from './session.entity'; import { SessionEntity } from "./session.entity";
import type { Cookie } from 'playwright'; import type { Cookie } from "playwright";
import { PaginationQueryDto, PaginatedResult } from '../common/dto/pagination.dto'; import {
PaginationQueryDto,
PaginatedResult,
} from "../common/dto/pagination.dto";
export type SessionOrderBy = 'id' | 'sessionName' | 'createdAt' | 'updatedAt'; export type SessionOrderBy = "id" | "sessionName" | "createdAt" | "updatedAt";
@Injectable() @Injectable()
export class SessionService { export class SessionService {
@@ -43,13 +46,19 @@ export class SessionService {
return this.repo.findOneBy({ sessionName }); return this.repo.findOneBy({ sessionName });
} }
async findAll(query: PaginationQueryDto<SessionOrderBy> = {}): Promise<PaginatedResult<Pick<SessionEntity, 'id' | 'sessionName' | 'createdAt' | 'updatedAt'>>> { async findAll(
query: PaginationQueryDto<SessionOrderBy> = {},
): Promise<
PaginatedResult<
Pick<SessionEntity, "id" | "sessionName" | "createdAt" | "updatedAt">
>
> {
const page = query.page ?? 1; const page = query.page ?? 1;
const limit = query.limit ?? 20; const limit = query.limit ?? 20;
const orderBy = query.orderBy ?? 'id'; const orderBy = query.orderBy ?? "id";
const orderDir = query.orderDir ?? 'ASC'; const orderDir = query.orderDir ?? "ASC";
const [data, total] = await this.repo.findAndCount({ const [data, total] = await this.repo.findAndCount({
select: ['id', 'sessionName', 'createdAt', 'updatedAt'], select: ["id", "sessionName", "createdAt", "updatedAt"],
order: { [orderBy]: orderDir }, order: { [orderBy]: orderDir },
skip: (page - 1) * limit, skip: (page - 1) * limit,
take: limit, take: limit,
+6 -3
View File
@@ -1,14 +1,17 @@
const mockElement = { const mockElement = {
outerHTML: '<div id="mock">mock content</div>', outerHTML: '<div id="mock">mock content</div>',
textContent: 'mock content', textContent: "mock content",
}; };
export class JSDOM { export class JSDOM {
constructor(public html: string, public options?: any) {} constructor(
public html: string,
public options?: Record<string, unknown>,
) {}
get window() { get window() {
return { return {
document: { document: {
querySelector: (_selector: string) => mockElement, querySelector: () => mockElement,
}, },
}; };
} }
+3 -3
View File
@@ -3,9 +3,9 @@ export const chromium = {
newContext: jest.fn().mockResolvedValue({ newContext: jest.fn().mockResolvedValue({
newPage: jest.fn().mockResolvedValue({ newPage: jest.fn().mockResolvedValue({
goto: jest.fn(), goto: jest.fn(),
content: jest.fn().mockResolvedValue('<html></html>'), content: jest.fn().mockResolvedValue("<html></html>"),
title: jest.fn().mockReturnValue(''), title: jest.fn().mockReturnValue(""),
url: jest.fn().mockReturnValue(''), url: jest.fn().mockReturnValue(""),
evaluate: jest.fn(), evaluate: jest.fn(),
close: jest.fn(), close: jest.fn(),
}), }),
+2 -2
View File
@@ -1,6 +1,6 @@
export class Readability { export class Readability {
constructor(private doc: any) {} constructor(private doc: unknown) {}
parse() { parse() {
return { textContent: '' }; return { textContent: "" };
} }
} }
+39 -33
View File
@@ -1,23 +1,27 @@
import { INestApplication, ValidationPipe } from '@nestjs/common'; import { INestApplication, ValidationPipe } from "@nestjs/common";
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from "@nestjs/testing";
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from "@nestjs/typeorm";
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from "@nestjs/config";
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from "@nestjs/schedule";
import debug from 'debug'; 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', () => { Logger.prototype.error = function (
const actual = jest.requireActual('@nestjs/common'); message: unknown,
const log = require('debug')('test'); stack?: string,
const { Logger } = require('@nestjs/common/services/logger.service'); context?: string,
) {
Logger.prototype.error = function (message: unknown, stack?: string, context?: string) { const ctx = context ?? this.context ?? "App";
const ctx = context ?? this.context ?? 'App'; log(`[${ctx}]`, "error", message, ...(stack ? [stack] : []));
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) { Logger.prototype[level] = function (message: unknown, context?: string) {
const ctx = context ?? this.context ?? 'App'; const ctx = context ?? this.context ?? "App";
log(`[${ctx}]`, level, message); log(`[${ctx}]`, level, message);
}; };
} }
@@ -25,29 +29,30 @@ jest.mock('@nestjs/common', () => {
return actual; return actual;
}); });
import { AuthModule } from '../src/auth/auth.module'; import { AuthModule } from "../src/auth/auth.module";
import { BrowserModule } from '../src/browser/browser.module'; import { BrowserModule } from "../src/browser/browser.module";
import { SessionModule } from '../src/session/session.module'; import { SessionModule } from "../src/session/session.module";
import { EnvironmentModule } from '../src/environment/environment.module'; import { EnvironmentModule } from "../src/environment/environment.module";
import { ScenarioModule } from '../src/scenario/scenario.module'; import { ScenarioModule } from "../src/scenario/scenario.module";
import { McpModule } from '../src/mcp/mcp.module'; import { McpModule } from "../src/mcp/mcp.module";
import { SessionEntity } from '../src/session/session.entity'; import { SessionEntity } from "../src/session/session.entity";
import { EnvironmentEntity } from '../src/environment/environment.entity'; import { EnvironmentEntity } from "../src/environment/environment.entity";
import { ScenarioEntity } from '../src/scenario/scenario.entity'; import { ScenarioEntity } from "../src/scenario/scenario.entity";
import { ScenarioStepEntity } from '../src/scenario/scenario-step.entity'; import { ScenarioStepEntity } from "../src/scenario/scenario-step.entity";
import { ScenarioRunEntity } from '../src/scenario/scenario-run.entity'; import { ScenarioRunEntity } from "../src/scenario/scenario-run.entity";
import { ScenarioRunStepEntity } from '../src/scenario/scenario-run-step.entity'; import { ScenarioRunStepEntity } from "../src/scenario/scenario-run-step.entity";
import { HealthController } from '../src/health/health.controller'; import { ScenarioRunLogEntity } from "../src/scenario/scenario-run-log.entity";
import { HttpExceptionFilter } from '../src/filters/http-exception.filter'; import { HealthController } from "../src/health/health.controller";
import { LoggingInterceptor } from '../src/interceptors/logging.interceptor'; import { HttpExceptionFilter } from "../src/filters/http-exception.filter";
import { LoggingInterceptor } from "../src/interceptors/logging.interceptor";
export async function buildTestApp(): Promise<INestApplication> { export async function buildTestApp(): Promise<INestApplication> {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
imports: [ imports: [
ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true }), ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true }),
TypeOrmModule.forRoot({ TypeOrmModule.forRoot({
type: 'better-sqlite3', type: "better-sqlite3",
database: ':memory:', database: ":memory:",
entities: [ entities: [
SessionEntity, SessionEntity,
EnvironmentEntity, EnvironmentEntity,
@@ -55,6 +60,7 @@ export async function buildTestApp(): Promise<INestApplication> {
ScenarioStepEntity, ScenarioStepEntity,
ScenarioRunEntity, ScenarioRunEntity,
ScenarioRunStepEntity, ScenarioRunStepEntity,
ScenarioRunLogEntity,
], ],
synchronize: true, synchronize: true,
}), }),
+20 -20
View File
@@ -1,6 +1,6 @@
import { INestApplication } from '@nestjs/common'; import { INestApplication } from "@nestjs/common";
import request from 'supertest'; import request from "supertest";
import { buildTestApp } from './app.harness'; import { buildTestApp } from "./app.harness";
/** /**
* Auth controller integration tests. * Auth controller integration tests.
@@ -9,7 +9,7 @@ import { buildTestApp } from './app.harness';
* exercised here (those belong to e2e tests with real credentials). * exercised here (those belong to e2e tests with real credentials).
* We cover the parts that can be tested without external dependencies. * We cover the parts that can be tested without external dependencies.
*/ */
describe('AuthController', () => { describe("AuthController", () => {
let app: INestApplication; let app: INestApplication;
beforeAll(async () => { beforeAll(async () => {
@@ -22,39 +22,39 @@ describe('AuthController', () => {
// ── GET /keys ────────────────────────────────────────────────────────────── // ── GET /keys ──────────────────────────────────────────────────────────────
describe('GET /keys', () => { describe("GET /keys", () => {
it('returns 200 with a keys array', async () => { it("returns 200 with a keys array", async () => {
const res = await request(app.getHttpServer()).get('/keys').expect(200); const res = await request(app.getHttpServer()).get("/keys").expect(200);
expect(res.body).toHaveProperty('keys'); expect(res.body).toHaveProperty("keys");
expect(Array.isArray(res.body.keys)).toBe(true); expect(Array.isArray(res.body.keys)).toBe(true);
}); });
}); });
// ── POST /login ──────────────────────────────────────────────────────────── // ── POST /login ────────────────────────────────────────────────────────────
describe('POST /login', () => { describe("POST /login", () => {
it('returns 400 when body is empty', async () => { it("returns 400 when body is empty", async () => {
await request(app.getHttpServer()).post('/login').send({}).expect(400); 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()) await request(app.getHttpServer())
.post('/login') .post("/login")
.send({ environmentName: 'test-env' }) .send({ environmentName: "test-env" })
.expect(400); .expect(400);
}); });
it('returns 400 when environmentName is missing', async () => { it("returns 400 when environmentName is missing", async () => {
await request(app.getHttpServer()) await request(app.getHttpServer())
.post('/login') .post("/login")
.send({ key: 'some-key' }) .send({ key: "some-key" })
.expect(400); .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()) await request(app.getHttpServer())
.post('/login') .post("/login")
.send({ key: 'nonexistent-key', environmentName: 'test-env' }) .send({ key: "nonexistent-key", environmentName: "test-env" })
.expect(404); // NotFoundException for missing environment .expect(404); // NotFoundException for missing environment
}); });
}); });
+50 -44
View File
@@ -1,9 +1,9 @@
import { INestApplication } from '@nestjs/common'; import { INestApplication } from "@nestjs/common";
import { getRepositoryToken } from '@nestjs/typeorm'; import { getRepositoryToken } from "@nestjs/typeorm";
import request from 'supertest'; import request from "supertest";
import { buildTestApp } from './app.harness'; import { buildTestApp } from "./app.harness";
import { SessionEntity } from '../src/session/session.entity'; import { SessionEntity } from "../src/session/session.entity";
import { Repository } from 'typeorm'; import { Repository } from "typeorm";
/** /**
* Browser controller integration tests. * Browser controller integration tests.
@@ -12,24 +12,26 @@ import { Repository } from 'typeorm';
* validation rejections (no browser launched) and session-not-found paths, * validation rejections (no browser launched) and session-not-found paths,
* which are safe to run in a headless CI environment. * which are safe to run in a headless CI environment.
*/ */
describe('BrowserController', () => { describe("BrowserController", () => {
let app: INestApplication; let app: INestApplication;
let sessionRepo: Repository<SessionEntity>; let sessionRepo: Repository<SessionEntity>;
const FAKE_SESSION = 'test-browser-session'; const FAKE_SESSION = "test-browser-session";
beforeAll(async () => { beforeAll(async () => {
app = await buildTestApp(); app = await buildTestApp();
sessionRepo = app.get<Repository<SessionEntity>>(getRepositoryToken(SessionEntity)); sessionRepo = app.get<Repository<SessionEntity>>(
getRepositoryToken(SessionEntity),
);
// Seed a session with minimal but valid JSON so the browser code can // 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) // deserialise it (it will still fail to open a real page, tested separately)
await sessionRepo.save( await sessionRepo.save(
sessionRepo.create({ sessionRepo.create({
sessionName: FAKE_SESSION, sessionName: FAKE_SESSION,
token: 'fake-token', token: "fake-token",
cookies: '[]', cookies: "[]",
localStorage: '{}', localStorage: "{}",
}), }),
); );
}); });
@@ -40,29 +42,29 @@ describe('BrowserController', () => {
// ── POST /open ───────────────────────────────────────────────────────────── // ── POST /open ─────────────────────────────────────────────────────────────
describe('POST /open', () => { describe("POST /open", () => {
it('returns 400 when body is empty', async () => { it("returns 400 when body is empty", async () => {
await request(app.getHttpServer()).post('/open').send({}).expect(400); 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()) await request(app.getHttpServer())
.post('/open') .post("/open")
.send({ sessionName: FAKE_SESSION }) .send({ sessionName: FAKE_SESSION })
.expect(400); .expect(400);
}); });
it('returns 404 when session does not exist', async () => { it("returns 404 when session does not exist", async () => {
await request(app.getHttpServer()) await request(app.getHttpServer())
.post('/open') .post("/open")
.send({ sessionName: 'no-such-session', url: 'https://example.com' }) .send({ sessionName: "no-such-session", url: "https://example.com" })
.expect(404); .expect(404);
}); });
it('succeeds without a session (sessionless open)', async () => { it("succeeds without a session (sessionless open)", async () => {
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.post('/open') .post("/open")
.send({ url: 'https://example.com' }) .send({ url: "https://example.com" })
.expect(201); .expect(201);
expect(res.body).toMatchObject({ expect(res.body).toMatchObject({
url: expect.any(String), 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()) const res = await request(app.getHttpServer())
.post('/open') .post("/open")
.send({ url: 'https://example.com', selector: '#mock' }) .send({ url: "https://example.com", selector: "#mock" })
.expect(201); .expect(201);
expect(res.body.content).toBe('<div id="mock">mock content</div>'); expect(res.body.content).toBe('<div id="mock">mock content</div>');
}); });
it('returns selector text content in reader mode', async () => { it("returns selector text content in reader mode", async () => {
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.post('/open') .post("/open")
.send({ url: 'https://example.com', selector: '#mock', readerMode: true }) .send({
url: "https://example.com",
selector: "#mock",
readerMode: true,
})
.expect(201); .expect(201);
expect(res.body.content).toBe('mock content'); expect(res.body.content).toBe("mock content");
}); });
}); });
// ── POST /exec ───────────────────────────────────────────────────────────── // ── POST /exec ─────────────────────────────────────────────────────────────
describe('POST /exec', () => { describe("POST /exec", () => {
it('returns 400 when body is empty', async () => { it("returns 400 when body is empty", async () => {
await request(app.getHttpServer()).post('/exec').send({}).expect(400); 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()) await request(app.getHttpServer())
.post('/exec') .post("/exec")
.send({ sessionName: FAKE_SESSION }) .send({ sessionName: FAKE_SESSION })
.expect(400); .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()) await request(app.getHttpServer())
.post('/exec') .post("/exec")
.send({ sessionName: FAKE_SESSION, code: 'this is not valid {{{' }) .send({ sessionName: FAKE_SESSION, code: "this is not valid {{{" })
.expect(400); .expect(400);
}); });
it('returns 404 when session does not exist', async () => { it("returns 404 when session does not exist", async () => {
await request(app.getHttpServer()) await request(app.getHttpServer())
.post('/exec') .post("/exec")
.send({ sessionName: 'no-such-session', code: 'return 1;' }) .send({ sessionName: "no-such-session", code: "return 1;" })
.expect(404); .expect(404);
}); });
it('succeeds without a session (sessionless exec)', async () => { it("succeeds without a session (sessionless exec)", async () => {
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.post('/exec') .post("/exec")
.send({ code: 'return 42;' }) .send({ code: "return 42;" })
.expect(201); .expect(201);
expect(res.body).toEqual({ result: 42 }); expect(res.body).toEqual({ result: 42 });
}); });
+219 -140
View File
@@ -8,7 +8,7 @@
* fake globals injected as named parameters. * 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 // Fake DOM builder
@@ -39,10 +39,10 @@ interface FakeEl {
type ElAttrs = Partial<{ type ElAttrs = Partial<{
role: string; role: string;
'data-testid': string; "data-testid": string;
'data-qa': string; "data-qa": string;
'data-action': string; "data-action": string;
'data-element-id': string; "data-element-id": string;
id: string; id: string;
type: string; type: string;
name: string; name: string;
@@ -52,19 +52,31 @@ type ElAttrs = Partial<{
style: string; 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 const ownTextNodes: FakeText[] = children
.filter((c): c is string => typeof c === 'string') .filter((c): c is string => typeof c === "string")
.map(t => ({ nodeType: 3, textContent: t })); .map((t) => ({ nodeType: 3, textContent: t }));
const childEls = children.filter((c): c is FakeEl => typeof c !== 'string'); const childEls = children.filter((c): c is FakeEl => typeof c !== "string");
const deepText = children.map(c => (typeof c === 'string' ? c : c.innerText)).join(''); const deepText = children
.map((c) => (typeof c === "string" ? c : c.innerText))
.join("");
const style = attrs.style ?? ''; const style = attrs.style ?? "";
const display = /display\s*:\s*none/.test(style) ? 'none' : ''; const display = /display\s*:\s*none/.test(style) ? "none" : "";
const visibility = /visibility\s*:\s*hidden/.test(style) ? 'hidden' : ''; const visibility = /visibility\s*:\s*hidden/.test(style) ? "hidden" : "";
const attrMap: Record<string, string | null> = {}; const attrMap: Record<string, string | null> = {};
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; 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, children: childEls,
childNodes: ownTextNodes, childNodes: ownTextNodes,
offsetParent: display || visibility ? null : {}, offsetParent: display || visibility ? null : {},
id: attrs.id ?? '', id: attrs.id ?? "",
type: attrs.type ?? '', type: attrs.type ?? "",
name: attrs.name ?? '', name: attrs.name ?? "",
href: attrs.href href: attrs.href
? attrs.href.startsWith('http') ? attrs.href.startsWith("http")
? attrs.href ? attrs.href
: `https://example.com${attrs.href}` : `https://example.com${attrs.href}`
: '', : "",
checked: !!attrs.checked, checked: !!attrs.checked,
disabled: !!attrs.disabled, disabled: !!attrs.disabled,
innerText: deepText, innerText: deepText,
textContent: deepText, textContent: deepText,
getAttribute(name: string) { return attrMap[name] ?? null; }, getAttribute(name: string) {
return attrMap[name] ?? null;
},
_display: display, _display: display,
_visibility: visibility, _visibility: visibility,
}; };
} }
function body(...children: (FakeEl | string)[]): FakeEl { function body(...children: (FakeEl | string)[]): FakeEl {
return el('body', {}, ...children); return el("body", {}, ...children);
} }
// ── Fake page ────────────────────────────────────────────────────────────── // ── Fake page ──────────────────────────────────────────────────────────────
@@ -109,26 +123,34 @@ function findByTag(root: FakeEl, tag: string): FakeEl | null {
function makePage(rootEl: FakeEl) { function makePage(rootEl: FakeEl) {
const fakeDocument = { const fakeDocument = {
querySelector(sel: string): FakeEl | null { 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); return findByTag(rootEl, sel);
}, },
}; };
const fakeWindow = { const fakeWindow = {
getComputedStyle: (e: FakeEl) => ({ display: e._display, visibility: e._visibility }), getComputedStyle: (e: FakeEl) => ({
location: { origin: 'https://example.com' }, display: e._display,
visibility: e._visibility,
}),
location: { origin: "https://example.com" },
}; };
const fakeNode = { TEXT_NODE: 3 }; const fakeNode = { TEXT_NODE: 3 };
const evaluate = jest.fn().mockImplementation((fn: Function, args: unknown) => { const evaluate = jest
// eslint-disable-next-line no-new-func .fn()
const exec = new Function( .mockImplementation((fn: (...args: unknown[]) => unknown, args: unknown) => {
'document', 'window', 'Node', '__args__', const exec = new Function(
`return (${fn.toString()})(__args__)`, "document",
); "window",
return Promise.resolve(exec(fakeDocument, fakeWindow, fakeNode, args)); "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<DomNode> => const dump = (rootEl: FakeEl, sel?: string): Promise<DomNode> =>
@@ -138,205 +160,262 @@ const dump = (rootEl: FakeEl, sel?: string): Promise<DomNode> =>
// Tests // Tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe('dumpDom', () => { describe("dumpDom", () => {
// ── Error handling ──────────────────────────────────────────────────────── // ── Error handling ────────────────────────────────────────────────────────
it('returns an ERROR node when the root selector is not found', async () => { it("returns an ERROR node when the root selector is not found", async () => {
const result = await dump(body(el('p', {}, 'hello')), '#does-not-exist'); const result = await dump(body(el("p", {}, "hello")), "#does-not-exist");
expect(result.tag).toBe('ERROR'); expect(result.tag).toBe("ERROR");
expect(result.text).toContain('#does-not-exist'); expect(result.text).toContain("#does-not-exist");
}); });
// ── Scope & defaults ────────────────────────────────────────────────────── // ── Scope & defaults ──────────────────────────────────────────────────────
it('defaults to body scope', async () => { it("defaults to body scope", async () => {
const result = await dump(body(el('button', {}, 'Go'))); const result = await dump(body(el("button", {}, "Go")));
expect(result.tag).toBe('body'); expect(result.tag).toBe("body");
}); });
it('scopes to an arbitrary sub-selector', async () => { it("scopes to an arbitrary sub-selector", async () => {
const root = body( const root = body(
el('header', {}, el('a', { href: '/nav' }, 'Nav')), el("header", {}, el("a", { href: "/nav" }, "Nav")),
el('main', {}, el('button', {}, 'Action')), el("main", {}, el("button", {}, "Action")),
); );
const result = await dump(root, 'main'); const result = await dump(root, "main");
expect(result.tag).toBe('main'); expect(result.tag).toBe("main");
expect(result.children.find(c => c.tag === 'header')).toBeUndefined(); expect(result.children.find((c) => c.tag === "header")).toBeUndefined();
}); });
// ── Visibility filtering ────────────────────────────────────────────────── // ── Visibility filtering ──────────────────────────────────────────────────
it('skips elements with display:none', async () => { it("skips elements with display:none", async () => {
const root = body( const root = body(
el('button', { style: 'display:none' }, 'Hidden'), el("button", { style: "display:none" }, "Hidden"),
el('button', {}, 'Visible'), el("button", {}, "Visible"),
); );
const result = await dump(root); 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).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( const root = body(
el('button', { style: 'visibility:hidden' }, 'Hidden'), el("button", { style: "visibility:hidden" }, "Hidden"),
el('button', {}, 'Visible'), el("button", {}, "Visible"),
); );
const result = await dump(root); 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).toHaveLength(1);
expect(btns[0].text).toBe('Visible'); expect(btns[0].text).toBe("Visible");
}); });
// ── Ignored tag types ───────────────────────────────────────────────────── // ── Ignored tag types ─────────────────────────────────────────────────────
it('skips SVG elements', async () => { it("skips SVG elements", async () => {
const svgEl = el('svg', {}, el('path', {})); const svgEl = el("svg", {}, el("path", {}));
const result = await dump(body(el('button', {}, svgEl, 'Click'))); const result = await dump(body(el("button", {}, svgEl, "Click")));
const btn = result.children.find(c => c.tag === 'button'); const btn = result.children.find((c) => c.tag === "button");
expect(btn).toBeDefined(); 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 () => { it("skips SCRIPT elements", async () => {
const result = await dump(body(el('script', {}, 'alert(1)'), el('button', {}, 'OK'))); const result = await dump(
expect(result.children.some(c => c.tag === 'script')).toBe(false); body(el("script", {}, "alert(1)"), el("button", {}, "OK")),
);
expect(result.children.some((c) => c.tag === "script")).toBe(false);
}); });
// ── Semantic attributes ─────────────────────────────────────────────────── // ── Semantic attributes ───────────────────────────────────────────────────
it('captures data-testid', async () => { it("captures data-testid", async () => {
const result = await dump(body(el('button', { 'data-testid': 'save-btn' }, 'Save'))); const result = await dump(
expect(result.children.find(c => c.tag === 'button')?.testid).toBe('save-btn'); 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 () => { it("captures data-qa", async () => {
const result = await dump(body(el('div', { 'data-qa': 'process-name' }, el('input', { type: 'text' })))); const result = await dump(
expect(result.children.find(c => c.qa === 'process-name')).toBeDefined(); 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 () => { it("captures data-action", async () => {
const result = await dump(body(el('div', { 'data-action': 'append.append-task' }))); const result = await dump(
expect(result.children.find(c => c.action === 'append.append-task')).toBeDefined(); 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 () => { it("captures data-element-id", async () => {
const result = await dump(body(el('div', { 'data-element-id': 'Activity_1abc' }))); const result = await dump(
expect(result.children.find(c => c.elementId === 'Activity_1abc')).toBeDefined(); body(el("div", { "data-element-id": "Activity_1abc" })),
);
expect(
result.children.find((c) => c.elementId === "Activity_1abc"),
).toBeDefined();
}); });
it('captures role attribute', async () => { it("captures role attribute", async () => {
const result = await dump(body(el('div', { role: 'dialog' }, el('button', {}, 'OK')))); const result = await dump(
const dialog = result.children.find(c => c.role === 'dialog'); body(el("div", { role: "dialog" }, el("button", {}, "OK"))),
);
const dialog = result.children.find((c) => c.role === "dialog");
expect(dialog).toBeDefined(); expect(dialog).toBeDefined();
expect(dialog!.tag).toBe('div'); expect(dialog!.tag).toBe("div");
}); });
// ── Interactive element attributes ──────────────────────────────────────── // ── Interactive element attributes ────────────────────────────────────────
it('captures input id, type, and name', async () => { it("captures input id, type, and name", async () => {
const result = await dump(body(el('input', { id: 'email', type: 'email', name: 'userEmail' }))); const result = await dump(
const input = result.children.find(c => c.tag === 'input'); body(el("input", { id: "email", type: "email", name: "userEmail" })),
expect(input?.id).toBe('email'); );
expect(input?.type).toBe('email'); const input = result.children.find((c) => c.tag === "input");
expect(input?.name).toBe('userEmail'); expect(input?.id).toBe("email");
expect(input?.type).toBe("email");
expect(input?.name).toBe("userEmail");
}); });
it('captures checked:true on a checked checkbox', async () => { it("captures checked:true on a checked checkbox", async () => {
const result = await dump(body(el('input', { type: 'checkbox', checked: true }))); const result = await dump(
expect(result.children.find(c => c.tag === 'input')?.checked).toBe(true); 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 () => { it("captures checked:false on an unchecked checkbox", async () => {
const result = await dump(body(el('input', { type: 'checkbox' }))); const result = await dump(body(el("input", { type: "checkbox" })));
expect(result.children.find(c => c.tag === 'input')?.checked).toBe(false); expect(result.children.find((c) => c.tag === "input")?.checked).toBe(false);
}); });
it('captures disabled:true on a disabled button', async () => { it("captures disabled:true on a disabled button", async () => {
const result = await dump(body(el('button', { disabled: true }, 'Nope'))); const result = await dump(body(el("button", { disabled: true }, "Nope")));
expect(result.children.find(c => c.tag === 'button')?.disabled).toBe(true); expect(result.children.find((c) => c.tag === "button")?.disabled).toBe(
true,
);
}); });
it('does not set disabled for a non-disabled button', async () => { it("does not set disabled for a non-disabled button", async () => {
const result = await dump(body(el('button', {}, 'OK'))); const result = await dump(body(el("button", {}, "OK")));
expect(result.children.find(c => c.tag === 'button')?.disabled).toBeUndefined(); expect(
result.children.find((c) => c.tag === "button")?.disabled,
).toBeUndefined();
}); });
it('does not capture type for button elements', async () => { it("does not capture type for button elements", async () => {
const result = await dump(body(el('button', { type: 'submit' }, 'Go'))); const result = await dump(body(el("button", { type: "submit" }, "Go")));
expect(result.children.find(c => c.tag === 'button')?.type).toBeUndefined(); expect(
result.children.find((c) => c.tag === "button")?.type,
).toBeUndefined();
}); });
it('relativizes same-origin anchor href', async () => { it("relativizes same-origin anchor href", async () => {
const result = await dump(body(el('a', { href: '/workflow/123' }, 'Link'))); const result = await dump(body(el("a", { href: "/workflow/123" }, "Link")));
expect(result.children.find(c => c.tag === 'a')?.href).toBe('/workflow/123'); expect(result.children.find((c) => c.tag === "a")?.href).toBe(
"/workflow/123",
);
}); });
it('keeps full href for cross-origin anchors', async () => { it("keeps full href for cross-origin anchors", async () => {
const result = await dump(body(el('a', { href: 'https://other.com/page' }, 'Ext'))); const result = await dump(
expect(result.children.find(c => c.tag === 'a')?.href).toContain('other.com'); body(el("a", { href: "https://other.com/page" }, "Ext")),
);
expect(result.children.find((c) => c.tag === "a")?.href).toContain(
"other.com",
);
}); });
// ── Text content ────────────────────────────────────────────────────────── // ── Text content ──────────────────────────────────────────────────────────
it('captures own text content of a button', async () => { it("captures own text content of a button", async () => {
const result = await dump(body(el('button', {}, 'Save'))); const result = await dump(body(el("button", {}, "Save")));
expect(result.children.find(c => c.tag === 'button')?.text).toBe('Save'); expect(result.children.find((c) => c.tag === "button")?.text).toBe("Save");
}); });
it('truncates text to 80 characters', async () => { it("truncates text to 80 characters", async () => {
const long = 'x'.repeat(100); const long = "x".repeat(100);
const result = await dump(body(el('button', {}, long))); const result = await dump(body(el("button", {}, long)));
expect(result.children.find(c => c.tag === 'button')?.text?.length).toBe(80); 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 // button wraps a span — no direct text node on button, innerText = span text
const result = await dump(body(el('button', {}, el('span', {}, 'Nested')))); const result = await dump(body(el("button", {}, el("span", {}, "Nested"))));
expect(result.children.find(c => c.tag === 'button')?.text).toBe('Nested'); expect(result.children.find((c) => c.tag === "button")?.text).toBe(
"Nested",
);
}); });
// ── Tree pruning / unwrapping ───────────────────────────────────────────── // ── Tree pruning / unwrapping ─────────────────────────────────────────────
it('unwraps a non-significant div that has exactly one significant child', async () => { it("unwraps a non-significant div that has exactly one significant child", async () => {
const result = await dump(body(el('div', {}, el('button', {}, 'Click')))); const result = await dump(body(el("div", {}, el("button", {}, "Click"))));
const btn = result.children.find(c => c.tag === 'button'); const btn = result.children.find((c) => c.tag === "button");
expect(btn).toBeDefined(); 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 () => { 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 result = await dump(
const wrapper = result.children.find(c => c.tag === 'div'); body(el("div", {}, el("button", {}, "A"), el("button", {}, "B"))),
);
const wrapper = result.children.find((c) => c.tag === "div");
expect(wrapper).toBeDefined(); expect(wrapper).toBeDefined();
expect(wrapper!.children).toHaveLength(2); expect(wrapper!.children).toHaveLength(2);
}); });
it('discards non-significant childless elements', async () => { it("discards non-significant childless elements", async () => {
const result = await dump(body(el('div', {}), el('button', {}, 'Keep'))); const result = await dump(body(el("div", {}), el("button", {}, "Keep")));
expect(result.children.find(c => c.tag === 'div' && c.children.length === 0)).toBeUndefined(); expect(
expect(result.children.some(c => c.tag === 'button')).toBe(true); result.children.find((c) => c.tag === "div" && c.children.length === 0),
).toBeUndefined();
expect(result.children.some((c) => c.tag === "button")).toBe(true);
}); });
// ── Structural tags ─────────────────────────────────────────────────────── // ── Structural tags ───────────────────────────────────────────────────────
it('preserves nested structure inside a form', async () => { it("preserves nested structure inside a form", async () => {
const result = await dump( 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).toBeDefined();
expect(form!.children.find(c => c.tag === 'input')).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 === "button")).toBeDefined();
}); });
it('preserves dialog element', async () => { it("preserves dialog element", async () => {
const result = await dump(body(el('dialog', { role: 'dialog' }, el('button', {}, 'Close')))); const result = await dump(
expect(result.children.find(c => c.tag === 'dialog')).toBeDefined(); 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 () => { it("returns empty node when root has no visible significant content", async () => {
const result = await dumpDom(makePage(el('div', {})), 'div'); const result = await dumpDom(makePage(el("div", {})), "div");
expect(result.tag).toBe('empty'); expect(result.tag).toBe("empty");
}); });
}); });
+101 -72
View File
@@ -1,8 +1,8 @@
import { INestApplication } from '@nestjs/common'; import { INestApplication } from "@nestjs/common";
import request from 'supertest'; import request from "supertest";
import { buildTestApp } from './app.harness'; import { buildTestApp } from "./app.harness";
describe('EnvironmentController', () => { describe("EnvironmentController", () => {
let app: INestApplication; let app: INestApplication;
beforeAll(async () => { beforeAll(async () => {
@@ -15,160 +15,187 @@ describe('EnvironmentController', () => {
// ── POST /environments ───────────────────────────────────────────────────── // ── POST /environments ─────────────────────────────────────────────────────
describe('POST /environments', () => { describe("POST /environments", () => {
it('creates an environment and returns 201', async () => { it("creates an environment and returns 201", async () => {
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.post('/environments') .post("/environments")
.send({ name: 'env-a', urls: { id_url: 'https://id.example.com' } }) .send({ name: "env-a", urls: { id_url: "https://id.example.com" } })
.expect(201); .expect(201);
expect(res.body.id).toBeDefined(); expect(res.body.id).toBeDefined();
expect(res.body.name).toBe('env-a'); expect(res.body.name).toBe("env-a");
expect(res.body.urls.id_url).toBe('https://id.example.com'); 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()) await request(app.getHttpServer())
.post('/environments') .post("/environments")
.send({ urls: { id_url: 'https://id.example.com' } }) .send({ urls: { id_url: "https://id.example.com" } })
.expect(400); .expect(400);
}); });
it('returns 400 when urls is missing', async () => { it("returns 400 when urls is missing", async () => {
await request(app.getHttpServer()) await request(app.getHttpServer())
.post('/environments') .post("/environments")
.send({ name: 'env-no-urls' }) .send({ name: "env-no-urls" })
.expect(400); .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()) await request(app.getHttpServer())
.post('/environments') .post("/environments")
.send({ name: 'env-bad-urls', urls: 'not-an-object' }) .send({ name: "env-bad-urls", urls: "not-an-object" })
.expect(400); .expect(400);
}); });
it('returns 409 when name already exists', async () => { it("returns 409 when name already exists", async () => {
await request(app.getHttpServer()) await request(app.getHttpServer())
.post('/environments') .post("/environments")
.send({ name: 'env-duplicate', urls: {} }) .send({ name: "env-duplicate", urls: {} })
.expect(201); .expect(201);
await request(app.getHttpServer()) await request(app.getHttpServer())
.post('/environments') .post("/environments")
.send({ name: 'env-duplicate', urls: {} }) .send({ name: "env-duplicate", urls: {} })
.expect(409); .expect(409);
}); });
}); });
// ── GET /environments ────────────────────────────────────────────────────── // ── GET /environments ──────────────────────────────────────────────────────
describe('GET /environments', () => { describe("GET /environments", () => {
it('returns 200 with a paginated result', async () => { it("returns 200 with a paginated result", async () => {
const res = await request(app.getHttpServer()).get('/environments').expect(200); const res = await request(app.getHttpServer())
.get("/environments")
.expect(200);
expect(Array.isArray(res.body.data)).toBe(true); expect(Array.isArray(res.body.data)).toBe(true);
expect(typeof res.body.total).toBe('number'); expect(typeof res.body.total).toBe("number");
expect(res.body).toHaveProperty('page'); expect(res.body).toHaveProperty("page");
expect(res.body).toHaveProperty('limit'); expect(res.body).toHaveProperty("limit");
}); });
it('respects page and limit params', async () => { it("respects page and limit params", async () => {
// seed two extra environments // seed two extra environments
await request(app.getHttpServer()).post('/environments').send({ name: 'env-page-1', urls: {} }).expect(201); await request(app.getHttpServer())
await request(app.getHttpServer()).post('/environments').send({ name: 'env-page-2', urls: {} }).expect(201); .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.data).toHaveLength(1);
expect(res.body.page).toBe(1); expect(res.body.page).toBe(1);
expect(res.body.limit).toBe(1); expect(res.body.limit).toBe(1);
}); });
it('returns empty data array for out-of-range page', async () => { 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); const res = await request(app.getHttpServer())
.get("/environments?page=9999&limit=20")
.expect(200);
expect(res.body.data).toHaveLength(0); expect(res.body.data).toHaveLength(0);
}); });
it('returns 400 for invalid page param', async () => { it("returns 400 for invalid page param", async () => {
await request(app.getHttpServer()).get('/environments?page=0').expect(400); await request(app.getHttpServer())
.get("/environments?page=0")
.expect(400);
}); });
it('orders by name ASC', async () => { it("orders by name ASC", async () => {
await request(app.getHttpServer()).post('/environments').send({ name: 'zzz-env', urls: {} }); await request(app.getHttpServer())
await request(app.getHttpServer()).post('/environments').send({ name: 'aaa-env', urls: {} }); .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 res = await request(app.getHttpServer())
const names: string[] = res.body.data.map((e: any) => e.name); .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()); expect(names).toEqual([...names].sort());
}); });
it('orders by name DESC', async () => { it("orders by name DESC", async () => {
const res = await request(app.getHttpServer()).get('/environments?orderBy=name&orderDir=DESC').expect(200); const res = await request(app.getHttpServer())
const names: string[] = res.body.data.map((e: any) => e.name); .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()); expect(names).toEqual([...names].sort().reverse());
}); });
it('returns 400 for invalid orderDir', async () => { it("returns 400 for invalid orderDir", async () => {
await request(app.getHttpServer()).get('/environments?orderDir=SIDEWAYS').expect(400); await request(app.getHttpServer())
.get("/environments?orderDir=SIDEWAYS")
.expect(400);
}); });
}); });
// ── GET /environments/:id ────────────────────────────────────────────────── // ── GET /environments/:id ──────────────────────────────────────────────────
describe('GET /environments/:id', () => { describe("GET /environments/:id", () => {
it('returns the created environment', async () => { it("returns the created environment", async () => {
const created = await request(app.getHttpServer()) const created = await request(app.getHttpServer())
.post('/environments') .post("/environments")
.send({ name: 'env-get-one', urls: { cabinet_url: 'https://cabinet.example.com' } }) .send({
name: "env-get-one",
urls: { cabinet_url: "https://cabinet.example.com" },
})
.expect(201); .expect(201);
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.get(`/environments/${created.body.id}`) .get(`/environments/${created.body.id}`)
.expect(200); .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 () => { it("returns 404 for unknown id", async () => {
await request(app.getHttpServer()).get('/environments/99999').expect(404); await request(app.getHttpServer()).get("/environments/99999").expect(404);
}); });
it('returns 400 for non-numeric id', async () => { it("returns 400 for non-numeric id", async () => {
await request(app.getHttpServer()).get('/environments/abc').expect(400); await request(app.getHttpServer()).get("/environments/abc").expect(400);
}); });
}); });
// ── PATCH /environments/:id ──────────────────────────────────────────────── // ── PATCH /environments/:id ────────────────────────────────────────────────
describe('PATCH /environments/:id', () => { describe("PATCH /environments/:id", () => {
it('updates name and returns 200', async () => { it("updates name and returns 200", async () => {
const created = await request(app.getHttpServer()) const created = await request(app.getHttpServer())
.post('/environments') .post("/environments")
.send({ name: 'env-patch-me', urls: {} }) .send({ name: "env-patch-me", urls: {} })
.expect(201); .expect(201);
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.patch(`/environments/${created.body.id}`) .patch(`/environments/${created.body.id}`)
.send({ name: 'env-patched' }) .send({ name: "env-patched" })
.expect(200); .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()) await request(app.getHttpServer())
.patch('/environments/99999') .patch("/environments/99999")
.send({ name: 'x' }) .send({ name: "x" })
.expect(404); .expect(404);
}); });
}); });
// ── DELETE /environments/:id ─────────────────────────────────────────────── // ── DELETE /environments/:id ───────────────────────────────────────────────
describe('DELETE /environments/:id', () => { describe("DELETE /environments/:id", () => {
it('deletes and returns 204', async () => { it("deletes and returns 204", async () => {
const created = await request(app.getHttpServer()) const created = await request(app.getHttpServer())
.post('/environments') .post("/environments")
.send({ name: 'env-delete-me', urls: {} }) .send({ name: "env-delete-me", urls: {} })
.expect(201); .expect(201);
await request(app.getHttpServer()) await request(app.getHttpServer())
@@ -180,8 +207,10 @@ describe('EnvironmentController', () => {
.expect(404); .expect(404);
}); });
it('returns 404 for unknown id', async () => { it("returns 404 for unknown id", async () => {
await request(app.getHttpServer()).delete('/environments/99999').expect(404); await request(app.getHttpServer())
.delete("/environments/99999")
.expect(404);
}); });
}); });
}); });
+46 -40
View File
@@ -1,6 +1,6 @@
import { INestApplication } from '@nestjs/common'; import { INestApplication } from "@nestjs/common";
import request from 'supertest'; import request from "supertest";
import { buildTestApp } from './app.harness'; import { buildTestApp } from "./app.harness";
/** /**
* MCP controller integration tests. * MCP controller integration tests.
@@ -14,7 +14,7 @@ import { buildTestApp } from './app.harness';
* Browser-dependent tools (open_url, exec_code) require a live Playwright * Browser-dependent tools (open_url, exec_code) require a live Playwright
* session and are not covered here. * session and are not covered here.
*/ */
describe('McpController', () => { describe("McpController", () => {
let app: INestApplication; let app: INestApplication;
beforeAll(async () => { beforeAll(async () => {
@@ -35,13 +35,13 @@ describe('McpController', () => {
/** Send a single MCP tool call and return the parsed response body. */ /** Send a single MCP tool call and return the parsed response body. */
async function mcpCall(toolName: string, args: Record<string, unknown> = {}) { async function mcpCall(toolName: string, args: Record<string, unknown> = {}) {
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.post('/mcp') .post("/mcp")
.set('Content-Type', 'application/json') .set("Content-Type", "application/json")
.set('Accept', 'application/json, text/event-stream') .set("Accept", "application/json, text/event-stream")
.send({ .send({
jsonrpc: '2.0', jsonrpc: "2.0",
id: 1, id: 1,
method: 'tools/call', method: "tools/call",
params: { name: toolName, arguments: args }, params: { name: toolName, arguments: args },
}); });
return { status: res.status, rpc: parseSse(res.text) }; return { status: res.status, rpc: parseSse(res.text) };
@@ -49,20 +49,20 @@ describe('McpController', () => {
// ── Connectivity ─────────────────────────────────────────────────────────── // ── Connectivity ───────────────────────────────────────────────────────────
describe('POST /mcp — connectivity', () => { describe("POST /mcp — connectivity", () => {
it('is reachable and returns a non-5xx status', async () => { it("is reachable and returns a non-5xx status", async () => {
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.post('/mcp') .post("/mcp")
.set('Content-Type', 'application/json') .set("Content-Type", "application/json")
.set('Accept', 'application/json, text/event-stream') .set("Accept", "application/json, text/event-stream")
.send({ .send({
jsonrpc: '2.0', jsonrpc: "2.0",
id: 1, id: 1,
method: 'initialize', method: "initialize",
params: { params: {
protocolVersion: '2024-11-05', protocolVersion: "2024-11-05",
capabilities: {}, capabilities: {},
clientInfo: { name: 'test', version: '0' }, clientInfo: { name: "test", version: "0" },
}, },
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
@@ -71,9 +71,9 @@ describe('McpController', () => {
// ── list_keys tool ───────────────────────────────────────────────────────── // ── list_keys tool ─────────────────────────────────────────────────────────
describe('list_keys', () => { describe("list_keys", () => {
it('returns a result with text content containing a JSON array', async () => { it("returns a result with text content containing a JSON array", async () => {
const { status, rpc } = await mcpCall('list_keys'); const { status, rpc } = await mcpCall("list_keys");
expect(status).toBe(200); expect(status).toBe(200);
const result = rpc.result as { content: { text: string }[] }; const result = rpc.result as { content: { text: string }[] };
expect(Array.isArray(JSON.parse(result.content[0].text))).toBe(true); expect(Array.isArray(JSON.parse(result.content[0].text))).toBe(true);
@@ -82,50 +82,56 @@ describe('McpController', () => {
// ── list_sessions tool ───────────────────────────────────────────────────── // ── list_sessions tool ─────────────────────────────────────────────────────
describe('list_sessions', () => { describe("list_sessions", () => {
it('returns a paginated result with a data array', async () => { it("returns a paginated result with a data array", async () => {
const { status, rpc } = await mcpCall('list_sessions'); const { status, rpc } = await mcpCall("list_sessions");
expect(status).toBe(200); expect(status).toBe(200);
const result = rpc.result as { content: { text: string }[] }; 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(Array.isArray(body.data)).toBe(true);
expect(typeof body.total).toBe('number'); expect(typeof body.total).toBe("number");
}); });
}); });
// ── list_environments tool ───────────────────────────────────────────────── // ── list_environments tool ─────────────────────────────────────────────────
describe('list_environments', () => { describe("list_environments", () => {
it('returns a paginated result with a data array', async () => { it("returns a paginated result with a data array", async () => {
const { status, rpc } = await mcpCall('list_environments'); const { status, rpc } = await mcpCall("list_environments");
expect(status).toBe(200); expect(status).toBe(200);
const result = rpc.result as { content: { text: string }[] }; 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(Array.isArray(body.data)).toBe(true);
expect(typeof body.total).toBe('number'); expect(typeof body.total).toBe("number");
}); });
}); });
// ── create_environment tool ──────────────────────────────────────────────── // ── create_environment tool ────────────────────────────────────────────────
describe('create_environment', () => { describe("create_environment", () => {
it('creates an environment via MCP', async () => { it("creates an environment via MCP", async () => {
const { status, rpc } = await mcpCall('create_environment', { const { status, rpc } = await mcpCall("create_environment", {
name: 'mcp-test-env', name: "mcp-test-env",
urls: { id_url: 'https://id.example.com' }, urls: { id_url: "https://id.example.com" },
}); });
expect(status).toBe(200); expect(status).toBe(200);
const result = rpc.result as { content: { text: string }[] }; const result = rpc.result as { content: { text: string }[] };
const created = JSON.parse(result.content[0].text) as { name: 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 ──────────────────────────────────── // ── delete_session tool with unknown id ────────────────────────────────────
describe('delete_session', () => { describe("delete_session", () => {
it('returns an MCP error result for a non-existent session id', async () => { it("returns an MCP error result for a non-existent session id", async () => {
const { status, rpc } = await mcpCall('delete_session', { id: 999999 }); const { status, rpc } = await mcpCall("delete_session", { id: 999999 });
expect(status).toBe(200); expect(status).toBe(200);
// MCP wraps service errors as isError:true content, not HTTP errors // MCP wraps service errors as isError:true content, not HTTP errors
const result = rpc.result as { isError: boolean }; const result = rpc.result as { isError: boolean };
+378 -169
View File
@@ -1,8 +1,9 @@
import { INestApplication } from '@nestjs/common'; import { INestApplication } from "@nestjs/common";
import request from 'supertest'; import request from "supertest";
import { buildTestApp } from './app.harness'; import { DataSource } from "typeorm";
import { buildTestApp } from "./app.harness";
describe('ScenarioController', () => { describe("ScenarioController", () => {
let app: INestApplication; let app: INestApplication;
beforeAll(async () => { beforeAll(async () => {
@@ -15,22 +16,25 @@ describe('ScenarioController', () => {
// ── helpers ──────────────────────────────────────────────────────────────── // ── helpers ────────────────────────────────────────────────────────────────
async function createScenario(name = 'test scenario') { async function createScenario(name = "test scenario") {
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.post('/scenarios') .post("/scenarios")
.send({ name }) .send({ name })
.expect(201); .expect(201);
return res.body as { id: number; name: string }; return res.body as { id: number; name: string };
} }
async function createStep(scenarioId: number, overrides: Record<string, unknown> = {}) { async function createStep(
scenarioId: number,
overrides: Record<string, unknown> = {},
) {
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.post(`/scenarios/${scenarioId}/steps`) .post(`/scenarios/${scenarioId}/steps`)
.send({ .send({
order: 0, order: 0,
type: 'exec', type: "exec",
sessionName: 'test-session', sessionName: "test-session",
execCode: 'return 1;', execCode: "return 1;",
...overrides, ...overrides,
}) })
.expect(201); .expect(201);
@@ -39,80 +43,93 @@ describe('ScenarioController', () => {
// ── POST /scenarios ──────────────────────────────────────────────────────── // ── POST /scenarios ────────────────────────────────────────────────────────
describe('POST /scenarios', () => { describe("POST /scenarios", () => {
it('creates a scenario and returns 201', async () => { it("creates a scenario and returns 201", async () => {
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.post('/scenarios') .post("/scenarios")
.send({ name: 'my scenario' }) .send({ name: "my scenario" })
.expect(201); .expect(201);
expect(res.body.id).toBeDefined(); 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 () => { it("returns 400 when name is missing", async () => {
await request(app.getHttpServer()).post('/scenarios').send({}).expect(400); await request(app.getHttpServer())
.post("/scenarios")
.send({})
.expect(400);
}); });
}); });
// ── GET /scenarios ───────────────────────────────────────────────────────── // ── GET /scenarios ─────────────────────────────────────────────────────────
describe('GET /scenarios', () => { describe("GET /scenarios", () => {
it('returns paginated result', async () => { it("returns paginated result", async () => {
const res = await request(app.getHttpServer()).get('/scenarios').expect(200); const res = await request(app.getHttpServer())
expect(res.body).toHaveProperty('data'); .get("/scenarios")
expect(res.body).toHaveProperty('total'); .expect(200);
expect(res.body).toHaveProperty('page'); expect(res.body).toHaveProperty("data");
expect(res.body).toHaveProperty('limit'); expect(res.body).toHaveProperty("total");
expect(res.body).toHaveProperty("page");
expect(res.body).toHaveProperty("limit");
expect(Array.isArray(res.body.data)).toBe(true); expect(Array.isArray(res.body.data)).toBe(true);
}); });
it('respects page and limit params', async () => { it("respects page and limit params", async () => {
await createScenario('paged-sc-a'); await createScenario("paged-sc-a");
await createScenario('paged-sc-b'); await createScenario("paged-sc-b");
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.get('/scenarios?page=1&limit=1') .get("/scenarios?page=1&limit=1")
.expect(200); .expect(200);
expect(res.body.data).toHaveLength(1); expect(res.body.data).toHaveLength(1);
expect(res.body.limit).toBe(1); expect(res.body.limit).toBe(1);
expect(res.body.page).toBe(1); expect(res.body.page).toBe(1);
}); });
it('returns empty data for out-of-range page', async () => { it("returns empty data for out-of-range page", async () => {
const res = await request(app.getHttpServer()).get('/scenarios?page=9999&limit=20').expect(200); const res = await request(app.getHttpServer())
.get("/scenarios?page=9999&limit=20")
.expect(200);
expect(res.body.data).toHaveLength(0); expect(res.body.data).toHaveLength(0);
}); });
it('returns 400 for invalid pagination params', async () => { it("returns 400 for invalid pagination params", async () => {
await request(app.getHttpServer()).get('/scenarios?page=0').expect(400); await request(app.getHttpServer()).get("/scenarios?page=0").expect(400);
}); });
it('orders by name ASC', async () => { it("orders by name ASC", async () => {
await createScenario('zzz-order-sc'); await createScenario("zzz-order-sc");
await createScenario('aaa-order-sc'); await createScenario("aaa-order-sc");
const res = await request(app.getHttpServer()).get('/scenarios?orderBy=name&orderDir=ASC').expect(200); const res = await request(app.getHttpServer())
const names: string[] = res.body.data.map((s: any) => s.name); .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()); expect(names).toEqual([...names].sort());
}); });
it('orders by name DESC', async () => { it("orders by name DESC", async () => {
const res = await request(app.getHttpServer()).get('/scenarios?orderBy=name&orderDir=DESC').expect(200); const res = await request(app.getHttpServer())
const names: string[] = res.body.data.map((s: any) => s.name); .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()); expect(names).toEqual([...names].sort().reverse());
}); });
it('returns 400 for invalid orderDir', async () => { it("returns 400 for invalid orderDir", async () => {
await request(app.getHttpServer()).get('/scenarios?orderDir=SIDEWAYS').expect(400); await request(app.getHttpServer())
.get("/scenarios?orderDir=SIDEWAYS")
.expect(400);
}); });
}); });
// ── GET /scenarios/:id ───────────────────────────────────────────────────── // ── GET /scenarios/:id ─────────────────────────────────────────────────────
describe('GET /scenarios/:id', () => { describe("GET /scenarios/:id", () => {
it('returns the scenario with steps array', async () => { it("returns the scenario with steps array", async () => {
const sc = await createScenario('scenario-get-one'); const sc = await createScenario("scenario-get-one");
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.get(`/scenarios/${sc.id}`) .get(`/scenarios/${sc.id}`)
.expect(200); .expect(200);
@@ -120,121 +137,128 @@ describe('ScenarioController', () => {
expect(Array.isArray(res.body.steps)).toBe(true); expect(Array.isArray(res.body.steps)).toBe(true);
}); });
it('returns 404 for unknown id', async () => { it("returns 404 for unknown id", async () => {
await request(app.getHttpServer()).get('/scenarios/99999').expect(404); await request(app.getHttpServer()).get("/scenarios/99999").expect(404);
}); });
}); });
// ── PATCH /scenarios/:id ─────────────────────────────────────────────────── // ── PATCH /scenarios/:id ───────────────────────────────────────────────────
describe('PATCH /scenarios/:id', () => { describe("PATCH /scenarios/:id", () => {
it('updates scenario name', async () => { it("updates scenario name", async () => {
const sc = await createScenario('patch-me'); const sc = await createScenario("patch-me");
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.patch(`/scenarios/${sc.id}`) .patch(`/scenarios/${sc.id}`)
.send({ name: 'patched' }) .send({ name: "patched" })
.expect(200); .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()) await request(app.getHttpServer())
.patch('/scenarios/99999') .patch("/scenarios/99999")
.send({ name: 'x' }) .send({ name: "x" })
.expect(404); .expect(404);
}); });
}); });
// ── DELETE /scenarios/:id ────────────────────────────────────────────────── // ── DELETE /scenarios/:id ──────────────────────────────────────────────────
describe('DELETE /scenarios/:id', () => { describe("DELETE /scenarios/:id", () => {
it('deletes and returns 204', async () => { it("deletes and returns 204", async () => {
const sc = await createScenario('delete-me'); const sc = await createScenario("delete-me");
await request(app.getHttpServer()).delete(`/scenarios/${sc.id}`).expect(204); await request(app.getHttpServer())
.delete(`/scenarios/${sc.id}`)
.expect(204);
await request(app.getHttpServer()).get(`/scenarios/${sc.id}`).expect(404); await request(app.getHttpServer()).get(`/scenarios/${sc.id}`).expect(404);
}); });
it('returns 404 for unknown id', async () => { it("returns 404 for unknown id", async () => {
await request(app.getHttpServer()).delete('/scenarios/99999').expect(404); await request(app.getHttpServer()).delete("/scenarios/99999").expect(404);
}); });
}); });
// ── POST /scenarios/:id/steps ────────────────────────────────────────────── // ── POST /scenarios/:id/steps ──────────────────────────────────────────────
describe('POST /scenarios/:id/steps', () => { describe("POST /scenarios/:id/steps", () => {
it('creates a step with required fields', async () => { it("creates a step with required fields", async () => {
const sc = await createScenario(); const sc = await createScenario();
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.post(`/scenarios/${sc.id}/steps`) .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(201);
expect(res.body.id).toBeDefined(); expect(res.body.id).toBeDefined();
expect(res.body.order).toBe(0); expect(res.body.order).toBe(0);
expect(res.body.type).toBe('exec'); expect(res.body.type).toBe("exec");
expect(res.body.sessionName).toBe('my-session'); expect(res.body.sessionName).toBe("my-session");
}); });
it('creates a login step', async () => { it("creates a login step", async () => {
const sc = await createScenario(); const sc = await createScenario();
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.post(`/scenarios/${sc.id}/steps`) .post(`/scenarios/${sc.id}/steps`)
.send({ order: 0, type: 'login', sessionName: 'session-x' }) .send({ order: 0, type: "login", sessionName: "session-x" })
.expect(201); .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(); const sc = await createScenario();
await request(app.getHttpServer()) await request(app.getHttpServer())
.post(`/scenarios/${sc.id}/steps`) .post(`/scenarios/${sc.id}/steps`)
.send({ type: 'exec', sessionName: 'x' }) .send({ type: "exec", sessionName: "x" })
.expect(400); .expect(400);
}); });
it('returns 400 when type is invalid', async () => { it("returns 400 when type is invalid", async () => {
const sc = await createScenario(); const sc = await createScenario();
await request(app.getHttpServer()) await request(app.getHttpServer())
.post(`/scenarios/${sc.id}/steps`) .post(`/scenarios/${sc.id}/steps`)
.send({ order: 0, type: 'unknown', sessionName: 'x' }) .send({ order: 0, type: "unknown", sessionName: "x" })
.expect(400); .expect(400);
}); });
it('returns 400 when sessionName is missing', async () => { it("returns 400 when sessionName is missing", async () => {
const sc = await createScenario(); const sc = await createScenario();
await request(app.getHttpServer()) await request(app.getHttpServer())
.post(`/scenarios/${sc.id}/steps`) .post(`/scenarios/${sc.id}/steps`)
.send({ order: 0, type: 'exec' }) .send({ order: 0, type: "exec" })
.expect(400); .expect(400);
}); });
it('returns 404 for unknown scenario', async () => { it("returns 404 for unknown scenario", async () => {
await request(app.getHttpServer()) await request(app.getHttpServer())
.post('/scenarios/99999/steps') .post("/scenarios/99999/steps")
.send({ order: 0, type: 'exec', sessionName: 'x' }) .send({ order: 0, type: "exec", sessionName: "x" })
.expect(404); .expect(404);
}); });
it('returns steps ordered by order field', async () => { it("returns steps ordered by order field", async () => {
const sc = await createScenario(); const sc = await createScenario();
await createStep(sc.id, { order: 2, 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: 0, type: "exec", sessionName: "s" });
await createStep(sc.id, { order: 1, type: 'exec', sessionName: 's' }); await createStep(sc.id, { order: 1, type: "exec", sessionName: "s" });
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.get(`/scenarios/${sc.id}`) .get(`/scenarios/${sc.id}`)
.expect(200); .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]); expect(orders).toEqual([0, 1, 2]);
}); });
}); });
// ── GET /scenarios/:id/steps/:stepId ────────────────────────────────────── // ── GET /scenarios/:id/steps/:stepId ──────────────────────────────────────
describe('GET /scenarios/:id/steps/:stepId', () => { describe("GET /scenarios/:id/steps/:stepId", () => {
it('returns the step', async () => { it("returns the step", async () => {
const sc = await createScenario(); const sc = await createScenario();
const step = await createStep(sc.id); const step = await createStep(sc.id);
@@ -245,7 +269,7 @@ describe('ScenarioController', () => {
expect(res.body.id).toBe(step.id); 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(); const sc = await createScenario();
await request(app.getHttpServer()) await request(app.getHttpServer())
.get(`/scenarios/${sc.id}/steps/99999`) .get(`/scenarios/${sc.id}/steps/99999`)
@@ -255,21 +279,21 @@ describe('ScenarioController', () => {
// ── PATCH /scenarios/:id/steps/:stepId ──────────────────────────────────── // ── PATCH /scenarios/:id/steps/:stepId ────────────────────────────────────
describe('PATCH /scenarios/:id/steps/:stepId', () => { describe("PATCH /scenarios/:id/steps/:stepId", () => {
it('updates step fields', async () => { it("updates step fields", async () => {
const sc = await createScenario(); 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()) const res = await request(app.getHttpServer())
.patch(`/scenarios/${sc.id}/steps/${step.id}`) .patch(`/scenarios/${sc.id}/steps/${step.id}`)
.send({ order: 5, execCode: 'return 99;' }) .send({ order: 5, execCode: "return 99;" })
.expect(200); .expect(200);
expect(res.body.order).toBe(5); 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(); const sc = await createScenario();
await request(app.getHttpServer()) await request(app.getHttpServer())
.patch(`/scenarios/${sc.id}/steps/99999`) .patch(`/scenarios/${sc.id}/steps/99999`)
@@ -280,8 +304,8 @@ describe('ScenarioController', () => {
// ── DELETE /scenarios/:id/steps/:stepId ─────────────────────────────────── // ── DELETE /scenarios/:id/steps/:stepId ───────────────────────────────────
describe('DELETE /scenarios/:id/steps/:stepId', () => { describe("DELETE /scenarios/:id/steps/:stepId", () => {
it('deletes the step and returns 204', async () => { it("deletes the step and returns 204", async () => {
const sc = await createScenario(); const sc = await createScenario();
const step = await createStep(sc.id); const step = await createStep(sc.id);
@@ -297,8 +321,8 @@ describe('ScenarioController', () => {
// ── POST /scenarios/:id/run ──────────────────────────────────────────────── // ── POST /scenarios/:id/run ────────────────────────────────────────────────
describe('POST /scenarios/:id/run', () => { describe("POST /scenarios/:id/run", () => {
it('creates a run with stepRuns in correct initial states', async () => { it("creates a run with stepRuns in correct initial states", async () => {
const sc = await createScenario(); const sc = await createScenario();
await createStep(sc.id, { order: 0 }); await createStep(sc.id, { order: 0 });
await createStep(sc.id, { order: 1 }); await createStep(sc.id, { order: 1 });
@@ -308,30 +332,32 @@ describe('ScenarioController', () => {
.post(`/scenarios/${sc.id}/run`) .post(`/scenarios/${sc.id}/run`)
.expect(201); .expect(201);
expect(res.body.status).toBe('pending'); expect(res.body.status).toBe("pending");
expect(Array.isArray(res.body.stepRuns)).toBe(true); expect(Array.isArray(res.body.stepRuns)).toBe(true);
expect(res.body.stepRuns).toHaveLength(3); expect(res.body.stepRuns).toHaveLength(3);
const statuses = res.body.stepRuns.map((s: any) => s.status); const statuses = res.body.stepRuns.map((s: { status: string }) => s.status);
expect(statuses[0]).toBe('pending'); expect(statuses[0]).toBe("pending");
expect(statuses[1]).toBe('waiting'); expect(statuses[1]).toBe("waiting");
expect(statuses[2]).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()) await request(app.getHttpServer())
.post('/scenarios/99999/run') .post("/scenarios/99999/run")
.expect(404); .expect(404);
}); });
}); });
// ── GET /scenarios/:id/runs ──────────────────────────────────────────────── // ── GET /scenarios/:id/runs ────────────────────────────────────────────────
describe('GET /scenarios/:id/runs', () => { describe("GET /scenarios/:id/runs", () => {
it('returns paginated runs with stepRuns embedded', async () => { it("returns paginated runs with stepRuns embedded", async () => {
const sc = await createScenario(); const sc = await createScenario();
await createStep(sc.id, { order: 0 }); 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()) const res = await request(app.getHttpServer())
.get(`/scenarios/${sc.id}/runs`) .get(`/scenarios/${sc.id}/runs`)
@@ -341,17 +367,21 @@ describe('ScenarioController', () => {
expect(Array.isArray(res.body.data[0].stepRuns)).toBe(true); expect(Array.isArray(res.body.data[0].stepRuns)).toBe(true);
}); });
it('filters by status', async () => { it("filters by status", async () => {
const sc = await createScenario(); const sc = await createScenario();
await createStep(sc.id, { order: 0 }); 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()) const pendingRes = await request(app.getHttpServer())
.get(`/scenarios/${sc.id}/runs?status=pending`) .get(`/scenarios/${sc.id}/runs?status=pending`)
.expect(200); .expect(200);
expect(pendingRes.body.total).toBeGreaterThanOrEqual(1); 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()) const passRes = await request(app.getHttpServer())
.get(`/scenarios/${sc.id}/runs?status=pass`) .get(`/scenarios/${sc.id}/runs?status=pass`)
@@ -360,67 +390,84 @@ describe('ScenarioController', () => {
expect(passRes.body.total).toBe(0); 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(); const sc = await createScenario();
await request(app.getHttpServer()) await request(app.getHttpServer())
.get(`/scenarios/${sc.id}/runs?status=invalid`) .get(`/scenarios/${sc.id}/runs?status=invalid`)
.expect(400); .expect(400);
}); });
it('returns 404 for unknown scenario', async () => { it("returns 404 for unknown scenario", async () => {
await request(app.getHttpServer()).get('/scenarios/99999/runs').expect(404); await request(app.getHttpServer())
.get("/scenarios/99999/runs")
.expect(404);
}); });
}); });
// ── GET /scenarios/:id/export ───────────────────────────────────────────── // ── GET /scenarios/:id/export ─────────────────────────────────────────────
describe('GET /scenarios/:id/export', () => { describe("GET /scenarios/:id/export", () => {
it('returns name and steps array', async () => { it("returns name and steps array", async () => {
const sc = await createScenario('export-me'); 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, {
await createStep(sc.id, { order: 1, type: 'exec', sessionName: 's', execCode: 'return 1;', validateCode: 'return true;' }); 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()) const res = await request(app.getHttpServer())
.get(`/scenarios/${sc.id}/export`) .get(`/scenarios/${sc.id}/export`)
.expect(200); .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(Array.isArray(res.body.steps)).toBe(true);
expect(res.body.steps).toHaveLength(2); expect(res.body.steps).toHaveLength(2);
}); });
it('exports steps ordered by order field', async () => { it("exports steps ordered by order field", async () => {
const sc = await createScenario('export-order'); const sc = await createScenario("export-order");
await createStep(sc.id, { order: 2, sessionName: 's' }); await createStep(sc.id, { order: 2, sessionName: "s" });
await createStep(sc.id, { order: 0, sessionName: 's' }); await createStep(sc.id, { order: 0, sessionName: "s" });
await createStep(sc.id, { order: 1, sessionName: 's' }); await createStep(sc.id, { order: 1, sessionName: "s" });
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.get(`/scenarios/${sc.id}/export`) .get(`/scenarios/${sc.id}/export`)
.expect(200); .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]); expect(orders).toEqual([0, 1, 2]);
}); });
it('omits internal fields (id, scenarioId, timestamps)', async () => { it("omits internal fields (id, scenarioId, timestamps)", async () => {
const sc = await createScenario('export-shape'); const sc = await createScenario("export-shape");
await createStep(sc.id, { order: 0, sessionName: 's' }); await createStep(sc.id, { order: 0, sessionName: "s" });
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.get(`/scenarios/${sc.id}/export`) .get(`/scenarios/${sc.id}/export`)
.expect(200); .expect(200);
const step = res.body.steps[0]; const step = res.body.steps[0];
expect(step).not.toHaveProperty('id'); expect(step).not.toHaveProperty("id");
expect(step).not.toHaveProperty('scenarioId'); expect(step).not.toHaveProperty("scenarioId");
expect(step).not.toHaveProperty('createdAt'); expect(step).not.toHaveProperty("createdAt");
expect(step).not.toHaveProperty('updatedAt'); expect(step).not.toHaveProperty("updatedAt");
}); });
it('exports null validateCode as null', async () => { it("exports null validateCode as null", async () => {
const sc = await createScenario('export-null-validate'); const sc = await createScenario("export-null-validate");
await createStep(sc.id, { order: 0, sessionName: 's', execCode: 'return 1;' }); await createStep(sc.id, {
order: 0,
sessionName: "s",
execCode: "return 1;",
});
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.get(`/scenarios/${sc.id}/export`) .get(`/scenarios/${sc.id}/export`)
@@ -429,99 +476,261 @@ describe('ScenarioController', () => {
expect(res.body.steps[0].validateCode).toBeNull(); expect(res.body.steps[0].validateCode).toBeNull();
}); });
it('returns 404 for unknown scenario', async () => { it("returns 404 for unknown scenario", async () => {
await request(app.getHttpServer()).get('/scenarios/99999/export').expect(404); await request(app.getHttpServer())
.get("/scenarios/99999/export")
.expect(404);
}); });
}); });
// ── POST /scenarios/import ──────────────────────────────────────────────── // ── POST /scenarios/import ────────────────────────────────────────────────
describe('POST /scenarios/import', () => { describe("POST /scenarios/import", () => {
it('creates a new scenario with all steps', async () => { it("creates a new scenario with all steps", async () => {
const payload = { const payload = {
name: 'imported scenario', name: "imported scenario",
steps: [ 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: 0,
{ order: 2, type: 'sign', sessionName: 's', execCode: '{"keyId":"k"}', validateCode: null }, 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()) const res = await request(app.getHttpServer())
.post('/scenarios/import') .post("/scenarios/import")
.send(payload) .send(payload)
.expect(201); .expect(201);
expect(res.body.id).toBeDefined(); 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).toHaveLength(3);
expect(res.body.steps[0].type).toBe('login'); expect(res.body.steps[0].type).toBe("login");
expect(res.body.steps[1].type).toBe('exec'); expect(res.body.steps[1].type).toBe("exec");
expect(res.body.steps[2].type).toBe('sign'); expect(res.body.steps[2].type).toBe("sign");
}); });
it('assigns a new id (does not collide with source)', async () => { it("assigns a new id (does not collide with source)", async () => {
const sc = await createScenario('original'); const sc = await createScenario("original");
const exportRes = await request(app.getHttpServer()) const exportRes = await request(app.getHttpServer())
.get(`/scenarios/${sc.id}/export`) .get(`/scenarios/${sc.id}/export`)
.expect(200); .expect(200);
const importRes = await request(app.getHttpServer()) const importRes = await request(app.getHttpServer())
.post('/scenarios/import') .post("/scenarios/import")
.send(exportRes.body) .send(exportRes.body)
.expect(201); .expect(201);
expect(importRes.body.id).not.toBe(sc.id); expect(importRes.body.id).not.toBe(sc.id);
}); });
it('round-trips a scenario faithfully', async () => { it("round-trips a scenario faithfully", async () => {
const sc = await createScenario('roundtrip'); const sc = await createScenario("roundtrip");
await createStep(sc.id, { order: 0, type: 'exec', sessionName: 'rs', execCode: 'return 42;', validateCode: 'return true;' }); await createStep(sc.id, {
order: 0,
type: "exec",
sessionName: "rs",
execCode: "return 42;",
validateCode: "return true;",
});
const exportRes = await request(app.getHttpServer()) const exportRes = await request(app.getHttpServer())
.get(`/scenarios/${sc.id}/export`) .get(`/scenarios/${sc.id}/export`)
.expect(200); .expect(200);
const importRes = await request(app.getHttpServer()) const importRes = await request(app.getHttpServer())
.post('/scenarios/import') .post("/scenarios/import")
.send(exportRes.body) .send(exportRes.body)
.expect(201); .expect(201);
expect(importRes.body.name).toBe('roundtrip'); expect(importRes.body.name).toBe("roundtrip");
expect(importRes.body.steps).toHaveLength(1); expect(importRes.body.steps).toHaveLength(1);
expect(importRes.body.steps[0].execCode).toBe(exportRes.body.steps[0].execCode); expect(importRes.body.steps[0].execCode).toBe(
expect(importRes.body.steps[0].validateCode).toBe(exportRes.body.steps[0].validateCode); 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()) const res = await request(app.getHttpServer())
.post('/scenarios/import') .post("/scenarios/import")
.send({ name: 'empty-import', steps: [] }) .send({ name: "empty-import", steps: [] })
.expect(201); .expect(201);
expect(res.body.name).toBe('empty-import'); expect(res.body.name).toBe("empty-import");
expect(res.body.steps).toHaveLength(0); 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()) await request(app.getHttpServer())
.post('/scenarios/import') .post("/scenarios/import")
.send({ steps: [] }) .send({ steps: [] })
.expect(400); .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()) await request(app.getHttpServer())
.post('/scenarios/import') .post("/scenarios/import")
.send({ name: 'bad', steps: 'oops' }) .send({ name: "bad", steps: "oops" })
.expect(400); .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()) await request(app.getHttpServer())
.post('/scenarios/import') .post("/scenarios/import")
.send({ name: 'bad-type', steps: [{ order: 0, type: 'unknown', sessionName: 's' }] }) .send({
name: "bad-type",
steps: [{ order: 0, type: "unknown", sessionName: "s" }],
})
.expect(400); .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);
});
});
}); });
+79 -55
View File
@@ -1,17 +1,19 @@
import { INestApplication } from '@nestjs/common'; import { INestApplication } from "@nestjs/common";
import { getRepositoryToken } from '@nestjs/typeorm'; import { getRepositoryToken } from "@nestjs/typeorm";
import { Repository } from 'typeorm'; import { Repository } from "typeorm";
import request from 'supertest'; import request from "supertest";
import { buildTestApp } from './app.harness'; import { buildTestApp } from "./app.harness";
import { SessionEntity } from '../src/session/session.entity'; import { SessionEntity } from "../src/session/session.entity";
describe('SessionController', () => { describe("SessionController", () => {
let app: INestApplication; let app: INestApplication;
let repo: Repository<SessionEntity>; let repo: Repository<SessionEntity>;
beforeAll(async () => { beforeAll(async () => {
app = await buildTestApp(); app = await buildTestApp();
repo = app.get<Repository<SessionEntity>>(getRepositoryToken(SessionEntity)); repo = app.get<Repository<SessionEntity>>(
getRepositoryToken(SessionEntity),
);
}); });
afterAll(async () => { afterAll(async () => {
@@ -22,98 +24,120 @@ describe('SessionController', () => {
return repo.save( return repo.save(
repo.create({ repo.create({
sessionName: name, sessionName: name,
token: 'tok', token: "tok",
cookies: '[]', cookies: "[]",
localStorage: '{}', localStorage: "{}",
}), }),
); );
} }
// ── GET /sessions ────────────────────────────────────────────────────────── // ── GET /sessions ──────────────────────────────────────────────────────────
describe('GET /sessions', () => { describe("GET /sessions", () => {
it('returns 200 with a paginated result', async () => { it("returns 200 with a paginated result", async () => {
const res = await request(app.getHttpServer()).get('/sessions').expect(200); const res = await request(app.getHttpServer())
.get("/sessions")
.expect(200);
expect(Array.isArray(res.body.data)).toBe(true); expect(Array.isArray(res.body.data)).toBe(true);
expect(typeof res.body.total).toBe('number'); expect(typeof res.body.total).toBe("number");
expect(res.body).toHaveProperty('page'); expect(res.body).toHaveProperty("page");
expect(res.body).toHaveProperty('limit'); expect(res.body).toHaveProperty("limit");
}); });
it('includes seeded sessions', async () => { it("includes seeded sessions", async () => {
await seedSession('visible-session'); await seedSession("visible-session");
const res = await request(app.getHttpServer()).get('/sessions').expect(200); const res = await request(app.getHttpServer())
const names = res.body.data.map((s: any) => s.sessionName); .get("/sessions")
expect(names).toContain('visible-session'); .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 () => { it("does not expose token, cookies or localStorage fields", async () => {
await seedSession('private-session'); await seedSession("private-session");
const res = await request(app.getHttpServer()).get('/sessions').expect(200); const res = await request(app.getHttpServer())
const item = res.body.data.find((s: any) => s.sessionName === 'private-session'); .get("/sessions")
.expect(200);
const item = res.body.data.find(
(s: { sessionName: string }) => s.sessionName === "private-session",
) as Record<string, unknown>;
expect(item).toBeDefined(); expect(item).toBeDefined();
expect(item.token).toBeUndefined(); expect(item.token).toBeUndefined();
expect(item.cookies).toBeUndefined(); expect(item.cookies).toBeUndefined();
expect(item.localStorage).toBeUndefined(); expect(item.localStorage).toBeUndefined();
}); });
it('respects page and limit params', async () => { it("respects page and limit params", async () => {
await seedSession('paged-session-a'); await seedSession("paged-session-a");
await seedSession('paged-session-b'); 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.data).toHaveLength(1);
expect(res.body.page).toBe(1); expect(res.body.page).toBe(1);
expect(res.body.limit).toBe(1); expect(res.body.limit).toBe(1);
}); });
it('returns empty data array for out-of-range page', async () => { 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); const res = await request(app.getHttpServer())
.get("/sessions?page=9999&limit=20")
.expect(200);
expect(res.body.data).toHaveLength(0); expect(res.body.data).toHaveLength(0);
}); });
it('returns 400 for invalid page param', async () => { it("returns 400 for invalid page param", async () => {
await request(app.getHttpServer()).get('/sessions?page=0').expect(400); await request(app.getHttpServer()).get("/sessions?page=0").expect(400);
}); });
it('orders by sessionName ASC', async () => { it("orders by sessionName ASC", async () => {
await seedSession('zzz-sort-session'); await seedSession("zzz-sort-session");
await seedSession('aaa-sort-session'); await seedSession("aaa-sort-session");
const res = await request(app.getHttpServer()).get('/sessions?orderBy=sessionName&orderDir=ASC').expect(200); const res = await request(app.getHttpServer())
const names: string[] = res.body.data.map((s: any) => s.sessionName); .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()); expect(names).toEqual([...names].sort());
}); });
it('orders by sessionName DESC', async () => { it("orders by sessionName DESC", async () => {
const res = await request(app.getHttpServer()).get('/sessions?orderBy=sessionName&orderDir=DESC').expect(200); const res = await request(app.getHttpServer())
const names: string[] = res.body.data.map((s: any) => s.sessionName); .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()); expect(names).toEqual([...names].sort().reverse());
}); });
it('returns 400 for invalid orderDir', async () => { it("returns 400 for invalid orderDir", async () => {
await request(app.getHttpServer()).get('/sessions?orderDir=SIDEWAYS').expect(400); await request(app.getHttpServer())
.get("/sessions?orderDir=SIDEWAYS")
.expect(400);
}); });
}); });
// ── DELETE /sessions/:id ─────────────────────────────────────────────────── // ── DELETE /sessions/:id ───────────────────────────────────────────────────
describe('DELETE /sessions/:id', () => { describe("DELETE /sessions/:id", () => {
it('deletes an existing session and returns 200', async () => { it("deletes an existing session and returns 200", async () => {
const s = await seedSession('delete-me-session'); const s = await seedSession("delete-me-session");
await request(app.getHttpServer()).delete(`/sessions/${s.id}`).expect(200); await request(app.getHttpServer())
.delete(`/sessions/${s.id}`)
.expect(200);
const res = await request(app.getHttpServer()).get('/sessions').expect(200); const res = await request(app.getHttpServer())
const names = res.body.data.map((sess: any) => sess.sessionName); .get("/sessions")
expect(names).not.toContain('delete-me-session'); .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 () => { it("returns 404 for unknown id", async () => {
await request(app.getHttpServer()).delete('/sessions/99999').expect(404); await request(app.getHttpServer()).delete("/sessions/99999").expect(404);
}); });
it('returns 400 for non-numeric id', async () => { it("returns 400 for non-numeric id", async () => {
await request(app.getHttpServer()).delete('/sessions/abc').expect(400); await request(app.getHttpServer()).delete("/sessions/abc").expect(400);
}); });
}); });
}); });