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'
This commit is contained in:
@@ -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 `<keyId>.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)
|
||||||
|
```
|
||||||
+52
-13
@@ -7,6 +7,7 @@ import {
|
|||||||
import { TraceLogger } from '../common/trace-logger';
|
import { TraceLogger } from '../common/trace-logger';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { chromium } from 'playwright';
|
import { chromium } from 'playwright';
|
||||||
|
import type { Page } from 'playwright';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
@@ -41,6 +42,18 @@ export class AuthService {
|
|||||||
.map(f => path.basename(f, '.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 }> {
|
async login(keyId: string, environmentName: string, sessionName?: string): Promise<{ token: string; sessionName: string }> {
|
||||||
const resolvedSession = sessionName ?? crypto.randomUUID();
|
const resolvedSession = sessionName ?? crypto.randomUUID();
|
||||||
|
|
||||||
@@ -52,19 +65,7 @@ export class AuthService {
|
|||||||
if (!loginUrl) throw new BadRequestException(`Environment "${environmentName}" is missing id_url`);
|
if (!loginUrl) throw new BadRequestException(`Environment "${environmentName}" is missing id_url`);
|
||||||
if (!cabinetUrl) throw new BadRequestException(`Environment "${environmentName}" is missing cabinet_url`);
|
if (!cabinetUrl) throw new BadRequestException(`Environment "${environmentName}" is missing cabinet_url`);
|
||||||
|
|
||||||
const keyJsonPath = path.join(this.keysDir, `${keyId}.json`);
|
const descriptor = this.loadKeyDescriptor(keyId);
|
||||||
|
|
||||||
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 useLoginPassword = !!descriptor.login;
|
const useLoginPassword = !!descriptor.login;
|
||||||
|
|
||||||
if (!useLoginPassword) {
|
if (!useLoginPassword) {
|
||||||
@@ -158,4 +159,42 @@ export class AuthService {
|
|||||||
await browser.close();
|
await browser.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -333,7 +333,7 @@ export class McpService {
|
|||||||
inputSchema: {
|
inputSchema: {
|
||||||
scenarioId: z.number().int().describe('Parent scenario ID'),
|
scenarioId: z.number().int().describe('Parent scenario ID'),
|
||||||
order: z.number().int().min(0).describe('Execution order (ascending)'),
|
order: z.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'),
|
sessionName: z.string().describe('Session name used by this step'),
|
||||||
execCode: z.string().optional().describe('Playwright JS code to execute (exec steps)'),
|
execCode: z.string().optional().describe('Playwright JS code to execute (exec steps)'),
|
||||||
validateCode: z.string().optional().describe('Validation JS code returning { success, description }'),
|
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'),
|
scenarioId: z.number().int().describe('Scenario ID'),
|
||||||
stepId: z.number().int().describe('Step ID'),
|
stepId: z.number().int().describe('Step ID'),
|
||||||
order: z.number().int().min(0).optional().describe('New execution order'),
|
order: z.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'),
|
sessionName: z.string().optional().describe('New session name'),
|
||||||
execCode: z.string().optional().describe('New exec code'),
|
execCode: z.string().optional().describe('New exec code'),
|
||||||
validateCode: z.string().optional().describe('New validation code'),
|
validateCode: z.string().optional().describe('New validation code'),
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ export class CreateScenarioStepDto {
|
|||||||
@Min(0)
|
@Min(0)
|
||||||
order: number;
|
order: number;
|
||||||
|
|
||||||
@ApiProperty({ enum: ['login', 'exec'], example: 'exec' })
|
@ApiProperty({ enum: ['login', 'exec', 'sign'], example: 'exec' })
|
||||||
@IsIn(['login', 'exec'])
|
@IsIn(['login', 'exec', 'sign'])
|
||||||
type: StepType;
|
type: StepType;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Session name used by this step. login steps create it; exec steps consume it.', example: 'my-session' })
|
@ApiProperty({ description: 'Session name used by this step. login steps create it; exec steps consume it.', example: 'my-session' })
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ export class UpdateScenarioStepDto {
|
|||||||
@Min(0)
|
@Min(0)
|
||||||
order?: number;
|
order?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: ['login', 'exec'] })
|
@ApiPropertyOptional({ enum: ['login', 'exec', 'sign'] })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(['login', 'exec'])
|
@IsIn(['login', 'exec', 'sign'])
|
||||||
type?: StepType;
|
type?: StepType;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Interval } from '@nestjs/schedule';
|
|||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { chromium } from 'playwright';
|
import { chromium } from 'playwright';
|
||||||
|
import type { Browser, BrowserContext, Page } from 'playwright';
|
||||||
import { ScenarioRunEntity } from './scenario-run.entity';
|
import { ScenarioRunEntity } from './scenario-run.entity';
|
||||||
import { ScenarioRunStepEntity } from './scenario-run-step.entity';
|
import { ScenarioRunStepEntity } from './scenario-run-step.entity';
|
||||||
import { ScenarioStepEntity } from './scenario-step.entity';
|
import { ScenarioStepEntity } from './scenario-step.entity';
|
||||||
@@ -16,10 +17,17 @@ interface ValidateResult {
|
|||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BrowserHandle {
|
||||||
|
browser: Browser;
|
||||||
|
context: BrowserContext;
|
||||||
|
page: Page;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ScenarioSchedulerService {
|
export class ScenarioSchedulerService {
|
||||||
private readonly logger = new TraceLogger(ScenarioSchedulerService.name);
|
private readonly logger = new TraceLogger(ScenarioSchedulerService.name);
|
||||||
private isProcessingStep = false;
|
private isProcessingStep = false;
|
||||||
|
private readonly runBrowsers = new Map<number, BrowserHandle>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(ScenarioRunEntity)
|
@InjectRepository(ScenarioRunEntity)
|
||||||
@@ -76,6 +84,8 @@ export class ScenarioSchedulerService {
|
|||||||
try {
|
try {
|
||||||
if (step.type === 'login') {
|
if (step.type === 'login') {
|
||||||
await this.executeLoginStep(stepRun, step);
|
await this.executeLoginStep(stepRun, step);
|
||||||
|
} else if (step.type === 'sign') {
|
||||||
|
await this.executeSignStep(stepRun, step);
|
||||||
} else {
|
} else {
|
||||||
await this.executeExecStep(stepRun, step);
|
await this.executeExecStep(stepRun, step);
|
||||||
}
|
}
|
||||||
@@ -86,6 +96,47 @@ export class ScenarioSchedulerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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,
|
||||||
|
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 ─────────────────────────────────────────────────────────────
|
// ── Login step ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async executeLoginStep(stepRun: ScenarioRunStepEntity, step: ScenarioStepEntity): Promise<void> {
|
private async executeLoginStep(stepRun: ScenarioRunStepEntity, step: ScenarioStepEntity): Promise<void> {
|
||||||
@@ -103,12 +154,12 @@ export class ScenarioSchedulerService {
|
|||||||
const loginResult = await this.authService.login(params.keyId, params.environmentName, step.sessionName);
|
const loginResult = await this.authService.login(params.keyId, params.environmentName, step.sessionName);
|
||||||
this.logger.log(`StepRun #${stepRun.id}: login OK, session=${loginResult.sessionName}`);
|
this.logger.log(`StepRun #${stepRun.id}: login OK, session=${loginResult.sessionName}`);
|
||||||
|
|
||||||
// Optional validation
|
|
||||||
if (step.validateCode) {
|
if (step.validateCode) {
|
||||||
const validateResult = await this.runValidateCode(step.sessionName, step.validateCode);
|
const { page, context } = await this.getOrCreateBrowserHandle(stepRun.runId, step.sessionName);
|
||||||
if (!validateResult.success) {
|
this.codeExecutor.validate(step.validateCode);
|
||||||
throw new Error(validateResult.description ?? 'Validation failed');
|
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);
|
await this.passStepRun(stepRun, null);
|
||||||
@@ -117,57 +168,52 @@ export class ScenarioSchedulerService {
|
|||||||
// ── Exec step ──────────────────────────────────────────────────────────────
|
// ── Exec step ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async executeExecStep(stepRun: ScenarioRunStepEntity, step: ScenarioStepEntity): Promise<void> {
|
private async executeExecStep(stepRun: ScenarioRunStepEntity, step: ScenarioStepEntity): Promise<void> {
|
||||||
if (!step.execCode) {
|
if (!step.execCode) throw new Error('exec step has no execCode');
|
||||||
throw new Error('exec step has no execCode');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.codeExecutor.validate(step.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`);
|
this.logger.log(`StepRun #${stepRun.id}: exec OK`);
|
||||||
|
|
||||||
// Optional validation
|
|
||||||
if (step.validateCode) {
|
if (step.validateCode) {
|
||||||
const validateResult = await this.runValidateCode(step.sessionName, step.validateCode);
|
this.codeExecutor.validate(step.validateCode);
|
||||||
if (!validateResult.success) {
|
const { result } = await this.codeExecutor.execute(page, context, step.validateCode);
|
||||||
throw new Error(validateResult.description ?? 'Validation failed');
|
const vr = this.parseValidateResult(result);
|
||||||
}
|
if (!vr.success) throw new Error(vr.description ?? 'Validation failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.passStepRun(stepRun, null);
|
await this.passStepRun(stepRun, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Browser helpers ────────────────────────────────────────────────────────
|
// ── Sign step ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async runExecCode(sessionName: string, code: string): Promise<unknown> {
|
private async executeSignStep(stepRun: ScenarioRunStepEntity, step: ScenarioStepEntity): Promise<void> {
|
||||||
const session = await this.sessionService.findBySessionName(sessionName);
|
// execCode must be a JSON object: { "keyId": "..." }
|
||||||
if (!session) throw new Error(`Session not found: ${sessionName}`);
|
let params: { keyId: string };
|
||||||
|
|
||||||
const cookies: unknown[] = JSON.parse(session.cookies);
|
|
||||||
const localStorageData: Record<string, string> = JSON.parse(session.localStorage);
|
|
||||||
|
|
||||||
const browser = await chromium.launch({ headless: true, executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH });
|
|
||||||
try {
|
try {
|
||||||
const context = await browser.newContext();
|
params = JSON.parse(step.execCode ?? '{}');
|
||||||
await context.addCookies(cookies as Parameters<typeof context.addCookies>[0]);
|
} catch {
|
||||||
const page = await context.newPage();
|
throw new Error('sign step execCode must be valid JSON with keyId');
|
||||||
await context.addInitScript((entries: Record<string, string>) => {
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
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');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runValidateCode(sessionName: string, validateCode: string): Promise<ValidateResult> {
|
await this.passStepRun(stepRun, null);
|
||||||
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 (typeof raw === 'boolean') return { success: raw };
|
||||||
if (raw && typeof raw === 'object') {
|
if (raw && typeof raw === 'object') {
|
||||||
const r = raw as Record<string, unknown>;
|
const r = raw as Record<string, unknown>;
|
||||||
@@ -176,7 +222,6 @@ export class ScenarioSchedulerService {
|
|||||||
description: r['description'] != null ? String(r['description']) : undefined,
|
description: r['description'] != null ? String(r['description']) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Anything truthy = pass
|
|
||||||
return { success: Boolean(raw) };
|
return { success: Boolean(raw) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,6 +253,7 @@ export class ScenarioSchedulerService {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
if (remaining === 0) {
|
if (remaining === 0) {
|
||||||
|
await this.closeBrowserHandle(stepRun.runId);
|
||||||
await this.runRepo.update(stepRun.runId, { status: 'pass' });
|
await this.runRepo.update(stepRun.runId, { status: 'pass' });
|
||||||
this.logger.log(`Run #${stepRun.runId} → pass (all steps passed)`);
|
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`);
|
this.logger.log(`Run #${stepRun.runId}: remaining steps cancelled`);
|
||||||
|
|
||||||
|
await this.closeBrowserHandle(stepRun.runId);
|
||||||
await this.runRepo.update(stepRun.runId, { status: 'fail' });
|
await this.runRepo.update(stepRun.runId, { status: 'fail' });
|
||||||
this.logger.log(`Run #${stepRun.runId} → fail`);
|
this.logger.log(`Run #${stepRun.runId} → fail`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { ScenarioEntity } from './scenario.entity';
|
import { ScenarioEntity } from './scenario.entity';
|
||||||
|
|
||||||
export type StepType = 'login' | 'exec';
|
export type StepType = 'login' | 'exec' | 'sign';
|
||||||
|
|
||||||
@Entity('scenario_steps')
|
@Entity('scenario_steps')
|
||||||
export class ScenarioStepEntity {
|
export class ScenarioStepEntity {
|
||||||
|
|||||||
Reference in New Issue
Block a user