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:
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user