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:
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user