From e85acc8fb2103c33a023cd758ad714c67ec50943 Mon Sep 17 00:00:00 2001 From: Andrii Arsenin Date: Wed, 8 Apr 2026 15:42:42 +0300 Subject: [PATCH] feat(scenario): shared browser per run, sign step type, key loading helper - all steps in a run share one browser/page instance so wizard state persists - add 'sign' step type: execCode is {keyId} and signs via EDS widget on current page - extract AuthService.loadKeyDescriptor() to deduplicate key file parsing - extend StepType, DTOs, and MCP enums to include 'sign' --- README.md | 229 +++++++++++++++++++ src/auth/auth.service.ts | 65 ++++-- src/mcp/mcp.service.ts | 4 +- src/scenario/dto/create-scenario-step.dto.ts | 4 +- src/scenario/dto/update-scenario-step.dto.ts | 4 +- src/scenario/scenario-scheduler.service.ts | 129 +++++++---- src/scenario/scenario-step.entity.ts | 2 +- 7 files changed, 376 insertions(+), 61 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..39ab17c --- /dev/null +++ b/README.md @@ -0,0 +1,229 @@ +# liquio-qa-bot + +A headless QA automation service for Liquio environments. Exposes an **MCP (Model Context Protocol)** server that lets AI agents define, run, and monitor multi-step browser test scenarios using [Playwright](https://playwright.dev/). + +--- + +## Overview + +The bot manages four core concepts: + +| Concept | Description | +|---|---| +| **Environment** | A named set of URLs for a deployment target (e.g. `liquio-diia-stg`) | +| **Key** | A JSON descriptor in `keys/` that points to an EDS key file + password | +| **Session** | Saved browser state (cookies + localStorage) produced by a login | +| **Scenario** | An ordered list of steps that run in sequence against a shared browser instance | + +--- + +## Architecture + +``` +MCP client (AI agent / VS Code Copilot) + │ HTTP (Streamable MCP) + ▼ +┌──────────────────────────────────┐ +│ NestJS app (src/) │ +│ │ +│ McpService ← registers │ +│ AuthService all tools │ +│ BrowserService │ +│ ScenarioService │ +│ ScenarioSchedulerService │ +│ └─ @Interval(1s) tick loop │ +└──────────┬───────────────────────┘ + │ better-sqlite3 (TypeORM) + ▼ + data/sessions.db +``` + +- **McpService** — registers every MCP tool and delegates to domain services. +- **AuthService** — logs in via Playwright using a key descriptor, saves the resulting session. +- **ScenarioSchedulerService** — polls for pending step runs every second and executes them. All steps belonging to the same run share **one browser instance** (see [Scenario execution](#scenario-execution)). +- **CodeExecutorService** — compiles and runs user-supplied Playwright JS inside a sandboxed `async` function with `page`, `context`, and `helpers` in scope. + +--- + +## Environment variables + +| Variable | Default | Description | +|---|---|---| +| `PORT` | `3000` | HTTP port | +| `KEYS_DIR` | `keys` | Directory containing `*.json` key descriptors | +| `DB_PATH` | `data/sessions.db` | SQLite database file | +| `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` | _(Playwright default)_ | Path to Chromium binary. Set automatically in Docker (`/usr/bin/chromium`). | + +Copy `.env.example` to `.env` and adjust. + +--- + +## Running locally + +```bash +npm install +npx playwright install chromium # first time only +npm run start:dev +``` + +The MCP endpoint is available at `http://localhost:3000/mcp`. + +With Docker: + +```bash +docker build -t liquio-qa-bot . +docker run -p 3000:3000 -v $(pwd)/keys:/app/keys -v $(pwd)/data:/app/data liquio-qa-bot +``` + +--- + +## Key descriptors + +Each file in `KEYS_DIR` is named `.json` and takes one of two forms: + +**File key (EDS):** +```json +{ + "keyFile": "path/to/key.dat", + "password": "secret" +} +``` +`keyFile` is resolved relative to `KEYS_DIR`. + +**Login + password:** +```json +{ + "login": "user@example.com", + "password": "secret" +} +``` + +--- + +## MCP tools + +### Auth / session + +| Tool | Description | +|---|---| +| `list_keys` | List available key IDs from `KEYS_DIR` | +| `login` | Log in with a key against an environment; stores resulting session | +| `list_sessions` | Paginated list of saved sessions | +| `delete_session` | Delete a session by ID | + +### Environments + +| Tool | Description | +|---|---| +| `list_environments` | Paginated list of environments | +| `get_environment` | Get one environment by ID | +| `create_environment` | Create an environment (`name` + `urls` map) | +| `update_environment` | Update name / urls | +| `delete_environment` | Delete by ID | + +`urls` is a free-form map. Recognised keys: `id_url` (auth server), `cabinet_url` (cabinet redirect target), `admin_url`. + +### Browser (ad-hoc, stateless) + +| Tool | Description | +|---|---| +| `open_url` | Navigate to a URL and return page content / reader-mode text | +| `exec_code` | Run Playwright JS in a session's browser context | + +### Scenarios + +| Tool | Description | +|---|---| +| `list_scenarios` | Paginated list | +| `get_scenario` | Fetch scenario with all steps | +| `create_scenario` | Create a new scenario by name | +| `update_scenario` | Rename | +| `delete_scenario` | Delete (cascades to steps and runs) | +| `create_scenario_step` | Add a step | +| `get_scenario_step` | Fetch one step | +| `update_scenario_step` | Update step fields | +| `delete_scenario_step` | Remove a step | +| `list_scenario_runs` | Paginated run history (with optional status filter) | +| `run_scenario` | Trigger an immediate run | + +--- + +## Scenario execution + +When a scenario is run, the scheduler creates a `ScenarioRun` with one `ScenarioRunStep` per step. Steps are executed **in order** (by `step.order`), one per scheduler tick. + +**All steps in the same run share a single Playwright browser instance.** The browser is created on the first `exec` or `sign` step (using session cookies + localStorage), reused for every subsequent step, and closed when the run finishes (pass or fail). + +### Step types + +#### `login` + +Authenticates in a **separate, ephemeral browser** and saves the resulting session to the database. Subsequent exec/sign steps load that session into the shared browser. + +`execCode` must be a JSON object: +```json +{ "keyId": "3137411915", "environmentName": "liquio-diia-stg" } +``` + +#### `exec` + +Runs arbitrary Playwright JS on the **shared page**. The page carries over state from the previous step (URL, form inputs, cookies, etc.). + +`execCode` is executed as the body of an `async` function with these variables in scope: + +- `page` — current Playwright `Page` +- `context` — current Playwright `BrowserContext` +- `helpers.dumpDom(selector?)` — returns a simplified DOM string + +```js +// example execCode +await page.goto('https://cabinet.example.com/tasks/create/987825'); +await page.locator('input[name="institution"]').fill('caltech'); +await page.locator('button:has-text("Далі")').click(); +await page.waitForLoadState('networkidle'); +return page.url(); +``` + +#### `sign` + +Performs EDS signing on the **current shared page** using a key from `KEYS_DIR`. The step clicks the sign widget, selects the file key method, uploads the key file, enters the password, and submits — all without requiring the step author to handle key material in their code. + +`execCode` must be a JSON object: +```json +{ "keyId": "3137411915" } +``` + +### Validation code + +Any step type optionally accepts `validateCode` — Playwright JS that runs on the shared page **after** the main action and must return a result evaluated as: + +```js +return { success: true, description: 'Application created' }; +// or +return { success: false, description: 'Expected success banner not found' }; +// or simply +return someBoolean; +``` + +If `success` is false the step (and the whole run) fails with the provided `description`. + +### Run statuses + +`pending` → `in_progress` → `pass` | `fail` + +Individual step runs also expose `waiting` (not yet reached) and `cancelled` (run failed before this step). + +--- + +## Adding a new scenario (example workflow) + +``` +1. create_environment (if not already present) +2. create_scenario → get scenarioId +3. create_scenario_step type=login, order=0 +4. create_scenario_step type=exec, order=1 (navigate + fill form) +5. create_scenario_step type=sign, order=2 +6. create_scenario_step type=exec, order=3 (validateCode checks success banner) +7. run_scenario +8. list_scenario_runs (poll until status != in_progress) +``` diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 7f215d8..f1d1705 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -7,6 +7,7 @@ import { import { TraceLogger } from '../common/trace-logger'; import { ConfigService } from '@nestjs/config'; import { chromium } from 'playwright'; +import type { Page } from 'playwright'; import * as fs from 'fs'; import * as path from 'path'; import * as crypto from 'crypto'; @@ -41,6 +42,18 @@ export class AuthService { .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(); @@ -52,19 +65,7 @@ export class AuthService { if (!loginUrl) throw new BadRequestException(`Environment "${environmentName}" is missing id_url`); if (!cabinetUrl) throw new BadRequestException(`Environment "${environmentName}" is missing cabinet_url`); - const keyJsonPath = path.join(this.keysDir, `${keyId}.json`); - - if (!fs.existsSync(keyJsonPath)) { - throw new BadRequestException(`Key not found: ${keyId}`); - } - - let descriptor: KeyDescriptor; - try { - descriptor = JSON.parse(fs.readFileSync(keyJsonPath, 'utf-8')); - } catch (err) { - throw new BadRequestException(`Cannot read key descriptor: ${keyId}`, { cause: err }); - } - + const descriptor = this.loadKeyDescriptor(keyId); const useLoginPassword = !!descriptor.login; if (!useLoginPassword) { @@ -158,4 +159,42 @@ export class AuthService { await browser.close(); } } + + async signWithKey(keyId: string, page: Page): Promise { + 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 + 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}`); + } } diff --git a/src/mcp/mcp.service.ts b/src/mcp/mcp.service.ts index d4064db..98a40bb 100644 --- a/src/mcp/mcp.service.ts +++ b/src/mcp/mcp.service.ts @@ -333,7 +333,7 @@ export class McpService { inputSchema: { scenarioId: z.number().int().describe('Parent scenario ID'), order: z.number().int().min(0).describe('Execution order (ascending)'), - type: z.enum(['login', 'exec']).describe('Step type'), + 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 }'), @@ -376,7 +376,7 @@ export class McpService { 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']).optional().describe('New step type'), + 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'), diff --git a/src/scenario/dto/create-scenario-step.dto.ts b/src/scenario/dto/create-scenario-step.dto.ts index 2006756..d2da3e3 100644 --- a/src/scenario/dto/create-scenario-step.dto.ts +++ b/src/scenario/dto/create-scenario-step.dto.ts @@ -8,8 +8,8 @@ export class CreateScenarioStepDto { @Min(0) order: number; - @ApiProperty({ enum: ['login', 'exec'], example: 'exec' }) - @IsIn(['login', 'exec']) + @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' }) diff --git a/src/scenario/dto/update-scenario-step.dto.ts b/src/scenario/dto/update-scenario-step.dto.ts index 12290bf..651afd7 100644 --- a/src/scenario/dto/update-scenario-step.dto.ts +++ b/src/scenario/dto/update-scenario-step.dto.ts @@ -9,9 +9,9 @@ export class UpdateScenarioStepDto { @Min(0) order?: number; - @ApiPropertyOptional({ enum: ['login', 'exec'] }) + @ApiPropertyOptional({ enum: ['login', 'exec', 'sign'] }) @IsOptional() - @IsIn(['login', 'exec']) + @IsIn(['login', 'exec', 'sign']) type?: StepType; @ApiPropertyOptional() diff --git a/src/scenario/scenario-scheduler.service.ts b/src/scenario/scenario-scheduler.service.ts index ff282fe..7372c41 100644 --- a/src/scenario/scenario-scheduler.service.ts +++ b/src/scenario/scenario-scheduler.service.ts @@ -4,6 +4,7 @@ import { Interval } from '@nestjs/schedule'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { chromium } from 'playwright'; +import type { Browser, BrowserContext, Page } from 'playwright'; import { ScenarioRunEntity } from './scenario-run.entity'; import { ScenarioRunStepEntity } from './scenario-run-step.entity'; import { ScenarioStepEntity } from './scenario-step.entity'; @@ -16,10 +17,17 @@ interface ValidateResult { description?: string; } +interface BrowserHandle { + browser: Browser; + context: BrowserContext; + page: Page; +} + @Injectable() export class ScenarioSchedulerService { private readonly logger = new TraceLogger(ScenarioSchedulerService.name); private isProcessingStep = false; + private readonly runBrowsers = new Map(); constructor( @InjectRepository(ScenarioRunEntity) @@ -76,6 +84,8 @@ export class ScenarioSchedulerService { 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); } @@ -86,6 +96,47 @@ export class ScenarioSchedulerService { } } + // ── Shared browser per run ───────────────────────────────────────────────── + + private async getOrCreateBrowserHandle(runId: number, sessionName: string): Promise { + const existing = this.runBrowsers.get(runId); + if (existing) return existing; + + const session = await this.sessionService.findBySessionName(sessionName); + if (!session) throw new Error(`Session not found: ${sessionName}`); + + const cookies = JSON.parse(session.cookies) as Parameters[0]; + const localStorageData: Record = JSON.parse(session.localStorage); + + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, + }); + const context = await browser.newContext(); + await context.addCookies(cookies); + await context.addInitScript((entries: Record) => { + for (const [k, v] of Object.entries(entries)) window.localStorage.setItem(k, v); + }, 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 { + 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 { @@ -103,12 +154,12 @@ export class ScenarioSchedulerService { const loginResult = await this.authService.login(params.keyId, params.environmentName, step.sessionName); this.logger.log(`StepRun #${stepRun.id}: login OK, session=${loginResult.sessionName}`); - // Optional validation if (step.validateCode) { - const validateResult = await this.runValidateCode(step.sessionName, step.validateCode); - if (!validateResult.success) { - throw new Error(validateResult.description ?? 'Validation failed'); - } + 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); + const vr = this.parseValidateResult(result); + if (!vr.success) throw new Error(vr.description ?? 'Validation failed'); } await this.passStepRun(stepRun, null); @@ -117,57 +168,52 @@ export class ScenarioSchedulerService { // ── Exec step ────────────────────────────────────────────────────────────── private async executeExecStep(stepRun: ScenarioRunStepEntity, step: ScenarioStepEntity): Promise { - if (!step.execCode) { - throw new Error('exec step has no execCode'); - } + if (!step.execCode) throw new Error('exec step has no execCode'); this.codeExecutor.validate(step.execCode); - await this.runExecCode(step.sessionName, step.execCode); + const { page, context } = await this.getOrCreateBrowserHandle(stepRun.runId, step.sessionName); + await this.codeExecutor.execute(page, context, step.execCode); this.logger.log(`StepRun #${stepRun.id}: exec OK`); - // Optional validation if (step.validateCode) { - const validateResult = await this.runValidateCode(step.sessionName, step.validateCode); - if (!validateResult.success) { - throw new Error(validateResult.description ?? 'Validation failed'); - } + this.codeExecutor.validate(step.validateCode); + const { result } = await this.codeExecutor.execute(page, context, step.validateCode); + const vr = this.parseValidateResult(result); + if (!vr.success) throw new Error(vr.description ?? 'Validation failed'); } await this.passStepRun(stepRun, null); } - // ── Browser helpers ──────────────────────────────────────────────────────── + // ── Sign step ────────────────────────────────────────────────────────────── - private async runExecCode(sessionName: string, code: string): Promise { - const session = await this.sessionService.findBySessionName(sessionName); - if (!session) throw new Error(`Session not found: ${sessionName}`); - - const cookies: unknown[] = JSON.parse(session.cookies); - const localStorageData: Record = JSON.parse(session.localStorage); - - const browser = await chromium.launch({ headless: true, executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH }); + private async executeSignStep(stepRun: ScenarioRunStepEntity, step: ScenarioStepEntity): Promise { + // execCode must be a JSON object: { "keyId": "..." } + let params: { keyId: string }; try { - const context = await browser.newContext(); - await context.addCookies(cookies as Parameters[0]); - const page = await context.newPage(); - await context.addInitScript((entries: Record) => { - for (const [k, v] of Object.entries(entries)) window.localStorage.setItem(k, v); - }, localStorageData); - const { result } = await this.codeExecutor.execute(page, context, code); - return result; - } finally { - await browser.close(); + 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); + const vr = this.parseValidateResult(result); + if (!vr.success) throw new Error(vr.description ?? 'Validation failed'); + } + + await this.passStepRun(stepRun, null); } - private async runValidateCode(sessionName: string, validateCode: string): Promise { - let raw: unknown; - try { - raw = await this.runExecCode(sessionName, validateCode); - } catch (err) { - return { success: false, description: (err as Error).message }; - } + // ── Validation helper ────────────────────────────────────────────────────── + private parseValidateResult(raw: unknown): ValidateResult { if (typeof raw === 'boolean') return { success: raw }; if (raw && typeof raw === 'object') { const r = raw as Record; @@ -176,7 +222,6 @@ export class ScenarioSchedulerService { description: r['description'] != null ? String(r['description']) : undefined, }; } - // Anything truthy = pass return { success: Boolean(raw) }; } @@ -208,6 +253,7 @@ export class ScenarioSchedulerService { ], }); 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)`); } @@ -233,6 +279,7 @@ export class ScenarioSchedulerService { 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`); } diff --git a/src/scenario/scenario-step.entity.ts b/src/scenario/scenario-step.entity.ts index 5841078..d2ca907 100644 --- a/src/scenario/scenario-step.entity.ts +++ b/src/scenario/scenario-step.entity.ts @@ -9,7 +9,7 @@ import { } from 'typeorm'; import { ScenarioEntity } from './scenario.entity'; -export type StepType = 'login' | 'exec'; +export type StepType = 'login' | 'exec' | 'sign'; @Entity('scenario_steps') export class ScenarioStepEntity {