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,
+10 -10
View File
@@ -136,35 +136,35 @@ describe("BrowserController", () => {
expect(res.body).toEqual({ result: 42 });
});
it("exposes environment via helpers.env in a named session", async () => {
it("exposes environment via context.env in a named session", async () => {
const res = await request(app.getHttpServer())
.post("/exec")
.send({
sessionName: "no-such-session",
code: "return helpers.env.BASE_URL;",
code: "return context.env.BASE_URL;",
environment: { BASE_URL: "https://env.example.com" },
})
.expect(201);
expect(res.body).toEqual({ result: "https://env.example.com" });
});
it("exposes environment via helpers.env in a sessionless exec", async () => {
it("exposes environment via context.env in a sessionless exec", async () => {
const res = await request(app.getHttpServer())
.post("/exec")
.send({
code: "return helpers.env.KEY;",
code: "return context.env.KEY;",
environment: { KEY: "value123" },
})
.expect(201);
expect(res.body).toEqual({ result: "value123" });
});
it("exposes credentials via helpers.getCredential in a named session", async () => {
it("exposes credentials via context.getCredential in a named session", async () => {
const res = await request(app.getHttpServer())
.post("/exec")
.send({
sessionName: "no-such-session",
code: "return helpers.getCredential('admin');",
code: "return context.getCredential('admin');",
credentials: { admin: { username: "user1", password: "pass1" } },
})
.expect(201);
@@ -173,11 +173,11 @@ describe("BrowserController", () => {
});
});
it("exposes credentials via helpers.getCredential in a sessionless exec", async () => {
it("exposes credentials via context.getCredential in a sessionless exec", async () => {
const res = await request(app.getHttpServer())
.post("/exec")
.send({
code: "return helpers.getCredential('svc');",
code: "return context.getCredential('svc');",
credentials: { svc: { token: "abc" } },
})
.expect(201);
@@ -188,7 +188,7 @@ describe("BrowserController", () => {
const res = await request(app.getHttpServer())
.post("/exec")
.send({
code: "return helpers.getCredential('missing');",
code: "return context.getCredential('missing');",
credentials: {},
})
.expect(500);
@@ -199,7 +199,7 @@ describe("BrowserController", () => {
const res = await request(app.getHttpServer())
.post("/exec")
.send({
code: "return Object.keys(helpers.env).length === 0 ? 'empty' : 'not-empty';",
code: "return Object.keys(context.env).length === 0 ? 'empty' : 'not-empty';",
})
.expect(201);
expect(res.body).toEqual({ result: "empty" });
+10 -10
View File
@@ -1,6 +1,6 @@
/**
* Integration tests for ScenarioRunStepEntity.output column and the
* helpers.getStepOutput() API available inside exec step scripts.
* context.getStepOutput() API available inside exec step scripts.
*
* Strategy:
* - Seed a session row so the scheduler can create a browser context.
@@ -17,7 +17,7 @@ import { ScenarioService } from "../src/scenario/scenario.service";
import { ScenarioSchedulerService } from "../src/scenario/scenario-scheduler.service";
import { EnvironmentEntity } from "../src/environment/environment.entity";
describe("ScenarioRunStepEntity.output + helpers.getStepOutput", () => {
describe("ScenarioRunStepEntity.output + context.getStepOutput", () => {
let app: INestApplication;
let dataSource: DataSource;
let scenarioService: ScenarioService;
@@ -135,15 +135,15 @@ describe("ScenarioRunStepEntity.output + helpers.getStepOutput", () => {
});
});
// ── helpers.getStepOutput ──────────────────────────────────────────────────
// ── context.getStepOutput ──────────────────────────────────────────────────
describe("helpers.getStepOutput()", () => {
describe("context.getStepOutput()", () => {
it("returns output of a previous step by absolute order", async () => {
await seedSession();
const scId = await createScenario("getStepOutput-absolute");
await createStep(scId, 0, "return 99;");
// Step 1 reads step 0's output via absolute index 0
await createStep(scId, 1, "return await helpers.getStepOutput(0);");
// Step 1 reads step 1's output via absolute index 1 (1-based)
await createStep(scId, 1, "return await context.getStepOutput(1);");
const runId = await runScenario(scId);
const rows = await dataSource.query(
@@ -159,7 +159,7 @@ describe("ScenarioRunStepEntity.output + helpers.getStepOutput", () => {
const scId = await createScenario("getStepOutput-relative");
await createStep(scId, 0, 'return "step-zero";');
// Step 1 uses relative index -1 to reference step 0
await createStep(scId, 1, "return await helpers.getStepOutput(-1);");
await createStep(scId, 1, "return await context.getStepOutput(-1);");
const runId = await runScenario(scId);
const rows = await dataSource.query(
@@ -173,7 +173,7 @@ describe("ScenarioRunStepEntity.output + helpers.getStepOutput", () => {
await seedSession();
const scId = await createScenario("getStepOutput-missing");
// Step 0 tries to read step order 99 which does not exist
await createStep(scId, 0, "return await helpers.getStepOutput(99);");
await createStep(scId, 0, "return await context.getStepOutput(99);");
const runId = await runScenario(scId);
const row = await dataSource.query(
@@ -190,12 +190,12 @@ describe("ScenarioRunStepEntity.output + helpers.getStepOutput", () => {
await createStep(
scId,
1,
"const prev = await helpers.getStepOutput(-1); return [...prev, 3];",
"const prev = await context.getStepOutput(-1); return [...prev, 3];",
);
await createStep(
scId,
2,
"const prev = await helpers.getStepOutput(-1); return [...prev, 4];",
"const prev = await context.getStepOutput(-1); return [...prev, 4];",
);
const runId = await runScenario(scId);
+4 -4
View File
@@ -216,7 +216,7 @@ describe("ScenarioController", () => {
.expect(201);
expect(res.body.id).toBeDefined();
expect(res.body.order).toBe(0);
expect(res.body.order).toBe(1);
});
it("creates a step without execCode", async () => {
@@ -274,7 +274,7 @@ describe("ScenarioController", () => {
.expect(200);
const orders = res.body.steps.map((s: { order: number }) => s.order);
expect(orders).toEqual([0, 1, 2]);
expect(orders).toEqual([1, 2, 3]);
});
});
@@ -573,7 +573,7 @@ describe("ScenarioController", () => {
expect(res.body.name).toBe("imported scenario");
expect(res.body.steps).toHaveLength(3);
expect(res.body.steps.map((s: { order: number }) => s.order)).toEqual([
0, 1, 2,
1, 2, 3,
]);
});
@@ -689,7 +689,7 @@ describe("ScenarioController", () => {
.expect(200);
const orders = res.body.stepRuns.map((s: { order: number }) => s.order);
expect(orders).toEqual([0, 1, 2]);
expect(orders).toEqual([1, 2, 3]);
});
it("returns 404 for unknown run", async () => {