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:
2026-04-08 21:28:01 +03:00
parent afc4627353
commit 5cc16725fb
88 changed files with 12637 additions and 405 deletions
+53
View File
@@ -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 {}
+51
View File
@@ -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,
);
}
}
+13
View File
@@ -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 {}
+265
View File
@@ -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}`);
}
}
+30
View File
@@ -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;
}
+61
View File
@@ -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);
}
}
+13
View File
@@ -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 {}
+166
View File
@@ -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();
}
}
}
+30
View File
@@ -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;
}
+38
View File
@@ -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 },
);
}
}
}
+204
View File
@@ -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;
+40
View File
@@ -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;
}
+11
View File
@@ -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;
}
+31
View File
@@ -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);
}
}
+46
View File
@@ -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);
}
}
+13
View File
@@ -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" };
}
}
+7
View File
@@ -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);
});
});
}
}
+45
View File
@@ -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();
+69
View File
@@ -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);
}
}
+23
View File
@@ -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 {}
+883
View File
@@ -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";
+28
View File
@@ -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;
}
+184
View File
@@ -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);
}
}
+30
View File
@@ -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;
}
+32
View File
@@ -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 {}
+245
View File
@@ -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)`,
);
}
}
}
+37
View File
@@ -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);
}
}
+39
View File
@@ -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;
}
+15
View File
@@ -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 {}
+143
View File
@@ -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);
}
}