refactor(code-executor): migrate user scripts to single context argument

- replace (page, context, helpers) signature with async (context) => {}
- add ScriptContext interface exposing page, browser, env, getEnv,
  getCredential, getStepOutput, runSnippet, dumpDom, log/warn/error
- rename getEnvUrl to getEnv throughout service and docs
- fix step ordering: normalizeStepOrder and run step rows are now 1-based
- migrate existing DB rows (scenario_steps, scenario_run_steps) +1
- update all tests and stored snippet/step code in DB to new API
This commit is contained in:
2026-04-14 17:44:59 +03:00
parent 1a2e786ca8
commit 90be5a5490
10 changed files with 166 additions and 91 deletions
+15 -13
View File
@@ -187,16 +187,15 @@ export class BrowserService {
this.logger.log(`[${label}] exec: running user code`);
const scriptLogger: ScriptLogger = (level, msg) =>
this.logger[level](`[${label}] script: ${msg}`);
const result = await this.codeExecutor.execute(
const result = await this.codeExecutor.execute({
page,
context,
browser: context,
code,
scriptLogger,
undefined,
log: scriptLogger,
credentials,
environment,
snippets,
);
});
this.logger.log(`[${label}] exec: done`);
return result;
} catch (err) {
@@ -208,13 +207,17 @@ export class BrowserService {
const snippetMap = await this.snippetService
.buildSnippetMap()
.catch(() => ({}) as Record<string, string>);
// TODO: instantiate one browser per server instance and reuse contexts for anonymous sessions,
// instead of launching a new browser for each request.
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();
const browserContext = await browser.newContext();
const page = await browserContext.newPage();
if (url) {
this.logger.log(`[${label}] exec: navigating to ${url}`);
@@ -224,16 +227,15 @@ export class BrowserService {
this.logger.log(`[${label}] exec: running user code`);
const scriptLogger: ScriptLogger = (level, msg) =>
this.logger[level](`[${label}] script: ${msg}`);
const result = await this.codeExecutor.execute(
const result = await this.codeExecutor.execute({
page,
context,
browser: browserContext,
code,
scriptLogger,
undefined,
log: scriptLogger,
credentials,
environment,
snippetMap,
);
snippets: snippetMap,
});
this.logger.log(`[${label}] exec: done`);
return result;
} catch (err) {
+1 -1
View File
@@ -31,7 +31,7 @@ export class ExecDto {
@ApiPropertyOptional({
description:
"Key/value map of environment variables available via `helpers.env` and `helpers.getEnvUrl()` in the script.",
"Key/value map of environment variables available via `helpers.env` and `helpers.getEnv()` in the script.",
example: { BASE_URL: "https://example.com" },
})
@IsOptional()
@@ -8,6 +8,7 @@ import { parse } from "acorn";
import type { Page, BrowserContext } from "playwright";
import { expect as playwrightExpect } from "@playwright/test";
import { dumpDom } from "./dom-helpers";
import type { DomNode } from "./dom-helpers";
import type { EnvironmentData } from "../environment/environment.entity";
export interface ExecResult {
@@ -19,6 +20,41 @@ export type ScriptLogger = (
message: string,
) => void;
/** The `context` object available inside user scripts as the sole argument. */
export interface ScriptContext {
/** Playwright Page for the current session. */
page: Page;
/** Playwright BrowserContext for the current session. */
browser: BrowserContext;
/** All environment values for the current run (empty object if none set). */
env: Record<string, string | undefined>;
/** Returns the environment value for `key`, or throws if not defined. */
getEnv: (key: string) => string;
/** Returns the credential value for `alias`, or throws if not found. */
getCredential: (alias: string) => unknown;
/** Returns the serialised output of a previous step by order index (negative = relative). */
getStepOutput: (order: number) => Promise<unknown>;
/** Dumps the DOM of the current page, optionally scoped to a CSS selector. */
dumpDom: (selector?: string) => Promise<DomNode>;
log: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
/** Runs a named snippet with the same context, plus any extra positional args. */
runSnippet: (alias: string, ...args: unknown[]) => Promise<unknown>;
}
export interface ExecContext {
page: Page;
browser: BrowserContext;
code: string;
log?: ScriptLogger;
getStepOutput?: (order: number) => Promise<unknown>;
credentials?: Record<string, unknown>;
environment?: EnvironmentData | null;
snippets?: Record<string, string> | null;
result?: unknown;
}
@Injectable()
export class CodeExecutorService {
private readonly logger = new TraceLogger(CodeExecutorService.name);
@@ -28,7 +64,7 @@ export class CodeExecutorService {
* 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} }`;
const wrapped = `async function __validate__(context) { ${code} }`;
try {
parse(wrapped, { ecmaVersion: 2022 });
} catch (err) {
@@ -40,20 +76,12 @@ export class CodeExecutorService {
}
/**
* Executes `code` as an async function body with `page` and `context` in
* scope. Always call `validate()` before this method.
* Executes `code` as an async function body with a single `context` argument
* that exposes page, browser, env helpers, credentials, snippets, and logging.
* Always call `validate()` before this method.
*/
async execute(
page: Page,
context: BrowserContext,
code: string,
log?: ScriptLogger,
getStepOutput?: (order: number) => Promise<unknown>,
credentials?: Record<string, unknown>,
environment?: EnvironmentData | null,
snippets?: Record<string, string> | null,
result?: unknown,
): Promise<ExecResult> {
async execute(ctx: ExecContext): Promise<ExecResult> {
const { page, browser, code, log, getStepOutput, credentials, environment, snippets, result } = ctx;
const scriptLog: ScriptLogger =
log ?? ((level, msg) => this.logger[level](msg));
const toStr = (args: unknown[]) =>
@@ -65,9 +93,8 @@ export class CodeExecutorService {
const envData: EnvironmentData = environment ?? {};
const snippetMap: Record<string, string> = snippets ?? {};
// pageHelpers is referenced by runSnippet, so we declare it as a var first.
let pageHelpers: Record<string, unknown>;
// scriptContext is referenced by runSnippet, so we declare it first.
let scriptContext: ScriptContext;
const fakeConsole = {
log: (...args: unknown[]) => scriptLog("log", toStr(args)),
@@ -78,7 +105,9 @@ export class CodeExecutorService {
};
try {
pageHelpers = {
scriptContext = {
page,
browser,
dumpDom: (selector?: string) => dumpDom(page, selector),
log: (...args: unknown[]) => scriptLog("log", toStr(args)),
warn: (...args: unknown[]) => scriptLog("warn", toStr(args)),
@@ -92,10 +121,8 @@ export class CodeExecutorService {
}
return credMap[alias];
},
/** All values defined for the current environment (may be empty if no environment is set). */
env: { ...envData },
/** Returns the value for the given key, or throws if it is not defined. */
getEnvUrl: (key: string): string => {
getEnv: (key: string): string => {
const value = envData[key];
if (value == null) {
throw new Error(
@@ -104,12 +131,6 @@ export class CodeExecutorService {
}
return value;
},
/**
* Runs a snippet by alias. Snippets receive the same page/context/helpers
* as regular exec code, plus any positional args you pass.
*
* Example: await helpers.runSnippet('clickLoginButton', '#submit')
*/
runSnippet: async (
alias: string,
...args: unknown[]
@@ -119,18 +140,14 @@ export class CodeExecutorService {
throw new Error(`Snippet alias "${alias}" not found`);
}
const snippetFn = new Function(
"page",
"context",
"helpers",
"console",
"snippetArgs",
"expect",
`return (async (page, context, helpers, ...args) => { ${snippetCode} })(page, context, helpers, ...snippetArgs)`,
`return (async (context, ...args) => { ${snippetCode} })(context, ...snippetArgs)`,
);
return snippetFn(
page,
context,
pageHelpers,
scriptContext,
fakeConsole,
args,
playwrightExpect,
@@ -138,21 +155,18 @@ export class CodeExecutorService {
},
};
// Passing `console` and `expect` as named parameters exposes them in script scope.
// `console`, `result`, and `expect` are injected as named parameters so
// existing scripts that reference them directly still work.
const fn = new Function(
"page",
"context",
"helpers",
"console",
"result",
"expect",
`return (async (page, context, helpers) => { ${code} })(page, context, helpers)`,
`return (async (context) => { ${code} })(context)`,
);
this.logger.debug("Executing user code");
const execResult = await fn(
page,
context,
pageHelpers,
scriptContext,
fakeConsole,
result,
playwrightExpect,
@@ -0,0 +1,59 @@
import type { Page, BrowserContext } from "playwright";
import type { ExecContext, ScriptLogger } from "./code-executor.service";
import type { EnvironmentData } from "../environment/environment.entity";
export class ExecContextBuilder {
private readonly ctx: Partial<ExecContext> = {};
page(page: Page): this {
this.ctx.page = page;
return this;
}
browser(browser: BrowserContext): this {
this.ctx.browser = browser;
return this;
}
code(code: string): this {
this.ctx.code = code;
return this;
}
log(log: ScriptLogger): this {
this.ctx.log = log;
return this;
}
getStepOutput(fn: (order: number) => Promise<unknown>): this {
this.ctx.getStepOutput = fn;
return this;
}
credentials(credentials: Record<string, unknown>): this {
this.ctx.credentials = credentials;
return this;
}
environment(environment: EnvironmentData | null): this {
this.ctx.environment = environment;
return this;
}
snippets(snippets: Record<string, string> | null): this {
this.ctx.snippets = snippets;
return this;
}
result(result: unknown): this {
this.ctx.result = result;
return this;
}
build(): ExecContext {
if (!this.ctx.page) throw new Error("ExecContextBuilder: page is required");
if (!this.ctx.browser) throw new Error("ExecContextBuilder: browser is required");
if (this.ctx.code === undefined) throw new Error("ExecContextBuilder: code is required");
return this.ctx as ExecContext;
}
}
@@ -72,7 +72,7 @@ export class ScenarioSchedulerService {
): (order: number) => Promise<unknown> {
return async (order: number) => {
const targetOrder = order < 0 ? stepRun.order + order : order;
if (targetOrder < 0) return null;
if (targetOrder < 1) return null;
const sr = await this.runStepRepo.findOne({
where: { runId: stepRun.runId, order: targetOrder },
});
@@ -172,16 +172,16 @@ export class ScenarioSchedulerService {
const creds = this.runCredentials.get(stepRun.runId);
const snips = this.runSnippets.get(stepRun.runId);
const env = this.runEnvironments.get(stepRun.runId);
const { result: execOutput } = await this.codeExecutor.execute(
const { result: execOutput } = await this.codeExecutor.execute({
page,
context,
step.execCode,
this.stepLogger(stepRun.id, stepRun.runId),
browser: context,
code: step.execCode,
log: this.stepLogger(stepRun.id, stepRun.runId),
getStepOutput,
creds,
env,
snips,
);
credentials: creds,
environment: env,
snippets: snips,
});
await this.passStepRun(stepRun, null, execOutput);
} catch (err) {
+3 -3
View File
@@ -104,8 +104,8 @@ export class ScenarioService {
});
for (let i = 0; i < ordered.length; i += 1) {
const step = ordered[i];
if (step.order !== i) {
step.order = i;
if (step.order !== i + 1) {
step.order = i + 1;
await this.stepRepo.save(step);
}
}
@@ -120,7 +120,7 @@ export class ScenarioService {
const created = await this.stepRepo.save(
this.stepRepo.create({
...dto,
order: currentCount,
order: currentCount + 1,
scenarioId,
title: dto.title ?? null,
execCode: dto.execCode ?? null,