refactor(session): auto-create named browser sessions
- create and register named browser sessions on open/exec when missing - align scenario step DTO/entity/service by removing sessionName usage - update MCP scenario-step schemas to use optional title fields
This commit is contained in:
@@ -9,8 +9,10 @@ import type { BrowserContext } from "playwright";
|
|||||||
import { Readability } from "@mozilla/readability";
|
import { Readability } from "@mozilla/readability";
|
||||||
import { JSDOM } from "jsdom";
|
import { JSDOM } from "jsdom";
|
||||||
import { SessionContextService } from "../session/session-context.service";
|
import { SessionContextService } from "../session/session-context.service";
|
||||||
|
import { SessionService } from "../session/session.service";
|
||||||
import { CodeExecutorService } from "../code-executor/code-executor.service";
|
import { CodeExecutorService } from "../code-executor/code-executor.service";
|
||||||
import type { ExecResult } from "../code-executor/code-executor.service";
|
import type { ExecResult } from "../code-executor/code-executor.service";
|
||||||
|
import type { Cookie } from "playwright";
|
||||||
|
|
||||||
export type { ExecResult } from "../code-executor/code-executor.service";
|
export type { ExecResult } from "../code-executor/code-executor.service";
|
||||||
|
|
||||||
@@ -26,9 +28,54 @@ export class BrowserService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly sessionContextService: SessionContextService,
|
private readonly sessionContextService: SessionContextService,
|
||||||
|
private readonly sessionService: SessionService,
|
||||||
private readonly codeExecutor: CodeExecutorService,
|
private readonly codeExecutor: CodeExecutorService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private parseCookies(raw: string | null | undefined): Cookie[] {
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
return Array.isArray(parsed) ? (parsed as Cookie[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getOrCreateNamedHandle(sessionName: string) {
|
||||||
|
try {
|
||||||
|
return await this.sessionContextService.getHandle(sessionName);
|
||||||
|
} catch {
|
||||||
|
this.logger.log(
|
||||||
|
`[${sessionName}] creating session context automatically`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
headless: true,
|
||||||
|
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
|
||||||
|
});
|
||||||
|
const context = await browser.newContext();
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
const existing = await this.sessionService.findBySessionName(sessionName);
|
||||||
|
const cookies = this.parseCookies(existing?.cookies);
|
||||||
|
if (cookies.length > 0) {
|
||||||
|
await context.addCookies(cookies);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sessionContextService.register(sessionName, browser, context, page);
|
||||||
|
|
||||||
|
await this.sessionService.upsert(
|
||||||
|
sessionName,
|
||||||
|
existing?.token ?? `auto:${sessionName}`,
|
||||||
|
await context.cookies(),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
return { browser, context, page };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private rethrow(err: unknown, label: string, operation: string): never {
|
private rethrow(err: unknown, label: string, operation: string): never {
|
||||||
if (err instanceof HttpException) {
|
if (err instanceof HttpException) {
|
||||||
throw err;
|
throw err;
|
||||||
@@ -90,8 +137,7 @@ export class BrowserService {
|
|||||||
this.logger.log(`[${label}] open: ${url}`);
|
this.logger.log(`[${label}] open: ${url}`);
|
||||||
|
|
||||||
if (sessionName) {
|
if (sessionName) {
|
||||||
const { context } =
|
const { context } = await this.getOrCreateNamedHandle(sessionName);
|
||||||
await this.sessionContextService.getHandle(sessionName);
|
|
||||||
try {
|
try {
|
||||||
return await this.extractContent(context, url, readerMode, selector);
|
return await this.extractContent(context, url, readerMode, selector);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -123,7 +169,7 @@ export class BrowserService {
|
|||||||
|
|
||||||
if (sessionName) {
|
if (sessionName) {
|
||||||
const { page, context } =
|
const { page, context } =
|
||||||
await this.sessionContextService.getHandle(sessionName);
|
await this.getOrCreateNamedHandle(sessionName);
|
||||||
this.logger.log(`[${label}] exec: using persistent context`);
|
this.logger.log(`[${label}] exec: using persistent context`);
|
||||||
try {
|
try {
|
||||||
if (url) {
|
if (url) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { IsOptional, IsString, IsUrl } from "class-validator";
|
|||||||
export class ExecDto {
|
export class ExecDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description:
|
description:
|
||||||
"Session name previously created by POST /login. If omitted, executes without a stored session.",
|
"Optional named browser session. If it does not exist yet, it is created automatically.",
|
||||||
example: "test-session-1",
|
example: "test-session-1",
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { IsBoolean, IsOptional, IsString, IsUrl } from "class-validator";
|
|||||||
export class OpenDto {
|
export class OpenDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description:
|
description:
|
||||||
"Session name previously created by POST /login. If omitted, opens the URL without a stored session.",
|
"Optional named browser session. If it does not exist yet, it is created automatically.",
|
||||||
example: "test-session-1",
|
example: "test-session-1",
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -502,7 +502,7 @@ export class McpService {
|
|||||||
.min(0)
|
.min(0)
|
||||||
.describe("Execution order (ascending)"),
|
.describe("Execution order (ascending)"),
|
||||||
type: z.enum(["login", "exec", "sign"]).describe("Step type"),
|
type: z.enum(["login", "exec", "sign"]).describe("Step type"),
|
||||||
sessionName: z.string().describe("Session name used by this step"),
|
title: z.string().optional().describe("Optional step title"),
|
||||||
execCode: z
|
execCode: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -569,7 +569,7 @@ export class McpService {
|
|||||||
.enum(["login", "exec", "sign"])
|
.enum(["login", "exec", "sign"])
|
||||||
.optional()
|
.optional()
|
||||||
.describe("New step type"),
|
.describe("New step type"),
|
||||||
sessionName: z.string().optional().describe("New session name"),
|
title: z.string().optional().describe("New step title"),
|
||||||
execCode: z.string().optional().describe("New exec code"),
|
execCode: z.string().optional().describe("New exec code"),
|
||||||
validateCode: z.string().optional().describe("New validation code"),
|
validateCode: z.string().optional().describe("New validation code"),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export class AddScenarioCredentialDto {
|
|||||||
@ApiProperty({ example: "uuid-here" })
|
@ApiProperty({ example: "uuid-here" })
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
credentialId: string;
|
credentialId: string;
|
||||||
|
|
||||||
@ApiProperty({ example: "api_key" })
|
@ApiProperty({ example: "api_key" })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
|||||||
@@ -7,16 +7,6 @@ export class CreateScenarioStepDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description:
|
|
||||||
"Session name (deprecated — browser is created automatically per run).",
|
|
||||||
example: "my-session",
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
sessionName?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: "return await page.title();" })
|
@ApiPropertyOptional({ example: "return await page.title();" })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -13,12 +13,6 @@ export class UpdateScenarioStepDto {
|
|||||||
@Min(0)
|
@Min(0)
|
||||||
order?: number;
|
order?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
sessionName?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -29,9 +29,6 @@ export class ScenarioStepEntity {
|
|||||||
@Column({ type: "text", nullable: true })
|
@Column({ type: "text", nullable: true })
|
||||||
title: string | null;
|
title: string | null;
|
||||||
|
|
||||||
@Column({ type: "text", nullable: true })
|
|
||||||
sessionName: string | null;
|
|
||||||
|
|
||||||
@Column({ type: "text", nullable: true })
|
@Column({ type: "text", nullable: true })
|
||||||
execCode: string | null;
|
execCode: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -120,7 +120,6 @@ export class ScenarioService {
|
|||||||
order: currentCount,
|
order: currentCount,
|
||||||
scenarioId,
|
scenarioId,
|
||||||
title: dto.title ?? null,
|
title: dto.title ?? null,
|
||||||
sessionName: dto.sessionName ?? null,
|
|
||||||
execCode: dto.execCode ?? null,
|
execCode: dto.execCode ?? null,
|
||||||
validateCode: dto.validateCode ?? null,
|
validateCode: dto.validateCode ?? null,
|
||||||
}),
|
}),
|
||||||
@@ -153,7 +152,6 @@ export class ScenarioService {
|
|||||||
...dto,
|
...dto,
|
||||||
order: step.order,
|
order: step.order,
|
||||||
title: dto.title ?? step.title,
|
title: dto.title ?? step.title,
|
||||||
sessionName: dto.sessionName ?? step.sessionName,
|
|
||||||
execCode: dto.execCode ?? step.execCode,
|
execCode: dto.execCode ?? step.execCode,
|
||||||
validateCode: dto.validateCode ?? step.validateCode,
|
validateCode: dto.validateCode ?? step.validateCode,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user