chore(repo): restructure as monorepo with server and client workspaces
- move NestJS app into server/ subdirectory - add client/ React+TypeScript (Vite) app with Hello World - update docker-compose to build and run both services - add root package.json declaring npm workspaces - update .gitignore to cover node_modules and dist at all depths
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
data
|
||||
keys
|
||||
.env
|
||||
.git
|
||||
*.log
|
||||
@@ -0,0 +1,39 @@
|
||||
# ── Build stage ──────────────────────────────────────────────────────────────
|
||||
FROM node:22-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY nest-cli.json tsconfig*.json ./
|
||||
COPY src ./src
|
||||
RUN npm run build
|
||||
|
||||
# ── Runtime stage ─────────────────────────────────────────────────────────────
|
||||
FROM node:22-slim AS runtime
|
||||
|
||||
# Playwright / Chromium system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
chromium \
|
||||
fonts-noto \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Tell Playwright to use the system Chromium instead of downloading its own
|
||||
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
||||
ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Runtime directories (keys and SQLite DB mounted via volumes)
|
||||
RUN mkdir -p data keys
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "dist/main"]
|
||||
@@ -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,
|
||||
);
|
||||
@@ -0,0 +1,15 @@
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
moduleFileExtensions: ['js', 'json', 'ts'],
|
||||
rootDir: '.',
|
||||
testRegex: 'test/.*\\.spec\\.ts$',
|
||||
transform: {
|
||||
'^.+\\.ts$': ['ts-jest', { tsconfig: 'test/tsconfig.json' }],
|
||||
},
|
||||
moduleNameMapper: {
|
||||
'^jsdom$': '<rootDir>/test/__mocks__/jsdom.ts',
|
||||
'^playwright$': '<rootDir>/test/__mocks__/playwright.ts',
|
||||
'^@mozilla/readability$': '<rootDir>/test/__mocks__/readability.ts',
|
||||
},
|
||||
testEnvironment: 'node',
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
Generated
+11239
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "liquio-qa-bot",
|
||||
"version": "1.2.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"lint:fix": "eslint --fix \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"test": "jest",
|
||||
"test:debug": "DEBUG=test jest",
|
||||
"test:watch": "jest --watch"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@nestjs/common": "^11.1.18",
|
||||
"@nestjs/config": "^4.0.3",
|
||||
"@nestjs/core": "^11.1.18",
|
||||
"@nestjs/platform-express": "^11.1.18",
|
||||
"@nestjs/schedule": "^6.1.1",
|
||||
"@nestjs/swagger": "^11.2.6",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"acorn": "^8.16.0",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.4",
|
||||
"jsdom": "^29.0.2",
|
||||
"playwright": "^1.59.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"typeorm": "^0.3.28",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.17",
|
||||
"@nestjs/testing": "^11.1.18",
|
||||
"@types/acorn": "^4.0.6",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/debug": "^4.1.13",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/jsdom": "^28.0.1",
|
||||
"@types/mozilla__readability": "^0.4.2",
|
||||
"@types/node": "^25.5.2",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"debug": "^4.4.3",
|
||||
"eslint": "^10.2.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"jest": "^30.3.0",
|
||||
"prettier": "^3.8.1",
|
||||
"supertest": "^7.2.2",
|
||||
"ts-jest": "^29.4.9",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { AppConfig, validateAppConfig } from "./config/app.config";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
import { HealthModule } from "./health/health.module";
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
import { BrowserModule } from "./browser/browser.module";
|
||||
import { SessionEntity } from "./session/session.entity";
|
||||
import { EnvironmentEntity } from "./environment/environment.entity";
|
||||
import { EnvironmentModule } from "./environment/environment.module";
|
||||
import { McpModule } from "./mcp/mcp.module";
|
||||
import { ScenarioEntity } from "./scenario/scenario.entity";
|
||||
import { ScenarioStepEntity } from "./scenario/scenario-step.entity";
|
||||
import { ScenarioRunEntity } from "./scenario/scenario-run.entity";
|
||||
import { ScenarioRunStepEntity } from "./scenario/scenario-run-step.entity";
|
||||
import { ScenarioRunLogEntity } from "./scenario/scenario-run-log.entity";
|
||||
import { ScenarioModule } from "./scenario/scenario.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: ".env",
|
||||
validate: validateAppConfig,
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
TypeOrmModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService<AppConfig, true>) => ({
|
||||
type: "better-sqlite3",
|
||||
database: config.get("DB_PATH"),
|
||||
entities: [
|
||||
SessionEntity,
|
||||
EnvironmentEntity,
|
||||
ScenarioEntity,
|
||||
ScenarioStepEntity,
|
||||
ScenarioRunEntity,
|
||||
ScenarioRunStepEntity,
|
||||
ScenarioRunLogEntity,
|
||||
],
|
||||
synchronize: true,
|
||||
}),
|
||||
}),
|
||||
AuthModule,
|
||||
BrowserModule,
|
||||
EnvironmentModule,
|
||||
ScenarioModule,
|
||||
McpModule,
|
||||
HealthModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Body, Controller, Get, Post } from "@nestjs/common";
|
||||
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { LoginDto } from "./dto/login.dto";
|
||||
|
||||
@ApiTags("auth")
|
||||
@Controller()
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Get("keys")
|
||||
@ApiOperation({
|
||||
summary: "List available key identifiers from the keys directory",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "List of key names",
|
||||
schema: {
|
||||
properties: { keys: { type: "array", items: { type: "string" } } },
|
||||
},
|
||||
})
|
||||
listKeys(): { keys: string[] } {
|
||||
return { keys: this.authService.listKeys() };
|
||||
}
|
||||
|
||||
@Post("login")
|
||||
@ApiOperation({
|
||||
summary: "Log in using a file key and return the session token",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: "Login successful",
|
||||
schema: {
|
||||
properties: {
|
||||
token: { type: "string" },
|
||||
sessionName: { type: "string" },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 400, description: "Key not found or invalid" })
|
||||
@ApiResponse({ status: 500, description: "Automation failed" })
|
||||
login(
|
||||
@Body() dto: LoginDto,
|
||||
): Promise<{ token: string; sessionName: string }> {
|
||||
return this.authService.login(
|
||||
dto.key,
|
||||
dto.environmentName,
|
||||
dto.sessionName,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { SessionModule } from "../session/session.module";
|
||||
import { EnvironmentModule } from "../environment/environment.module";
|
||||
|
||||
@Module({
|
||||
imports: [SessionModule, EnvironmentModule],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,265 @@
|
||||
import {
|
||||
Injectable,
|
||||
BadRequestException,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { TraceLogger } from "../common/trace-logger";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { AppConfig } from "../config/app.config";
|
||||
import { chromium } from "playwright";
|
||||
import type { Page } from "playwright";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as crypto from "crypto";
|
||||
import { SessionService } from "../session/session.service";
|
||||
import { SessionContextService } from "../session/session-context.service";
|
||||
import { EnvironmentService } from "../environment/environment.service";
|
||||
|
||||
interface KeyDescriptor {
|
||||
keyFile?: string;
|
||||
login?: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new TraceLogger(AuthService.name);
|
||||
private readonly keysDir: string;
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService<AppConfig, true>,
|
||||
private readonly sessionService: SessionService,
|
||||
private readonly sessionContextService: SessionContextService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
) {
|
||||
this.keysDir = path.resolve(this.config.get("KEYS_DIR"));
|
||||
}
|
||||
|
||||
listKeys(): string[] {
|
||||
if (!fs.existsSync(this.keysDir)) return [];
|
||||
return fs
|
||||
.readdirSync(this.keysDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => path.basename(f, ".json"));
|
||||
}
|
||||
|
||||
private loadKeyDescriptor(keyId: string): KeyDescriptor {
|
||||
const keyJsonPath = path.join(this.keysDir, `${keyId}.json`);
|
||||
if (!fs.existsSync(keyJsonPath)) {
|
||||
throw new BadRequestException(`Key not found: ${keyId}`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(keyJsonPath, "utf-8")) as KeyDescriptor;
|
||||
} catch (err) {
|
||||
throw new BadRequestException(`Cannot read key descriptor: ${keyId}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async login(
|
||||
keyId: string,
|
||||
environmentName: string,
|
||||
sessionName?: string,
|
||||
): Promise<{ token: string; sessionName: string }> {
|
||||
const resolvedSession = sessionName ?? crypto.randomUUID();
|
||||
|
||||
const env = await this.environmentService
|
||||
.findAll()
|
||||
.then(({ data }) => data.find((e) => e.name === environmentName));
|
||||
if (!env)
|
||||
throw new NotFoundException(`Environment "${environmentName}" not found`);
|
||||
|
||||
const loginUrl = env.urls.id_url;
|
||||
const cabinetUrl = env.urls.cabinet_url;
|
||||
if (!loginUrl)
|
||||
throw new BadRequestException(
|
||||
`Environment "${environmentName}" is missing id_url`,
|
||||
);
|
||||
if (!cabinetUrl)
|
||||
throw new BadRequestException(
|
||||
`Environment "${environmentName}" is missing cabinet_url`,
|
||||
);
|
||||
|
||||
const descriptor = this.loadKeyDescriptor(keyId);
|
||||
const useLoginPassword = !!descriptor.login;
|
||||
|
||||
if (!useLoginPassword) {
|
||||
if (!descriptor.keyFile) {
|
||||
throw new BadRequestException(
|
||||
`Key descriptor for "${keyId}" must have either "login" or "keyFile"`,
|
||||
);
|
||||
}
|
||||
const keyFilePath = path.resolve(this.keysDir, descriptor.keyFile);
|
||||
if (!fs.existsSync(keyFilePath)) {
|
||||
throw new BadRequestException(
|
||||
`Key file not found: ${descriptor.keyFile}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
|
||||
});
|
||||
try {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
this.logger.log(`Navigating to ${loginUrl}`);
|
||||
await page.goto(loginUrl, { waitUntil: "networkidle" });
|
||||
|
||||
if (useLoginPassword) {
|
||||
// Click "Логін і пароль" auth method
|
||||
await page.locator('p[aria-label="Логін і пароль"]').click();
|
||||
|
||||
// Fill login and password
|
||||
await page.getByLabel("Електронна пошта").fill(descriptor.login!);
|
||||
await page.getByLabel("Пароль").fill(descriptor.password);
|
||||
|
||||
// Click "Увійти"
|
||||
await page.locator('button:has-text("Увійти")').click();
|
||||
} else {
|
||||
const keyFilePath = path.resolve(this.keysDir, descriptor.keyFile!);
|
||||
|
||||
// Click "Файловий ключ" button
|
||||
await page.getByText("Файловий ключ").click();
|
||||
|
||||
// Upload key file via hidden file input
|
||||
const fileInput = page.locator(
|
||||
'input[accept=".dat,.pfx,.pk8,.zs2,.jks"][type="file"]',
|
||||
);
|
||||
await fileInput.setInputFiles(keyFilePath);
|
||||
|
||||
// Enter password
|
||||
await page
|
||||
.locator("#id-app-login-file-key-password")
|
||||
.fill(descriptor.password);
|
||||
|
||||
// Click "Продовжити"
|
||||
await page.locator("#id-app-login-file-key-sign-button").click();
|
||||
}
|
||||
|
||||
// Wait until redirected to cabinet
|
||||
this.logger.log(`Waiting for redirect to ${cabinetUrl}`);
|
||||
await page.waitForURL(cabinetUrl, { timeout: 30000 });
|
||||
|
||||
// Extract token from localStorage
|
||||
const token = await page.evaluate(() => localStorage.getItem("token"));
|
||||
|
||||
if (!token) {
|
||||
throw new InternalServerErrorException(
|
||||
"Login succeeded but token was not found in localStorage",
|
||||
);
|
||||
}
|
||||
|
||||
// Capture all cookies and localStorage from the browser context
|
||||
const cookies = await context.cookies();
|
||||
const localStorageData = await page.evaluate(() => {
|
||||
const entries: Record<string, string> = {};
|
||||
for (let i = 0; i < window.localStorage.length; i++) {
|
||||
const k = window.localStorage.key(i);
|
||||
if (k !== null) entries[k] = window.localStorage.getItem(k) ?? "";
|
||||
}
|
||||
return entries;
|
||||
});
|
||||
|
||||
await this.sessionService.upsert(
|
||||
resolvedSession,
|
||||
token,
|
||||
cookies,
|
||||
localStorageData,
|
||||
);
|
||||
|
||||
// Register the live Playwright context — browser stays open for reuse
|
||||
this.sessionContextService.register(
|
||||
resolvedSession,
|
||||
browser,
|
||||
context,
|
||||
page,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Login successful for key ${keyId}, session: ${resolvedSession}`,
|
||||
);
|
||||
return { token, sessionName: resolvedSession };
|
||||
} catch (err) {
|
||||
// Close browser only on failure — on success it is kept alive in SessionContextService
|
||||
await browser.close().catch(() => {});
|
||||
if (
|
||||
err instanceof BadRequestException ||
|
||||
err instanceof InternalServerErrorException
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
this.logger.error(`Login failed: ${(err as Error).message}`);
|
||||
throw new InternalServerErrorException(
|
||||
`Login automation failed: ${(err as Error).message}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async signWithKey(keyId: string, page: Page): Promise<void> {
|
||||
const descriptor = this.loadKeyDescriptor(keyId);
|
||||
|
||||
if (!descriptor.keyFile) {
|
||||
throw new BadRequestException(
|
||||
`Key descriptor for "${keyId}" must have "keyFile" to sign`,
|
||||
);
|
||||
}
|
||||
|
||||
const keyFilePath = path.resolve(this.keysDir, descriptor.keyFile);
|
||||
if (!fs.existsSync(keyFilePath)) {
|
||||
throw new BadRequestException(
|
||||
`Key file not found: ${descriptor.keyFile}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(`Signing with key ${keyId} on page: ${page.url()}`);
|
||||
|
||||
// Open the EDS sign widget (skip if it's already open)
|
||||
const isSignDialogOpen = await page
|
||||
.locator('input[accept=".dat,.pfx,.pk8,.zs2,.jks"][type="file"]')
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
if (!isSignDialogOpen) {
|
||||
await page
|
||||
.locator("button")
|
||||
.filter({ hasText: /підпис|sign/i })
|
||||
.first()
|
||||
.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// Select the file key tab inside the widget
|
||||
await page
|
||||
.locator('button, [role="tab"], li')
|
||||
.filter({ hasText: /файлов|file key/i })
|
||||
.first()
|
||||
.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Upload the key file
|
||||
const fileInput = page.locator(
|
||||
'input[accept=".dat,.pfx,.pk8,.zs2,.jks"][type="file"]',
|
||||
);
|
||||
await fileInput.setInputFiles(keyFilePath);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Enter password
|
||||
await page.locator('input[type="password"]').fill(descriptor.password);
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Submit
|
||||
await page
|
||||
.locator("button")
|
||||
.filter({ hasText: /підпис|sign|підтвер/i })
|
||||
.last()
|
||||
.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
this.logger.log(`Sign completed for key ${keyId}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsNotEmpty, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Key identifier — filename (without extension) from the keys/ directory",
|
||||
example: "3273334361",
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
key: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Environment name to resolve login/cabinet URLs from",
|
||||
example: "liquio-diia-stg",
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
environmentName: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Session name to store credentials under. Auto-generated UUID if omitted.",
|
||||
example: "my-test-session",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sessionName?: string;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Body, Controller, Post } from "@nestjs/common";
|
||||
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
||||
import { BrowserService, ExecResult, OpenResult } from "./browser.service";
|
||||
import { CodeExecutorService } from "../code-executor/code-executor.service";
|
||||
import { OpenDto } from "./dto/open.dto";
|
||||
import { ExecDto } from "./dto/exec.dto";
|
||||
|
||||
@ApiTags("browser")
|
||||
@Controller()
|
||||
export class BrowserController {
|
||||
constructor(
|
||||
private readonly browserService: BrowserService,
|
||||
private readonly codeExecutor: CodeExecutorService,
|
||||
) {}
|
||||
|
||||
@Post("open")
|
||||
@ApiOperation({
|
||||
summary: "Open a URL with a stored session (cookies + localStorage)",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: "Page loaded successfully",
|
||||
schema: {
|
||||
properties: {
|
||||
url: { type: "string" },
|
||||
title: { type: "string" },
|
||||
content: { type: "string" },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 400, description: "Invalid input" })
|
||||
@ApiResponse({ status: 404, description: "Session not found" })
|
||||
@ApiResponse({ status: 500, description: "Browser automation failed" })
|
||||
open(@Body() dto: OpenDto): Promise<OpenResult> {
|
||||
return this.browserService.open(
|
||||
dto.sessionName,
|
||||
dto.url,
|
||||
dto.readerMode ?? false,
|
||||
dto.selector,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("exec")
|
||||
@ApiOperation({
|
||||
summary: "Execute custom Playwright JavaScript within a stored session",
|
||||
description:
|
||||
"The `code` string is executed as an async function body with `page` (Playwright Page) and `context` (BrowserContext) in scope. The return value is serialised and returned as `result`.",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: "Code executed successfully",
|
||||
schema: { properties: { result: {} } },
|
||||
})
|
||||
@ApiResponse({ status: 400, description: "Invalid input" })
|
||||
@ApiResponse({ status: 404, description: "Session not found" })
|
||||
@ApiResponse({ status: 500, description: "Execution failed" })
|
||||
exec(@Body() dto: ExecDto): Promise<ExecResult> {
|
||||
this.codeExecutor.validate(dto.code);
|
||||
return this.browserService.exec(dto.sessionName, dto.code, dto.url);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { BrowserController } from "./browser.controller";
|
||||
import { BrowserService } from "./browser.service";
|
||||
import { SessionModule } from "../session/session.module";
|
||||
import { CodeExecutorModule } from "../code-executor/code-executor.module";
|
||||
|
||||
@Module({
|
||||
imports: [SessionModule, CodeExecutorModule],
|
||||
controllers: [BrowserController],
|
||||
providers: [BrowserService],
|
||||
exports: [BrowserService],
|
||||
})
|
||||
export class BrowserModule {}
|
||||
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
Injectable,
|
||||
HttpException,
|
||||
InternalServerErrorException,
|
||||
} from "@nestjs/common";
|
||||
import { TraceLogger } from "../common/trace-logger";
|
||||
import { chromium } from "playwright";
|
||||
import type { BrowserContext } from "playwright";
|
||||
import { Readability } from "@mozilla/readability";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { SessionContextService } from "../session/session-context.service";
|
||||
import { CodeExecutorService } from "../code-executor/code-executor.service";
|
||||
import type { ExecResult } from "../code-executor/code-executor.service";
|
||||
|
||||
export type { ExecResult } from "../code-executor/code-executor.service";
|
||||
|
||||
export interface OpenResult {
|
||||
url: string;
|
||||
title: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BrowserService {
|
||||
private readonly logger = new TraceLogger(BrowserService.name);
|
||||
|
||||
constructor(
|
||||
private readonly sessionContextService: SessionContextService,
|
||||
private readonly codeExecutor: CodeExecutorService,
|
||||
) {}
|
||||
|
||||
private rethrow(err: unknown, label: string, operation: string): never {
|
||||
if (err instanceof HttpException) {
|
||||
throw err;
|
||||
}
|
||||
throw new InternalServerErrorException(
|
||||
`[${label}] Browser ${operation} failed: ${(err as Error).message}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
|
||||
private async extractContent(
|
||||
context: BrowserContext,
|
||||
url: string,
|
||||
readerMode: boolean,
|
||||
selector?: string,
|
||||
): Promise<OpenResult> {
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
this.logger.log(`Opening ${url}`);
|
||||
await page.goto(url, { waitUntil: "networkidle" });
|
||||
|
||||
const finalUrl = page.url();
|
||||
const title = await page.title();
|
||||
const rawHtml = await page.content();
|
||||
|
||||
let content: string;
|
||||
if (selector) {
|
||||
const dom = new JSDOM(rawHtml, { url: finalUrl });
|
||||
const el = dom.window.document.querySelector(selector);
|
||||
content = readerMode
|
||||
? (el?.textContent?.replace(/\s+/g, " ").trim() ?? "")
|
||||
: (el?.outerHTML ?? "");
|
||||
} else if (readerMode) {
|
||||
const dom = new JSDOM(rawHtml, { url: finalUrl });
|
||||
const article = new Readability(dom.window.document).parse();
|
||||
content = article
|
||||
? article.textContent.replace(/\s+/g, " ").trim()
|
||||
: rawHtml;
|
||||
} else {
|
||||
content = rawHtml;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Loaded: ${finalUrl} — "${title}"${readerMode ? " (reader mode)" : ""}${selector ? ` (selector: ${selector})` : ""}`,
|
||||
);
|
||||
return { url: finalUrl, title, content };
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
async open(
|
||||
sessionName: string | undefined,
|
||||
url: string,
|
||||
readerMode = false,
|
||||
selector?: string,
|
||||
): Promise<OpenResult> {
|
||||
const label = sessionName ?? "anonymous";
|
||||
this.logger.log(`[${label}] open: ${url}`);
|
||||
|
||||
if (sessionName) {
|
||||
const { context } =
|
||||
await this.sessionContextService.getHandle(sessionName);
|
||||
try {
|
||||
return await this.extractContent(context, url, readerMode, selector);
|
||||
} catch (err) {
|
||||
this.rethrow(err, label, "open");
|
||||
}
|
||||
}
|
||||
|
||||
// Anonymous — ephemeral browser
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
|
||||
});
|
||||
try {
|
||||
const context = await browser.newContext();
|
||||
return await this.extractContent(context, url, readerMode, selector);
|
||||
} catch (err) {
|
||||
this.rethrow(err, label, "open");
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
async exec(
|
||||
sessionName: string | undefined,
|
||||
code: string,
|
||||
url?: string,
|
||||
): Promise<ExecResult> {
|
||||
const label = sessionName ?? "anonymous";
|
||||
|
||||
if (sessionName) {
|
||||
const { page, context } =
|
||||
await this.sessionContextService.getHandle(sessionName);
|
||||
this.logger.log(`[${label}] exec: using persistent context`);
|
||||
try {
|
||||
if (url) {
|
||||
this.logger.log(`[${label}] exec: navigating to ${url}`);
|
||||
await page.goto(url, { waitUntil: "networkidle" });
|
||||
}
|
||||
this.logger.log(`[${label}] exec: running user code`);
|
||||
const result = await this.codeExecutor.execute(page, context, code);
|
||||
this.logger.log(`[${label}] exec: done`);
|
||||
return result;
|
||||
} catch (err) {
|
||||
this.rethrow(err, label, "exec");
|
||||
}
|
||||
}
|
||||
|
||||
// Anonymous — ephemeral browser
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
|
||||
});
|
||||
try {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
if (url) {
|
||||
this.logger.log(`[${label}] exec: navigating to ${url}`);
|
||||
await page.goto(url, { waitUntil: "networkidle" });
|
||||
}
|
||||
|
||||
this.logger.log(`[${label}] exec: running user code`);
|
||||
const result = await this.codeExecutor.execute(page, context, code);
|
||||
this.logger.log(`[${label}] exec: done`);
|
||||
return result;
|
||||
} catch (err) {
|
||||
this.rethrow(err, label, "exec");
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsOptional, IsString, IsUrl } from "class-validator";
|
||||
|
||||
export class ExecDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Session name previously created by POST /login. If omitted, executes without a stored session.",
|
||||
example: "test-session-1",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sessionName?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"URL to navigate to before executing code. Skipped if omitted.",
|
||||
example: "https://cabinet-liquio-diia-stg.kitsoft.ua/messages",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUrl({ require_tld: true, require_protocol: true })
|
||||
url?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
"JavaScript code to execute. Receives `page` (Playwright Page) and `context` (BrowserContext) as arguments. May be async. Return value is serialised and returned.",
|
||||
example: "return await page.title();",
|
||||
})
|
||||
@IsString()
|
||||
code: string;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsBoolean, IsOptional, IsString, IsUrl } from "class-validator";
|
||||
|
||||
export class OpenDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Session name previously created by POST /login. If omitted, opens the URL without a stored session.",
|
||||
example: "test-session-1",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sessionName?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: "URL to open with the authenticated session",
|
||||
example: "https://cabinet-liquio-diia-stg.kitsoft.ua/messages",
|
||||
})
|
||||
@IsUrl({ require_tld: true, require_protocol: true })
|
||||
url: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"When true, return a plain-text reader-mode summary instead of raw HTML",
|
||||
default: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
readerMode?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"CSS selector whose matching element content is returned. When omitted the full page HTML is used.",
|
||||
example: "#main-content",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
selector?: string;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { CodeExecutorService } from "./code-executor.service";
|
||||
|
||||
@Module({
|
||||
providers: [CodeExecutorService],
|
||||
exports: [CodeExecutorService],
|
||||
})
|
||||
export class CodeExecutorModule {}
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
} from "@nestjs/common";
|
||||
import { TraceLogger } from "../common/trace-logger";
|
||||
import { parse } from "acorn";
|
||||
import type { Page, BrowserContext } from "playwright";
|
||||
import { dumpDom } from "./dom-helpers";
|
||||
|
||||
export interface ExecResult {
|
||||
result: unknown;
|
||||
}
|
||||
|
||||
export type ScriptLogger = (
|
||||
level: "log" | "warn" | "error",
|
||||
message: string,
|
||||
) => void;
|
||||
|
||||
@Injectable()
|
||||
export class CodeExecutorService {
|
||||
private readonly logger = new TraceLogger(CodeExecutorService.name);
|
||||
|
||||
/**
|
||||
* Validates `code` by wrapping it in an async function body and attempting
|
||||
* to parse it with acorn. Throws BadRequestException if it is not valid JS.
|
||||
*/
|
||||
validate(code: string): void {
|
||||
const wrapped = `async function __validate__(page, context, helpers) { ${code} }`;
|
||||
try {
|
||||
parse(wrapped, { ecmaVersion: 2022 });
|
||||
} catch (err) {
|
||||
throw new BadRequestException(
|
||||
`Code parse error: ${(err as Error).message}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes `code` as an async function body with `page` and `context` in
|
||||
* scope. Always call `validate()` before this method.
|
||||
*/
|
||||
async execute(
|
||||
page: Page,
|
||||
context: BrowserContext,
|
||||
code: string,
|
||||
log?: ScriptLogger,
|
||||
getStepOutput?: (order: number) => Promise<unknown>,
|
||||
): 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 {
|
||||
const pageHelpers = {
|
||||
dumpDom: (selector?: string) => dumpDom(page, selector),
|
||||
log: (...args: unknown[]) => scriptLog("log", toStr(args)),
|
||||
warn: (...args: unknown[]) => scriptLog("warn", toStr(args)),
|
||||
error: (...args: unknown[]) => scriptLog("error", toStr(args)),
|
||||
getStepOutput: getStepOutput ?? (() => Promise.resolve(null)),
|
||||
};
|
||||
|
||||
const fakeConsole = {
|
||||
log: (...args: unknown[]) => scriptLog("log", toStr(args)),
|
||||
warn: (...args: unknown[]) => scriptLog("warn", toStr(args)),
|
||||
error: (...args: unknown[]) => scriptLog("error", toStr(args)),
|
||||
info: (...args: unknown[]) => scriptLog("log", toStr(args)),
|
||||
debug: (...args: unknown[]) => scriptLog("log", toStr(args)),
|
||||
};
|
||||
|
||||
// Passing `console` as a named parameter shadows the global in the script scope.
|
||||
const fn = new Function(
|
||||
"page",
|
||||
"context",
|
||||
"helpers",
|
||||
"console",
|
||||
`return (async (page, context, helpers) => { ${code} })(page, context, helpers)`,
|
||||
);
|
||||
this.logger.debug("Executing user code");
|
||||
const result = await fn(page, context, pageHelpers, fakeConsole);
|
||||
return { result };
|
||||
} catch (err) {
|
||||
throw new InternalServerErrorException(
|
||||
`Code execution failed: ${(err as Error).message}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import type { Page } from "playwright";
|
||||
|
||||
export interface DomNode {
|
||||
tag: string;
|
||||
role?: string;
|
||||
testid?: string;
|
||||
qa?: string;
|
||||
action?: string;
|
||||
elementId?: string;
|
||||
id?: string;
|
||||
type?: string;
|
||||
name?: string;
|
||||
text?: string;
|
||||
href?: string;
|
||||
checked?: boolean;
|
||||
disabled?: boolean;
|
||||
children: DomNode[];
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dumps a logical component tree of the live DOM scoped to `rootSelector`.
|
||||
* Filters out decorative/layout noise — keeps only structural landmarks,
|
||||
* interactive elements, and elements with semantic attributes
|
||||
* (role, data-testid, data-qa, data-action, data-element-id).
|
||||
*
|
||||
* Useful for debugging Playwright selectors without screenshotting.
|
||||
*/
|
||||
export async function dumpDom(
|
||||
page: Page,
|
||||
rootSelector = "body",
|
||||
): Promise<DomNode> {
|
||||
return page.evaluate(
|
||||
([sel, maxDepth]) => {
|
||||
const root = document.querySelector(sel as string);
|
||||
if (!root)
|
||||
return {
|
||||
tag: "ERROR",
|
||||
text: `selector not found: ${sel}`,
|
||||
children: [],
|
||||
};
|
||||
|
||||
const STRUCTURAL_TAGS = new Set([
|
||||
"BODY",
|
||||
"MAIN",
|
||||
"HEADER",
|
||||
"FOOTER",
|
||||
"NAV",
|
||||
"ASIDE",
|
||||
"SECTION",
|
||||
"FORM",
|
||||
"DIALOG",
|
||||
"DETAILS",
|
||||
"SUMMARY",
|
||||
"TABLE",
|
||||
"THEAD",
|
||||
"TBODY",
|
||||
"TR",
|
||||
"FIELDSET",
|
||||
"LEGEND",
|
||||
]);
|
||||
|
||||
const INTERACTIVE_TAGS = new Set([
|
||||
"A",
|
||||
"BUTTON",
|
||||
"INPUT",
|
||||
"SELECT",
|
||||
"TEXTAREA",
|
||||
"LABEL",
|
||||
"TH",
|
||||
"TD",
|
||||
]);
|
||||
|
||||
const IGNORED_TAGS = new Set([
|
||||
"SCRIPT",
|
||||
"STYLE",
|
||||
"SVG",
|
||||
"PATH",
|
||||
"DEFS",
|
||||
"USE",
|
||||
"CIRCLE",
|
||||
"RECT",
|
||||
"POLYGON",
|
||||
"POLYLINE",
|
||||
"LINE",
|
||||
"ELLIPSE",
|
||||
"G",
|
||||
"CLIPPATH",
|
||||
"IMAGE",
|
||||
]);
|
||||
|
||||
function trimText(el: Element): string | undefined {
|
||||
const t =
|
||||
(el as HTMLElement).innerText?.trim() ?? el.textContent?.trim() ?? "";
|
||||
// Only include if short enough to be meaningful, not a dump of all child text
|
||||
const ownText = Array.from(el.childNodes)
|
||||
.filter((n) => n.nodeType === Node.TEXT_NODE)
|
||||
.map((n) => n.textContent?.trim() ?? "")
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const candidate = ownText || t;
|
||||
return candidate.length > 0 ? candidate.substring(0, 80) : undefined;
|
||||
}
|
||||
|
||||
function isVisible(el: Element): boolean {
|
||||
const s = window.getComputedStyle(el);
|
||||
return (
|
||||
s.display !== "none" &&
|
||||
s.visibility !== "hidden" &&
|
||||
(el as HTMLElement).offsetParent !== null
|
||||
);
|
||||
}
|
||||
|
||||
function isSignificant(el: Element): boolean {
|
||||
if (STRUCTURAL_TAGS.has(el.tagName)) return true;
|
||||
if (INTERACTIVE_TAGS.has(el.tagName)) return true;
|
||||
if (el.getAttribute("role")) return true;
|
||||
if (el.getAttribute("data-testid")) return true;
|
||||
if (el.getAttribute("data-qa")) return true;
|
||||
if (el.getAttribute("data-action")) return true;
|
||||
if (el.getAttribute("data-element-id")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function build(el: Element, depth: number): DomNode | null {
|
||||
if (IGNORED_TAGS.has(el.tagName)) return null;
|
||||
if (!isVisible(el)) return null;
|
||||
|
||||
const significant = isSignificant(el);
|
||||
const childResults: DomNode[] = [];
|
||||
|
||||
if (depth < (maxDepth as number)) {
|
||||
for (const child of Array.from(el.children)) {
|
||||
const node = build(child, depth + 1);
|
||||
if (node) childResults.push(node);
|
||||
}
|
||||
} else if (el.children.length > 0) {
|
||||
return significant
|
||||
? { tag: el.tagName.toLowerCase(), children: [], truncated: true }
|
||||
: null;
|
||||
}
|
||||
|
||||
// If not significant and no meaningful children, discard
|
||||
if (!significant && childResults.length === 0) return null;
|
||||
|
||||
// If not significant but has exactly one child, pass through (unwrap)
|
||||
if (!significant && childResults.length === 1) return childResults[0];
|
||||
|
||||
// If not significant but has children, keep as anonymous group only if > 1 child
|
||||
if (!significant)
|
||||
return { tag: el.tagName.toLowerCase(), children: childResults };
|
||||
|
||||
const node: DomNode = {
|
||||
tag: el.tagName.toLowerCase(),
|
||||
children: childResults,
|
||||
};
|
||||
|
||||
const role = el.getAttribute("role");
|
||||
if (role) node.role = role;
|
||||
|
||||
const testid = el.getAttribute("data-testid");
|
||||
if (testid) node.testid = testid;
|
||||
|
||||
const qa = el.getAttribute("data-qa");
|
||||
if (qa) node.qa = qa;
|
||||
|
||||
const action = el.getAttribute("data-action");
|
||||
if (action) node.action = action;
|
||||
|
||||
const elementId = el.getAttribute("data-element-id");
|
||||
if (elementId) node.elementId = elementId;
|
||||
|
||||
const id = el.id;
|
||||
if (id) node.id = id;
|
||||
|
||||
const type = (el as HTMLInputElement).type;
|
||||
if (type && type !== "submit" && el.tagName !== "BUTTON")
|
||||
node.type = type;
|
||||
|
||||
const name = (el as HTMLInputElement).name;
|
||||
if (name) node.name = name;
|
||||
|
||||
const href = (el as HTMLAnchorElement).href;
|
||||
if (href && el.tagName === "A")
|
||||
node.href = href.replace(window.location.origin, "");
|
||||
|
||||
if ("checked" in el) node.checked = (el as HTMLInputElement).checked;
|
||||
if ((el as HTMLButtonElement).disabled) node.disabled = true;
|
||||
|
||||
const text = trimText(el);
|
||||
if (text) node.text = text;
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
const result = build(root, 0);
|
||||
return result ?? { tag: "empty", children: [] };
|
||||
},
|
||||
[rootSelector, 12] as [string, number],
|
||||
);
|
||||
}
|
||||
|
||||
export const helpers = { dumpDom };
|
||||
export type Helpers = typeof helpers;
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
|
||||
|
||||
export class PaginationQueryDto<TOrderBy extends string = string> {
|
||||
@ApiPropertyOptional({ example: 1, default: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@ApiPropertyOptional({ example: 20, default: 20 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
limit?: number = 20;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: "id",
|
||||
default: "id",
|
||||
description: "Field to order by",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
orderBy?: TOrderBy;
|
||||
|
||||
@ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "ASC" })
|
||||
@IsOptional()
|
||||
@IsIn(["ASC", "DESC"])
|
||||
orderDir?: "ASC" | "DESC" = "ASC";
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { AsyncLocalStorage } from "async_hooks";
|
||||
|
||||
export interface TraceStore {
|
||||
traceId: string;
|
||||
}
|
||||
|
||||
export const traceStorage = new AsyncLocalStorage<TraceStore>();
|
||||
|
||||
export function getTraceId(): string | undefined {
|
||||
return traceStorage.getStore()?.traceId;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ConsoleLogger, ConsoleLoggerOptions, LogLevel } from "@nestjs/common";
|
||||
import { getTraceId } from "./trace-context";
|
||||
|
||||
export class TraceLogger extends ConsoleLogger {
|
||||
constructor(context?: string, options: ConsoleLoggerOptions = {}) {
|
||||
super(context as string, options);
|
||||
}
|
||||
|
||||
protected override getTimestamp(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
protected override formatMessage(
|
||||
logLevel: LogLevel,
|
||||
message: unknown,
|
||||
_pidMessage: string,
|
||||
_formattedLogLevel: string,
|
||||
contextMessage: string,
|
||||
timestampDiff: string,
|
||||
): string {
|
||||
const output = this.stringifyMessage(message, logLevel);
|
||||
const level = this.colorize(logLevel.toUpperCase(), logLevel);
|
||||
return `${this.getTimestamp()} ${level} ${contextMessage}${output}${timestampDiff}\n`;
|
||||
}
|
||||
|
||||
protected override formatContext(context: string): string {
|
||||
const traceId = getTraceId();
|
||||
const traced = traceId ? `${context}:${traceId}` : context;
|
||||
return super.formatContext(traced);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { IsInt, IsString, Min, Max } from "class-validator";
|
||||
import { plainToInstance } from "class-transformer";
|
||||
import { validateSync } from "class-validator";
|
||||
|
||||
import pkg from "../../package.json";
|
||||
|
||||
export class AppConfig {
|
||||
@IsString()
|
||||
NODE_ENV: string = "development";
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(65535)
|
||||
PORT: number = 3000;
|
||||
|
||||
@IsString()
|
||||
APP_NAME: string = pkg.name;
|
||||
|
||||
@IsString()
|
||||
APP_VERSION: string = pkg.version;
|
||||
|
||||
@IsString()
|
||||
KEYS_DIR: string = "keys";
|
||||
|
||||
@IsString()
|
||||
DB_PATH: string = "data/sessions.db";
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
SESSION_IDLE_TIMEOUT_MINUTES: number = 30;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
SESSION_DELETE_CLOSED_DAYS: number = 7;
|
||||
}
|
||||
|
||||
export function validateAppConfig(config: Record<string, unknown>): AppConfig {
|
||||
const validated = plainToInstance(AppConfig, config, {
|
||||
enableImplicitConversion: true,
|
||||
});
|
||||
const errors = validateSync(validated, { skipMissingProperties: false });
|
||||
if (errors.length > 0) {
|
||||
throw new Error(errors.toString());
|
||||
}
|
||||
return validated;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsNotEmpty, IsObject, IsString } from "class-validator";
|
||||
import { EnvironmentUrls } from "../environment.entity";
|
||||
|
||||
export class CreateEnvironmentDto {
|
||||
@ApiProperty({ example: "liquio-diia-stg" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Map of URL identifiers to URL strings",
|
||||
example: {
|
||||
id_url: "https://id-liquio-diia-stg.kitsoft.ua/",
|
||||
cabinet_url: "https://cabinet-liquio-diia-stg.kitsoft.ua/",
|
||||
admin_url: "https://admin-liquio-diia-stg.kitsoft.ua/",
|
||||
},
|
||||
})
|
||||
@IsObject()
|
||||
urls: EnvironmentUrls;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from "@nestjs/swagger";
|
||||
import { CreateEnvironmentDto } from "./create-environment.dto";
|
||||
|
||||
export class UpdateEnvironmentDto extends PartialType(CreateEnvironmentDto) {}
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
||||
import { EnvironmentService } from "./environment.service";
|
||||
import { CreateEnvironmentDto } from "./dto/create-environment.dto";
|
||||
import { UpdateEnvironmentDto } from "./dto/update-environment.dto";
|
||||
import { PaginationQueryDto } from "../common/dto/pagination.dto";
|
||||
import { EnvironmentOrderBy } from "./environment.service";
|
||||
|
||||
@ApiTags("environments")
|
||||
@Controller("environments")
|
||||
export class EnvironmentController {
|
||||
constructor(private readonly environmentService: EnvironmentService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: "Create a new environment" })
|
||||
@ApiResponse({ status: 201, description: "Environment created" })
|
||||
@ApiResponse({ status: 409, description: "Environment name already exists" })
|
||||
create(@Body() dto: CreateEnvironmentDto) {
|
||||
return this.environmentService.create(dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "List all environments (paginated)" })
|
||||
@ApiResponse({ status: 200, description: "Paginated environments" })
|
||||
findAll(@Query() query: PaginationQueryDto<EnvironmentOrderBy>) {
|
||||
return this.environmentService.findAll(query);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@ApiOperation({ summary: "Get environment by ID" })
|
||||
@ApiResponse({ status: 200, description: "Environment record" })
|
||||
@ApiResponse({ status: 404, description: "Environment not found" })
|
||||
findOne(@Param("id", ParseIntPipe) id: number) {
|
||||
return this.environmentService.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@ApiOperation({ summary: "Update an environment" })
|
||||
@ApiResponse({ status: 200, description: "Environment updated" })
|
||||
@ApiResponse({ status: 404, description: "Environment not found" })
|
||||
update(
|
||||
@Param("id", ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateEnvironmentDto,
|
||||
) {
|
||||
return this.environmentService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: "Delete an environment" })
|
||||
@ApiResponse({ status: 204, description: "Environment deleted" })
|
||||
@ApiResponse({ status: 404, description: "Environment not found" })
|
||||
remove(@Param("id", ParseIntPipe) id: number) {
|
||||
return this.environmentService.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from "typeorm";
|
||||
|
||||
export interface EnvironmentUrls {
|
||||
id_url?: string;
|
||||
cabinet_url?: string;
|
||||
admin_url?: string;
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
|
||||
@Entity("environments")
|
||||
export class EnvironmentEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ unique: true })
|
||||
name: string;
|
||||
|
||||
@Column("simple-json")
|
||||
urls: EnvironmentUrls;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { EnvironmentEntity } from "./environment.entity";
|
||||
import { EnvironmentService } from "./environment.service";
|
||||
import { EnvironmentController } from "./environment.controller";
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([EnvironmentEntity])],
|
||||
controllers: [EnvironmentController],
|
||||
providers: [EnvironmentService],
|
||||
exports: [EnvironmentService],
|
||||
})
|
||||
export class EnvironmentModule {}
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { EnvironmentEntity } from "./environment.entity";
|
||||
import { CreateEnvironmentDto } from "./dto/create-environment.dto";
|
||||
import { UpdateEnvironmentDto } from "./dto/update-environment.dto";
|
||||
import {
|
||||
PaginationQueryDto,
|
||||
PaginatedResult,
|
||||
} from "../common/dto/pagination.dto";
|
||||
|
||||
export type EnvironmentOrderBy = "id" | "name" | "createdAt" | "updatedAt";
|
||||
|
||||
@Injectable()
|
||||
export class EnvironmentService {
|
||||
constructor(
|
||||
@InjectRepository(EnvironmentEntity)
|
||||
private readonly repo: Repository<EnvironmentEntity>,
|
||||
) {}
|
||||
|
||||
async create(dto: CreateEnvironmentDto): Promise<EnvironmentEntity> {
|
||||
const existing = await this.repo.findOneBy({ name: dto.name });
|
||||
if (existing) {
|
||||
throw new ConflictException(`Environment "${dto.name}" already exists`);
|
||||
}
|
||||
return this.repo.save(this.repo.create(dto));
|
||||
}
|
||||
|
||||
async findAll(
|
||||
query: PaginationQueryDto<EnvironmentOrderBy> = {},
|
||||
): Promise<PaginatedResult<EnvironmentEntity>> {
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const orderBy = query.orderBy ?? "id";
|
||||
const orderDir = query.orderDir ?? "ASC";
|
||||
const [data, total] = await this.repo.findAndCount({
|
||||
order: { [orderBy]: orderDir },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
});
|
||||
return { data, total, page, limit };
|
||||
}
|
||||
|
||||
async findOne(id: number): Promise<EnvironmentEntity> {
|
||||
const env = await this.repo.findOneBy({ id });
|
||||
if (!env) throw new NotFoundException(`Environment ${id} not found`);
|
||||
return env;
|
||||
}
|
||||
|
||||
async update(
|
||||
id: number,
|
||||
dto: UpdateEnvironmentDto,
|
||||
): Promise<EnvironmentEntity> {
|
||||
const env = await this.findOne(id);
|
||||
Object.assign(env, dto);
|
||||
return this.repo.save(env);
|
||||
}
|
||||
|
||||
async remove(id: number): Promise<void> {
|
||||
await this.findOne(id);
|
||||
await this.repo.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
BadRequestException,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
InternalServerErrorException,
|
||||
} from "@nestjs/common";
|
||||
import { TraceLogger } from "../common/trace-logger";
|
||||
import type { Request, Response } from "express";
|
||||
|
||||
@Catch(BadRequestException, InternalServerErrorException)
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new TraceLogger(HttpExceptionFilter.name);
|
||||
|
||||
catch(exception: HttpException, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const request = ctx.getRequest<Request>();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const status = exception.getStatus();
|
||||
const body = exception.getResponse();
|
||||
|
||||
const cause = (exception as unknown as { cause?: Error }).cause;
|
||||
const causeMessage = cause ? ` | cause: ${cause.message}` : "";
|
||||
const message = `${exception.message}${causeMessage}`;
|
||||
|
||||
if (status >= 500) {
|
||||
this.logger.error(
|
||||
`[${request.method} ${request.url}] ${status} — ${message}`,
|
||||
cause?.stack ?? exception.stack,
|
||||
);
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`[${request.method} ${request.url}] ${status} — ${message}`,
|
||||
);
|
||||
}
|
||||
|
||||
response.status(status).json(body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
@ApiTags("health")
|
||||
@Controller()
|
||||
export class HealthController {
|
||||
@Get("healthz")
|
||||
@ApiOperation({ summary: "Health check" })
|
||||
@ApiResponse({ status: 200, description: "Service is healthy" })
|
||||
healthz(): { status: string } {
|
||||
return { status: "ok" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { HealthController } from "./health.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from "@nestjs/common";
|
||||
import { TraceLogger } from "../common/trace-logger";
|
||||
import type { Request, Response } from "express";
|
||||
import { Observable, tap } from "rxjs";
|
||||
|
||||
@Injectable()
|
||||
export class LoggingInterceptor implements NestInterceptor {
|
||||
private readonly logger = new TraceLogger(LoggingInterceptor.name);
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const http = context.switchToHttp();
|
||||
const req = http.getRequest<Request>();
|
||||
const res = http.getResponse<Response>();
|
||||
const { method, url, body } = req;
|
||||
const start = Date.now();
|
||||
const bodyStr =
|
||||
body && Object.keys(body).length ? ` ${JSON.stringify(body)}` : "";
|
||||
|
||||
this.logger.debug(`→ ${method} ${url}${bodyStr}`);
|
||||
|
||||
return next.handle().pipe(
|
||||
tap((responseBody) => {
|
||||
const ms = Date.now() - start;
|
||||
const len =
|
||||
responseBody != null ? JSON.stringify(responseBody).length : 0;
|
||||
const lenStr = len > 0 ? ` [${len}b]` : "";
|
||||
this.logger.debug(
|
||||
`← ${method} ${url} ${res.statusCode} (${ms}ms)${lenStr}`,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import * as crypto from "crypto";
|
||||
import { Observable } from "rxjs";
|
||||
import { traceStorage } from "../common/trace-context";
|
||||
|
||||
@Injectable()
|
||||
export class TraceInterceptor implements NestInterceptor {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const req = context.switchToHttp().getRequest<Request>();
|
||||
const traceId =
|
||||
(req.headers["x-trace-id"] as string | undefined) ?? crypto.randomUUID();
|
||||
|
||||
return new Observable((subscriber) => {
|
||||
traceStorage.run({ traceId }, () => {
|
||||
next.handle().subscribe(subscriber);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { AppConfig } from "./config/app.config";
|
||||
import { ValidationPipe } from "@nestjs/common";
|
||||
import { TraceLogger } from "./common/trace-logger";
|
||||
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||
import { AppModule } from "./app.module";
|
||||
import { HttpExceptionFilter } from "./filters/http-exception.filter";
|
||||
import { TraceInterceptor } from "./interceptors/trace.interceptor";
|
||||
import { LoggingInterceptor } from "./interceptors/logging.interceptor";
|
||||
import { name as pkgName, version as pkgVersion } from "../package.json";
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new TraceLogger("Bootstrap");
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger: new TraceLogger("Bootstrap", { timestamp: true }),
|
||||
});
|
||||
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true }));
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalInterceptors(new TraceInterceptor(), new LoggingInterceptor());
|
||||
|
||||
const config = app.get<ConfigService<AppConfig, true>>(ConfigService);
|
||||
const port = config.get("PORT");
|
||||
const nodeEnv = config.get("NODE_ENV");
|
||||
|
||||
const swaggerConfig = new DocumentBuilder()
|
||||
.setTitle(pkgName)
|
||||
.setDescription(`${pkgName} API`)
|
||||
.setVersion(pkgVersion)
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, swaggerConfig);
|
||||
SwaggerModule.setup("api", app, document);
|
||||
|
||||
await app.listen(port);
|
||||
|
||||
logger.log(
|
||||
`Application "${pkgName}" v${pkgVersion} running on port ${port} [${nodeEnv}]`,
|
||||
);
|
||||
logger.log(`Swagger UI available at http://localhost:${port}/api`);
|
||||
logger.log(`MCP endpoint available at http://localhost:${port}/mcp`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Controller, Delete, Get, Post, Req, Res } from "@nestjs/common";
|
||||
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
||||
import type { Request, Response } from "express";
|
||||
import { McpService } from "./mcp.service";
|
||||
|
||||
@ApiTags("mcp")
|
||||
@Controller("mcp")
|
||||
export class McpController {
|
||||
constructor(private readonly mcpService: McpService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({
|
||||
summary: "Send JSON-RPC message",
|
||||
description:
|
||||
"Accepts a JSON-RPC request, notification, or response. " +
|
||||
"Returns either `application/json` for a single response or " +
|
||||
"`text/event-stream` (SSE) when the server streams multiple messages.",
|
||||
})
|
||||
@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> {
|
||||
return this.mcpService.handle(req, res);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: "Open server-sent event stream",
|
||||
description:
|
||||
"Opens a persistent SSE stream so the server can push JSON-RPC requests and " +
|
||||
"notifications to the client without a prior POST. " +
|
||||
"Requires `Accept: text/event-stream`. Pass `Last-Event-ID` to resume a broken stream.",
|
||||
})
|
||||
@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> {
|
||||
return this.mcpService.handle(req, res);
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@ApiOperation({
|
||||
summary: "Terminate session",
|
||||
description:
|
||||
"Explicitly terminates a session identified by the `Mcp-Session-Id` header. " +
|
||||
"The server may return 405 if it does not support client-initiated session termination.",
|
||||
})
|
||||
@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> {
|
||||
return this.mcpService.handle(req, res);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { McpController } from "./mcp.controller";
|
||||
import { McpService } from "./mcp.service";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { SessionModule } from "../session/session.module";
|
||||
import { EnvironmentModule } from "../environment/environment.module";
|
||||
import { BrowserModule } from "../browser/browser.module";
|
||||
import { CodeExecutorModule } from "../code-executor/code-executor.module";
|
||||
import { ScenarioModule } from "../scenario/scenario.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AuthModule,
|
||||
SessionModule,
|
||||
EnvironmentModule,
|
||||
BrowserModule,
|
||||
CodeExecutorModule,
|
||||
ScenarioModule,
|
||||
],
|
||||
controllers: [McpController],
|
||||
providers: [McpService],
|
||||
})
|
||||
export class McpModule {}
|
||||
@@ -0,0 +1,883 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import { z } from "zod";
|
||||
import type { Request, Response } from "express";
|
||||
import { AuthService } from "../auth/auth.service";
|
||||
import { SessionService } from "../session/session.service";
|
||||
import { SessionContextService } from "../session/session-context.service";
|
||||
import { EnvironmentService } from "../environment/environment.service";
|
||||
import type { EnvironmentUrls } from "../environment/environment.entity";
|
||||
import { BrowserService } from "../browser/browser.service";
|
||||
import { CodeExecutorService } from "../code-executor/code-executor.service";
|
||||
import { ScenarioService } from "../scenario/scenario.service";
|
||||
|
||||
import pkg from "../../package.json";
|
||||
|
||||
@Injectable()
|
||||
export class McpService {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly sessionService: SessionService,
|
||||
private readonly sessionContextService: SessionContextService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly browserService: BrowserService,
|
||||
private readonly codeExecutor: CodeExecutorService,
|
||||
private readonly scenarioService: ScenarioService,
|
||||
) {}
|
||||
|
||||
private createServer(): McpServer {
|
||||
const server = new McpServer({ name: pkg.name, version: pkg.version });
|
||||
this.registerTools(server);
|
||||
return server;
|
||||
}
|
||||
|
||||
private registerTools(server: McpServer): void {
|
||||
// ── Auth ──────────────────────────────────────────────────────────────────
|
||||
|
||||
server.registerTool(
|
||||
"list_keys",
|
||||
{ description: "List available key identifiers from the keys directory" },
|
||||
async () => {
|
||||
const keys = this.authService.listKeys();
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(keys) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"login",
|
||||
{
|
||||
description:
|
||||
"Log in using a file key against a named environment and store the session",
|
||||
inputSchema: {
|
||||
key: z
|
||||
.string()
|
||||
.describe(
|
||||
"Key identifier (filename without extension from keys/ dir)",
|
||||
),
|
||||
environmentName: z
|
||||
.string()
|
||||
.describe("Environment name to resolve login/cabinet URLs"),
|
||||
sessionName: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Session name to store credentials under. Auto-UUID if omitted.",
|
||||
),
|
||||
},
|
||||
},
|
||||
async ({ key, environmentName, sessionName }) => {
|
||||
const result = await this.authService.login(
|
||||
key,
|
||||
environmentName,
|
||||
sessionName,
|
||||
);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// ── Sessions ──────────────────────────────────────────────────────────────
|
||||
|
||||
server.registerTool(
|
||||
"list_sessions",
|
||||
{
|
||||
description:
|
||||
"List all stored sessions (id, sessionName, createdAt, updatedAt), paginated",
|
||||
inputSchema: {
|
||||
page: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Page number (default 1)"),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Items per page (default 20)"),
|
||||
orderBy: z
|
||||
.enum([
|
||||
"id",
|
||||
"sessionName",
|
||||
"status",
|
||||
"lastUsedAt",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
])
|
||||
.optional()
|
||||
.describe("Field to order by (default id)"),
|
||||
orderDir: z
|
||||
.enum(["ASC", "DESC"])
|
||||
.optional()
|
||||
.describe("Sort direction (default ASC)"),
|
||||
},
|
||||
},
|
||||
async ({ page, limit, orderBy, orderDir }) => {
|
||||
const sessions = await this.sessionService.findAll({
|
||||
page,
|
||||
limit,
|
||||
orderBy,
|
||||
orderDir,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(sessions) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"delete_session",
|
||||
{
|
||||
description: "Delete a session by numeric ID (closes it first if open)",
|
||||
inputSchema: {
|
||||
id: z.number().int().describe("Session ID to delete"),
|
||||
},
|
||||
},
|
||||
async ({ id }) => {
|
||||
try {
|
||||
await this.sessionContextService.delete(id);
|
||||
} catch (err) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Session ${id} deleted` }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// ── Environments ──────────────────────────────────────────────────────────
|
||||
|
||||
server.registerTool(
|
||||
"list_environments",
|
||||
{
|
||||
description: "List all environments, paginated",
|
||||
inputSchema: {
|
||||
page: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Page number (default 1)"),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Items per page (default 20)"),
|
||||
orderBy: z
|
||||
.enum(["id", "name", "createdAt", "updatedAt"])
|
||||
.optional()
|
||||
.describe("Field to order by (default id)"),
|
||||
orderDir: z
|
||||
.enum(["ASC", "DESC"])
|
||||
.optional()
|
||||
.describe("Sort direction (default ASC)"),
|
||||
},
|
||||
},
|
||||
async ({ page, limit, orderBy, orderDir }) => {
|
||||
const envs = await this.environmentService.findAll({
|
||||
page,
|
||||
limit,
|
||||
orderBy,
|
||||
orderDir,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(envs) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"get_environment",
|
||||
{
|
||||
description: "Get an environment record by ID",
|
||||
inputSchema: {
|
||||
id: z.number().int().describe("Environment ID"),
|
||||
},
|
||||
},
|
||||
async ({ id }) => {
|
||||
try {
|
||||
const env = await this.environmentService.findOne(id);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(env) }],
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{ type: "text" as const, text: `Environment ${id} not found` },
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"create_environment",
|
||||
{
|
||||
description: "Create a new named environment with a set of URLs",
|
||||
inputSchema: {
|
||||
name: z
|
||||
.string()
|
||||
.describe("Unique environment name, e.g. liquio-diia-stg"),
|
||||
urls: z
|
||||
.record(z.string(), z.string())
|
||||
.describe(
|
||||
"Map of URL keys to URL strings (id_url, cabinet_url, admin_url, …)",
|
||||
),
|
||||
},
|
||||
},
|
||||
async ({ name, urls }) => {
|
||||
try {
|
||||
const env = await this.environmentService.create({
|
||||
name,
|
||||
urls: urls as EnvironmentUrls,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(env) }],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"update_environment",
|
||||
{
|
||||
description: "Update an existing environment (name and/or urls)",
|
||||
inputSchema: {
|
||||
id: z.number().int().describe("Environment ID to update"),
|
||||
name: z.string().optional().describe("New name"),
|
||||
urls: z
|
||||
.record(z.string(), z.string())
|
||||
.optional()
|
||||
.describe("New URLs map"),
|
||||
},
|
||||
},
|
||||
async ({ id, name, urls }) => {
|
||||
try {
|
||||
const env = await this.environmentService.update(id, {
|
||||
name,
|
||||
urls: urls as EnvironmentUrls | undefined,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(env) }],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"delete_environment",
|
||||
{
|
||||
description: "Delete an environment by ID",
|
||||
inputSchema: {
|
||||
id: z.number().int().describe("Environment ID to delete"),
|
||||
},
|
||||
},
|
||||
async ({ id }) => {
|
||||
try {
|
||||
await this.environmentService.remove(id);
|
||||
return {
|
||||
content: [
|
||||
{ type: "text" as const, text: `Environment ${id} deleted` },
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── Browser ───────────────────────────────────────────────────────────────
|
||||
|
||||
server.registerTool(
|
||||
"open_url",
|
||||
{
|
||||
description:
|
||||
"Open a URL using a stored session and return the page title and content",
|
||||
inputSchema: {
|
||||
sessionName: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Session name to restore cookies and localStorage from. Omit to open without a stored session.",
|
||||
),
|
||||
url: z.string().url().describe("URL to navigate to"),
|
||||
readerMode: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Extract readable plain text instead of raw HTML"),
|
||||
selector: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"CSS selector whose matching element content is returned; applied before readerMode",
|
||||
),
|
||||
},
|
||||
},
|
||||
async ({ sessionName, url, readerMode, selector }) => {
|
||||
try {
|
||||
const result = await this.browserService.open(
|
||||
sessionName,
|
||||
url,
|
||||
readerMode ?? false,
|
||||
selector,
|
||||
);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"exec_code",
|
||||
{
|
||||
description:
|
||||
"Execute arbitrary Playwright JavaScript with `page` and `context` in scope",
|
||||
inputSchema: {
|
||||
sessionName: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Session name to restore. Omit to run without a stored session.",
|
||||
),
|
||||
code: z
|
||||
.string()
|
||||
.describe(
|
||||
"JavaScript code body to execute (async-safe, may use `page` and `context`)",
|
||||
),
|
||||
url: z
|
||||
.string()
|
||||
.url()
|
||||
.optional()
|
||||
.describe("Optional URL to navigate to before running code"),
|
||||
},
|
||||
},
|
||||
async ({ sessionName, code, url }) => {
|
||||
try {
|
||||
this.codeExecutor.validate(code);
|
||||
const result = await this.browserService.exec(sessionName, code, url);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── Scenarios ─────────────────────────────────────────────────────────────
|
||||
|
||||
server.registerTool(
|
||||
"list_scenarios",
|
||||
{
|
||||
description: "List all scenarios (paginated)",
|
||||
inputSchema: {
|
||||
page: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Page number (default 1)"),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Items per page (default 20)"),
|
||||
orderBy: z
|
||||
.enum(["id", "name", "createdAt", "updatedAt"])
|
||||
.optional()
|
||||
.describe("Field to order by (default id)"),
|
||||
orderDir: z
|
||||
.enum(["ASC", "DESC"])
|
||||
.optional()
|
||||
.describe("Sort direction (default ASC)"),
|
||||
},
|
||||
},
|
||||
async ({ page, limit, orderBy, orderDir }) => {
|
||||
const result = await this.scenarioService.findAll({
|
||||
page,
|
||||
limit,
|
||||
orderBy,
|
||||
orderDir,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"get_scenario",
|
||||
{
|
||||
description: "Get a scenario with its steps by ID",
|
||||
inputSchema: {
|
||||
id: z.number().int().describe("Scenario ID"),
|
||||
},
|
||||
},
|
||||
async ({ id }) => {
|
||||
try {
|
||||
const scenario = await this.scenarioService.findOne(id);
|
||||
return {
|
||||
content: [
|
||||
{ type: "text" as const, text: JSON.stringify(scenario) },
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"create_scenario",
|
||||
{
|
||||
description: "Create a new scenario",
|
||||
inputSchema: {
|
||||
name: z.string().describe("Scenario name"),
|
||||
},
|
||||
},
|
||||
async ({ name }) => {
|
||||
try {
|
||||
const scenario = await this.scenarioService.create({ name });
|
||||
return {
|
||||
content: [
|
||||
{ type: "text" as const, text: JSON.stringify(scenario) },
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"update_scenario",
|
||||
{
|
||||
description: "Update a scenario name",
|
||||
inputSchema: {
|
||||
id: z.number().int().describe("Scenario ID"),
|
||||
name: z.string().optional().describe("New name"),
|
||||
},
|
||||
},
|
||||
async ({ id, name }) => {
|
||||
try {
|
||||
const scenario = await this.scenarioService.update(id, { name });
|
||||
return {
|
||||
content: [
|
||||
{ type: "text" as const, text: JSON.stringify(scenario) },
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"delete_scenario",
|
||||
{
|
||||
description: "Delete a scenario by ID",
|
||||
inputSchema: {
|
||||
id: z.number().int().describe("Scenario ID"),
|
||||
},
|
||||
},
|
||||
async ({ id }) => {
|
||||
try {
|
||||
await this.scenarioService.remove(id);
|
||||
return {
|
||||
content: [
|
||||
{ type: "text" as const, text: `Scenario ${id} deleted` },
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"create_scenario_step",
|
||||
{
|
||||
description: "Add a step to a scenario",
|
||||
inputSchema: {
|
||||
scenarioId: z.number().int().describe("Parent scenario ID"),
|
||||
order: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.describe("Execution order (ascending)"),
|
||||
type: z.enum(["login", "exec", "sign"]).describe("Step type"),
|
||||
sessionName: z.string().describe("Session name used by this step"),
|
||||
execCode: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Playwright JS code to execute (exec steps)"),
|
||||
validateCode: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Validation JS code returning { success, description }"),
|
||||
},
|
||||
},
|
||||
async ({ scenarioId, ...dto }) => {
|
||||
try {
|
||||
const step = await this.scenarioService.createStep(scenarioId, dto);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(step) }],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"get_scenario_step",
|
||||
{
|
||||
description: "Get a single step of a scenario",
|
||||
inputSchema: {
|
||||
scenarioId: z.number().int().describe("Scenario ID"),
|
||||
stepId: z.number().int().describe("Step ID"),
|
||||
},
|
||||
},
|
||||
async ({ scenarioId, stepId }) => {
|
||||
try {
|
||||
const step = await this.scenarioService.findStep(scenarioId, stepId);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(step) }],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"update_scenario_step",
|
||||
{
|
||||
description: "Update a step within a scenario",
|
||||
inputSchema: {
|
||||
scenarioId: z.number().int().describe("Scenario ID"),
|
||||
stepId: z.number().int().describe("Step ID"),
|
||||
order: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.optional()
|
||||
.describe("New execution order"),
|
||||
type: z
|
||||
.enum(["login", "exec", "sign"])
|
||||
.optional()
|
||||
.describe("New step type"),
|
||||
sessionName: z.string().optional().describe("New session name"),
|
||||
execCode: z.string().optional().describe("New exec code"),
|
||||
validateCode: z.string().optional().describe("New validation code"),
|
||||
},
|
||||
},
|
||||
async ({ scenarioId, stepId, ...dto }) => {
|
||||
try {
|
||||
const step = await this.scenarioService.updateStep(
|
||||
scenarioId,
|
||||
stepId,
|
||||
dto,
|
||||
);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(step) }],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"delete_scenario_step",
|
||||
{
|
||||
description: "Delete a step from a scenario",
|
||||
inputSchema: {
|
||||
scenarioId: z.number().int().describe("Scenario ID"),
|
||||
stepId: z.number().int().describe("Step ID"),
|
||||
},
|
||||
},
|
||||
async ({ scenarioId, stepId }) => {
|
||||
try {
|
||||
await this.scenarioService.removeStep(scenarioId, stepId);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `Step ${stepId} deleted from scenario ${scenarioId}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"list_scenario_runs",
|
||||
{
|
||||
description:
|
||||
"List runs for a scenario (paginated, optionally filtered by status)",
|
||||
inputSchema: {
|
||||
scenarioId: z.number().int().describe("Scenario ID"),
|
||||
status: z
|
||||
.enum(["pending", "in_progress", "pass", "fail"])
|
||||
.optional()
|
||||
.describe("Filter by run status"),
|
||||
page: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Page number (default 1)"),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Items per page (default 20)"),
|
||||
},
|
||||
},
|
||||
async ({ scenarioId, status, page, limit }) => {
|
||||
try {
|
||||
const result = await this.scenarioService.findRuns(scenarioId, {
|
||||
status,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"run_scenario",
|
||||
{
|
||||
description: "Trigger an immediate run of a scenario by ID",
|
||||
inputSchema: {
|
||||
id: z.number().int().describe("Scenario ID to run"),
|
||||
},
|
||||
},
|
||||
async ({ id }) => {
|
||||
try {
|
||||
const run = await this.scenarioService.createRun(id);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(run) }],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"get_scenario_run",
|
||||
{
|
||||
description:
|
||||
"Get a specific scenario run with all step runs and their outputs",
|
||||
inputSchema: {
|
||||
scenarioId: z.number().int().describe("Scenario ID"),
|
||||
runId: z.number().int().describe("Run ID"),
|
||||
},
|
||||
},
|
||||
async ({ scenarioId, runId }) => {
|
||||
try {
|
||||
const run = await this.scenarioService.findRun(scenarioId, runId);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(run) }],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"wait_for_scenario_run",
|
||||
{
|
||||
description:
|
||||
"Block until a scenario run reaches pass or fail (max 5 min), then return the full run with step outputs",
|
||||
inputSchema: {
|
||||
scenarioId: z.number().int().describe("Scenario ID"),
|
||||
runId: z.number().int().describe("Run ID"),
|
||||
},
|
||||
},
|
||||
async ({ scenarioId, runId }) => {
|
||||
try {
|
||||
const run = await this.scenarioService.waitForRun(scenarioId, runId);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(run) }],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"export_scenario",
|
||||
{
|
||||
description:
|
||||
"Export a scenario as a portable JSON payload (name + steps)",
|
||||
inputSchema: {
|
||||
id: z.number().int().describe("Scenario ID to export"),
|
||||
},
|
||||
},
|
||||
async ({ id }) => {
|
||||
try {
|
||||
const exported = await this.scenarioService.exportScenario(id);
|
||||
return {
|
||||
content: [
|
||||
{ type: "text" as const, text: JSON.stringify(exported) },
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"import_scenario",
|
||||
{
|
||||
description:
|
||||
"Import a scenario from an export payload, creating a new scenario with all its steps",
|
||||
inputSchema: {
|
||||
name: z.string().describe("Scenario name"),
|
||||
steps: z
|
||||
.array(
|
||||
z.object({
|
||||
order: z.number().int().min(0).describe("Execution order"),
|
||||
type: z.enum(["login", "exec", "sign"]).describe("Step type"),
|
||||
sessionName: z.string().describe("Session name"),
|
||||
execCode: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe("Exec/sign code"),
|
||||
validateCode: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe("Validation code"),
|
||||
}),
|
||||
)
|
||||
.describe("Ordered list of steps"),
|
||||
},
|
||||
},
|
||||
async ({ name, steps }) => {
|
||||
try {
|
||||
const scenario = await this.scenarioService.importScenario({
|
||||
name,
|
||||
steps: steps as Parameters<
|
||||
typeof this.scenarioService.importScenario
|
||||
>[0]["steps"],
|
||||
});
|
||||
return {
|
||||
content: [
|
||||
{ type: "text" as const, text: JSON.stringify(scenario) },
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── Transport ─────────────────────────────────────────────────────────────
|
||||
}
|
||||
|
||||
async handle(req: Request, res: Response): Promise<void> {
|
||||
const server = this.createServer();
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
});
|
||||
await server.connect(transport);
|
||||
try {
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
} finally {
|
||||
await transport.close();
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import {
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from "class-validator";
|
||||
import { StepType } from "../scenario-step.entity";
|
||||
|
||||
export class CreateScenarioStepDto {
|
||||
@ApiProperty({ example: 0, description: "Execution order (ascending)" })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
order: number;
|
||||
|
||||
@ApiProperty({ enum: ["login", "exec", "sign"], example: "exec" })
|
||||
@IsIn(["login", "exec", "sign"])
|
||||
type: StepType;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Session name used by this step. login steps create it; exec steps consume it.",
|
||||
example: "my-session",
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
sessionName: string;
|
||||
|
||||
@ApiPropertyOptional({ example: "return await page.title();" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
execCode?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example:
|
||||
'return { success: result !== null, description: "title present" };',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
validateCode?: string;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsNotEmpty, IsString } from "class-validator";
|
||||
|
||||
export class CreateScenarioDto {
|
||||
@ApiProperty({ example: "Login and verify cabinet" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { PaginationQueryDto } from "../../common/dto/pagination.dto";
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsIn, IsInt, IsOptional, Min } from "class-validator";
|
||||
import { RunStatus } from "../scenario-run.entity";
|
||||
|
||||
export class RunsQueryDto {
|
||||
@ApiPropertyOptional({ example: 1, default: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@ApiPropertyOptional({ example: 20, default: 20 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
limit?: number = 20;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: ["pending", "in_progress", "pass", "fail"],
|
||||
description: "Filter by run status",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(["pending", "in_progress", "pass", "fail"])
|
||||
status?: RunStatus;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { StepType } from "../scenario-step.entity";
|
||||
|
||||
export class ScenarioStepExportDto {
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
order: number;
|
||||
|
||||
@ApiProperty({ enum: ["login", "exec", "sign"] })
|
||||
@IsIn(["login", "exec", "sign"])
|
||||
type: StepType;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
sessionName: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
execCode: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
validateCode: string | null;
|
||||
}
|
||||
|
||||
export class ScenarioExportDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ type: [ScenarioStepExportDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ScenarioStepExportDto)
|
||||
steps: ScenarioStepExportDto[];
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import {
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from "class-validator";
|
||||
import { StepType } from "../scenario-step.entity";
|
||||
|
||||
export class UpdateScenarioStepDto {
|
||||
@ApiPropertyOptional({ example: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
order?: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: ["login", "exec", "sign"] })
|
||||
@IsOptional()
|
||||
@IsIn(["login", "exec", "sign"])
|
||||
type?: StepType;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
sessionName?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
execCode?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
validateCode?: string;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsOptional, IsString, IsNotEmpty } from "class-validator";
|
||||
|
||||
export class UpdateScenarioDto {
|
||||
@ApiPropertyOptional({ example: "Updated scenario name" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name?: string;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from "typeorm";
|
||||
import { ScenarioRunEntity } from "./scenario-run.entity";
|
||||
import { ScenarioStepEntity } from "./scenario-step.entity";
|
||||
|
||||
export type RunStepStatus =
|
||||
| "waiting"
|
||||
| "pending"
|
||||
| "in_progress"
|
||||
| "pass"
|
||||
| "fail"
|
||||
| "cancelled";
|
||||
|
||||
@Entity("scenario_run_steps")
|
||||
export class ScenarioRunStepEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column()
|
||||
runId: number;
|
||||
|
||||
@ManyToOne(() => ScenarioRunEntity, (run) => run.stepRuns, {
|
||||
onDelete: "CASCADE",
|
||||
})
|
||||
@JoinColumn({ name: "runId" })
|
||||
run: ScenarioRunEntity;
|
||||
|
||||
@Column()
|
||||
scenarioStepId: number;
|
||||
|
||||
@ManyToOne(() => ScenarioStepEntity, { onDelete: "CASCADE" })
|
||||
@JoinColumn({ name: "scenarioStepId" })
|
||||
scenarioStep: ScenarioStepEntity;
|
||||
|
||||
@Column({ type: "text", default: "waiting" })
|
||||
status: RunStepStatus;
|
||||
|
||||
@Column({ default: 0 })
|
||||
order: number;
|
||||
|
||||
@Column({ type: "text", nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Column({ type: "text", nullable: true })
|
||||
output: string | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
JoinColumn,
|
||||
} from "typeorm";
|
||||
import { ScenarioEntity } from "./scenario.entity";
|
||||
import { ScenarioRunStepEntity } from "./scenario-run-step.entity";
|
||||
|
||||
export type RunStatus = "pending" | "in_progress" | "pass" | "fail";
|
||||
|
||||
@Entity("scenario_runs")
|
||||
export class ScenarioRunEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column()
|
||||
scenarioId: number;
|
||||
|
||||
@ManyToOne(() => ScenarioEntity, { onDelete: "CASCADE" })
|
||||
@JoinColumn({ name: "scenarioId" })
|
||||
scenario: ScenarioEntity;
|
||||
|
||||
@Column({ type: "text", default: "pending" })
|
||||
status: RunStatus;
|
||||
|
||||
@OneToMany(() => ScenarioRunStepEntity, (rs) => rs.run, {
|
||||
cascade: true,
|
||||
eager: false,
|
||||
})
|
||||
stepRuns: ScenarioRunStepEntity[];
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { TraceLogger } from "../common/trace-logger";
|
||||
import { traceStorage } from "../common/trace-context";
|
||||
import { Interval } from "@nestjs/schedule";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import * as crypto from "crypto";
|
||||
import { chromium } from "playwright";
|
||||
import type { Browser, BrowserContext, Page } from "playwright";
|
||||
import { ScenarioRunEntity } from "./scenario-run.entity";
|
||||
import { ScenarioRunStepEntity } from "./scenario-run-step.entity";
|
||||
import { ScenarioRunLogEntity } from "./scenario-run-log.entity";
|
||||
import { ScenarioStepEntity } from "./scenario-step.entity";
|
||||
import type { ScriptLogger } from "../code-executor/code-executor.service";
|
||||
import { AuthService } from "../auth/auth.service";
|
||||
import { CodeExecutorService } from "../code-executor/code-executor.service";
|
||||
import { SessionService } from "../session/session.service";
|
||||
|
||||
interface ValidateResult {
|
||||
success: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface BrowserHandle {
|
||||
browser: Browser;
|
||||
context: BrowserContext;
|
||||
page: Page;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ScenarioSchedulerService {
|
||||
private readonly logger = new TraceLogger(ScenarioSchedulerService.name);
|
||||
private readonly activeRuns = new Set<number>();
|
||||
private readonly runBrowsers = new Map<number, BrowserHandle>();
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ScenarioRunEntity)
|
||||
private readonly runRepo: Repository<ScenarioRunEntity>,
|
||||
@InjectRepository(ScenarioRunStepEntity)
|
||||
private readonly runStepRepo: Repository<ScenarioRunStepEntity>,
|
||||
@InjectRepository(ScenarioRunLogEntity)
|
||||
private readonly runLogRepo: Repository<ScenarioRunLogEntity>,
|
||||
private readonly authService: AuthService,
|
||||
private readonly codeExecutor: CodeExecutorService,
|
||||
private readonly sessionService: SessionService,
|
||||
) {}
|
||||
|
||||
private persistLog(
|
||||
runId: number,
|
||||
stepRunId: number | null,
|
||||
level: "log" | "warn" | "error",
|
||||
message: string,
|
||||
): void {
|
||||
void this.runLogRepo.save(
|
||||
this.runLogRepo.create({ runId, stepRunId, level, message }),
|
||||
);
|
||||
}
|
||||
|
||||
private stepLogger(stepRunId: number, runId: number): ScriptLogger {
|
||||
return (level, msg) => {
|
||||
this.logger[level](`StepRun #${stepRunId} script: ${msg}`);
|
||||
this.persistLog(runId, stepRunId, level, msg);
|
||||
};
|
||||
}
|
||||
|
||||
private makeGetStepOutput(
|
||||
stepRun: ScenarioRunStepEntity,
|
||||
): (order: number) => Promise<unknown> {
|
||||
return async (order: number) => {
|
||||
const targetOrder = order < 0 ? stepRun.order + order : order;
|
||||
if (targetOrder < 0) return null;
|
||||
const sr = await this.runStepRepo.findOne({
|
||||
where: { runId: stepRun.runId, order: targetOrder },
|
||||
});
|
||||
if (!sr?.output) return null;
|
||||
try {
|
||||
return JSON.parse(sr.output) as unknown;
|
||||
} catch {
|
||||
return sr.output;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ── Job: pick up pending runs and process each to completion ─────────────
|
||||
|
||||
@Interval(1000)
|
||||
async pickUpPendingRuns(): Promise<void> {
|
||||
const pending = await this.runRepo.find({ where: { status: "pending" } });
|
||||
for (const run of pending) {
|
||||
if (this.activeRuns.has(run.id)) continue;
|
||||
this.activeRuns.add(run.id);
|
||||
run.status = "in_progress";
|
||||
await this.runRepo.save(run);
|
||||
this.logger.log(`Run #${run.id} → in_progress`);
|
||||
const traceId = crypto.randomUUID();
|
||||
void traceStorage.run({ traceId }, () =>
|
||||
this.processRunToCompletion(run.id),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async processRunToCompletion(runId: number): Promise<void> {
|
||||
try {
|
||||
let stepRun = await this.runStepRepo.findOne({
|
||||
where: { runId, status: "pending" },
|
||||
relations: ["scenarioStep"],
|
||||
order: { order: "ASC" },
|
||||
});
|
||||
while (stepRun) {
|
||||
await this.executeStepRun(stepRun);
|
||||
stepRun = await this.runStepRepo.findOne({
|
||||
where: { runId, status: "pending" },
|
||||
relations: ["scenarioStep"],
|
||||
order: { order: "ASC" },
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Run #${runId}: unexpected error: ${(err as Error).message}`,
|
||||
);
|
||||
await this.runRepo.update(runId, { status: "fail" });
|
||||
} finally {
|
||||
this.activeRuns.delete(runId);
|
||||
}
|
||||
}
|
||||
|
||||
private async executeStepRun(stepRun: ScenarioRunStepEntity): Promise<void> {
|
||||
const step = stepRun.scenarioStep as ScenarioStepEntity;
|
||||
|
||||
stepRun.status = "in_progress";
|
||||
await this.runStepRepo.save(stepRun);
|
||||
this.logger.log(
|
||||
`StepRun #${stepRun.id} (run #${stepRun.runId}, step #${step.id} order=${step.order}) → in_progress`,
|
||||
);
|
||||
|
||||
try {
|
||||
if (step.type === "login") {
|
||||
await this.executeLoginStep(stepRun, step);
|
||||
} else if (step.type === "sign") {
|
||||
await this.executeSignStep(stepRun, step);
|
||||
} else {
|
||||
await this.executeExecStep(stepRun, step);
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message ?? String(err);
|
||||
this.logger.error(`StepRun #${stepRun.id} threw: ${msg}`);
|
||||
await this.failStepRun(stepRun, msg);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared browser per run ─────────────────────────────────────────────────
|
||||
|
||||
private async getOrCreateBrowserHandle(
|
||||
runId: number,
|
||||
sessionName: string,
|
||||
): Promise<BrowserHandle> {
|
||||
const existing = this.runBrowsers.get(runId);
|
||||
if (existing) return existing;
|
||||
|
||||
const session = await this.sessionService.findBySessionName(sessionName);
|
||||
if (!session) throw new Error(`Session not found: ${sessionName}`);
|
||||
|
||||
const cookies = JSON.parse(session.cookies) as Parameters<
|
||||
BrowserContext["addCookies"]
|
||||
>[0];
|
||||
const localStorageData: Record<string, string> = JSON.parse(
|
||||
session.localStorage,
|
||||
);
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
// TODO: env var move to config
|
||||
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
|
||||
});
|
||||
const context = await browser.newContext();
|
||||
await context.addCookies(cookies);
|
||||
await context.addInitScript((entries: Record<string, string>) => {
|
||||
for (const [k, v] of Object.entries(entries))
|
||||
window.localStorage.setItem(k, v);
|
||||
}, localStorageData);
|
||||
const page = await context.newPage();
|
||||
|
||||
const handle: BrowserHandle = { browser, context, page };
|
||||
this.runBrowsers.set(runId, handle);
|
||||
this.logger.log(`Run #${runId}: browser created (session: ${sessionName})`);
|
||||
return handle;
|
||||
}
|
||||
|
||||
private async closeBrowserHandle(runId: number): Promise<void> {
|
||||
const handle = this.runBrowsers.get(runId);
|
||||
if (!handle) return;
|
||||
this.runBrowsers.delete(runId);
|
||||
try {
|
||||
await handle.browser.close();
|
||||
this.logger.log(`Run #${runId}: browser closed`);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Run #${runId}: error closing browser: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Login step ─────────────────────────────────────────────────────────────
|
||||
|
||||
private async executeLoginStep(
|
||||
stepRun: ScenarioRunStepEntity,
|
||||
step: ScenarioStepEntity,
|
||||
): Promise<void> {
|
||||
// execCode must be a JSON object: { "keyId": "...", "environmentName": "..." }
|
||||
let params: { keyId: string; environmentName: string };
|
||||
try {
|
||||
params = JSON.parse(step.execCode ?? "{}");
|
||||
} catch {
|
||||
throw new Error(
|
||||
"login step execCode must be valid JSON with keyId and environmentName",
|
||||
);
|
||||
}
|
||||
if (!params.keyId || !params.environmentName) {
|
||||
throw new Error(
|
||||
"login step execCode must include keyId and environmentName",
|
||||
);
|
||||
}
|
||||
|
||||
const loginResult = await this.authService.login(
|
||||
params.keyId,
|
||||
params.environmentName,
|
||||
step.sessionName,
|
||||
);
|
||||
this.logger.log(
|
||||
`StepRun #${stepRun.id}: login OK, session=${loginResult.sessionName}`,
|
||||
);
|
||||
|
||||
if (step.validateCode) {
|
||||
const { page, context } = await this.getOrCreateBrowserHandle(
|
||||
stepRun.runId,
|
||||
step.sessionName,
|
||||
);
|
||||
this.codeExecutor.validate(step.validateCode);
|
||||
const { result } = await this.codeExecutor.execute(
|
||||
page,
|
||||
context,
|
||||
step.validateCode,
|
||||
this.stepLogger(stepRun.id, stepRun.runId),
|
||||
this.makeGetStepOutput(stepRun),
|
||||
);
|
||||
const vr = this.parseValidateResult(result);
|
||||
if (!vr.success) throw new Error(vr.description ?? "Validation failed");
|
||||
}
|
||||
|
||||
await this.passStepRun(stepRun, null);
|
||||
}
|
||||
|
||||
// ── Exec step ──────────────────────────────────────────────────────────────
|
||||
|
||||
private async executeExecStep(
|
||||
stepRun: ScenarioRunStepEntity,
|
||||
step: ScenarioStepEntity,
|
||||
): Promise<void> {
|
||||
if (!step.execCode) throw new Error("exec step has no execCode");
|
||||
|
||||
this.codeExecutor.validate(step.execCode);
|
||||
const { page, context } = await this.getOrCreateBrowserHandle(
|
||||
stepRun.runId,
|
||||
step.sessionName,
|
||||
);
|
||||
const getStepOutput = this.makeGetStepOutput(stepRun);
|
||||
const { result: execOutput } = await this.codeExecutor.execute(
|
||||
page,
|
||||
context,
|
||||
step.execCode,
|
||||
this.stepLogger(stepRun.id, stepRun.runId),
|
||||
getStepOutput,
|
||||
);
|
||||
this.logger.log(`StepRun #${stepRun.id}: exec OK`);
|
||||
|
||||
if (step.validateCode) {
|
||||
this.codeExecutor.validate(step.validateCode);
|
||||
const { result } = await this.codeExecutor.execute(
|
||||
page,
|
||||
context,
|
||||
step.validateCode,
|
||||
this.stepLogger(stepRun.id, stepRun.runId),
|
||||
getStepOutput,
|
||||
);
|
||||
const vr = this.parseValidateResult(result);
|
||||
if (!vr.success) throw new Error(vr.description ?? "Validation failed");
|
||||
}
|
||||
|
||||
await this.passStepRun(stepRun, null, execOutput);
|
||||
}
|
||||
|
||||
// ── Sign step ──────────────────────────────────────────────────────────────
|
||||
|
||||
private async executeSignStep(
|
||||
stepRun: ScenarioRunStepEntity,
|
||||
step: ScenarioStepEntity,
|
||||
): Promise<void> {
|
||||
// execCode must be a JSON object: { "keyId": "..." }
|
||||
let params: { keyId: string };
|
||||
try {
|
||||
params = JSON.parse(step.execCode ?? "{}");
|
||||
} catch {
|
||||
throw new Error("sign step execCode must be valid JSON with keyId");
|
||||
}
|
||||
if (!params.keyId) throw new Error("sign step execCode must include keyId");
|
||||
|
||||
const { page, context } = await this.getOrCreateBrowserHandle(
|
||||
stepRun.runId,
|
||||
step.sessionName,
|
||||
);
|
||||
await this.authService.signWithKey(params.keyId, page);
|
||||
this.logger.log(`StepRun #${stepRun.id}: sign OK`);
|
||||
|
||||
if (step.validateCode) {
|
||||
this.codeExecutor.validate(step.validateCode);
|
||||
const { result } = await this.codeExecutor.execute(
|
||||
page,
|
||||
context,
|
||||
step.validateCode,
|
||||
this.stepLogger(stepRun.id, stepRun.runId),
|
||||
this.makeGetStepOutput(stepRun),
|
||||
);
|
||||
const vr = this.parseValidateResult(result);
|
||||
if (!vr.success) throw new Error(vr.description ?? "Validation failed");
|
||||
}
|
||||
|
||||
await this.passStepRun(stepRun, null);
|
||||
}
|
||||
|
||||
// ── Validation helper ──────────────────────────────────────────────────────
|
||||
|
||||
private parseValidateResult(raw: unknown): ValidateResult {
|
||||
if (typeof raw === "boolean") return { success: raw };
|
||||
if (raw && typeof raw === "object") {
|
||||
const r = raw as Record<string, unknown>;
|
||||
return {
|
||||
success: Boolean(r["success"]),
|
||||
description:
|
||||
r["description"] != null ? String(r["description"]) : undefined,
|
||||
};
|
||||
}
|
||||
return { success: Boolean(raw) };
|
||||
}
|
||||
|
||||
// ── Pass / fail helpers ────────────────────────────────────────────────────
|
||||
|
||||
private async passStepRun(
|
||||
stepRun: ScenarioRunStepEntity,
|
||||
description: string | null,
|
||||
output: unknown = null,
|
||||
): Promise<void> {
|
||||
stepRun.status = "pass";
|
||||
stepRun.description = description;
|
||||
stepRun.output =
|
||||
output !== null && output !== undefined ? JSON.stringify(output) : null;
|
||||
await this.runStepRepo.save(stepRun);
|
||||
this.logger.log(`StepRun #${stepRun.id} → pass`);
|
||||
|
||||
// Find the next waiting step in this run (next by order)
|
||||
const nextStep = await this.runStepRepo.findOne({
|
||||
where: { runId: stepRun.runId, status: "waiting" },
|
||||
order: { order: "ASC" },
|
||||
});
|
||||
|
||||
if (nextStep) {
|
||||
nextStep.status = "pending";
|
||||
await this.runStepRepo.save(nextStep);
|
||||
} else {
|
||||
// No more waiting steps — check if any are still in_progress/pending (shouldn't be, but guard anyway)
|
||||
const remaining = await this.runStepRepo.count({
|
||||
where: [
|
||||
{ runId: stepRun.runId, status: "pending" },
|
||||
{ runId: stepRun.runId, status: "in_progress" },
|
||||
{ runId: stepRun.runId, status: "waiting" },
|
||||
],
|
||||
});
|
||||
if (remaining === 0) {
|
||||
await this.closeBrowserHandle(stepRun.runId);
|
||||
await this.runRepo.update(stepRun.runId, { status: "pass" });
|
||||
this.logger.log(`Run #${stepRun.runId} → pass (all steps passed)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async failStepRun(
|
||||
stepRun: ScenarioRunStepEntity,
|
||||
description: string,
|
||||
): Promise<void> {
|
||||
stepRun.status = "fail";
|
||||
stepRun.description = description;
|
||||
await this.runStepRepo.save(stepRun);
|
||||
this.logger.log(`StepRun #${stepRun.id} → fail: ${description}`);
|
||||
|
||||
// Cancel all remaining waiting/pending step runs in this run
|
||||
await this.runStepRepo
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
.set({ status: "cancelled" })
|
||||
.where("runId = :runId AND status IN (:...statuses)", {
|
||||
runId: stepRun.runId,
|
||||
statuses: ["waiting", "pending"],
|
||||
})
|
||||
.execute();
|
||||
|
||||
this.logger.log(`Run #${stepRun.runId}: remaining steps cancelled`);
|
||||
|
||||
await this.closeBrowserHandle(stepRun.runId);
|
||||
await this.runRepo.update(stepRun.runId, { status: "fail" });
|
||||
this.logger.log(`Run #${stepRun.runId} → fail`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from "typeorm";
|
||||
import { ScenarioEntity } from "./scenario.entity";
|
||||
|
||||
export type StepType = "login" | "exec" | "sign";
|
||||
|
||||
@Entity("scenario_steps")
|
||||
export class ScenarioStepEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column()
|
||||
scenarioId: number;
|
||||
|
||||
@ManyToOne(() => ScenarioEntity, (scenario) => scenario.steps, {
|
||||
onDelete: "CASCADE",
|
||||
})
|
||||
@JoinColumn({ name: "scenarioId" })
|
||||
scenario: ScenarioEntity;
|
||||
|
||||
@Column({ default: 0 })
|
||||
order: number;
|
||||
|
||||
@Column({ type: "text" })
|
||||
type: StepType;
|
||||
|
||||
@Column({ type: "text" })
|
||||
sessionName: string;
|
||||
|
||||
@Column({ type: "text", nullable: true })
|
||||
execCode: string | null;
|
||||
|
||||
@Column({ type: "text", nullable: true })
|
||||
validateCode: string | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
||||
import { ScenarioService } from "./scenario.service";
|
||||
import { CreateScenarioDto } from "./dto/create-scenario.dto";
|
||||
import { UpdateScenarioDto } from "./dto/update-scenario.dto";
|
||||
import { CreateScenarioStepDto } from "./dto/create-scenario-step.dto";
|
||||
import { UpdateScenarioStepDto } from "./dto/update-scenario-step.dto";
|
||||
import { PaginationQueryDto } from "./dto/pagination-query.dto";
|
||||
import { ScenarioOrderBy } from "./scenario.service";
|
||||
import { RunsQueryDto } from "./dto/runs-query.dto";
|
||||
import { ScenarioExportDto } from "./dto/scenario-export.dto";
|
||||
|
||||
@ApiTags("scenarios")
|
||||
@Controller("scenarios")
|
||||
export class ScenarioController {
|
||||
constructor(private readonly scenarioService: ScenarioService) {}
|
||||
|
||||
// ── Scenarios ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: "Create a scenario" })
|
||||
@ApiResponse({ status: 201, description: "Scenario created" })
|
||||
create(@Body() dto: CreateScenarioDto) {
|
||||
return this.scenarioService.create(dto);
|
||||
}
|
||||
|
||||
@Post("import")
|
||||
@ApiOperation({ summary: "Import a scenario from an export payload" })
|
||||
@ApiResponse({ status: 201, description: "Scenario imported" })
|
||||
importScenario(@Body() dto: ScenarioExportDto) {
|
||||
return this.scenarioService.importScenario(dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "List all scenarios (paginated)" })
|
||||
@ApiResponse({ status: 200 })
|
||||
findAll(@Query() query: PaginationQueryDto<ScenarioOrderBy>) {
|
||||
return this.scenarioService.findAll(query);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@ApiOperation({ summary: "Get a scenario with its steps" })
|
||||
@ApiResponse({ status: 200 })
|
||||
@ApiResponse({ status: 404, description: "Scenario not found" })
|
||||
findOne(@Param("id", ParseIntPipe) id: number) {
|
||||
return this.scenarioService.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@ApiOperation({ summary: "Update a scenario" })
|
||||
@ApiResponse({ status: 200 })
|
||||
@ApiResponse({ status: 404, description: "Scenario not found" })
|
||||
update(
|
||||
@Param("id", ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateScenarioDto,
|
||||
) {
|
||||
return this.scenarioService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: "Delete a scenario and all its steps" })
|
||||
@ApiResponse({ status: 204 })
|
||||
@ApiResponse({ status: 404, description: "Scenario not found" })
|
||||
remove(@Param("id", ParseIntPipe) id: number) {
|
||||
return this.scenarioService.remove(id);
|
||||
}
|
||||
|
||||
@Get(":id/export")
|
||||
@ApiOperation({ summary: "Export a scenario as a portable JSON payload" })
|
||||
@ApiResponse({ status: 200 })
|
||||
@ApiResponse({ status: 404, description: "Scenario not found" })
|
||||
exportScenario(@Param("id", ParseIntPipe) id: number) {
|
||||
return this.scenarioService.exportScenario(id);
|
||||
}
|
||||
|
||||
// ── Steps ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@Post(":id/steps")
|
||||
@ApiOperation({ summary: "Add a step to a scenario" })
|
||||
@ApiResponse({ status: 201, description: "Step created" })
|
||||
@ApiResponse({ status: 404, description: "Scenario not found" })
|
||||
createStep(
|
||||
@Param("id", ParseIntPipe) id: number,
|
||||
@Body() dto: CreateScenarioStepDto,
|
||||
) {
|
||||
return this.scenarioService.createStep(id, dto);
|
||||
}
|
||||
|
||||
@Get(":id/steps/:stepId")
|
||||
@ApiOperation({ summary: "Get a single step" })
|
||||
@ApiResponse({ status: 200 })
|
||||
@ApiResponse({ status: 404, description: "Scenario or step not found" })
|
||||
findStep(
|
||||
@Param("id", ParseIntPipe) id: number,
|
||||
@Param("stepId", ParseIntPipe) stepId: number,
|
||||
) {
|
||||
return this.scenarioService.findStep(id, stepId);
|
||||
}
|
||||
|
||||
@Patch(":id/steps/:stepId")
|
||||
@ApiOperation({ summary: "Update a step" })
|
||||
@ApiResponse({ status: 200 })
|
||||
@ApiResponse({ status: 404, description: "Scenario or step not found" })
|
||||
updateStep(
|
||||
@Param("id", ParseIntPipe) id: number,
|
||||
@Param("stepId", ParseIntPipe) stepId: number,
|
||||
@Body() dto: UpdateScenarioStepDto,
|
||||
) {
|
||||
return this.scenarioService.updateStep(id, stepId, dto);
|
||||
}
|
||||
|
||||
@Delete(":id/steps/:stepId")
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: "Delete a step" })
|
||||
@ApiResponse({ status: 204 })
|
||||
@ApiResponse({ status: 404, description: "Scenario or step not found" })
|
||||
removeStep(
|
||||
@Param("id", ParseIntPipe) id: number,
|
||||
@Param("stepId", ParseIntPipe) stepId: number,
|
||||
) {
|
||||
return this.scenarioService.removeStep(id, stepId);
|
||||
}
|
||||
|
||||
// ── Runs ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@Get(":id/runs")
|
||||
@ApiOperation({
|
||||
summary: "List runs for a scenario (paginated, filterable by status)",
|
||||
})
|
||||
@ApiResponse({ status: 200 })
|
||||
@ApiResponse({ status: 404, description: "Scenario not found" })
|
||||
findRuns(
|
||||
@Param("id", ParseIntPipe) id: number,
|
||||
@Query() query: RunsQueryDto,
|
||||
) {
|
||||
return this.scenarioService.findRuns(id, query);
|
||||
}
|
||||
|
||||
@Post(":id/run")
|
||||
@ApiOperation({ summary: "Create a new run for a scenario" })
|
||||
@ApiResponse({ status: 201, description: "Run created with step runs" })
|
||||
@ApiResponse({ status: 404, description: "Scenario not found" })
|
||||
createRun(@Param("id", ParseIntPipe) id: number) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
} from "typeorm";
|
||||
import { ScenarioStepEntity } from "./scenario-step.entity";
|
||||
|
||||
@Entity("scenarios")
|
||||
export class ScenarioEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column()
|
||||
name: string;
|
||||
|
||||
@OneToMany(() => ScenarioStepEntity, (step) => step.scenario, {
|
||||
cascade: true,
|
||||
eager: false,
|
||||
})
|
||||
steps: ScenarioStepEntity[];
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { ScenarioEntity } from "./scenario.entity";
|
||||
import { ScenarioStepEntity } from "./scenario-step.entity";
|
||||
import { ScenarioRunEntity } from "./scenario-run.entity";
|
||||
import { ScenarioRunStepEntity } from "./scenario-run-step.entity";
|
||||
import { ScenarioRunLogEntity } from "./scenario-run-log.entity";
|
||||
import { ScenarioService } from "./scenario.service";
|
||||
import { ScenarioController } from "./scenario.controller";
|
||||
import { ScenarioSchedulerService } from "./scenario-scheduler.service";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { CodeExecutorModule } from "../code-executor/code-executor.module";
|
||||
import { SessionModule } from "../session/session.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
ScenarioEntity,
|
||||
ScenarioStepEntity,
|
||||
ScenarioRunEntity,
|
||||
ScenarioRunStepEntity,
|
||||
ScenarioRunLogEntity,
|
||||
]),
|
||||
AuthModule,
|
||||
CodeExecutorModule,
|
||||
SessionModule,
|
||||
],
|
||||
controllers: [ScenarioController],
|
||||
providers: [ScenarioService, ScenarioSchedulerService],
|
||||
exports: [ScenarioService],
|
||||
})
|
||||
export class ScenarioModule {}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { ScenarioEntity } from "./scenario.entity";
|
||||
import { ScenarioStepEntity } from "./scenario-step.entity";
|
||||
import { ScenarioRunEntity } from "./scenario-run.entity";
|
||||
import { ScenarioRunStepEntity } from "./scenario-run-step.entity";
|
||||
import { ScenarioRunLogEntity } from "./scenario-run-log.entity";
|
||||
import { CreateScenarioDto } from "./dto/create-scenario.dto";
|
||||
import { UpdateScenarioDto } from "./dto/update-scenario.dto";
|
||||
import { CreateScenarioStepDto } from "./dto/create-scenario-step.dto";
|
||||
import { UpdateScenarioStepDto } from "./dto/update-scenario-step.dto";
|
||||
import {
|
||||
PaginationQueryDto,
|
||||
PaginatedResult,
|
||||
} from "../common/dto/pagination.dto";
|
||||
import { RunsQueryDto } from "./dto/runs-query.dto";
|
||||
import { ScenarioExportDto } from "./dto/scenario-export.dto";
|
||||
|
||||
export { PaginatedResult } from "../common/dto/pagination.dto";
|
||||
export type ScenarioOrderBy = "id" | "name" | "createdAt" | "updatedAt";
|
||||
|
||||
@Injectable()
|
||||
export class ScenarioService {
|
||||
constructor(
|
||||
@InjectRepository(ScenarioEntity)
|
||||
private readonly scenarioRepo: Repository<ScenarioEntity>,
|
||||
@InjectRepository(ScenarioStepEntity)
|
||||
private readonly stepRepo: Repository<ScenarioStepEntity>,
|
||||
@InjectRepository(ScenarioRunEntity)
|
||||
private readonly runRepo: Repository<ScenarioRunEntity>,
|
||||
@InjectRepository(ScenarioRunStepEntity)
|
||||
private readonly runStepRepo: Repository<ScenarioRunStepEntity>,
|
||||
@InjectRepository(ScenarioRunLogEntity)
|
||||
private readonly runLogRepo: Repository<ScenarioRunLogEntity>,
|
||||
) {}
|
||||
|
||||
// ── Scenarios ─────────────────────────────────────────────────────────────
|
||||
|
||||
create(dto: CreateScenarioDto): Promise<ScenarioEntity> {
|
||||
return this.scenarioRepo.save(this.scenarioRepo.create(dto));
|
||||
}
|
||||
|
||||
async findAll(
|
||||
query: PaginationQueryDto<ScenarioOrderBy>,
|
||||
): Promise<PaginatedResult<ScenarioEntity>> {
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const orderBy = query.orderBy ?? "id";
|
||||
const orderDir = query.orderDir ?? "ASC";
|
||||
const [data, total] = await this.scenarioRepo.findAndCount({
|
||||
order: { [orderBy]: orderDir },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
});
|
||||
return { data, total, page, limit };
|
||||
}
|
||||
|
||||
async findOne(id: number): Promise<ScenarioEntity> {
|
||||
const scenario = await this.scenarioRepo.findOne({
|
||||
where: { id },
|
||||
relations: ["steps"],
|
||||
order: { steps: { order: "ASC" } },
|
||||
});
|
||||
if (!scenario) throw new NotFoundException(`Scenario ${id} not found`);
|
||||
return scenario;
|
||||
}
|
||||
|
||||
async update(id: number, dto: UpdateScenarioDto): Promise<ScenarioEntity> {
|
||||
const scenario = await this.findOne(id);
|
||||
Object.assign(scenario, dto);
|
||||
return this.scenarioRepo.save(scenario);
|
||||
}
|
||||
|
||||
async remove(id: number): Promise<void> {
|
||||
await this.findOne(id);
|
||||
await this.scenarioRepo.delete(id);
|
||||
}
|
||||
|
||||
// ── Steps ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async createStep(
|
||||
scenarioId: number,
|
||||
dto: CreateScenarioStepDto,
|
||||
): Promise<ScenarioStepEntity> {
|
||||
await this.findOne(scenarioId);
|
||||
return this.stepRepo.save(
|
||||
this.stepRepo.create({
|
||||
...dto,
|
||||
scenarioId,
|
||||
execCode: dto.execCode ?? null,
|
||||
validateCode: dto.validateCode ?? null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async findStep(
|
||||
scenarioId: number,
|
||||
stepId: number,
|
||||
): Promise<ScenarioStepEntity> {
|
||||
const step = await this.stepRepo.findOneBy({ id: stepId, scenarioId });
|
||||
if (!step)
|
||||
throw new NotFoundException(
|
||||
`Step ${stepId} not found in scenario ${scenarioId}`,
|
||||
);
|
||||
return step;
|
||||
}
|
||||
|
||||
async updateStep(
|
||||
scenarioId: number,
|
||||
stepId: number,
|
||||
dto: UpdateScenarioStepDto,
|
||||
): Promise<ScenarioStepEntity> {
|
||||
const step = await this.findStep(scenarioId, stepId);
|
||||
Object.assign(step, dto);
|
||||
return this.stepRepo.save(step);
|
||||
}
|
||||
|
||||
async removeStep(scenarioId: number, stepId: number): Promise<void> {
|
||||
await this.findStep(scenarioId, stepId);
|
||||
await this.stepRepo.delete(stepId);
|
||||
}
|
||||
|
||||
async findRuns(
|
||||
scenarioId: number,
|
||||
query: RunsQueryDto,
|
||||
): Promise<PaginatedResult<ScenarioRunEntity>> {
|
||||
await this.findOne(scenarioId); // 404 guard
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const where: Record<string, unknown> = { scenarioId };
|
||||
if (query.status) where["status"] = query.status;
|
||||
const [data, total] = await this.runRepo.findAndCount({
|
||||
where,
|
||||
relations: ["stepRuns"],
|
||||
order: { id: "DESC", stepRuns: { order: "ASC" } },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
});
|
||||
return { data, total, page, limit };
|
||||
}
|
||||
|
||||
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(
|
||||
this.runRepo.create({ scenarioId, status: "pending" }),
|
||||
);
|
||||
|
||||
const stepRuns = scenario.steps.map((step, index) =>
|
||||
this.runStepRepo.create({
|
||||
runId: run.id,
|
||||
scenarioStepId: step.id,
|
||||
order: step.order,
|
||||
status: index === 0 ? "pending" : "waiting",
|
||||
description: null,
|
||||
}),
|
||||
);
|
||||
|
||||
await this.runStepRepo.save(stepRuns);
|
||||
|
||||
return this.runRepo.findOne({
|
||||
where: { id: run.id },
|
||||
relations: ["stepRuns"],
|
||||
order: { stepRuns: { order: "ASC" } },
|
||||
}) as Promise<ScenarioRunEntity>;
|
||||
}
|
||||
|
||||
// ── Export / Import ───────────────────────────────────────────────────────
|
||||
|
||||
async exportScenario(id: number): Promise<ScenarioExportDto> {
|
||||
const scenario = await this.findOne(id);
|
||||
return {
|
||||
name: scenario.name,
|
||||
steps: scenario.steps.map((s) => ({
|
||||
order: s.order,
|
||||
type: s.type,
|
||||
sessionName: s.sessionName,
|
||||
execCode: s.execCode,
|
||||
validateCode: s.validateCode,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async importScenario(dto: ScenarioExportDto): Promise<ScenarioEntity> {
|
||||
const scenario = await this.scenarioRepo.save(
|
||||
this.scenarioRepo.create({ name: dto.name }),
|
||||
);
|
||||
if (dto.steps.length > 0) {
|
||||
const steps = dto.steps.map((s) =>
|
||||
this.stepRepo.create({
|
||||
scenarioId: scenario.id,
|
||||
order: s.order,
|
||||
type: s.type,
|
||||
sessionName: s.sessionName,
|
||||
execCode: s.execCode ?? null,
|
||||
validateCode: s.validateCode ?? null,
|
||||
}),
|
||||
);
|
||||
await this.stepRepo.save(steps);
|
||||
}
|
||||
return this.findOne(scenario.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
Injectable,
|
||||
OnModuleDestroy,
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import type { Browser, BrowserContext, Page } from "playwright";
|
||||
import { TraceLogger } from "../common/trace-logger";
|
||||
import { SessionService } from "./session.service";
|
||||
|
||||
export interface SessionHandle {
|
||||
browser: Browser;
|
||||
context: BrowserContext;
|
||||
page: Page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps live Playwright browser contexts in memory, keyed by sessionName.
|
||||
* A session must be explicitly registered (after login) to be usable.
|
||||
* Sessions marked as closed in the DB cannot be used via getHandle().
|
||||
*/
|
||||
@Injectable()
|
||||
export class SessionContextService implements OnModuleDestroy {
|
||||
private readonly logger = new TraceLogger(SessionContextService.name);
|
||||
private readonly handles = new Map<string, SessionHandle>();
|
||||
|
||||
constructor(private readonly sessionService: SessionService) {}
|
||||
|
||||
/**
|
||||
* Store a live browser context after a successful login.
|
||||
* If a handle already exists for this session it is closed first.
|
||||
*/
|
||||
register(
|
||||
sessionName: string,
|
||||
browser: Browser,
|
||||
context: BrowserContext,
|
||||
page: Page,
|
||||
): void {
|
||||
const existing = this.handles.get(sessionName);
|
||||
if (existing) {
|
||||
existing.browser.close().catch((err: unknown) => {
|
||||
this.logger.warn(
|
||||
`Error closing stale browser for "${sessionName}": ${(err as Error).message}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
this.handles.set(sessionName, { browser, context, page });
|
||||
this.logger.log(`Session "${sessionName}" registered in context pool`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the live handle for a named session and bump lastUsedAt.
|
||||
* Throws 404 if session does not exist in DB.
|
||||
* Throws 400 if session is closed or context is not in memory.
|
||||
*/
|
||||
async getHandle(sessionName: string): Promise<SessionHandle> {
|
||||
const handle = this.handles.get(sessionName);
|
||||
if (handle) {
|
||||
await this.sessionService.touchLastUsed(sessionName);
|
||||
return handle;
|
||||
}
|
||||
|
||||
const session = await this.sessionService.findBySessionName(sessionName);
|
||||
if (!session) {
|
||||
throw new NotFoundException(`Session not found: ${sessionName}`);
|
||||
}
|
||||
if (session.status === "closed") {
|
||||
throw new BadRequestException(
|
||||
`Session "${sessionName}" is closed — please login again`,
|
||||
);
|
||||
}
|
||||
// Session is open in DB but context is not in memory (e.g. after unexpected restart).
|
||||
throw new BadRequestException(
|
||||
`Session "${sessionName}" context is not available — please login again`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the Playwright browser for a session and mark it as closed in DB.
|
||||
* Safe to call even if the session is not currently in memory.
|
||||
*/
|
||||
async close(sessionName: string): Promise<void> {
|
||||
const handle = this.handles.get(sessionName);
|
||||
if (handle) {
|
||||
try {
|
||||
await handle.browser.close();
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Error closing browser for session "${sessionName}": ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
this.handles.delete(sessionName);
|
||||
}
|
||||
await this.sessionService.markClosed(sessionName);
|
||||
this.logger.log(`Session "${sessionName}" closed`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all in-memory handles and mark every open session as closed in DB.
|
||||
*/
|
||||
async closeAll(): Promise<void> {
|
||||
const names = Array.from(this.handles.keys());
|
||||
await Promise.allSettled(names.map((name) => this.close(name)));
|
||||
if (names.length > 0) {
|
||||
this.logger.log(`Closed ${names.length} session context(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the context (if open) and delete the session record from DB.
|
||||
* Throws 404 if the session ID does not exist.
|
||||
*/
|
||||
async delete(id: number): Promise<void> {
|
||||
const session = await this.sessionService.findById(id);
|
||||
if (!session) {
|
||||
throw new NotFoundException(`Session ${id} not found`);
|
||||
}
|
||||
if (this.handles.has(session.sessionName)) {
|
||||
await this.close(session.sessionName);
|
||||
}
|
||||
await this.sessionService.remove(id);
|
||||
}
|
||||
|
||||
/** Returns true if a live Playwright context exists for this session. */
|
||||
isOpen(sessionName: string): boolean {
|
||||
return this.handles.has(sessionName);
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.closeAll();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Interval } from "@nestjs/schedule";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { AppConfig } from "../config/app.config";
|
||||
import { TraceLogger } from "../common/trace-logger";
|
||||
import { SessionService } from "./session.service";
|
||||
import { SessionContextService } from "./session-context.service";
|
||||
|
||||
/**
|
||||
* Periodically:
|
||||
* 1. Closes sessions that have been idle for longer than SESSION_IDLE_TIMEOUT_MINUTES.
|
||||
* 2. Deletes closed sessions whose updatedAt is older than SESSION_DELETE_CLOSED_DAYS.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SessionSchedulerService {
|
||||
private readonly logger = new TraceLogger(SessionSchedulerService.name);
|
||||
|
||||
constructor(
|
||||
private readonly sessionService: SessionService,
|
||||
private readonly sessionContextService: SessionContextService,
|
||||
private readonly config: ConfigService<AppConfig, true>,
|
||||
) {}
|
||||
|
||||
@Interval(60_000)
|
||||
async runScheduler(): Promise<void> {
|
||||
await this.closeExpired();
|
||||
await this.deleteOldClosed();
|
||||
}
|
||||
|
||||
private async closeExpired(): Promise<void> {
|
||||
const idleMinutes = this.config.get("SESSION_IDLE_TIMEOUT_MINUTES");
|
||||
const cutoff = new Date(Date.now() - idleMinutes * 60_000);
|
||||
const expired = await this.sessionService.findExpiredOpen(cutoff);
|
||||
for (const session of expired) {
|
||||
await this.sessionContextService.close(session.sessionName);
|
||||
this.logger.log(
|
||||
`Session "${session.sessionName}" closed (idle > ${idleMinutes} min)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteOldClosed(): Promise<void> {
|
||||
const days = this.config.get("SESSION_DELETE_CLOSED_DAYS");
|
||||
const cutoff = new Date(Date.now() - days * 86_400_000);
|
||||
const old = await this.sessionService.findOldClosed(cutoff);
|
||||
for (const session of old) {
|
||||
await this.sessionService.remove(session.id);
|
||||
this.logger.log(
|
||||
`Session "${session.sessionName}" deleted (closed > ${days} days ago)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
||||
import { SessionService } from "./session.service";
|
||||
import { SessionContextService } from "./session-context.service";
|
||||
import { PaginationQueryDto } from "../common/dto/pagination.dto";
|
||||
import { SessionOrderBy } from "./session.service";
|
||||
|
||||
@ApiTags("sessions")
|
||||
@Controller("sessions")
|
||||
export class SessionController {
|
||||
constructor(
|
||||
private readonly sessionService: SessionService,
|
||||
private readonly sessionContextService: SessionContextService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "List all stored sessions (paginated)" })
|
||||
@ApiResponse({ status: 200, description: "Paginated sessions" })
|
||||
findAll(@Query() query: PaginationQueryDto<SessionOrderBy>) {
|
||||
return this.sessionService.findAll(query);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@ApiOperation({ summary: "Delete a session by ID (closes it first if open)" })
|
||||
@ApiResponse({ status: 200, description: "Session deleted" })
|
||||
@ApiResponse({ status: 404, description: "Session not found" })
|
||||
async remove(@Param("id", ParseIntPipe) id: number): Promise<void> {
|
||||
await this.sessionContextService.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from "typeorm";
|
||||
|
||||
export type SessionStatus = "open" | "closed";
|
||||
|
||||
@Entity("sessions")
|
||||
export class SessionEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ unique: true })
|
||||
sessionName: string;
|
||||
|
||||
@Column("text")
|
||||
token: string;
|
||||
|
||||
@Column("text")
|
||||
cookies: string; // JSON-serialised Cookie[] from Playwright
|
||||
|
||||
@Column("text", { default: "{}" })
|
||||
localStorage: string; // JSON-serialised Record<string, string> from Playwright
|
||||
|
||||
@Column({ default: "closed" })
|
||||
status: SessionStatus;
|
||||
|
||||
@Column({ type: "datetime", nullable: true, default: null })
|
||||
lastUsedAt: Date | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { SessionEntity } from "./session.entity";
|
||||
import { SessionService } from "./session.service";
|
||||
import { SessionController } from "./session.controller";
|
||||
import { SessionContextService } from "./session-context.service";
|
||||
import { SessionSchedulerService } from "./session-scheduler.service";
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([SessionEntity])],
|
||||
controllers: [SessionController],
|
||||
providers: [SessionService, SessionContextService, SessionSchedulerService],
|
||||
exports: [SessionService, SessionContextService],
|
||||
})
|
||||
export class SessionModule {}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Injectable, OnApplicationBootstrap } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { LessThan, Repository } from "typeorm";
|
||||
import { SessionEntity } from "./session.entity";
|
||||
import { TraceLogger } from "../common/trace-logger";
|
||||
import type { Cookie } from "playwright";
|
||||
import {
|
||||
PaginationQueryDto,
|
||||
PaginatedResult,
|
||||
} from "../common/dto/pagination.dto";
|
||||
|
||||
export type SessionOrderBy =
|
||||
| "id"
|
||||
| "sessionName"
|
||||
| "status"
|
||||
| "lastUsedAt"
|
||||
| "createdAt"
|
||||
| "updatedAt";
|
||||
|
||||
@Injectable()
|
||||
export class SessionService implements OnApplicationBootstrap {
|
||||
private readonly logger = new TraceLogger(SessionService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(SessionEntity)
|
||||
private readonly repo: Repository<SessionEntity>,
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
const result = await this.repo.update(
|
||||
{ status: "open" },
|
||||
{ status: "closed" },
|
||||
);
|
||||
if ((result.affected ?? 0) > 0) {
|
||||
this.logger.log(
|
||||
`${result.affected} open session(s) closed on startup (no Playwright context available)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async upsert(
|
||||
sessionName: string,
|
||||
token: string,
|
||||
cookies: Cookie[],
|
||||
localStorage: Record<string, string>,
|
||||
): Promise<SessionEntity> {
|
||||
const now = new Date();
|
||||
const existing = await this.repo.findOneBy({ sessionName });
|
||||
|
||||
if (existing) {
|
||||
existing.token = token;
|
||||
existing.cookies = JSON.stringify(cookies);
|
||||
existing.localStorage = JSON.stringify(localStorage);
|
||||
existing.status = "open";
|
||||
existing.lastUsedAt = now;
|
||||
return this.repo.save(existing);
|
||||
}
|
||||
|
||||
return this.repo.save(
|
||||
this.repo.create({
|
||||
sessionName,
|
||||
token,
|
||||
cookies: JSON.stringify(cookies),
|
||||
localStorage: JSON.stringify(localStorage),
|
||||
status: "open",
|
||||
lastUsedAt: now,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
findBySessionName(sessionName: string): Promise<SessionEntity | null> {
|
||||
return this.repo.findOneBy({ sessionName });
|
||||
}
|
||||
|
||||
findById(id: number): Promise<SessionEntity | null> {
|
||||
return this.repo.findOneBy({ id });
|
||||
}
|
||||
|
||||
async markOpen(sessionName: string): Promise<void> {
|
||||
await this.repo.update({ sessionName }, { status: "open" });
|
||||
}
|
||||
|
||||
async markClosed(sessionName: string): Promise<void> {
|
||||
await this.repo.update({ sessionName }, { status: "closed" });
|
||||
}
|
||||
|
||||
async touchLastUsed(sessionName: string): Promise<void> {
|
||||
await this.repo.update({ sessionName }, { lastUsedAt: new Date() });
|
||||
}
|
||||
|
||||
findExpiredOpen(cutoff: Date): Promise<SessionEntity[]> {
|
||||
return this.repo
|
||||
.createQueryBuilder("s")
|
||||
.where("s.status = :status", { status: "open" })
|
||||
.andWhere("(s.lastUsedAt IS NULL OR s.lastUsedAt < :cutoff)", { cutoff })
|
||||
.getMany();
|
||||
}
|
||||
|
||||
findOldClosed(cutoff: Date): Promise<SessionEntity[]> {
|
||||
return this.repo.find({
|
||||
where: { status: "closed", updatedAt: LessThan(cutoff) },
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(
|
||||
query: PaginationQueryDto<SessionOrderBy> = {},
|
||||
): Promise<
|
||||
PaginatedResult<
|
||||
Pick<
|
||||
SessionEntity,
|
||||
| "id"
|
||||
| "sessionName"
|
||||
| "status"
|
||||
| "lastUsedAt"
|
||||
| "createdAt"
|
||||
| "updatedAt"
|
||||
>
|
||||
>
|
||||
> {
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const orderBy = query.orderBy ?? "id";
|
||||
const orderDir = query.orderDir ?? "ASC";
|
||||
const [data, total] = await this.repo.findAndCount({
|
||||
select: [
|
||||
"id",
|
||||
"sessionName",
|
||||
"status",
|
||||
"lastUsedAt",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
],
|
||||
order: { [orderBy]: orderDir },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
});
|
||||
return { data, total, page, limit };
|
||||
}
|
||||
|
||||
async remove(id: number): Promise<void> {
|
||||
await this.repo.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
const mockElement = {
|
||||
outerHTML: '<div id="mock">mock content</div>',
|
||||
textContent: "mock content",
|
||||
};
|
||||
|
||||
export class JSDOM {
|
||||
constructor(
|
||||
public html: string,
|
||||
public options?: Record<string, unknown>,
|
||||
) {}
|
||||
get window() {
|
||||
return {
|
||||
document: {
|
||||
querySelector: () => mockElement,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
const makePage = () => ({
|
||||
goto: jest.fn(),
|
||||
content: jest.fn().mockResolvedValue("<html></html>"),
|
||||
title: jest.fn().mockReturnValue(""),
|
||||
url: jest.fn().mockReturnValue(""),
|
||||
evaluate: jest.fn(),
|
||||
close: jest.fn(),
|
||||
});
|
||||
|
||||
const makeContext = () => ({
|
||||
newPage: jest.fn().mockResolvedValue(makePage()),
|
||||
addCookies: jest.fn().mockResolvedValue(undefined),
|
||||
addInitScript: jest.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
const makeBrowser = () => ({
|
||||
newContext: jest
|
||||
.fn()
|
||||
.mockImplementation(() => Promise.resolve(makeContext())),
|
||||
close: jest.fn(),
|
||||
});
|
||||
|
||||
export const chromium = {
|
||||
launch: jest.fn().mockImplementation(() => Promise.resolve(makeBrowser())),
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export class Readability {
|
||||
constructor(private doc: unknown) {}
|
||||
parse() {
|
||||
return { textContent: "" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { INestApplication, ValidationPipe } from "@nestjs/common";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
jest.mock("@nestjs/common", () => {
|
||||
const actual = jest.requireActual("@nestjs/common");
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const log = require("debug")("test");
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { Logger } = require("@nestjs/common/services/logger.service");
|
||||
|
||||
Logger.prototype.error = function (
|
||||
message: unknown,
|
||||
stack?: string,
|
||||
context?: string,
|
||||
) {
|
||||
const ctx = context ?? this.context ?? "App";
|
||||
log(`[${ctx}]`, "error", message, ...(stack ? [stack] : []));
|
||||
};
|
||||
|
||||
for (const level of ["log", "warn", "debug", "verbose", "fatal"] as const) {
|
||||
Logger.prototype[level] = function (message: unknown, context?: string) {
|
||||
const ctx = context ?? this.context ?? "App";
|
||||
log(`[${ctx}]`, level, message);
|
||||
};
|
||||
}
|
||||
|
||||
return actual;
|
||||
});
|
||||
|
||||
import { AuthModule } from "../src/auth/auth.module";
|
||||
import { BrowserModule } from "../src/browser/browser.module";
|
||||
import { SessionModule } from "../src/session/session.module";
|
||||
import { EnvironmentModule } from "../src/environment/environment.module";
|
||||
import { ScenarioModule } from "../src/scenario/scenario.module";
|
||||
import { McpModule } from "../src/mcp/mcp.module";
|
||||
import { SessionEntity } from "../src/session/session.entity";
|
||||
import { EnvironmentEntity } from "../src/environment/environment.entity";
|
||||
import { ScenarioEntity } from "../src/scenario/scenario.entity";
|
||||
import { ScenarioStepEntity } from "../src/scenario/scenario-step.entity";
|
||||
import { ScenarioRunEntity } from "../src/scenario/scenario-run.entity";
|
||||
import { ScenarioRunStepEntity } from "../src/scenario/scenario-run-step.entity";
|
||||
import { ScenarioRunLogEntity } from "../src/scenario/scenario-run-log.entity";
|
||||
import { HealthController } from "../src/health/health.controller";
|
||||
import { HttpExceptionFilter } from "../src/filters/http-exception.filter";
|
||||
import { LoggingInterceptor } from "../src/interceptors/logging.interceptor";
|
||||
import { validateAppConfig } from "../src/config/app.config";
|
||||
|
||||
export async function buildTestApp(): Promise<INestApplication> {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
ignoreEnvFile: true,
|
||||
validate: validateAppConfig,
|
||||
}),
|
||||
TypeOrmModule.forRoot({
|
||||
type: "better-sqlite3",
|
||||
database: ":memory:",
|
||||
entities: [
|
||||
SessionEntity,
|
||||
EnvironmentEntity,
|
||||
ScenarioEntity,
|
||||
ScenarioStepEntity,
|
||||
ScenarioRunEntity,
|
||||
ScenarioRunStepEntity,
|
||||
ScenarioRunLogEntity,
|
||||
],
|
||||
synchronize: true,
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
AuthModule,
|
||||
BrowserModule,
|
||||
SessionModule,
|
||||
EnvironmentModule,
|
||||
ScenarioModule,
|
||||
McpModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
}).compile();
|
||||
|
||||
const app = module.createNestApplication();
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalInterceptors(new LoggingInterceptor());
|
||||
await app.init();
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import request from "supertest";
|
||||
import { buildTestApp } from "./app.harness";
|
||||
|
||||
/**
|
||||
* Auth controller integration tests.
|
||||
*
|
||||
* The login endpoint requires a live browser + real key files, so it is not
|
||||
* exercised here (those belong to e2e tests with real credentials).
|
||||
* We cover the parts that can be tested without external dependencies.
|
||||
*/
|
||||
describe("AuthController", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildTestApp();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
// ── GET /keys ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("GET /keys", () => {
|
||||
it("returns 200 with a keys array", async () => {
|
||||
const res = await request(app.getHttpServer()).get("/keys").expect(200);
|
||||
expect(res.body).toHaveProperty("keys");
|
||||
expect(Array.isArray(res.body.keys)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /login ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /login", () => {
|
||||
it("returns 400 when body is empty", async () => {
|
||||
await request(app.getHttpServer()).post("/login").send({}).expect(400);
|
||||
});
|
||||
|
||||
it("returns 400 when key is missing", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/login")
|
||||
.send({ environmentName: "test-env" })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("returns 400 when environmentName is missing", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/login")
|
||||
.send({ key: "some-key" })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("returns 400 when key file does not exist", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/login")
|
||||
.send({ key: "nonexistent-key", environmentName: "test-env" })
|
||||
.expect(404); // NotFoundException for missing environment
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { getRepositoryToken } from "@nestjs/typeorm";
|
||||
import request from "supertest";
|
||||
import { buildTestApp } from "./app.harness";
|
||||
import { SessionEntity } from "../src/session/session.entity";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
/**
|
||||
* Browser controller integration tests.
|
||||
*
|
||||
* POST /open and POST /exec need a running Playwright browser. We test
|
||||
* validation rejections (no browser launched) and session-not-found paths,
|
||||
* which are safe to run in a headless CI environment.
|
||||
*/
|
||||
describe("BrowserController", () => {
|
||||
let app: INestApplication;
|
||||
let sessionRepo: Repository<SessionEntity>;
|
||||
|
||||
const FAKE_SESSION = "test-browser-session";
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildTestApp();
|
||||
sessionRepo = app.get<Repository<SessionEntity>>(
|
||||
getRepositoryToken(SessionEntity),
|
||||
);
|
||||
|
||||
// Seed a session with minimal but valid JSON so the browser code can
|
||||
// deserialise it (it will still fail to open a real page, tested separately)
|
||||
await sessionRepo.save(
|
||||
sessionRepo.create({
|
||||
sessionName: FAKE_SESSION,
|
||||
token: "fake-token",
|
||||
cookies: "[]",
|
||||
localStorage: "{}",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
// ── POST /open ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /open", () => {
|
||||
it("returns 400 when body is empty", async () => {
|
||||
await request(app.getHttpServer()).post("/open").send({}).expect(400);
|
||||
});
|
||||
|
||||
it("returns 400 when url is missing", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/open")
|
||||
.send({ sessionName: FAKE_SESSION })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("returns 404 when session does not exist", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/open")
|
||||
.send({ sessionName: "no-such-session", url: "https://example.com" })
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("succeeds without a session (sessionless open)", async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post("/open")
|
||||
.send({ url: "https://example.com" })
|
||||
.expect(201);
|
||||
expect(res.body).toMatchObject({
|
||||
url: expect.any(String),
|
||||
title: expect.any(String),
|
||||
content: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it("returns only selector content when selector is provided", async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post("/open")
|
||||
.send({ url: "https://example.com", selector: "#mock" })
|
||||
.expect(201);
|
||||
expect(res.body.content).toBe('<div id="mock">mock content</div>');
|
||||
});
|
||||
|
||||
it("returns selector text content in reader mode", async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post("/open")
|
||||
.send({
|
||||
url: "https://example.com",
|
||||
selector: "#mock",
|
||||
readerMode: true,
|
||||
})
|
||||
.expect(201);
|
||||
expect(res.body.content).toBe("mock content");
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /exec ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /exec", () => {
|
||||
it("returns 400 when body is empty", async () => {
|
||||
await request(app.getHttpServer()).post("/exec").send({}).expect(400);
|
||||
});
|
||||
|
||||
it("returns 400 when code is missing", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/exec")
|
||||
.send({ sessionName: FAKE_SESSION })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("returns 400 when code has a syntax error", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/exec")
|
||||
.send({ sessionName: FAKE_SESSION, code: "this is not valid {{{" })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("returns 404 when session does not exist", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/exec")
|
||||
.send({ sessionName: "no-such-session", code: "return 1;" })
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("succeeds without a session (sessionless exec)", async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post("/exec")
|
||||
.send({ code: "return 42;" })
|
||||
.expect(201);
|
||||
expect(res.body).toEqual({ result: 42 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
/**
|
||||
* Unit tests for dumpDom.
|
||||
*
|
||||
* We use a lightweight fake DOM rather than jsdom to avoid ESM-dependency
|
||||
* transform issues. Each fake element is a plain JS object that duck-types
|
||||
* the DOM API used by the dumpDom evaluate payload. The fake `page`
|
||||
* captures the evaluate callback and runs it via new Function() with the
|
||||
* fake globals injected as named parameters.
|
||||
*/
|
||||
|
||||
import { dumpDom, DomNode } from "../src/code-executor/dom-helpers";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake DOM builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FakeText {
|
||||
nodeType: 3;
|
||||
textContent: string;
|
||||
}
|
||||
|
||||
interface FakeEl {
|
||||
tagName: string;
|
||||
children: FakeEl[];
|
||||
childNodes: FakeText[];
|
||||
offsetParent: object | null;
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
href: string;
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
innerText: string;
|
||||
textContent: string;
|
||||
getAttribute(name: string): string | null;
|
||||
_display: string;
|
||||
_visibility: string;
|
||||
}
|
||||
|
||||
type ElAttrs = Partial<{
|
||||
role: string;
|
||||
"data-testid": string;
|
||||
"data-qa": string;
|
||||
"data-action": string;
|
||||
"data-element-id": string;
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
href: string;
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
style: string;
|
||||
}>;
|
||||
|
||||
function el(
|
||||
tag: string,
|
||||
attrs: ElAttrs = {},
|
||||
...children: (FakeEl | string)[]
|
||||
): FakeEl {
|
||||
const ownTextNodes: FakeText[] = children
|
||||
.filter((c): c is string => typeof c === "string")
|
||||
.map((t) => ({ nodeType: 3, textContent: t }));
|
||||
const childEls = children.filter((c): c is FakeEl => typeof c !== "string");
|
||||
const deepText = children
|
||||
.map((c) => (typeof c === "string" ? c : c.innerText))
|
||||
.join("");
|
||||
|
||||
const style = attrs.style ?? "";
|
||||
const display = /display\s*:\s*none/.test(style) ? "none" : "";
|
||||
const visibility = /visibility\s*:\s*hidden/.test(style) ? "hidden" : "";
|
||||
|
||||
const attrMap: Record<string, string | null> = {};
|
||||
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;
|
||||
}
|
||||
|
||||
return {
|
||||
tagName: tag.toUpperCase(),
|
||||
children: childEls,
|
||||
childNodes: ownTextNodes,
|
||||
offsetParent: display || visibility ? null : {},
|
||||
id: attrs.id ?? "",
|
||||
type: attrs.type ?? "",
|
||||
name: attrs.name ?? "",
|
||||
href: attrs.href
|
||||
? attrs.href.startsWith("http")
|
||||
? attrs.href
|
||||
: `https://example.com${attrs.href}`
|
||||
: "",
|
||||
checked: !!attrs.checked,
|
||||
disabled: !!attrs.disabled,
|
||||
innerText: deepText,
|
||||
textContent: deepText,
|
||||
getAttribute(name: string) {
|
||||
return attrMap[name] ?? null;
|
||||
},
|
||||
_display: display,
|
||||
_visibility: visibility,
|
||||
};
|
||||
}
|
||||
|
||||
function body(...children: (FakeEl | string)[]): FakeEl {
|
||||
return el("body", {}, ...children);
|
||||
}
|
||||
|
||||
// ── Fake page ──────────────────────────────────────────────────────────────
|
||||
|
||||
function findByTag(root: FakeEl, tag: string): FakeEl | null {
|
||||
if (root.tagName.toLowerCase() === tag.toLowerCase()) return root;
|
||||
for (const child of root.children) {
|
||||
const found = findByTag(child, tag);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function makePage(rootEl: FakeEl) {
|
||||
const fakeDocument = {
|
||||
querySelector(sel: string): FakeEl | null {
|
||||
if (sel.startsWith("#") || sel.startsWith("[") || sel.startsWith("."))
|
||||
return null;
|
||||
return findByTag(rootEl, sel);
|
||||
},
|
||||
};
|
||||
const fakeWindow = {
|
||||
getComputedStyle: (e: FakeEl) => ({
|
||||
display: e._display,
|
||||
visibility: e._visibility,
|
||||
}),
|
||||
location: { origin: "https://example.com" },
|
||||
};
|
||||
const fakeNode = { TEXT_NODE: 3 };
|
||||
|
||||
const evaluate = jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(fn: (...args: unknown[]) => unknown, args: unknown) => {
|
||||
const exec = new Function(
|
||||
"document",
|
||||
"window",
|
||||
"Node",
|
||||
"__args__",
|
||||
`return (${fn.toString()})(__args__)`,
|
||||
);
|
||||
return Promise.resolve(exec(fakeDocument, fakeWindow, fakeNode, args));
|
||||
},
|
||||
);
|
||||
|
||||
return { evaluate } as unknown as import("playwright").Page;
|
||||
}
|
||||
|
||||
const dump = (rootEl: FakeEl, sel?: string): Promise<DomNode> =>
|
||||
dumpDom(makePage(rootEl), sel);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("dumpDom", () => {
|
||||
// ── Error handling ────────────────────────────────────────────────────────
|
||||
|
||||
it("returns an ERROR node when the root selector is not found", async () => {
|
||||
const result = await dump(body(el("p", {}, "hello")), "#does-not-exist");
|
||||
expect(result.tag).toBe("ERROR");
|
||||
expect(result.text).toContain("#does-not-exist");
|
||||
});
|
||||
|
||||
// ── Scope & defaults ──────────────────────────────────────────────────────
|
||||
|
||||
it("defaults to body scope", async () => {
|
||||
const result = await dump(body(el("button", {}, "Go")));
|
||||
expect(result.tag).toBe("body");
|
||||
});
|
||||
|
||||
it("scopes to an arbitrary sub-selector", async () => {
|
||||
const root = body(
|
||||
el("header", {}, el("a", { href: "/nav" }, "Nav")),
|
||||
el("main", {}, el("button", {}, "Action")),
|
||||
);
|
||||
const result = await dump(root, "main");
|
||||
expect(result.tag).toBe("main");
|
||||
expect(result.children.find((c) => c.tag === "header")).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── Visibility filtering ──────────────────────────────────────────────────
|
||||
|
||||
it("skips elements with display:none", async () => {
|
||||
const root = body(
|
||||
el("button", { style: "display:none" }, "Hidden"),
|
||||
el("button", {}, "Visible"),
|
||||
);
|
||||
const result = await dump(root);
|
||||
const btns = result.children.filter((c) => c.tag === "button");
|
||||
expect(btns).toHaveLength(1);
|
||||
expect(btns[0].text).toBe("Visible");
|
||||
});
|
||||
|
||||
it("skips elements with visibility:hidden", async () => {
|
||||
const root = body(
|
||||
el("button", { style: "visibility:hidden" }, "Hidden"),
|
||||
el("button", {}, "Visible"),
|
||||
);
|
||||
const result = await dump(root);
|
||||
const btns = result.children.filter((c) => c.tag === "button");
|
||||
expect(btns).toHaveLength(1);
|
||||
expect(btns[0].text).toBe("Visible");
|
||||
});
|
||||
|
||||
// ── Ignored tag types ─────────────────────────────────────────────────────
|
||||
|
||||
it("skips SVG elements", async () => {
|
||||
const svgEl = el("svg", {}, el("path", {}));
|
||||
const result = await dump(body(el("button", {}, svgEl, "Click")));
|
||||
const btn = result.children.find((c) => c.tag === "button");
|
||||
expect(btn).toBeDefined();
|
||||
expect(btn!.children.some((c) => c.tag === "svg")).toBe(false);
|
||||
});
|
||||
|
||||
it("skips SCRIPT elements", async () => {
|
||||
const result = await dump(
|
||||
body(el("script", {}, "alert(1)"), el("button", {}, "OK")),
|
||||
);
|
||||
expect(result.children.some((c) => c.tag === "script")).toBe(false);
|
||||
});
|
||||
|
||||
// ── Semantic attributes ───────────────────────────────────────────────────
|
||||
|
||||
it("captures data-testid", async () => {
|
||||
const result = await dump(
|
||||
body(el("button", { "data-testid": "save-btn" }, "Save")),
|
||||
);
|
||||
expect(result.children.find((c) => c.tag === "button")?.testid).toBe(
|
||||
"save-btn",
|
||||
);
|
||||
});
|
||||
|
||||
it("captures data-qa", async () => {
|
||||
const result = await dump(
|
||||
body(
|
||||
el("div", { "data-qa": "process-name" }, el("input", { type: "text" })),
|
||||
),
|
||||
);
|
||||
expect(result.children.find((c) => c.qa === "process-name")).toBeDefined();
|
||||
});
|
||||
|
||||
it("captures data-action", async () => {
|
||||
const result = await dump(
|
||||
body(el("div", { "data-action": "append.append-task" })),
|
||||
);
|
||||
expect(
|
||||
result.children.find((c) => c.action === "append.append-task"),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("captures data-element-id", async () => {
|
||||
const result = await dump(
|
||||
body(el("div", { "data-element-id": "Activity_1abc" })),
|
||||
);
|
||||
expect(
|
||||
result.children.find((c) => c.elementId === "Activity_1abc"),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("captures role attribute", async () => {
|
||||
const result = await dump(
|
||||
body(el("div", { role: "dialog" }, el("button", {}, "OK"))),
|
||||
);
|
||||
const dialog = result.children.find((c) => c.role === "dialog");
|
||||
expect(dialog).toBeDefined();
|
||||
expect(dialog!.tag).toBe("div");
|
||||
});
|
||||
|
||||
// ── Interactive element attributes ────────────────────────────────────────
|
||||
|
||||
it("captures input id, type, and name", async () => {
|
||||
const result = await dump(
|
||||
body(el("input", { id: "email", type: "email", name: "userEmail" })),
|
||||
);
|
||||
const input = result.children.find((c) => c.tag === "input");
|
||||
expect(input?.id).toBe("email");
|
||||
expect(input?.type).toBe("email");
|
||||
expect(input?.name).toBe("userEmail");
|
||||
});
|
||||
|
||||
it("captures checked:true on a checked checkbox", async () => {
|
||||
const result = await dump(
|
||||
body(el("input", { type: "checkbox", checked: true })),
|
||||
);
|
||||
expect(result.children.find((c) => c.tag === "input")?.checked).toBe(true);
|
||||
});
|
||||
|
||||
it("captures checked:false on an unchecked checkbox", async () => {
|
||||
const result = await dump(body(el("input", { type: "checkbox" })));
|
||||
expect(result.children.find((c) => c.tag === "input")?.checked).toBe(false);
|
||||
});
|
||||
|
||||
it("captures disabled:true on a disabled button", async () => {
|
||||
const result = await dump(body(el("button", { disabled: true }, "Nope")));
|
||||
expect(result.children.find((c) => c.tag === "button")?.disabled).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not set disabled for a non-disabled button", async () => {
|
||||
const result = await dump(body(el("button", {}, "OK")));
|
||||
expect(
|
||||
result.children.find((c) => c.tag === "button")?.disabled,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not capture type for button elements", async () => {
|
||||
const result = await dump(body(el("button", { type: "submit" }, "Go")));
|
||||
expect(
|
||||
result.children.find((c) => c.tag === "button")?.type,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("relativizes same-origin anchor href", async () => {
|
||||
const result = await dump(body(el("a", { href: "/workflow/123" }, "Link")));
|
||||
expect(result.children.find((c) => c.tag === "a")?.href).toBe(
|
||||
"/workflow/123",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps full href for cross-origin anchors", async () => {
|
||||
const result = await dump(
|
||||
body(el("a", { href: "https://other.com/page" }, "Ext")),
|
||||
);
|
||||
expect(result.children.find((c) => c.tag === "a")?.href).toContain(
|
||||
"other.com",
|
||||
);
|
||||
});
|
||||
|
||||
// ── Text content ──────────────────────────────────────────────────────────
|
||||
|
||||
it("captures own text content of a button", async () => {
|
||||
const result = await dump(body(el("button", {}, "Save")));
|
||||
expect(result.children.find((c) => c.tag === "button")?.text).toBe("Save");
|
||||
});
|
||||
|
||||
it("truncates text to 80 characters", async () => {
|
||||
const long = "x".repeat(100);
|
||||
const result = await dump(body(el("button", {}, long)));
|
||||
expect(result.children.find((c) => c.tag === "button")?.text?.length).toBe(
|
||||
80,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to innerText when element has no direct text nodes", async () => {
|
||||
// button wraps a span — no direct text node on button, innerText = span text
|
||||
const result = await dump(body(el("button", {}, el("span", {}, "Nested"))));
|
||||
expect(result.children.find((c) => c.tag === "button")?.text).toBe(
|
||||
"Nested",
|
||||
);
|
||||
});
|
||||
|
||||
// ── Tree pruning / unwrapping ─────────────────────────────────────────────
|
||||
|
||||
it("unwraps a non-significant div that has exactly one significant child", async () => {
|
||||
const result = await dump(body(el("div", {}, el("button", {}, "Click"))));
|
||||
const btn = result.children.find((c) => c.tag === "button");
|
||||
expect(btn).toBeDefined();
|
||||
expect(
|
||||
result.children.some(
|
||||
(c) => c.tag === "div" && !c.role && !c.testid && !c.qa,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a non-significant div that has more than one significant child", async () => {
|
||||
const result = await dump(
|
||||
body(el("div", {}, el("button", {}, "A"), el("button", {}, "B"))),
|
||||
);
|
||||
const wrapper = result.children.find((c) => c.tag === "div");
|
||||
expect(wrapper).toBeDefined();
|
||||
expect(wrapper!.children).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("discards non-significant childless elements", async () => {
|
||||
const result = await dump(body(el("div", {}), el("button", {}, "Keep")));
|
||||
expect(
|
||||
result.children.find((c) => c.tag === "div" && c.children.length === 0),
|
||||
).toBeUndefined();
|
||||
expect(result.children.some((c) => c.tag === "button")).toBe(true);
|
||||
});
|
||||
|
||||
// ── Structural tags ───────────────────────────────────────────────────────
|
||||
|
||||
it("preserves nested structure inside a form", async () => {
|
||||
const result = await dump(
|
||||
body(
|
||||
el(
|
||||
"form",
|
||||
{},
|
||||
el("input", { id: "n", type: "text", name: "name" }),
|
||||
el("button", {}, "Send"),
|
||||
),
|
||||
),
|
||||
);
|
||||
const form = result.children.find((c) => c.tag === "form");
|
||||
expect(form).toBeDefined();
|
||||
expect(form!.children.find((c) => c.tag === "input")).toBeDefined();
|
||||
expect(form!.children.find((c) => c.tag === "button")).toBeDefined();
|
||||
});
|
||||
|
||||
it("preserves dialog element", async () => {
|
||||
const result = await dump(
|
||||
body(el("dialog", { role: "dialog" }, el("button", {}, "Close"))),
|
||||
);
|
||||
expect(result.children.find((c) => c.tag === "dialog")).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns empty node when root has no visible significant content", async () => {
|
||||
const result = await dumpDom(makePage(el("div", {})), "div");
|
||||
expect(result.tag).toBe("empty");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import request from "supertest";
|
||||
import { buildTestApp } from "./app.harness";
|
||||
|
||||
describe("EnvironmentController", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildTestApp();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
// ── POST /environments ─────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /environments", () => {
|
||||
it("creates an environment and returns 201", async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post("/environments")
|
||||
.send({ name: "env-a", urls: { id_url: "https://id.example.com" } })
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.id).toBeDefined();
|
||||
expect(res.body.name).toBe("env-a");
|
||||
expect(res.body.urls.id_url).toBe("https://id.example.com");
|
||||
});
|
||||
|
||||
it("returns 400 when name is missing", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/environments")
|
||||
.send({ urls: { id_url: "https://id.example.com" } })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("returns 400 when urls is missing", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/environments")
|
||||
.send({ name: "env-no-urls" })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("returns 400 when urls is not an object", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/environments")
|
||||
.send({ name: "env-bad-urls", urls: "not-an-object" })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("returns 409 when name already exists", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/environments")
|
||||
.send({ name: "env-duplicate", urls: {} })
|
||||
.expect(201);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post("/environments")
|
||||
.send({ name: "env-duplicate", urls: {} })
|
||||
.expect(409);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /environments ──────────────────────────────────────────────────────
|
||||
|
||||
describe("GET /environments", () => {
|
||||
it("returns 200 with a paginated result", async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get("/environments")
|
||||
.expect(200);
|
||||
expect(Array.isArray(res.body.data)).toBe(true);
|
||||
expect(typeof res.body.total).toBe("number");
|
||||
expect(res.body).toHaveProperty("page");
|
||||
expect(res.body).toHaveProperty("limit");
|
||||
});
|
||||
|
||||
it("respects page and limit params", async () => {
|
||||
// seed two extra environments
|
||||
await request(app.getHttpServer())
|
||||
.post("/environments")
|
||||
.send({ name: "env-page-1", urls: {} })
|
||||
.expect(201);
|
||||
await request(app.getHttpServer())
|
||||
.post("/environments")
|
||||
.send({ name: "env-page-2", urls: {} })
|
||||
.expect(201);
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get("/environments?page=1&limit=1")
|
||||
.expect(200);
|
||||
expect(res.body.data).toHaveLength(1);
|
||||
expect(res.body.page).toBe(1);
|
||||
expect(res.body.limit).toBe(1);
|
||||
});
|
||||
|
||||
it("returns empty data array for out-of-range page", async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get("/environments?page=9999&limit=20")
|
||||
.expect(200);
|
||||
expect(res.body.data).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns 400 for invalid page param", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get("/environments?page=0")
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("orders by name ASC", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/environments")
|
||||
.send({ name: "zzz-env", urls: {} });
|
||||
await request(app.getHttpServer())
|
||||
.post("/environments")
|
||||
.send({ name: "aaa-env", urls: {} });
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get("/environments?orderBy=name&orderDir=ASC")
|
||||
.expect(200);
|
||||
const names: string[] = res.body.data.map(
|
||||
(e: { name: string }) => e.name,
|
||||
);
|
||||
expect(names).toEqual([...names].sort());
|
||||
});
|
||||
|
||||
it("orders by name DESC", async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get("/environments?orderBy=name&orderDir=DESC")
|
||||
.expect(200);
|
||||
const names: string[] = res.body.data.map(
|
||||
(e: { name: string }) => e.name,
|
||||
);
|
||||
expect(names).toEqual([...names].sort().reverse());
|
||||
});
|
||||
|
||||
it("returns 400 for invalid orderDir", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get("/environments?orderDir=SIDEWAYS")
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /environments/:id ──────────────────────────────────────────────────
|
||||
|
||||
describe("GET /environments/:id", () => {
|
||||
it("returns the created environment", async () => {
|
||||
const created = await request(app.getHttpServer())
|
||||
.post("/environments")
|
||||
.send({
|
||||
name: "env-get-one",
|
||||
urls: { cabinet_url: "https://cabinet.example.com" },
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(`/environments/${created.body.id}`)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.name).toBe("env-get-one");
|
||||
});
|
||||
|
||||
it("returns 404 for unknown id", async () => {
|
||||
await request(app.getHttpServer()).get("/environments/99999").expect(404);
|
||||
});
|
||||
|
||||
it("returns 400 for non-numeric id", async () => {
|
||||
await request(app.getHttpServer()).get("/environments/abc").expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ── PATCH /environments/:id ────────────────────────────────────────────────
|
||||
|
||||
describe("PATCH /environments/:id", () => {
|
||||
it("updates name and returns 200", async () => {
|
||||
const created = await request(app.getHttpServer())
|
||||
.post("/environments")
|
||||
.send({ name: "env-patch-me", urls: {} })
|
||||
.expect(201);
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.patch(`/environments/${created.body.id}`)
|
||||
.send({ name: "env-patched" })
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.name).toBe("env-patched");
|
||||
});
|
||||
|
||||
it("returns 404 for unknown id", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.patch("/environments/99999")
|
||||
.send({ name: "x" })
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── DELETE /environments/:id ───────────────────────────────────────────────
|
||||
|
||||
describe("DELETE /environments/:id", () => {
|
||||
it("deletes and returns 204", async () => {
|
||||
const created = await request(app.getHttpServer())
|
||||
.post("/environments")
|
||||
.send({ name: "env-delete-me", urls: {} })
|
||||
.expect(201);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.delete(`/environments/${created.body.id}`)
|
||||
.expect(204);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get(`/environments/${created.body.id}`)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("returns 404 for unknown id", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.delete("/environments/99999")
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import request from "supertest";
|
||||
import { buildTestApp } from "./app.harness";
|
||||
|
||||
/**
|
||||
* MCP controller integration tests.
|
||||
*
|
||||
* The MCP endpoint speaks the Model Context Protocol (streamable HTTP
|
||||
* transport). We test:
|
||||
* - that the endpoint is reachable and returns a recognised MCP response
|
||||
* - that tool invocations for read-only, non-browser tools work end-to-end
|
||||
* - that tools with bad inputs return error payloads (not HTTP 5xx)
|
||||
*
|
||||
* Browser-dependent tools (open_url, exec_code) require a live Playwright
|
||||
* session and are not covered here.
|
||||
*/
|
||||
describe("McpController", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildTestApp();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
/** Parse the JSON-RPC payload from an SSE response body. */
|
||||
function parseSse(text: string): Record<string, unknown> {
|
||||
const match = text.match(/^data:\s*(.+)$/m);
|
||||
if (!match) throw new Error(`No SSE data line found in: ${text}`);
|
||||
return JSON.parse(match[1]) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Send a single MCP tool call and return the parsed response body. */
|
||||
async function mcpCall(toolName: string, args: Record<string, unknown> = {}) {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post("/mcp")
|
||||
.set("Content-Type", "application/json")
|
||||
.set("Accept", "application/json, text/event-stream")
|
||||
.send({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: { name: toolName, arguments: args },
|
||||
});
|
||||
return { status: res.status, rpc: parseSse(res.text) };
|
||||
}
|
||||
|
||||
// ── Connectivity ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /mcp — connectivity", () => {
|
||||
it("is reachable and returns a non-5xx status", async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post("/mcp")
|
||||
.set("Content-Type", "application/json")
|
||||
.set("Accept", "application/json, text/event-stream")
|
||||
.send({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "test", version: "0" },
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ── list_keys tool ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("list_keys", () => {
|
||||
it("returns a result with text content containing a JSON array", async () => {
|
||||
const { status, rpc } = await mcpCall("list_keys");
|
||||
expect(status).toBe(200);
|
||||
const result = rpc.result as { content: { text: string }[] };
|
||||
expect(Array.isArray(JSON.parse(result.content[0].text))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── list_sessions tool ─────────────────────────────────────────────────────
|
||||
|
||||
describe("list_sessions", () => {
|
||||
it("returns a paginated result with a data array", async () => {
|
||||
const { status, rpc } = await mcpCall("list_sessions");
|
||||
expect(status).toBe(200);
|
||||
const result = rpc.result as { content: { text: string }[] };
|
||||
const body = JSON.parse(result.content[0].text) as {
|
||||
data: unknown[];
|
||||
total: number;
|
||||
};
|
||||
expect(Array.isArray(body.data)).toBe(true);
|
||||
expect(typeof body.total).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
// ── list_environments tool ─────────────────────────────────────────────────
|
||||
|
||||
describe("list_environments", () => {
|
||||
it("returns a paginated result with a data array", async () => {
|
||||
const { status, rpc } = await mcpCall("list_environments");
|
||||
expect(status).toBe(200);
|
||||
const result = rpc.result as { content: { text: string }[] };
|
||||
const body = JSON.parse(result.content[0].text) as {
|
||||
data: unknown[];
|
||||
total: number;
|
||||
};
|
||||
expect(Array.isArray(body.data)).toBe(true);
|
||||
expect(typeof body.total).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
// ── create_environment tool ────────────────────────────────────────────────
|
||||
|
||||
describe("create_environment", () => {
|
||||
it("creates an environment via MCP", async () => {
|
||||
const { status, rpc } = await mcpCall("create_environment", {
|
||||
name: "mcp-test-env",
|
||||
urls: { id_url: "https://id.example.com" },
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
const result = rpc.result as { content: { text: string }[] };
|
||||
const created = JSON.parse(result.content[0].text) as { name: string };
|
||||
expect(created.name).toBe("mcp-test-env");
|
||||
});
|
||||
});
|
||||
|
||||
// ── delete_session tool with unknown id ────────────────────────────────────
|
||||
|
||||
describe("delete_session", () => {
|
||||
it("returns an MCP error result for a non-existent session id", async () => {
|
||||
const { status, rpc } = await mcpCall("delete_session", { id: 999999 });
|
||||
expect(status).toBe(200);
|
||||
// MCP wraps service errors as isError:true content, not HTTP errors
|
||||
const result = rpc.result as { isError: boolean };
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Integration tests for ScenarioRunStepEntity.output column and the
|
||||
* helpers.getStepOutput() API available inside exec step scripts.
|
||||
*
|
||||
* Strategy:
|
||||
* - Seed a session row so the scheduler can create a browser context.
|
||||
* - Create a scenario + steps with controlled return values.
|
||||
* - Call ScenarioSchedulerService.pickUpPendingRuns() directly to process
|
||||
* the run synchronously (no real timers needed).
|
||||
* - Assert step-run output persisted and getStepOutput() returns it.
|
||||
*/
|
||||
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { DataSource } from "typeorm";
|
||||
import { buildTestApp } from "./app.harness";
|
||||
import { ScenarioService } from "../src/scenario/scenario.service";
|
||||
import { ScenarioSchedulerService } from "../src/scenario/scenario-scheduler.service";
|
||||
|
||||
describe("ScenarioRunStepEntity.output + helpers.getStepOutput", () => {
|
||||
let app: INestApplication;
|
||||
let dataSource: DataSource;
|
||||
let scenarioService: ScenarioService;
|
||||
let scheduler: ScenarioSchedulerService;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildTestApp();
|
||||
dataSource = app.get(DataSource);
|
||||
scenarioService = app.get(ScenarioService);
|
||||
scheduler = app.get(ScenarioSchedulerService);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
/** Seed a minimal session so the scheduler can open a browser context. */
|
||||
async function seedSession(name = "output-test-session"): Promise<void> {
|
||||
await dataSource.query(
|
||||
`INSERT OR IGNORE INTO sessions (sessionName, token, cookies, localStorage)
|
||||
VALUES ('${name}', 'tok', '[]', '{}')`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Create a scenario and return its id. */
|
||||
async function createScenario(name = "output-scenario"): Promise<number> {
|
||||
const sc = await scenarioService.create({ name });
|
||||
return sc.id;
|
||||
}
|
||||
|
||||
/** Create a step and return its id. */
|
||||
async function createStep(
|
||||
scenarioId: number,
|
||||
order: number,
|
||||
execCode: string,
|
||||
sessionName = "output-test-session",
|
||||
): Promise<number> {
|
||||
const step = await scenarioService.createStep(scenarioId, {
|
||||
order,
|
||||
type: "exec",
|
||||
sessionName,
|
||||
execCode,
|
||||
});
|
||||
return step.id;
|
||||
}
|
||||
|
||||
/** Trigger a run and process it to completion via the scheduler. */
|
||||
async function runScenario(scenarioId: number): Promise<number> {
|
||||
const run = await scenarioService.createRun(scenarioId);
|
||||
// Drive the scheduler directly — keeps tests synchronous and fast.
|
||||
await scheduler.pickUpPendingRuns();
|
||||
// Wait for the run to reach a terminal state (max 10 s).
|
||||
const result = await scenarioService.waitForRun(scenarioId, run.id, 10_000);
|
||||
return result.id;
|
||||
}
|
||||
|
||||
// ── output column ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("output column", () => {
|
||||
it("stores the return value of an exec script as JSON", async () => {
|
||||
await seedSession();
|
||||
const scId = await createScenario("output-basic");
|
||||
await createStep(scId, 0, "return 42;");
|
||||
|
||||
const runId = await runScenario(scId);
|
||||
|
||||
const row = await dataSource.query(
|
||||
`SELECT output, status FROM scenario_run_steps WHERE runId = ${runId}`,
|
||||
);
|
||||
expect(row[0].status).toBe("pass");
|
||||
expect(JSON.parse(row[0].output as string)).toBe(42);
|
||||
});
|
||||
|
||||
it("stores object return values as JSON", async () => {
|
||||
await seedSession();
|
||||
const scId = await createScenario("output-object");
|
||||
await createStep(scId, 0, 'return { foo: "bar", n: 7 };');
|
||||
|
||||
const runId = await runScenario(scId);
|
||||
|
||||
const row = await dataSource.query(
|
||||
`SELECT output FROM scenario_run_steps WHERE runId = ${runId}`,
|
||||
);
|
||||
expect(JSON.parse(row[0].output as string)).toEqual({ foo: "bar", n: 7 });
|
||||
});
|
||||
|
||||
it("stores null when the script returns undefined/nothing", async () => {
|
||||
await seedSession();
|
||||
const scId = await createScenario("output-undefined");
|
||||
await createStep(scId, 0, "const x = 1;");
|
||||
|
||||
const runId = await runScenario(scId);
|
||||
|
||||
const row = await dataSource.query(
|
||||
`SELECT output FROM scenario_run_steps WHERE runId = ${runId}`,
|
||||
);
|
||||
expect(row[0].output).toBeNull();
|
||||
});
|
||||
|
||||
it("output is exposed on the stepRuns inside GET /scenarios/:id/run/:runId via findRun", async () => {
|
||||
await seedSession();
|
||||
const scId = await createScenario("output-findrun");
|
||||
await createStep(scId, 0, "return 'hello';");
|
||||
|
||||
const runId = await runScenario(scId);
|
||||
const run = await scenarioService.findRun(scId, runId);
|
||||
|
||||
expect(run.stepRuns[0].output).toBe(JSON.stringify("hello"));
|
||||
});
|
||||
});
|
||||
|
||||
// ── helpers.getStepOutput ──────────────────────────────────────────────────
|
||||
|
||||
describe("helpers.getStepOutput()", () => {
|
||||
it("returns output of a previous step by absolute order", async () => {
|
||||
await seedSession();
|
||||
const scId = await createScenario("getStepOutput-absolute");
|
||||
await createStep(scId, 0, "return 99;");
|
||||
// Step 1 reads step 0's output via absolute index 0
|
||||
await createStep(scId, 1, "return await helpers.getStepOutput(0);");
|
||||
|
||||
const runId = await runScenario(scId);
|
||||
const rows = await dataSource.query(
|
||||
`SELECT "order", output FROM scenario_run_steps WHERE runId = ${runId} ORDER BY "order"`,
|
||||
);
|
||||
|
||||
expect(JSON.parse(rows[0].output as string)).toBe(99);
|
||||
expect(JSON.parse(rows[1].output as string)).toBe(99);
|
||||
});
|
||||
|
||||
it("returns output of the previous step using relative index -1", async () => {
|
||||
await seedSession();
|
||||
const scId = await createScenario("getStepOutput-relative");
|
||||
await createStep(scId, 0, 'return "step-zero";');
|
||||
// Step 1 uses relative index -1 to reference step 0
|
||||
await createStep(scId, 1, "return await helpers.getStepOutput(-1);");
|
||||
|
||||
const runId = await runScenario(scId);
|
||||
const rows = await dataSource.query(
|
||||
`SELECT "order", output FROM scenario_run_steps WHERE runId = ${runId} ORDER BY "order"`,
|
||||
);
|
||||
|
||||
expect(JSON.parse(rows[1].output as string)).toBe("step-zero");
|
||||
});
|
||||
|
||||
it("returns null for a step that does not exist", async () => {
|
||||
await seedSession();
|
||||
const scId = await createScenario("getStepOutput-missing");
|
||||
// Step 0 tries to read step order 99 which does not exist
|
||||
await createStep(scId, 0, "return await helpers.getStepOutput(99);");
|
||||
|
||||
const runId = await runScenario(scId);
|
||||
const row = await dataSource.query(
|
||||
`SELECT output FROM scenario_run_steps WHERE runId = ${runId}`,
|
||||
);
|
||||
|
||||
expect(row[0].output).toBeNull();
|
||||
});
|
||||
|
||||
it("chains output across three steps", async () => {
|
||||
await seedSession();
|
||||
const scId = await createScenario("getStepOutput-chain");
|
||||
await createStep(scId, 0, "return [1, 2];");
|
||||
await createStep(
|
||||
scId,
|
||||
1,
|
||||
"const prev = await helpers.getStepOutput(-1); return [...prev, 3];",
|
||||
);
|
||||
await createStep(
|
||||
scId,
|
||||
2,
|
||||
"const prev = await helpers.getStepOutput(-1); return [...prev, 4];",
|
||||
);
|
||||
|
||||
const runId = await runScenario(scId);
|
||||
const rows = await dataSource.query(
|
||||
`SELECT "order", output FROM scenario_run_steps WHERE runId = ${runId} ORDER BY "order"`,
|
||||
);
|
||||
|
||||
expect(JSON.parse(rows[0].output as string)).toEqual([1, 2]);
|
||||
expect(JSON.parse(rows[1].output as string)).toEqual([1, 2, 3]);
|
||||
expect(JSON.parse(rows[2].output as string)).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,742 @@
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import request from "supertest";
|
||||
import { DataSource } from "typeorm";
|
||||
import { buildTestApp } from "./app.harness";
|
||||
|
||||
describe("ScenarioController", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildTestApp();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function createScenario(name = "test scenario") {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post("/scenarios")
|
||||
.send({ name })
|
||||
.expect(201);
|
||||
return res.body as { id: number; name: string };
|
||||
}
|
||||
|
||||
async function createStep(
|
||||
scenarioId: number,
|
||||
overrides: Record<string, unknown> = {},
|
||||
) {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(`/scenarios/${scenarioId}/steps`)
|
||||
.send({
|
||||
order: 0,
|
||||
type: "exec",
|
||||
sessionName: "test-session",
|
||||
execCode: "return 1;",
|
||||
...overrides,
|
||||
})
|
||||
.expect(201);
|
||||
return res.body as { id: number };
|
||||
}
|
||||
|
||||
// ── POST /scenarios ────────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /scenarios", () => {
|
||||
it("creates a scenario and returns 201", async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post("/scenarios")
|
||||
.send({ name: "my scenario" })
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.id).toBeDefined();
|
||||
expect(res.body.name).toBe("my scenario");
|
||||
});
|
||||
|
||||
it("returns 400 when name is missing", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/scenarios")
|
||||
.send({})
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /scenarios ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("GET /scenarios", () => {
|
||||
it("returns paginated result", async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get("/scenarios")
|
||||
.expect(200);
|
||||
expect(res.body).toHaveProperty("data");
|
||||
expect(res.body).toHaveProperty("total");
|
||||
expect(res.body).toHaveProperty("page");
|
||||
expect(res.body).toHaveProperty("limit");
|
||||
expect(Array.isArray(res.body.data)).toBe(true);
|
||||
});
|
||||
|
||||
it("respects page and limit params", async () => {
|
||||
await createScenario("paged-sc-a");
|
||||
await createScenario("paged-sc-b");
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get("/scenarios?page=1&limit=1")
|
||||
.expect(200);
|
||||
expect(res.body.data).toHaveLength(1);
|
||||
expect(res.body.limit).toBe(1);
|
||||
expect(res.body.page).toBe(1);
|
||||
});
|
||||
|
||||
it("returns empty data for out-of-range page", async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get("/scenarios?page=9999&limit=20")
|
||||
.expect(200);
|
||||
expect(res.body.data).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns 400 for invalid pagination params", async () => {
|
||||
await request(app.getHttpServer()).get("/scenarios?page=0").expect(400);
|
||||
});
|
||||
|
||||
it("orders by name ASC", async () => {
|
||||
await createScenario("zzz-order-sc");
|
||||
await createScenario("aaa-order-sc");
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get("/scenarios?orderBy=name&orderDir=ASC")
|
||||
.expect(200);
|
||||
const names: string[] = res.body.data.map(
|
||||
(s: { name: string }) => s.name,
|
||||
);
|
||||
expect(names).toEqual([...names].sort());
|
||||
});
|
||||
|
||||
it("orders by name DESC", async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get("/scenarios?orderBy=name&orderDir=DESC")
|
||||
.expect(200);
|
||||
const names: string[] = res.body.data.map(
|
||||
(s: { name: string }) => s.name,
|
||||
);
|
||||
expect(names).toEqual([...names].sort().reverse());
|
||||
});
|
||||
|
||||
it("returns 400 for invalid orderDir", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get("/scenarios?orderDir=SIDEWAYS")
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /scenarios/:id ─────────────────────────────────────────────────────
|
||||
|
||||
describe("GET /scenarios/:id", () => {
|
||||
it("returns the scenario with steps array", async () => {
|
||||
const sc = await createScenario("scenario-get-one");
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}`)
|
||||
.expect(200);
|
||||
expect(res.body.id).toBe(sc.id);
|
||||
expect(Array.isArray(res.body.steps)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 404 for unknown id", async () => {
|
||||
await request(app.getHttpServer()).get("/scenarios/99999").expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── PATCH /scenarios/:id ───────────────────────────────────────────────────
|
||||
|
||||
describe("PATCH /scenarios/:id", () => {
|
||||
it("updates scenario name", async () => {
|
||||
const sc = await createScenario("patch-me");
|
||||
const res = await request(app.getHttpServer())
|
||||
.patch(`/scenarios/${sc.id}`)
|
||||
.send({ name: "patched" })
|
||||
.expect(200);
|
||||
expect(res.body.name).toBe("patched");
|
||||
});
|
||||
|
||||
it("returns 404 for unknown id", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.patch("/scenarios/99999")
|
||||
.send({ name: "x" })
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── DELETE /scenarios/:id ──────────────────────────────────────────────────
|
||||
|
||||
describe("DELETE /scenarios/:id", () => {
|
||||
it("deletes and returns 204", async () => {
|
||||
const sc = await createScenario("delete-me");
|
||||
await request(app.getHttpServer())
|
||||
.delete(`/scenarios/${sc.id}`)
|
||||
.expect(204);
|
||||
await request(app.getHttpServer()).get(`/scenarios/${sc.id}`).expect(404);
|
||||
});
|
||||
|
||||
it("returns 404 for unknown id", async () => {
|
||||
await request(app.getHttpServer()).delete("/scenarios/99999").expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /scenarios/:id/steps ──────────────────────────────────────────────
|
||||
|
||||
describe("POST /scenarios/:id/steps", () => {
|
||||
it("creates a step with required fields", async () => {
|
||||
const sc = await createScenario();
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(`/scenarios/${sc.id}/steps`)
|
||||
.send({
|
||||
order: 0,
|
||||
type: "exec",
|
||||
sessionName: "my-session",
|
||||
execCode: "return 1;",
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.id).toBeDefined();
|
||||
expect(res.body.order).toBe(0);
|
||||
expect(res.body.type).toBe("exec");
|
||||
expect(res.body.sessionName).toBe("my-session");
|
||||
});
|
||||
|
||||
it("creates a login step", async () => {
|
||||
const sc = await createScenario();
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(`/scenarios/${sc.id}/steps`)
|
||||
.send({ order: 0, type: "login", sessionName: "session-x" })
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.type).toBe("login");
|
||||
});
|
||||
|
||||
it("returns 400 when order is missing", async () => {
|
||||
const sc = await createScenario();
|
||||
await request(app.getHttpServer())
|
||||
.post(`/scenarios/${sc.id}/steps`)
|
||||
.send({ type: "exec", sessionName: "x" })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("returns 400 when type is invalid", async () => {
|
||||
const sc = await createScenario();
|
||||
await request(app.getHttpServer())
|
||||
.post(`/scenarios/${sc.id}/steps`)
|
||||
.send({ order: 0, type: "unknown", sessionName: "x" })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("returns 400 when sessionName is missing", async () => {
|
||||
const sc = await createScenario();
|
||||
await request(app.getHttpServer())
|
||||
.post(`/scenarios/${sc.id}/steps`)
|
||||
.send({ order: 0, type: "exec" })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("returns 404 for unknown scenario", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/scenarios/99999/steps")
|
||||
.send({ order: 0, type: "exec", sessionName: "x" })
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("returns steps ordered by order field", async () => {
|
||||
const sc = await createScenario();
|
||||
await createStep(sc.id, { order: 2, type: "exec", sessionName: "s" });
|
||||
await createStep(sc.id, { order: 0, type: "exec", sessionName: "s" });
|
||||
await createStep(sc.id, { order: 1, type: "exec", sessionName: "s" });
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}`)
|
||||
.expect(200);
|
||||
|
||||
const orders = res.body.steps.map((s: { order: number }) => s.order);
|
||||
expect(orders).toEqual([0, 1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /scenarios/:id/steps/:stepId ──────────────────────────────────────
|
||||
|
||||
describe("GET /scenarios/:id/steps/:stepId", () => {
|
||||
it("returns the step", async () => {
|
||||
const sc = await createScenario();
|
||||
const step = await createStep(sc.id);
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}/steps/${step.id}`)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.id).toBe(step.id);
|
||||
});
|
||||
|
||||
it("returns 404 for unknown step", async () => {
|
||||
const sc = await createScenario();
|
||||
await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}/steps/99999`)
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── PATCH /scenarios/:id/steps/:stepId ────────────────────────────────────
|
||||
|
||||
describe("PATCH /scenarios/:id/steps/:stepId", () => {
|
||||
it("updates step fields", async () => {
|
||||
const sc = await createScenario();
|
||||
const step = await createStep(sc.id, { order: 0, execCode: "return 1;" });
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.patch(`/scenarios/${sc.id}/steps/${step.id}`)
|
||||
.send({ order: 5, execCode: "return 99;" })
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.order).toBe(5);
|
||||
expect(res.body.execCode).toBe("return 99;");
|
||||
});
|
||||
|
||||
it("returns 404 for unknown step", async () => {
|
||||
const sc = await createScenario();
|
||||
await request(app.getHttpServer())
|
||||
.patch(`/scenarios/${sc.id}/steps/99999`)
|
||||
.send({ order: 1 })
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── DELETE /scenarios/:id/steps/:stepId ───────────────────────────────────
|
||||
|
||||
describe("DELETE /scenarios/:id/steps/:stepId", () => {
|
||||
it("deletes the step and returns 204", async () => {
|
||||
const sc = await createScenario();
|
||||
const step = await createStep(sc.id);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.delete(`/scenarios/${sc.id}/steps/${step.id}`)
|
||||
.expect(204);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}/steps/${step.id}`)
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /scenarios/:id/run ────────────────────────────────────────────────
|
||||
|
||||
describe("POST /scenarios/:id/run", () => {
|
||||
it("creates a run with stepRuns in correct initial states", async () => {
|
||||
const sc = await createScenario();
|
||||
await createStep(sc.id, { order: 0 });
|
||||
await createStep(sc.id, { order: 1 });
|
||||
await createStep(sc.id, { order: 2 });
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(`/scenarios/${sc.id}/run`)
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.status).toBe("pending");
|
||||
expect(Array.isArray(res.body.stepRuns)).toBe(true);
|
||||
expect(res.body.stepRuns).toHaveLength(3);
|
||||
|
||||
const statuses = res.body.stepRuns.map(
|
||||
(s: { status: string }) => s.status,
|
||||
);
|
||||
expect(statuses[0]).toBe("pending");
|
||||
expect(statuses[1]).toBe("waiting");
|
||||
expect(statuses[2]).toBe("waiting");
|
||||
});
|
||||
|
||||
it("returns 404 for unknown scenario", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/scenarios/99999/run")
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /scenarios/:id/runs ────────────────────────────────────────────────
|
||||
|
||||
describe("GET /scenarios/:id/runs", () => {
|
||||
it("returns paginated runs with stepRuns embedded", async () => {
|
||||
const sc = await createScenario();
|
||||
await createStep(sc.id, { order: 0 });
|
||||
await request(app.getHttpServer())
|
||||
.post(`/scenarios/${sc.id}/run`)
|
||||
.expect(201);
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}/runs`)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.total).toBeGreaterThanOrEqual(1);
|
||||
expect(Array.isArray(res.body.data[0].stepRuns)).toBe(true);
|
||||
});
|
||||
|
||||
it("filters by status", async () => {
|
||||
const sc = await createScenario();
|
||||
await createStep(sc.id, { order: 0 });
|
||||
await request(app.getHttpServer())
|
||||
.post(`/scenarios/${sc.id}/run`)
|
||||
.expect(201);
|
||||
|
||||
const pendingRes = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}/runs?status=pending`)
|
||||
.expect(200);
|
||||
|
||||
expect(pendingRes.body.total).toBeGreaterThanOrEqual(1);
|
||||
pendingRes.body.data.forEach((r: { status: string }) =>
|
||||
expect(r.status).toBe("pending"),
|
||||
);
|
||||
|
||||
const passRes = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}/runs?status=pass`)
|
||||
.expect(200);
|
||||
|
||||
expect(passRes.body.total).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 400 for invalid status filter", async () => {
|
||||
const sc = await createScenario();
|
||||
await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}/runs?status=invalid`)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("returns 404 for unknown scenario", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get("/scenarios/99999/runs")
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /scenarios/:id/export ─────────────────────────────────────────────
|
||||
|
||||
describe("GET /scenarios/:id/export", () => {
|
||||
it("returns name and steps array", async () => {
|
||||
const sc = await createScenario("export-me");
|
||||
await createStep(sc.id, {
|
||||
order: 0,
|
||||
type: "login",
|
||||
sessionName: "s",
|
||||
execCode: '{"keyId":"k","environmentName":"e"}',
|
||||
});
|
||||
await createStep(sc.id, {
|
||||
order: 1,
|
||||
type: "exec",
|
||||
sessionName: "s",
|
||||
execCode: "return 1;",
|
||||
validateCode: "return true;",
|
||||
});
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}/export`)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.name).toBe("export-me");
|
||||
expect(Array.isArray(res.body.steps)).toBe(true);
|
||||
expect(res.body.steps).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("exports steps ordered by order field", async () => {
|
||||
const sc = await createScenario("export-order");
|
||||
await createStep(sc.id, { order: 2, sessionName: "s" });
|
||||
await createStep(sc.id, { order: 0, sessionName: "s" });
|
||||
await createStep(sc.id, { order: 1, sessionName: "s" });
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}/export`)
|
||||
.expect(200);
|
||||
|
||||
const orders = res.body.steps.map((s: { order: number }) => s.order);
|
||||
expect(orders).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it("omits internal fields (id, scenarioId, timestamps)", async () => {
|
||||
const sc = await createScenario("export-shape");
|
||||
await createStep(sc.id, { order: 0, sessionName: "s" });
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}/export`)
|
||||
.expect(200);
|
||||
|
||||
const step = res.body.steps[0];
|
||||
expect(step).not.toHaveProperty("id");
|
||||
expect(step).not.toHaveProperty("scenarioId");
|
||||
expect(step).not.toHaveProperty("createdAt");
|
||||
expect(step).not.toHaveProperty("updatedAt");
|
||||
});
|
||||
|
||||
it("exports null validateCode as null", async () => {
|
||||
const sc = await createScenario("export-null-validate");
|
||||
await createStep(sc.id, {
|
||||
order: 0,
|
||||
sessionName: "s",
|
||||
execCode: "return 1;",
|
||||
});
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}/export`)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.steps[0].validateCode).toBeNull();
|
||||
});
|
||||
|
||||
it("returns 404 for unknown scenario", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get("/scenarios/99999/export")
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /scenarios/import ────────────────────────────────────────────────
|
||||
|
||||
describe("POST /scenarios/import", () => {
|
||||
it("creates a new scenario with all steps", async () => {
|
||||
const payload = {
|
||||
name: "imported scenario",
|
||||
steps: [
|
||||
{
|
||||
order: 0,
|
||||
type: "login",
|
||||
sessionName: "s",
|
||||
execCode: '{"keyId":"k","environmentName":"e"}',
|
||||
validateCode: null,
|
||||
},
|
||||
{
|
||||
order: 1,
|
||||
type: "exec",
|
||||
sessionName: "s",
|
||||
execCode: "return 1;",
|
||||
validateCode: "return true;",
|
||||
},
|
||||
{
|
||||
order: 2,
|
||||
type: "sign",
|
||||
sessionName: "s",
|
||||
execCode: '{"keyId":"k"}',
|
||||
validateCode: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.post("/scenarios/import")
|
||||
.send(payload)
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.id).toBeDefined();
|
||||
expect(res.body.name).toBe("imported scenario");
|
||||
expect(res.body.steps).toHaveLength(3);
|
||||
expect(res.body.steps[0].type).toBe("login");
|
||||
expect(res.body.steps[1].type).toBe("exec");
|
||||
expect(res.body.steps[2].type).toBe("sign");
|
||||
});
|
||||
|
||||
it("assigns a new id (does not collide with source)", async () => {
|
||||
const sc = await createScenario("original");
|
||||
const exportRes = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}/export`)
|
||||
.expect(200);
|
||||
|
||||
const importRes = await request(app.getHttpServer())
|
||||
.post("/scenarios/import")
|
||||
.send(exportRes.body)
|
||||
.expect(201);
|
||||
|
||||
expect(importRes.body.id).not.toBe(sc.id);
|
||||
});
|
||||
|
||||
it("round-trips a scenario faithfully", async () => {
|
||||
const sc = await createScenario("roundtrip");
|
||||
await createStep(sc.id, {
|
||||
order: 0,
|
||||
type: "exec",
|
||||
sessionName: "rs",
|
||||
execCode: "return 42;",
|
||||
validateCode: "return true;",
|
||||
});
|
||||
|
||||
const exportRes = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}/export`)
|
||||
.expect(200);
|
||||
|
||||
const importRes = await request(app.getHttpServer())
|
||||
.post("/scenarios/import")
|
||||
.send(exportRes.body)
|
||||
.expect(201);
|
||||
|
||||
expect(importRes.body.name).toBe("roundtrip");
|
||||
expect(importRes.body.steps).toHaveLength(1);
|
||||
expect(importRes.body.steps[0].execCode).toBe(
|
||||
exportRes.body.steps[0].execCode,
|
||||
);
|
||||
expect(importRes.body.steps[0].validateCode).toBe(
|
||||
exportRes.body.steps[0].validateCode,
|
||||
);
|
||||
});
|
||||
|
||||
it("imports with empty steps array", async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post("/scenarios/import")
|
||||
.send({ name: "empty-import", steps: [] })
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.name).toBe("empty-import");
|
||||
expect(res.body.steps).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns 400 when name is missing", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/scenarios/import")
|
||||
.send({ steps: [] })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("returns 400 when steps is not an array", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/scenarios/import")
|
||||
.send({ name: "bad", steps: "oops" })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("returns 400 when a step has an invalid type", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/scenarios/import")
|
||||
.send({
|
||||
name: "bad-type",
|
||||
steps: [{ order: 0, type: "unknown", sessionName: "s" }],
|
||||
})
|
||||
.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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { getRepositoryToken } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import request from "supertest";
|
||||
import { buildTestApp } from "./app.harness";
|
||||
import { SessionEntity } from "../src/session/session.entity";
|
||||
|
||||
describe("SessionController", () => {
|
||||
let app: INestApplication;
|
||||
let repo: Repository<SessionEntity>;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildTestApp();
|
||||
repo = app.get<Repository<SessionEntity>>(
|
||||
getRepositoryToken(SessionEntity),
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
async function seedSession(name: string) {
|
||||
return repo.save(
|
||||
repo.create({
|
||||
sessionName: name,
|
||||
token: "tok",
|
||||
cookies: "[]",
|
||||
localStorage: "{}",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── GET /sessions ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("GET /sessions", () => {
|
||||
it("returns 200 with a paginated result", async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get("/sessions")
|
||||
.expect(200);
|
||||
expect(Array.isArray(res.body.data)).toBe(true);
|
||||
expect(typeof res.body.total).toBe("number");
|
||||
expect(res.body).toHaveProperty("page");
|
||||
expect(res.body).toHaveProperty("limit");
|
||||
});
|
||||
|
||||
it("includes seeded sessions", async () => {
|
||||
await seedSession("visible-session");
|
||||
const res = await request(app.getHttpServer())
|
||||
.get("/sessions")
|
||||
.expect(200);
|
||||
const names = res.body.data.map(
|
||||
(s: { sessionName: string }) => s.sessionName,
|
||||
);
|
||||
expect(names).toContain("visible-session");
|
||||
});
|
||||
|
||||
it("does not expose token, cookies or localStorage fields", async () => {
|
||||
await seedSession("private-session");
|
||||
const res = await request(app.getHttpServer())
|
||||
.get("/sessions")
|
||||
.expect(200);
|
||||
const item = res.body.data.find(
|
||||
(s: { sessionName: string }) => s.sessionName === "private-session",
|
||||
) as Record<string, unknown>;
|
||||
expect(item).toBeDefined();
|
||||
expect(item.token).toBeUndefined();
|
||||
expect(item.cookies).toBeUndefined();
|
||||
expect(item.localStorage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("respects page and limit params", async () => {
|
||||
await seedSession("paged-session-a");
|
||||
await seedSession("paged-session-b");
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get("/sessions?page=1&limit=1")
|
||||
.expect(200);
|
||||
expect(res.body.data).toHaveLength(1);
|
||||
expect(res.body.page).toBe(1);
|
||||
expect(res.body.limit).toBe(1);
|
||||
});
|
||||
|
||||
it("returns empty data array for out-of-range page", async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get("/sessions?page=9999&limit=20")
|
||||
.expect(200);
|
||||
expect(res.body.data).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns 400 for invalid page param", async () => {
|
||||
await request(app.getHttpServer()).get("/sessions?page=0").expect(400);
|
||||
});
|
||||
|
||||
it("orders by sessionName ASC", async () => {
|
||||
await seedSession("zzz-sort-session");
|
||||
await seedSession("aaa-sort-session");
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get("/sessions?orderBy=sessionName&orderDir=ASC")
|
||||
.expect(200);
|
||||
const names: string[] = res.body.data.map(
|
||||
(s: { sessionName: string }) => s.sessionName,
|
||||
);
|
||||
expect(names).toEqual([...names].sort());
|
||||
});
|
||||
|
||||
it("orders by sessionName DESC", async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get("/sessions?orderBy=sessionName&orderDir=DESC")
|
||||
.expect(200);
|
||||
const names: string[] = res.body.data.map(
|
||||
(s: { sessionName: string }) => s.sessionName,
|
||||
);
|
||||
expect(names).toEqual([...names].sort().reverse());
|
||||
});
|
||||
|
||||
it("returns 400 for invalid orderDir", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get("/sessions?orderDir=SIDEWAYS")
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ── DELETE /sessions/:id ───────────────────────────────────────────────────
|
||||
|
||||
describe("DELETE /sessions/:id", () => {
|
||||
it("deletes an existing session and returns 200", async () => {
|
||||
const s = await seedSession("delete-me-session");
|
||||
await request(app.getHttpServer())
|
||||
.delete(`/sessions/${s.id}`)
|
||||
.expect(200);
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get("/sessions")
|
||||
.expect(200);
|
||||
const names = res.body.data.map(
|
||||
(sess: { sessionName: string }) => sess.sessionName,
|
||||
);
|
||||
expect(names).not.toContain("delete-me-session");
|
||||
});
|
||||
|
||||
it("returns 404 for unknown id", async () => {
|
||||
await request(app.getHttpServer()).delete("/sessions/99999").expect(404);
|
||||
});
|
||||
|
||||
it("returns 400 for non-numeric id", async () => {
|
||||
await request(app.getHttpServer()).delete("/sessions/abc").expect(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "..",
|
||||
"noEmit": true,
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"exclude": []
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": false,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "test"]
|
||||
}
|
||||
Reference in New Issue
Block a user