feat(scenario): step output, getStepOutput helper, export/import MCP tools, doc restructure

- add output column to ScenarioRunStepEntity; exec step return value is JSON-serialised and stored
- expose helpers.getStepOutput(order) in exec code; negative order is relative to current step
- add get_scenario_run, wait_for_scenario_run, export_scenario, import_scenario MCP tools
- move Architecture, Key descriptors, MCP tools, Scenario, Development docs to docs/
- delete CONTRIBUTING.md (replaced by docs/development.md)
This commit is contained in:
2026-04-08 19:02:36 +03:00
parent e2813208ba
commit 71bcc8faec
17 changed files with 667 additions and 224 deletions
+23 -13
View File
@@ -1,15 +1,25 @@
const makePage = () => ({
goto: jest.fn(),
content: jest.fn().mockResolvedValue("<html></html>"),
title: jest.fn().mockReturnValue(""),
url: jest.fn().mockReturnValue(""),
evaluate: jest.fn(),
close: jest.fn(),
});
const makeContext = () => ({
newPage: jest.fn().mockResolvedValue(makePage()),
addCookies: jest.fn().mockResolvedValue(undefined),
addInitScript: jest.fn().mockResolvedValue(undefined),
});
const makeBrowser = () => ({
newContext: jest
.fn()
.mockImplementation(() => Promise.resolve(makeContext())),
close: jest.fn(),
});
export const chromium = {
launch: jest.fn().mockResolvedValue({
newContext: jest.fn().mockResolvedValue({
newPage: jest.fn().mockResolvedValue({
goto: jest.fn(),
content: jest.fn().mockResolvedValue("<html></html>"),
title: jest.fn().mockReturnValue(""),
url: jest.fn().mockReturnValue(""),
evaluate: jest.fn(),
close: jest.fn(),
}),
}),
close: jest.fn(),
}),
launch: jest.fn().mockImplementation(() => Promise.resolve(makeBrowser())),
};
+12 -10
View File
@@ -139,16 +139,18 @@ function makePage(rootEl: FakeEl) {
const evaluate = jest
.fn()
.mockImplementation((fn: (...args: unknown[]) => unknown, args: unknown) => {
const exec = new Function(
"document",
"window",
"Node",
"__args__",
`return (${fn.toString()})(__args__)`,
);
return Promise.resolve(exec(fakeDocument, fakeWindow, fakeNode, args));
});
.mockImplementation(
(fn: (...args: unknown[]) => unknown, args: unknown) => {
const exec = new Function(
"document",
"window",
"Node",
"__args__",
`return (${fn.toString()})(__args__)`,
);
return Promise.resolve(exec(fakeDocument, fakeWindow, fakeNode, args));
},
);
return { evaluate } as unknown as import("playwright").Page;
}
+6 -2
View File
@@ -117,7 +117,9 @@ describe("EnvironmentController", () => {
const res = await request(app.getHttpServer())
.get("/environments?orderBy=name&orderDir=ASC")
.expect(200);
const names: string[] = res.body.data.map((e: { name: string }) => e.name);
const names: string[] = res.body.data.map(
(e: { name: string }) => e.name,
);
expect(names).toEqual([...names].sort());
});
@@ -125,7 +127,9 @@ describe("EnvironmentController", () => {
const res = await request(app.getHttpServer())
.get("/environments?orderBy=name&orderDir=DESC")
.expect(200);
const names: string[] = res.body.data.map((e: { name: string }) => e.name);
const names: string[] = res.body.data.map(
(e: { name: string }) => e.name,
);
expect(names).toEqual([...names].sort().reverse());
});
+204
View File
@@ -0,0 +1,204 @@
/**
* Integration tests for ScenarioRunStepEntity.output column and the
* helpers.getStepOutput() API available inside exec step scripts.
*
* Strategy:
* - Seed a session row so the scheduler can create a browser context.
* - Create a scenario + steps with controlled return values.
* - Call ScenarioSchedulerService.pickUpPendingRuns() directly to process
* the run synchronously (no real timers needed).
* - Assert step-run output persisted and getStepOutput() returns it.
*/
import { INestApplication } from "@nestjs/common";
import { DataSource } from "typeorm";
import { buildTestApp } from "./app.harness";
import { ScenarioService } from "../src/scenario/scenario.service";
import { ScenarioSchedulerService } from "../src/scenario/scenario-scheduler.service";
describe("ScenarioRunStepEntity.output + helpers.getStepOutput", () => {
let app: INestApplication;
let dataSource: DataSource;
let scenarioService: ScenarioService;
let scheduler: ScenarioSchedulerService;
beforeAll(async () => {
app = await buildTestApp();
dataSource = app.get(DataSource);
scenarioService = app.get(ScenarioService);
scheduler = app.get(ScenarioSchedulerService);
});
afterAll(async () => {
await app.close();
});
/** Seed a minimal session so the scheduler can open a browser context. */
async function seedSession(name = "output-test-session"): Promise<void> {
await dataSource.query(
`INSERT OR IGNORE INTO sessions (sessionName, token, cookies, localStorage)
VALUES ('${name}', 'tok', '[]', '{}')`,
);
}
/** Create a scenario and return its id. */
async function createScenario(name = "output-scenario"): Promise<number> {
const sc = await scenarioService.create({ name });
return sc.id;
}
/** Create a step and return its id. */
async function createStep(
scenarioId: number,
order: number,
execCode: string,
sessionName = "output-test-session",
): Promise<number> {
const step = await scenarioService.createStep(scenarioId, {
order,
type: "exec",
sessionName,
execCode,
});
return step.id;
}
/** Trigger a run and process it to completion via the scheduler. */
async function runScenario(scenarioId: number): Promise<number> {
const run = await scenarioService.createRun(scenarioId);
// Drive the scheduler directly — keeps tests synchronous and fast.
await scheduler.pickUpPendingRuns();
// Wait for the run to reach a terminal state (max 10 s).
const result = await scenarioService.waitForRun(scenarioId, run.id, 10_000);
return result.id;
}
// ── output column ──────────────────────────────────────────────────────────
describe("output column", () => {
it("stores the return value of an exec script as JSON", async () => {
await seedSession();
const scId = await createScenario("output-basic");
await createStep(scId, 0, "return 42;");
const runId = await runScenario(scId);
const row = await dataSource.query(
`SELECT output, status FROM scenario_run_steps WHERE runId = ${runId}`,
);
expect(row[0].status).toBe("pass");
expect(JSON.parse(row[0].output as string)).toBe(42);
});
it("stores object return values as JSON", async () => {
await seedSession();
const scId = await createScenario("output-object");
await createStep(scId, 0, 'return { foo: "bar", n: 7 };');
const runId = await runScenario(scId);
const row = await dataSource.query(
`SELECT output FROM scenario_run_steps WHERE runId = ${runId}`,
);
expect(JSON.parse(row[0].output as string)).toEqual({ foo: "bar", n: 7 });
});
it("stores null when the script returns undefined/nothing", async () => {
await seedSession();
const scId = await createScenario("output-undefined");
await createStep(scId, 0, "const x = 1;");
const runId = await runScenario(scId);
const row = await dataSource.query(
`SELECT output FROM scenario_run_steps WHERE runId = ${runId}`,
);
expect(row[0].output).toBeNull();
});
it("output is exposed on the stepRuns inside GET /scenarios/:id/run/:runId via findRun", async () => {
await seedSession();
const scId = await createScenario("output-findrun");
await createStep(scId, 0, "return 'hello';");
const runId = await runScenario(scId);
const run = await scenarioService.findRun(scId, runId);
expect(run.stepRuns[0].output).toBe(JSON.stringify("hello"));
});
});
// ── helpers.getStepOutput ──────────────────────────────────────────────────
describe("helpers.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);");
const runId = await runScenario(scId);
const rows = await dataSource.query(
`SELECT "order", output FROM scenario_run_steps WHERE runId = ${runId} ORDER BY "order"`,
);
expect(JSON.parse(rows[0].output as string)).toBe(99);
expect(JSON.parse(rows[1].output as string)).toBe(99);
});
it("returns output of the previous step using relative index -1", async () => {
await seedSession();
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);");
const runId = await runScenario(scId);
const rows = await dataSource.query(
`SELECT "order", output FROM scenario_run_steps WHERE runId = ${runId} ORDER BY "order"`,
);
expect(JSON.parse(rows[1].output as string)).toBe("step-zero");
});
it("returns null for a step that does not exist", async () => {
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);");
const runId = await runScenario(scId);
const row = await dataSource.query(
`SELECT output FROM scenario_run_steps WHERE runId = ${runId}`,
);
expect(row[0].output).toBeNull();
});
it("chains output across three steps", async () => {
await seedSession();
const scId = await createScenario("getStepOutput-chain");
await createStep(scId, 0, "return [1, 2];");
await createStep(
scId,
1,
"const prev = await helpers.getStepOutput(-1); return [...prev, 3];",
);
await createStep(
scId,
2,
"const prev = await helpers.getStepOutput(-1); return [...prev, 4];",
);
const runId = await runScenario(scId);
const rows = await dataSource.query(
`SELECT "order", output FROM scenario_run_steps WHERE runId = ${runId} ORDER BY "order"`,
);
expect(JSON.parse(rows[0].output as string)).toEqual([1, 2]);
expect(JSON.parse(rows[1].output as string)).toEqual([1, 2, 3]);
expect(JSON.parse(rows[2].output as string)).toEqual([1, 2, 3, 4]);
});
});
});
+9 -3
View File
@@ -106,7 +106,9 @@ describe("ScenarioController", () => {
const res = await request(app.getHttpServer())
.get("/scenarios?orderBy=name&orderDir=ASC")
.expect(200);
const names: string[] = res.body.data.map((s: { name: string }) => s.name);
const names: string[] = res.body.data.map(
(s: { name: string }) => s.name,
);
expect(names).toEqual([...names].sort());
});
@@ -114,7 +116,9 @@ describe("ScenarioController", () => {
const res = await request(app.getHttpServer())
.get("/scenarios?orderBy=name&orderDir=DESC")
.expect(200);
const names: string[] = res.body.data.map((s: { name: string }) => s.name);
const names: string[] = res.body.data.map(
(s: { name: string }) => s.name,
);
expect(names).toEqual([...names].sort().reverse());
});
@@ -336,7 +340,9 @@ describe("ScenarioController", () => {
expect(Array.isArray(res.body.stepRuns)).toBe(true);
expect(res.body.stepRuns).toHaveLength(3);
const statuses = res.body.stepRuns.map((s: { status: string }) => s.status);
const statuses = res.body.stepRuns.map(
(s: { status: string }) => s.status,
);
expect(statuses[0]).toBe("pending");
expect(statuses[1]).toBe("waiting");
expect(statuses[2]).toBe("waiting");
+12 -4
View File
@@ -49,7 +49,9 @@ describe("SessionController", () => {
const res = await request(app.getHttpServer())
.get("/sessions")
.expect(200);
const names = res.body.data.map((s: { sessionName: string }) => s.sessionName);
const names = res.body.data.map(
(s: { sessionName: string }) => s.sessionName,
);
expect(names).toContain("visible-session");
});
@@ -97,7 +99,9 @@ describe("SessionController", () => {
const res = await request(app.getHttpServer())
.get("/sessions?orderBy=sessionName&orderDir=ASC")
.expect(200);
const names: string[] = res.body.data.map((s: { sessionName: string }) => s.sessionName);
const names: string[] = res.body.data.map(
(s: { sessionName: string }) => s.sessionName,
);
expect(names).toEqual([...names].sort());
});
@@ -105,7 +109,9 @@ describe("SessionController", () => {
const res = await request(app.getHttpServer())
.get("/sessions?orderBy=sessionName&orderDir=DESC")
.expect(200);
const names: string[] = res.body.data.map((s: { sessionName: string }) => s.sessionName);
const names: string[] = res.body.data.map(
(s: { sessionName: string }) => s.sessionName,
);
expect(names).toEqual([...names].sort().reverse());
});
@@ -128,7 +134,9 @@ describe("SessionController", () => {
const res = await request(app.getHttpServer())
.get("/sessions")
.expect(200);
const names = res.body.data.map((sess: { sessionName: string }) => sess.sessionName);
const names = res.body.data.map(
(sess: { sessionName: string }) => sess.sessionName,
);
expect(names).not.toContain("delete-me-session");
});