diff --git a/docs/scenario.md b/docs/scenario.md index 3663e25..902eed1 100644 --- a/docs/scenario.md +++ b/docs/scenario.md @@ -33,7 +33,7 @@ Current scheduler behavior is exec-centric: - `helpers.getStepOutput(order)`: prior step output by absolute order (`0`, `1`, ...) or relative (`-1` previous step) - `helpers.getCredential(alias)`: credential payload assigned to the scenario alias - `helpers.env`: shallow copy of environment URL map -- `helpers.getEnvUrl(key)`: required environment URL lookup (throws if missing) +- `helpers.getEnv(key)`: required environment value lookup (throws if missing) - `helpers.runSnippet(name, ...args)`: execute stored snippet code in the same page/context/helpers scope ## Validation contract diff --git a/server/src/browser/browser.service.ts b/server/src/browser/browser.service.ts index 122af59..ac4015e 100644 --- a/server/src/browser/browser.service.ts +++ b/server/src/browser/browser.service.ts @@ -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); + + // 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) { diff --git a/server/src/browser/dto/exec.dto.ts b/server/src/browser/dto/exec.dto.ts index f9753d3..022a0b7 100644 --- a/server/src/browser/dto/exec.dto.ts +++ b/server/src/browser/dto/exec.dto.ts @@ -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() diff --git a/server/src/code-executor/code-executor.service.ts b/server/src/code-executor/code-executor.service.ts index 2e80fcb..d7f3ebd 100644 --- a/server/src/code-executor/code-executor.service.ts +++ b/server/src/code-executor/code-executor.service.ts @@ -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; + /** 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; + /** Dumps the DOM of the current page, optionally scoped to a CSS selector. */ + dumpDom: (selector?: string) => Promise; + 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; +} + +export interface ExecContext { + page: Page; + browser: BrowserContext; + code: string; + log?: ScriptLogger; + getStepOutput?: (order: number) => Promise; + credentials?: Record; + environment?: EnvironmentData | null; + snippets?: Record | 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, - credentials?: Record, - environment?: EnvironmentData | null, - snippets?: Record | null, - result?: unknown, - ): Promise { + async execute(ctx: ExecContext): Promise { + 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 = snippets ?? {}; - // pageHelpers is referenced by runSnippet, so we declare it as a var first. - - let pageHelpers: Record; + // 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, diff --git a/server/src/code-executor/exec-context.builder.ts b/server/src/code-executor/exec-context.builder.ts new file mode 100644 index 0000000..6080109 --- /dev/null +++ b/server/src/code-executor/exec-context.builder.ts @@ -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 = {}; + + 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): this { + this.ctx.getStepOutput = fn; + return this; + } + + credentials(credentials: Record): this { + this.ctx.credentials = credentials; + return this; + } + + environment(environment: EnvironmentData | null): this { + this.ctx.environment = environment; + return this; + } + + snippets(snippets: Record | 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; + } +} diff --git a/server/src/scenario/scenario-scheduler.service.ts b/server/src/scenario/scenario-scheduler.service.ts index 4978d6a..a0f254b 100644 --- a/server/src/scenario/scenario-scheduler.service.ts +++ b/server/src/scenario/scenario-scheduler.service.ts @@ -72,7 +72,7 @@ export class ScenarioSchedulerService { ): (order: number) => Promise { 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) { diff --git a/server/src/scenario/scenario.service.ts b/server/src/scenario/scenario.service.ts index 16bc503..6bc4cde 100644 --- a/server/src/scenario/scenario.service.ts +++ b/server/src/scenario/scenario.service.ts @@ -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, diff --git a/server/test/browser.controller.spec.ts b/server/test/browser.controller.spec.ts index ee73deb..ddf1f6e 100644 --- a/server/test/browser.controller.spec.ts +++ b/server/test/browser.controller.spec.ts @@ -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" }); diff --git a/server/test/scenario-step-output.spec.ts b/server/test/scenario-step-output.spec.ts index 7e36403..7f2911b 100644 --- a/server/test/scenario-step-output.spec.ts +++ b/server/test/scenario-step-output.spec.ts @@ -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); diff --git a/server/test/scenario.controller.spec.ts b/server/test/scenario.controller.spec.ts index 6ac8881..44aba4b 100644 --- a/server/test/scenario.controller.spec.ts +++ b/server/test/scenario.controller.spec.ts @@ -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 () => {