chore(repo): restructure as monorepo with server and client workspaces
- move NestJS app into server/ subdirectory - add client/ React+TypeScript (Vite) app with Hello World - update docker-compose to build and run both services - add root package.json declaring npm workspaces - update .gitignore to cover node_modules and dist at all depths
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
const mockElement = {
|
||||
outerHTML: '<div id="mock">mock content</div>',
|
||||
textContent: "mock content",
|
||||
};
|
||||
|
||||
export class JSDOM {
|
||||
constructor(
|
||||
public html: string,
|
||||
public options?: Record<string, unknown>,
|
||||
) {}
|
||||
get window() {
|
||||
return {
|
||||
document: {
|
||||
querySelector: () => mockElement,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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().mockImplementation(() => Promise.resolve(makeBrowser())),
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export class Readability {
|
||||
constructor(private doc: unknown) {}
|
||||
parse() {
|
||||
return { textContent: "" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { INestApplication, ValidationPipe } from "@nestjs/common";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
jest.mock("@nestjs/common", () => {
|
||||
const actual = jest.requireActual("@nestjs/common");
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const log = require("debug")("test");
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { Logger } = require("@nestjs/common/services/logger.service");
|
||||
|
||||
Logger.prototype.error = function (
|
||||
message: unknown,
|
||||
stack?: string,
|
||||
context?: string,
|
||||
) {
|
||||
const ctx = context ?? this.context ?? "App";
|
||||
log(`[${ctx}]`, "error", message, ...(stack ? [stack] : []));
|
||||
};
|
||||
|
||||
for (const level of ["log", "warn", "debug", "verbose", "fatal"] as const) {
|
||||
Logger.prototype[level] = function (message: unknown, context?: string) {
|
||||
const ctx = context ?? this.context ?? "App";
|
||||
log(`[${ctx}]`, level, message);
|
||||
};
|
||||
}
|
||||
|
||||
return actual;
|
||||
});
|
||||
|
||||
import { AuthModule } from "../src/auth/auth.module";
|
||||
import { BrowserModule } from "../src/browser/browser.module";
|
||||
import { SessionModule } from "../src/session/session.module";
|
||||
import { EnvironmentModule } from "../src/environment/environment.module";
|
||||
import { ScenarioModule } from "../src/scenario/scenario.module";
|
||||
import { McpModule } from "../src/mcp/mcp.module";
|
||||
import { SessionEntity } from "../src/session/session.entity";
|
||||
import { EnvironmentEntity } from "../src/environment/environment.entity";
|
||||
import { ScenarioEntity } from "../src/scenario/scenario.entity";
|
||||
import { ScenarioStepEntity } from "../src/scenario/scenario-step.entity";
|
||||
import { ScenarioRunEntity } from "../src/scenario/scenario-run.entity";
|
||||
import { ScenarioRunStepEntity } from "../src/scenario/scenario-run-step.entity";
|
||||
import { ScenarioRunLogEntity } from "../src/scenario/scenario-run-log.entity";
|
||||
import { HealthController } from "../src/health/health.controller";
|
||||
import { HttpExceptionFilter } from "../src/filters/http-exception.filter";
|
||||
import { LoggingInterceptor } from "../src/interceptors/logging.interceptor";
|
||||
import { validateAppConfig } from "../src/config/app.config";
|
||||
|
||||
export async function buildTestApp(): Promise<INestApplication> {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
ignoreEnvFile: true,
|
||||
validate: validateAppConfig,
|
||||
}),
|
||||
TypeOrmModule.forRoot({
|
||||
type: "better-sqlite3",
|
||||
database: ":memory:",
|
||||
entities: [
|
||||
SessionEntity,
|
||||
EnvironmentEntity,
|
||||
ScenarioEntity,
|
||||
ScenarioStepEntity,
|
||||
ScenarioRunEntity,
|
||||
ScenarioRunStepEntity,
|
||||
ScenarioRunLogEntity,
|
||||
],
|
||||
synchronize: true,
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
AuthModule,
|
||||
BrowserModule,
|
||||
SessionModule,
|
||||
EnvironmentModule,
|
||||
ScenarioModule,
|
||||
McpModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
}).compile();
|
||||
|
||||
const app = module.createNestApplication();
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalInterceptors(new LoggingInterceptor());
|
||||
await app.init();
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import request from "supertest";
|
||||
import { buildTestApp } from "./app.harness";
|
||||
|
||||
/**
|
||||
* Auth controller integration tests.
|
||||
*
|
||||
* The login endpoint requires a live browser + real key files, so it is not
|
||||
* exercised here (those belong to e2e tests with real credentials).
|
||||
* We cover the parts that can be tested without external dependencies.
|
||||
*/
|
||||
describe("AuthController", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildTestApp();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
// ── GET /keys ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("GET /keys", () => {
|
||||
it("returns 200 with a keys array", async () => {
|
||||
const res = await request(app.getHttpServer()).get("/keys").expect(200);
|
||||
expect(res.body).toHaveProperty("keys");
|
||||
expect(Array.isArray(res.body.keys)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /login ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /login", () => {
|
||||
it("returns 400 when body is empty", async () => {
|
||||
await request(app.getHttpServer()).post("/login").send({}).expect(400);
|
||||
});
|
||||
|
||||
it("returns 400 when key is missing", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/login")
|
||||
.send({ environmentName: "test-env" })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("returns 400 when environmentName is missing", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/login")
|
||||
.send({ key: "some-key" })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("returns 400 when key file does not exist", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/login")
|
||||
.send({ key: "nonexistent-key", environmentName: "test-env" })
|
||||
.expect(404); // NotFoundException for missing environment
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { getRepositoryToken } from "@nestjs/typeorm";
|
||||
import request from "supertest";
|
||||
import { buildTestApp } from "./app.harness";
|
||||
import { SessionEntity } from "../src/session/session.entity";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
/**
|
||||
* Browser controller integration tests.
|
||||
*
|
||||
* POST /open and POST /exec need a running Playwright browser. We test
|
||||
* validation rejections (no browser launched) and session-not-found paths,
|
||||
* which are safe to run in a headless CI environment.
|
||||
*/
|
||||
describe("BrowserController", () => {
|
||||
let app: INestApplication;
|
||||
let sessionRepo: Repository<SessionEntity>;
|
||||
|
||||
const FAKE_SESSION = "test-browser-session";
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildTestApp();
|
||||
sessionRepo = app.get<Repository<SessionEntity>>(
|
||||
getRepositoryToken(SessionEntity),
|
||||
);
|
||||
|
||||
// Seed a session with minimal but valid JSON so the browser code can
|
||||
// deserialise it (it will still fail to open a real page, tested separately)
|
||||
await sessionRepo.save(
|
||||
sessionRepo.create({
|
||||
sessionName: FAKE_SESSION,
|
||||
token: "fake-token",
|
||||
cookies: "[]",
|
||||
localStorage: "{}",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
// ── POST /open ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /open", () => {
|
||||
it("returns 400 when body is empty", async () => {
|
||||
await request(app.getHttpServer()).post("/open").send({}).expect(400);
|
||||
});
|
||||
|
||||
it("returns 400 when url is missing", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/open")
|
||||
.send({ sessionName: FAKE_SESSION })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("returns 404 when session does not exist", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/open")
|
||||
.send({ sessionName: "no-such-session", url: "https://example.com" })
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("succeeds without a session (sessionless open)", async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post("/open")
|
||||
.send({ url: "https://example.com" })
|
||||
.expect(201);
|
||||
expect(res.body).toMatchObject({
|
||||
url: expect.any(String),
|
||||
title: expect.any(String),
|
||||
content: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it("returns only selector content when selector is provided", async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post("/open")
|
||||
.send({ url: "https://example.com", selector: "#mock" })
|
||||
.expect(201);
|
||||
expect(res.body.content).toBe('<div id="mock">mock content</div>');
|
||||
});
|
||||
|
||||
it("returns selector text content in reader mode", async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post("/open")
|
||||
.send({
|
||||
url: "https://example.com",
|
||||
selector: "#mock",
|
||||
readerMode: true,
|
||||
})
|
||||
.expect(201);
|
||||
expect(res.body.content).toBe("mock content");
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /exec ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /exec", () => {
|
||||
it("returns 400 when body is empty", async () => {
|
||||
await request(app.getHttpServer()).post("/exec").send({}).expect(400);
|
||||
});
|
||||
|
||||
it("returns 400 when code is missing", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/exec")
|
||||
.send({ sessionName: FAKE_SESSION })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("returns 400 when code has a syntax error", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/exec")
|
||||
.send({ sessionName: FAKE_SESSION, code: "this is not valid {{{" })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("returns 404 when session does not exist", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/exec")
|
||||
.send({ sessionName: "no-such-session", code: "return 1;" })
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("succeeds without a session (sessionless exec)", async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post("/exec")
|
||||
.send({ code: "return 42;" })
|
||||
.expect(201);
|
||||
expect(res.body).toEqual({ result: 42 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
/**
|
||||
* Unit tests for dumpDom.
|
||||
*
|
||||
* We use a lightweight fake DOM rather than jsdom to avoid ESM-dependency
|
||||
* transform issues. Each fake element is a plain JS object that duck-types
|
||||
* the DOM API used by the dumpDom evaluate payload. The fake `page`
|
||||
* captures the evaluate callback and runs it via new Function() with the
|
||||
* fake globals injected as named parameters.
|
||||
*/
|
||||
|
||||
import { dumpDom, DomNode } from "../src/code-executor/dom-helpers";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake DOM builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FakeText {
|
||||
nodeType: 3;
|
||||
textContent: string;
|
||||
}
|
||||
|
||||
interface FakeEl {
|
||||
tagName: string;
|
||||
children: FakeEl[];
|
||||
childNodes: FakeText[];
|
||||
offsetParent: object | null;
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
href: string;
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
innerText: string;
|
||||
textContent: string;
|
||||
getAttribute(name: string): string | null;
|
||||
_display: string;
|
||||
_visibility: string;
|
||||
}
|
||||
|
||||
type ElAttrs = Partial<{
|
||||
role: string;
|
||||
"data-testid": string;
|
||||
"data-qa": string;
|
||||
"data-action": string;
|
||||
"data-element-id": string;
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
href: string;
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
style: string;
|
||||
}>;
|
||||
|
||||
function el(
|
||||
tag: string,
|
||||
attrs: ElAttrs = {},
|
||||
...children: (FakeEl | string)[]
|
||||
): FakeEl {
|
||||
const ownTextNodes: FakeText[] = children
|
||||
.filter((c): c is string => typeof c === "string")
|
||||
.map((t) => ({ nodeType: 3, textContent: t }));
|
||||
const childEls = children.filter((c): c is FakeEl => typeof c !== "string");
|
||||
const deepText = children
|
||||
.map((c) => (typeof c === "string" ? c : c.innerText))
|
||||
.join("");
|
||||
|
||||
const style = attrs.style ?? "";
|
||||
const display = /display\s*:\s*none/.test(style) ? "none" : "";
|
||||
const visibility = /visibility\s*:\s*hidden/.test(style) ? "hidden" : "";
|
||||
|
||||
const attrMap: Record<string, string | null> = {};
|
||||
for (const key of [
|
||||
"role",
|
||||
"data-testid",
|
||||
"data-qa",
|
||||
"data-action",
|
||||
"data-element-id",
|
||||
] as const) {
|
||||
if (attrs[key] != null) attrMap[key] = attrs[key] as string;
|
||||
}
|
||||
|
||||
return {
|
||||
tagName: tag.toUpperCase(),
|
||||
children: childEls,
|
||||
childNodes: ownTextNodes,
|
||||
offsetParent: display || visibility ? null : {},
|
||||
id: attrs.id ?? "",
|
||||
type: attrs.type ?? "",
|
||||
name: attrs.name ?? "",
|
||||
href: attrs.href
|
||||
? attrs.href.startsWith("http")
|
||||
? attrs.href
|
||||
: `https://example.com${attrs.href}`
|
||||
: "",
|
||||
checked: !!attrs.checked,
|
||||
disabled: !!attrs.disabled,
|
||||
innerText: deepText,
|
||||
textContent: deepText,
|
||||
getAttribute(name: string) {
|
||||
return attrMap[name] ?? null;
|
||||
},
|
||||
_display: display,
|
||||
_visibility: visibility,
|
||||
};
|
||||
}
|
||||
|
||||
function body(...children: (FakeEl | string)[]): FakeEl {
|
||||
return el("body", {}, ...children);
|
||||
}
|
||||
|
||||
// ── Fake page ──────────────────────────────────────────────────────────────
|
||||
|
||||
function findByTag(root: FakeEl, tag: string): FakeEl | null {
|
||||
if (root.tagName.toLowerCase() === tag.toLowerCase()) return root;
|
||||
for (const child of root.children) {
|
||||
const found = findByTag(child, tag);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function makePage(rootEl: FakeEl) {
|
||||
const fakeDocument = {
|
||||
querySelector(sel: string): FakeEl | null {
|
||||
if (sel.startsWith("#") || sel.startsWith("[") || sel.startsWith("."))
|
||||
return null;
|
||||
return findByTag(rootEl, sel);
|
||||
},
|
||||
};
|
||||
const fakeWindow = {
|
||||
getComputedStyle: (e: FakeEl) => ({
|
||||
display: e._display,
|
||||
visibility: e._visibility,
|
||||
}),
|
||||
location: { origin: "https://example.com" },
|
||||
};
|
||||
const fakeNode = { TEXT_NODE: 3 };
|
||||
|
||||
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));
|
||||
},
|
||||
);
|
||||
|
||||
return { evaluate } as unknown as import("playwright").Page;
|
||||
}
|
||||
|
||||
const dump = (rootEl: FakeEl, sel?: string): Promise<DomNode> =>
|
||||
dumpDom(makePage(rootEl), sel);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("dumpDom", () => {
|
||||
// ── Error handling ────────────────────────────────────────────────────────
|
||||
|
||||
it("returns an ERROR node when the root selector is not found", async () => {
|
||||
const result = await dump(body(el("p", {}, "hello")), "#does-not-exist");
|
||||
expect(result.tag).toBe("ERROR");
|
||||
expect(result.text).toContain("#does-not-exist");
|
||||
});
|
||||
|
||||
// ── Scope & defaults ──────────────────────────────────────────────────────
|
||||
|
||||
it("defaults to body scope", async () => {
|
||||
const result = await dump(body(el("button", {}, "Go")));
|
||||
expect(result.tag).toBe("body");
|
||||
});
|
||||
|
||||
it("scopes to an arbitrary sub-selector", async () => {
|
||||
const root = body(
|
||||
el("header", {}, el("a", { href: "/nav" }, "Nav")),
|
||||
el("main", {}, el("button", {}, "Action")),
|
||||
);
|
||||
const result = await dump(root, "main");
|
||||
expect(result.tag).toBe("main");
|
||||
expect(result.children.find((c) => c.tag === "header")).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── Visibility filtering ──────────────────────────────────────────────────
|
||||
|
||||
it("skips elements with display:none", async () => {
|
||||
const root = body(
|
||||
el("button", { style: "display:none" }, "Hidden"),
|
||||
el("button", {}, "Visible"),
|
||||
);
|
||||
const result = await dump(root);
|
||||
const btns = result.children.filter((c) => c.tag === "button");
|
||||
expect(btns).toHaveLength(1);
|
||||
expect(btns[0].text).toBe("Visible");
|
||||
});
|
||||
|
||||
it("skips elements with visibility:hidden", async () => {
|
||||
const root = body(
|
||||
el("button", { style: "visibility:hidden" }, "Hidden"),
|
||||
el("button", {}, "Visible"),
|
||||
);
|
||||
const result = await dump(root);
|
||||
const btns = result.children.filter((c) => c.tag === "button");
|
||||
expect(btns).toHaveLength(1);
|
||||
expect(btns[0].text).toBe("Visible");
|
||||
});
|
||||
|
||||
// ── Ignored tag types ─────────────────────────────────────────────────────
|
||||
|
||||
it("skips SVG elements", async () => {
|
||||
const svgEl = el("svg", {}, el("path", {}));
|
||||
const result = await dump(body(el("button", {}, svgEl, "Click")));
|
||||
const btn = result.children.find((c) => c.tag === "button");
|
||||
expect(btn).toBeDefined();
|
||||
expect(btn!.children.some((c) => c.tag === "svg")).toBe(false);
|
||||
});
|
||||
|
||||
it("skips SCRIPT elements", async () => {
|
||||
const result = await dump(
|
||||
body(el("script", {}, "alert(1)"), el("button", {}, "OK")),
|
||||
);
|
||||
expect(result.children.some((c) => c.tag === "script")).toBe(false);
|
||||
});
|
||||
|
||||
// ── Semantic attributes ───────────────────────────────────────────────────
|
||||
|
||||
it("captures data-testid", async () => {
|
||||
const result = await dump(
|
||||
body(el("button", { "data-testid": "save-btn" }, "Save")),
|
||||
);
|
||||
expect(result.children.find((c) => c.tag === "button")?.testid).toBe(
|
||||
"save-btn",
|
||||
);
|
||||
});
|
||||
|
||||
it("captures data-qa", async () => {
|
||||
const result = await dump(
|
||||
body(
|
||||
el("div", { "data-qa": "process-name" }, el("input", { type: "text" })),
|
||||
),
|
||||
);
|
||||
expect(result.children.find((c) => c.qa === "process-name")).toBeDefined();
|
||||
});
|
||||
|
||||
it("captures data-action", async () => {
|
||||
const result = await dump(
|
||||
body(el("div", { "data-action": "append.append-task" })),
|
||||
);
|
||||
expect(
|
||||
result.children.find((c) => c.action === "append.append-task"),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("captures data-element-id", async () => {
|
||||
const result = await dump(
|
||||
body(el("div", { "data-element-id": "Activity_1abc" })),
|
||||
);
|
||||
expect(
|
||||
result.children.find((c) => c.elementId === "Activity_1abc"),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("captures role attribute", async () => {
|
||||
const result = await dump(
|
||||
body(el("div", { role: "dialog" }, el("button", {}, "OK"))),
|
||||
);
|
||||
const dialog = result.children.find((c) => c.role === "dialog");
|
||||
expect(dialog).toBeDefined();
|
||||
expect(dialog!.tag).toBe("div");
|
||||
});
|
||||
|
||||
// ── Interactive element attributes ────────────────────────────────────────
|
||||
|
||||
it("captures input id, type, and name", async () => {
|
||||
const result = await dump(
|
||||
body(el("input", { id: "email", type: "email", name: "userEmail" })),
|
||||
);
|
||||
const input = result.children.find((c) => c.tag === "input");
|
||||
expect(input?.id).toBe("email");
|
||||
expect(input?.type).toBe("email");
|
||||
expect(input?.name).toBe("userEmail");
|
||||
});
|
||||
|
||||
it("captures checked:true on a checked checkbox", async () => {
|
||||
const result = await dump(
|
||||
body(el("input", { type: "checkbox", checked: true })),
|
||||
);
|
||||
expect(result.children.find((c) => c.tag === "input")?.checked).toBe(true);
|
||||
});
|
||||
|
||||
it("captures checked:false on an unchecked checkbox", async () => {
|
||||
const result = await dump(body(el("input", { type: "checkbox" })));
|
||||
expect(result.children.find((c) => c.tag === "input")?.checked).toBe(false);
|
||||
});
|
||||
|
||||
it("captures disabled:true on a disabled button", async () => {
|
||||
const result = await dump(body(el("button", { disabled: true }, "Nope")));
|
||||
expect(result.children.find((c) => c.tag === "button")?.disabled).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not set disabled for a non-disabled button", async () => {
|
||||
const result = await dump(body(el("button", {}, "OK")));
|
||||
expect(
|
||||
result.children.find((c) => c.tag === "button")?.disabled,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not capture type for button elements", async () => {
|
||||
const result = await dump(body(el("button", { type: "submit" }, "Go")));
|
||||
expect(
|
||||
result.children.find((c) => c.tag === "button")?.type,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("relativizes same-origin anchor href", async () => {
|
||||
const result = await dump(body(el("a", { href: "/workflow/123" }, "Link")));
|
||||
expect(result.children.find((c) => c.tag === "a")?.href).toBe(
|
||||
"/workflow/123",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps full href for cross-origin anchors", async () => {
|
||||
const result = await dump(
|
||||
body(el("a", { href: "https://other.com/page" }, "Ext")),
|
||||
);
|
||||
expect(result.children.find((c) => c.tag === "a")?.href).toContain(
|
||||
"other.com",
|
||||
);
|
||||
});
|
||||
|
||||
// ── Text content ──────────────────────────────────────────────────────────
|
||||
|
||||
it("captures own text content of a button", async () => {
|
||||
const result = await dump(body(el("button", {}, "Save")));
|
||||
expect(result.children.find((c) => c.tag === "button")?.text).toBe("Save");
|
||||
});
|
||||
|
||||
it("truncates text to 80 characters", async () => {
|
||||
const long = "x".repeat(100);
|
||||
const result = await dump(body(el("button", {}, long)));
|
||||
expect(result.children.find((c) => c.tag === "button")?.text?.length).toBe(
|
||||
80,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to innerText when element has no direct text nodes", async () => {
|
||||
// button wraps a span — no direct text node on button, innerText = span text
|
||||
const result = await dump(body(el("button", {}, el("span", {}, "Nested"))));
|
||||
expect(result.children.find((c) => c.tag === "button")?.text).toBe(
|
||||
"Nested",
|
||||
);
|
||||
});
|
||||
|
||||
// ── Tree pruning / unwrapping ─────────────────────────────────────────────
|
||||
|
||||
it("unwraps a non-significant div that has exactly one significant child", async () => {
|
||||
const result = await dump(body(el("div", {}, el("button", {}, "Click"))));
|
||||
const btn = result.children.find((c) => c.tag === "button");
|
||||
expect(btn).toBeDefined();
|
||||
expect(
|
||||
result.children.some(
|
||||
(c) => c.tag === "div" && !c.role && !c.testid && !c.qa,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a non-significant div that has more than one significant child", async () => {
|
||||
const result = await dump(
|
||||
body(el("div", {}, el("button", {}, "A"), el("button", {}, "B"))),
|
||||
);
|
||||
const wrapper = result.children.find((c) => c.tag === "div");
|
||||
expect(wrapper).toBeDefined();
|
||||
expect(wrapper!.children).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("discards non-significant childless elements", async () => {
|
||||
const result = await dump(body(el("div", {}), el("button", {}, "Keep")));
|
||||
expect(
|
||||
result.children.find((c) => c.tag === "div" && c.children.length === 0),
|
||||
).toBeUndefined();
|
||||
expect(result.children.some((c) => c.tag === "button")).toBe(true);
|
||||
});
|
||||
|
||||
// ── Structural tags ───────────────────────────────────────────────────────
|
||||
|
||||
it("preserves nested structure inside a form", async () => {
|
||||
const result = await dump(
|
||||
body(
|
||||
el(
|
||||
"form",
|
||||
{},
|
||||
el("input", { id: "n", type: "text", name: "name" }),
|
||||
el("button", {}, "Send"),
|
||||
),
|
||||
),
|
||||
);
|
||||
const form = result.children.find((c) => c.tag === "form");
|
||||
expect(form).toBeDefined();
|
||||
expect(form!.children.find((c) => c.tag === "input")).toBeDefined();
|
||||
expect(form!.children.find((c) => c.tag === "button")).toBeDefined();
|
||||
});
|
||||
|
||||
it("preserves dialog element", async () => {
|
||||
const result = await dump(
|
||||
body(el("dialog", { role: "dialog" }, el("button", {}, "Close"))),
|
||||
);
|
||||
expect(result.children.find((c) => c.tag === "dialog")).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns empty node when root has no visible significant content", async () => {
|
||||
const result = await dumpDom(makePage(el("div", {})), "div");
|
||||
expect(result.tag).toBe("empty");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import request from "supertest";
|
||||
import { buildTestApp } from "./app.harness";
|
||||
|
||||
describe("EnvironmentController", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildTestApp();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
// ── POST /environments ─────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /environments", () => {
|
||||
it("creates an environment and returns 201", async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post("/environments")
|
||||
.send({ name: "env-a", urls: { id_url: "https://id.example.com" } })
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.id).toBeDefined();
|
||||
expect(res.body.name).toBe("env-a");
|
||||
expect(res.body.urls.id_url).toBe("https://id.example.com");
|
||||
});
|
||||
|
||||
it("returns 400 when name is missing", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/environments")
|
||||
.send({ urls: { id_url: "https://id.example.com" } })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("returns 400 when urls is missing", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/environments")
|
||||
.send({ name: "env-no-urls" })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("returns 400 when urls is not an object", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/environments")
|
||||
.send({ name: "env-bad-urls", urls: "not-an-object" })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("returns 409 when name already exists", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/environments")
|
||||
.send({ name: "env-duplicate", urls: {} })
|
||||
.expect(201);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post("/environments")
|
||||
.send({ name: "env-duplicate", urls: {} })
|
||||
.expect(409);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /environments ──────────────────────────────────────────────────────
|
||||
|
||||
describe("GET /environments", () => {
|
||||
it("returns 200 with a paginated result", async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get("/environments")
|
||||
.expect(200);
|
||||
expect(Array.isArray(res.body.data)).toBe(true);
|
||||
expect(typeof res.body.total).toBe("number");
|
||||
expect(res.body).toHaveProperty("page");
|
||||
expect(res.body).toHaveProperty("limit");
|
||||
});
|
||||
|
||||
it("respects page and limit params", async () => {
|
||||
// seed two extra environments
|
||||
await request(app.getHttpServer())
|
||||
.post("/environments")
|
||||
.send({ name: "env-page-1", urls: {} })
|
||||
.expect(201);
|
||||
await request(app.getHttpServer())
|
||||
.post("/environments")
|
||||
.send({ name: "env-page-2", urls: {} })
|
||||
.expect(201);
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get("/environments?page=1&limit=1")
|
||||
.expect(200);
|
||||
expect(res.body.data).toHaveLength(1);
|
||||
expect(res.body.page).toBe(1);
|
||||
expect(res.body.limit).toBe(1);
|
||||
});
|
||||
|
||||
it("returns empty data array for out-of-range page", async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get("/environments?page=9999&limit=20")
|
||||
.expect(200);
|
||||
expect(res.body.data).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns 400 for invalid page param", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get("/environments?page=0")
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("orders by name ASC", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/environments")
|
||||
.send({ name: "zzz-env", urls: {} });
|
||||
await request(app.getHttpServer())
|
||||
.post("/environments")
|
||||
.send({ name: "aaa-env", urls: {} });
|
||||
|
||||
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,
|
||||
);
|
||||
expect(names).toEqual([...names].sort());
|
||||
});
|
||||
|
||||
it("orders by name DESC", async () => {
|
||||
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,
|
||||
);
|
||||
expect(names).toEqual([...names].sort().reverse());
|
||||
});
|
||||
|
||||
it("returns 400 for invalid orderDir", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get("/environments?orderDir=SIDEWAYS")
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /environments/:id ──────────────────────────────────────────────────
|
||||
|
||||
describe("GET /environments/:id", () => {
|
||||
it("returns the created environment", async () => {
|
||||
const created = await request(app.getHttpServer())
|
||||
.post("/environments")
|
||||
.send({
|
||||
name: "env-get-one",
|
||||
urls: { cabinet_url: "https://cabinet.example.com" },
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(`/environments/${created.body.id}`)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.name).toBe("env-get-one");
|
||||
});
|
||||
|
||||
it("returns 404 for unknown id", async () => {
|
||||
await request(app.getHttpServer()).get("/environments/99999").expect(404);
|
||||
});
|
||||
|
||||
it("returns 400 for non-numeric id", async () => {
|
||||
await request(app.getHttpServer()).get("/environments/abc").expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ── PATCH /environments/:id ────────────────────────────────────────────────
|
||||
|
||||
describe("PATCH /environments/:id", () => {
|
||||
it("updates name and returns 200", async () => {
|
||||
const created = await request(app.getHttpServer())
|
||||
.post("/environments")
|
||||
.send({ name: "env-patch-me", urls: {} })
|
||||
.expect(201);
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.patch(`/environments/${created.body.id}`)
|
||||
.send({ name: "env-patched" })
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.name).toBe("env-patched");
|
||||
});
|
||||
|
||||
it("returns 404 for unknown id", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.patch("/environments/99999")
|
||||
.send({ name: "x" })
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── DELETE /environments/:id ───────────────────────────────────────────────
|
||||
|
||||
describe("DELETE /environments/:id", () => {
|
||||
it("deletes and returns 204", async () => {
|
||||
const created = await request(app.getHttpServer())
|
||||
.post("/environments")
|
||||
.send({ name: "env-delete-me", urls: {} })
|
||||
.expect(201);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.delete(`/environments/${created.body.id}`)
|
||||
.expect(204);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get(`/environments/${created.body.id}`)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("returns 404 for unknown id", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.delete("/environments/99999")
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import request from "supertest";
|
||||
import { buildTestApp } from "./app.harness";
|
||||
|
||||
/**
|
||||
* MCP controller integration tests.
|
||||
*
|
||||
* The MCP endpoint speaks the Model Context Protocol (streamable HTTP
|
||||
* transport). We test:
|
||||
* - that the endpoint is reachable and returns a recognised MCP response
|
||||
* - that tool invocations for read-only, non-browser tools work end-to-end
|
||||
* - that tools with bad inputs return error payloads (not HTTP 5xx)
|
||||
*
|
||||
* Browser-dependent tools (open_url, exec_code) require a live Playwright
|
||||
* session and are not covered here.
|
||||
*/
|
||||
describe("McpController", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildTestApp();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
/** Parse the JSON-RPC payload from an SSE response body. */
|
||||
function parseSse(text: string): Record<string, unknown> {
|
||||
const match = text.match(/^data:\s*(.+)$/m);
|
||||
if (!match) throw new Error(`No SSE data line found in: ${text}`);
|
||||
return JSON.parse(match[1]) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Send a single MCP tool call and return the parsed response body. */
|
||||
async function mcpCall(toolName: string, args: Record<string, unknown> = {}) {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post("/mcp")
|
||||
.set("Content-Type", "application/json")
|
||||
.set("Accept", "application/json, text/event-stream")
|
||||
.send({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: { name: toolName, arguments: args },
|
||||
});
|
||||
return { status: res.status, rpc: parseSse(res.text) };
|
||||
}
|
||||
|
||||
// ── Connectivity ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /mcp — connectivity", () => {
|
||||
it("is reachable and returns a non-5xx status", async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post("/mcp")
|
||||
.set("Content-Type", "application/json")
|
||||
.set("Accept", "application/json, text/event-stream")
|
||||
.send({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "test", version: "0" },
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ── list_keys tool ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("list_keys", () => {
|
||||
it("returns a result with text content containing a JSON array", async () => {
|
||||
const { status, rpc } = await mcpCall("list_keys");
|
||||
expect(status).toBe(200);
|
||||
const result = rpc.result as { content: { text: string }[] };
|
||||
expect(Array.isArray(JSON.parse(result.content[0].text))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── list_sessions tool ─────────────────────────────────────────────────────
|
||||
|
||||
describe("list_sessions", () => {
|
||||
it("returns a paginated result with a data array", async () => {
|
||||
const { status, rpc } = await mcpCall("list_sessions");
|
||||
expect(status).toBe(200);
|
||||
const result = rpc.result as { content: { text: string }[] };
|
||||
const body = JSON.parse(result.content[0].text) as {
|
||||
data: unknown[];
|
||||
total: number;
|
||||
};
|
||||
expect(Array.isArray(body.data)).toBe(true);
|
||||
expect(typeof body.total).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
// ── list_environments tool ─────────────────────────────────────────────────
|
||||
|
||||
describe("list_environments", () => {
|
||||
it("returns a paginated result with a data array", async () => {
|
||||
const { status, rpc } = await mcpCall("list_environments");
|
||||
expect(status).toBe(200);
|
||||
const result = rpc.result as { content: { text: string }[] };
|
||||
const body = JSON.parse(result.content[0].text) as {
|
||||
data: unknown[];
|
||||
total: number;
|
||||
};
|
||||
expect(Array.isArray(body.data)).toBe(true);
|
||||
expect(typeof body.total).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
// ── create_environment tool ────────────────────────────────────────────────
|
||||
|
||||
describe("create_environment", () => {
|
||||
it("creates an environment via MCP", async () => {
|
||||
const { status, rpc } = await mcpCall("create_environment", {
|
||||
name: "mcp-test-env",
|
||||
urls: { id_url: "https://id.example.com" },
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
const result = rpc.result as { content: { text: string }[] };
|
||||
const created = JSON.parse(result.content[0].text) as { name: string };
|
||||
expect(created.name).toBe("mcp-test-env");
|
||||
});
|
||||
});
|
||||
|
||||
// ── delete_session tool with unknown id ────────────────────────────────────
|
||||
|
||||
describe("delete_session", () => {
|
||||
it("returns an MCP error result for a non-existent session id", async () => {
|
||||
const { status, rpc } = await mcpCall("delete_session", { id: 999999 });
|
||||
expect(status).toBe(200);
|
||||
// MCP wraps service errors as isError:true content, not HTTP errors
|
||||
const result = rpc.result as { isError: boolean };
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,742 @@
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import request from "supertest";
|
||||
import { DataSource } from "typeorm";
|
||||
import { buildTestApp } from "./app.harness";
|
||||
|
||||
describe("ScenarioController", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildTestApp();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function createScenario(name = "test scenario") {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post("/scenarios")
|
||||
.send({ name })
|
||||
.expect(201);
|
||||
return res.body as { id: number; name: string };
|
||||
}
|
||||
|
||||
async function createStep(
|
||||
scenarioId: number,
|
||||
overrides: Record<string, unknown> = {},
|
||||
) {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(`/scenarios/${scenarioId}/steps`)
|
||||
.send({
|
||||
order: 0,
|
||||
type: "exec",
|
||||
sessionName: "test-session",
|
||||
execCode: "return 1;",
|
||||
...overrides,
|
||||
})
|
||||
.expect(201);
|
||||
return res.body as { id: number };
|
||||
}
|
||||
|
||||
// ── POST /scenarios ────────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /scenarios", () => {
|
||||
it("creates a scenario and returns 201", async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post("/scenarios")
|
||||
.send({ name: "my scenario" })
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.id).toBeDefined();
|
||||
expect(res.body.name).toBe("my scenario");
|
||||
});
|
||||
|
||||
it("returns 400 when name is missing", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/scenarios")
|
||||
.send({})
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /scenarios ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("GET /scenarios", () => {
|
||||
it("returns paginated result", async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get("/scenarios")
|
||||
.expect(200);
|
||||
expect(res.body).toHaveProperty("data");
|
||||
expect(res.body).toHaveProperty("total");
|
||||
expect(res.body).toHaveProperty("page");
|
||||
expect(res.body).toHaveProperty("limit");
|
||||
expect(Array.isArray(res.body.data)).toBe(true);
|
||||
});
|
||||
|
||||
it("respects page and limit params", async () => {
|
||||
await createScenario("paged-sc-a");
|
||||
await createScenario("paged-sc-b");
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get("/scenarios?page=1&limit=1")
|
||||
.expect(200);
|
||||
expect(res.body.data).toHaveLength(1);
|
||||
expect(res.body.limit).toBe(1);
|
||||
expect(res.body.page).toBe(1);
|
||||
});
|
||||
|
||||
it("returns empty data for out-of-range page", async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get("/scenarios?page=9999&limit=20")
|
||||
.expect(200);
|
||||
expect(res.body.data).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns 400 for invalid pagination params", async () => {
|
||||
await request(app.getHttpServer()).get("/scenarios?page=0").expect(400);
|
||||
});
|
||||
|
||||
it("orders by name ASC", async () => {
|
||||
await createScenario("zzz-order-sc");
|
||||
await createScenario("aaa-order-sc");
|
||||
|
||||
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,
|
||||
);
|
||||
expect(names).toEqual([...names].sort());
|
||||
});
|
||||
|
||||
it("orders by name DESC", async () => {
|
||||
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,
|
||||
);
|
||||
expect(names).toEqual([...names].sort().reverse());
|
||||
});
|
||||
|
||||
it("returns 400 for invalid orderDir", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get("/scenarios?orderDir=SIDEWAYS")
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /scenarios/:id ─────────────────────────────────────────────────────
|
||||
|
||||
describe("GET /scenarios/:id", () => {
|
||||
it("returns the scenario with steps array", async () => {
|
||||
const sc = await createScenario("scenario-get-one");
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}`)
|
||||
.expect(200);
|
||||
expect(res.body.id).toBe(sc.id);
|
||||
expect(Array.isArray(res.body.steps)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 404 for unknown id", async () => {
|
||||
await request(app.getHttpServer()).get("/scenarios/99999").expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── PATCH /scenarios/:id ───────────────────────────────────────────────────
|
||||
|
||||
describe("PATCH /scenarios/:id", () => {
|
||||
it("updates scenario name", async () => {
|
||||
const sc = await createScenario("patch-me");
|
||||
const res = await request(app.getHttpServer())
|
||||
.patch(`/scenarios/${sc.id}`)
|
||||
.send({ name: "patched" })
|
||||
.expect(200);
|
||||
expect(res.body.name).toBe("patched");
|
||||
});
|
||||
|
||||
it("returns 404 for unknown id", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.patch("/scenarios/99999")
|
||||
.send({ name: "x" })
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── DELETE /scenarios/:id ──────────────────────────────────────────────────
|
||||
|
||||
describe("DELETE /scenarios/:id", () => {
|
||||
it("deletes and returns 204", async () => {
|
||||
const sc = await createScenario("delete-me");
|
||||
await request(app.getHttpServer())
|
||||
.delete(`/scenarios/${sc.id}`)
|
||||
.expect(204);
|
||||
await request(app.getHttpServer()).get(`/scenarios/${sc.id}`).expect(404);
|
||||
});
|
||||
|
||||
it("returns 404 for unknown id", async () => {
|
||||
await request(app.getHttpServer()).delete("/scenarios/99999").expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /scenarios/:id/steps ──────────────────────────────────────────────
|
||||
|
||||
describe("POST /scenarios/:id/steps", () => {
|
||||
it("creates a step with required fields", async () => {
|
||||
const sc = await createScenario();
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(`/scenarios/${sc.id}/steps`)
|
||||
.send({
|
||||
order: 0,
|
||||
type: "exec",
|
||||
sessionName: "my-session",
|
||||
execCode: "return 1;",
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.id).toBeDefined();
|
||||
expect(res.body.order).toBe(0);
|
||||
expect(res.body.type).toBe("exec");
|
||||
expect(res.body.sessionName).toBe("my-session");
|
||||
});
|
||||
|
||||
it("creates a login step", async () => {
|
||||
const sc = await createScenario();
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(`/scenarios/${sc.id}/steps`)
|
||||
.send({ order: 0, type: "login", sessionName: "session-x" })
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.type).toBe("login");
|
||||
});
|
||||
|
||||
it("returns 400 when order is missing", async () => {
|
||||
const sc = await createScenario();
|
||||
await request(app.getHttpServer())
|
||||
.post(`/scenarios/${sc.id}/steps`)
|
||||
.send({ type: "exec", sessionName: "x" })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("returns 400 when type is invalid", async () => {
|
||||
const sc = await createScenario();
|
||||
await request(app.getHttpServer())
|
||||
.post(`/scenarios/${sc.id}/steps`)
|
||||
.send({ order: 0, type: "unknown", sessionName: "x" })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("returns 400 when sessionName is missing", async () => {
|
||||
const sc = await createScenario();
|
||||
await request(app.getHttpServer())
|
||||
.post(`/scenarios/${sc.id}/steps`)
|
||||
.send({ order: 0, type: "exec" })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("returns 404 for unknown scenario", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/scenarios/99999/steps")
|
||||
.send({ order: 0, type: "exec", sessionName: "x" })
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("returns steps ordered by order field", async () => {
|
||||
const sc = await createScenario();
|
||||
await createStep(sc.id, { order: 2, type: "exec", sessionName: "s" });
|
||||
await createStep(sc.id, { order: 0, type: "exec", sessionName: "s" });
|
||||
await createStep(sc.id, { order: 1, type: "exec", sessionName: "s" });
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}`)
|
||||
.expect(200);
|
||||
|
||||
const orders = res.body.steps.map((s: { order: number }) => s.order);
|
||||
expect(orders).toEqual([0, 1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /scenarios/:id/steps/:stepId ──────────────────────────────────────
|
||||
|
||||
describe("GET /scenarios/:id/steps/:stepId", () => {
|
||||
it("returns the step", async () => {
|
||||
const sc = await createScenario();
|
||||
const step = await createStep(sc.id);
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}/steps/${step.id}`)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.id).toBe(step.id);
|
||||
});
|
||||
|
||||
it("returns 404 for unknown step", async () => {
|
||||
const sc = await createScenario();
|
||||
await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}/steps/99999`)
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── PATCH /scenarios/:id/steps/:stepId ────────────────────────────────────
|
||||
|
||||
describe("PATCH /scenarios/:id/steps/:stepId", () => {
|
||||
it("updates step fields", async () => {
|
||||
const sc = await createScenario();
|
||||
const step = await createStep(sc.id, { order: 0, execCode: "return 1;" });
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.patch(`/scenarios/${sc.id}/steps/${step.id}`)
|
||||
.send({ order: 5, execCode: "return 99;" })
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.order).toBe(5);
|
||||
expect(res.body.execCode).toBe("return 99;");
|
||||
});
|
||||
|
||||
it("returns 404 for unknown step", async () => {
|
||||
const sc = await createScenario();
|
||||
await request(app.getHttpServer())
|
||||
.patch(`/scenarios/${sc.id}/steps/99999`)
|
||||
.send({ order: 1 })
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── DELETE /scenarios/:id/steps/:stepId ───────────────────────────────────
|
||||
|
||||
describe("DELETE /scenarios/:id/steps/:stepId", () => {
|
||||
it("deletes the step and returns 204", async () => {
|
||||
const sc = await createScenario();
|
||||
const step = await createStep(sc.id);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.delete(`/scenarios/${sc.id}/steps/${step.id}`)
|
||||
.expect(204);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}/steps/${step.id}`)
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /scenarios/:id/run ────────────────────────────────────────────────
|
||||
|
||||
describe("POST /scenarios/:id/run", () => {
|
||||
it("creates a run with stepRuns in correct initial states", async () => {
|
||||
const sc = await createScenario();
|
||||
await createStep(sc.id, { order: 0 });
|
||||
await createStep(sc.id, { order: 1 });
|
||||
await createStep(sc.id, { order: 2 });
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(`/scenarios/${sc.id}/run`)
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.status).toBe("pending");
|
||||
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,
|
||||
);
|
||||
expect(statuses[0]).toBe("pending");
|
||||
expect(statuses[1]).toBe("waiting");
|
||||
expect(statuses[2]).toBe("waiting");
|
||||
});
|
||||
|
||||
it("returns 404 for unknown scenario", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/scenarios/99999/run")
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /scenarios/:id/runs ────────────────────────────────────────────────
|
||||
|
||||
describe("GET /scenarios/:id/runs", () => {
|
||||
it("returns paginated runs with stepRuns embedded", async () => {
|
||||
const sc = await createScenario();
|
||||
await createStep(sc.id, { order: 0 });
|
||||
await request(app.getHttpServer())
|
||||
.post(`/scenarios/${sc.id}/run`)
|
||||
.expect(201);
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}/runs`)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.total).toBeGreaterThanOrEqual(1);
|
||||
expect(Array.isArray(res.body.data[0].stepRuns)).toBe(true);
|
||||
});
|
||||
|
||||
it("filters by status", async () => {
|
||||
const sc = await createScenario();
|
||||
await createStep(sc.id, { order: 0 });
|
||||
await request(app.getHttpServer())
|
||||
.post(`/scenarios/${sc.id}/run`)
|
||||
.expect(201);
|
||||
|
||||
const pendingRes = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}/runs?status=pending`)
|
||||
.expect(200);
|
||||
|
||||
expect(pendingRes.body.total).toBeGreaterThanOrEqual(1);
|
||||
pendingRes.body.data.forEach((r: { status: string }) =>
|
||||
expect(r.status).toBe("pending"),
|
||||
);
|
||||
|
||||
const passRes = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}/runs?status=pass`)
|
||||
.expect(200);
|
||||
|
||||
expect(passRes.body.total).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 400 for invalid status filter", async () => {
|
||||
const sc = await createScenario();
|
||||
await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}/runs?status=invalid`)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("returns 404 for unknown scenario", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get("/scenarios/99999/runs")
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /scenarios/:id/export ─────────────────────────────────────────────
|
||||
|
||||
describe("GET /scenarios/:id/export", () => {
|
||||
it("returns name and steps array", async () => {
|
||||
const sc = await createScenario("export-me");
|
||||
await createStep(sc.id, {
|
||||
order: 0,
|
||||
type: "login",
|
||||
sessionName: "s",
|
||||
execCode: '{"keyId":"k","environmentName":"e"}',
|
||||
});
|
||||
await createStep(sc.id, {
|
||||
order: 1,
|
||||
type: "exec",
|
||||
sessionName: "s",
|
||||
execCode: "return 1;",
|
||||
validateCode: "return true;",
|
||||
});
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}/export`)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.name).toBe("export-me");
|
||||
expect(Array.isArray(res.body.steps)).toBe(true);
|
||||
expect(res.body.steps).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("exports steps ordered by order field", async () => {
|
||||
const sc = await createScenario("export-order");
|
||||
await createStep(sc.id, { order: 2, sessionName: "s" });
|
||||
await createStep(sc.id, { order: 0, sessionName: "s" });
|
||||
await createStep(sc.id, { order: 1, sessionName: "s" });
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}/export`)
|
||||
.expect(200);
|
||||
|
||||
const orders = res.body.steps.map((s: { order: number }) => s.order);
|
||||
expect(orders).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it("omits internal fields (id, scenarioId, timestamps)", async () => {
|
||||
const sc = await createScenario("export-shape");
|
||||
await createStep(sc.id, { order: 0, sessionName: "s" });
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}/export`)
|
||||
.expect(200);
|
||||
|
||||
const step = res.body.steps[0];
|
||||
expect(step).not.toHaveProperty("id");
|
||||
expect(step).not.toHaveProperty("scenarioId");
|
||||
expect(step).not.toHaveProperty("createdAt");
|
||||
expect(step).not.toHaveProperty("updatedAt");
|
||||
});
|
||||
|
||||
it("exports null validateCode as null", async () => {
|
||||
const sc = await createScenario("export-null-validate");
|
||||
await createStep(sc.id, {
|
||||
order: 0,
|
||||
sessionName: "s",
|
||||
execCode: "return 1;",
|
||||
});
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}/export`)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.steps[0].validateCode).toBeNull();
|
||||
});
|
||||
|
||||
it("returns 404 for unknown scenario", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get("/scenarios/99999/export")
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /scenarios/import ────────────────────────────────────────────────
|
||||
|
||||
describe("POST /scenarios/import", () => {
|
||||
it("creates a new scenario with all steps", async () => {
|
||||
const payload = {
|
||||
name: "imported scenario",
|
||||
steps: [
|
||||
{
|
||||
order: 0,
|
||||
type: "login",
|
||||
sessionName: "s",
|
||||
execCode: '{"keyId":"k","environmentName":"e"}',
|
||||
validateCode: null,
|
||||
},
|
||||
{
|
||||
order: 1,
|
||||
type: "exec",
|
||||
sessionName: "s",
|
||||
execCode: "return 1;",
|
||||
validateCode: "return true;",
|
||||
},
|
||||
{
|
||||
order: 2,
|
||||
type: "sign",
|
||||
sessionName: "s",
|
||||
execCode: '{"keyId":"k"}',
|
||||
validateCode: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.post("/scenarios/import")
|
||||
.send(payload)
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.id).toBeDefined();
|
||||
expect(res.body.name).toBe("imported scenario");
|
||||
expect(res.body.steps).toHaveLength(3);
|
||||
expect(res.body.steps[0].type).toBe("login");
|
||||
expect(res.body.steps[1].type).toBe("exec");
|
||||
expect(res.body.steps[2].type).toBe("sign");
|
||||
});
|
||||
|
||||
it("assigns a new id (does not collide with source)", async () => {
|
||||
const sc = await createScenario("original");
|
||||
const exportRes = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}/export`)
|
||||
.expect(200);
|
||||
|
||||
const importRes = await request(app.getHttpServer())
|
||||
.post("/scenarios/import")
|
||||
.send(exportRes.body)
|
||||
.expect(201);
|
||||
|
||||
expect(importRes.body.id).not.toBe(sc.id);
|
||||
});
|
||||
|
||||
it("round-trips a scenario faithfully", async () => {
|
||||
const sc = await createScenario("roundtrip");
|
||||
await createStep(sc.id, {
|
||||
order: 0,
|
||||
type: "exec",
|
||||
sessionName: "rs",
|
||||
execCode: "return 42;",
|
||||
validateCode: "return true;",
|
||||
});
|
||||
|
||||
const exportRes = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}/export`)
|
||||
.expect(200);
|
||||
|
||||
const importRes = await request(app.getHttpServer())
|
||||
.post("/scenarios/import")
|
||||
.send(exportRes.body)
|
||||
.expect(201);
|
||||
|
||||
expect(importRes.body.name).toBe("roundtrip");
|
||||
expect(importRes.body.steps).toHaveLength(1);
|
||||
expect(importRes.body.steps[0].execCode).toBe(
|
||||
exportRes.body.steps[0].execCode,
|
||||
);
|
||||
expect(importRes.body.steps[0].validateCode).toBe(
|
||||
exportRes.body.steps[0].validateCode,
|
||||
);
|
||||
});
|
||||
|
||||
it("imports with empty steps array", async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post("/scenarios/import")
|
||||
.send({ name: "empty-import", steps: [] })
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.name).toBe("empty-import");
|
||||
expect(res.body.steps).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns 400 when name is missing", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/scenarios/import")
|
||||
.send({ steps: [] })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("returns 400 when steps is not an array", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/scenarios/import")
|
||||
.send({ name: "bad", steps: "oops" })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("returns 400 when a step has an invalid type", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/scenarios/import")
|
||||
.send({
|
||||
name: "bad-type",
|
||||
steps: [{ order: 0, type: "unknown", sessionName: "s" }],
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /scenarios/:id/run/:runId ─────────────────────────────────────────
|
||||
|
||||
describe("GET /scenarios/:id/run/:runId", () => {
|
||||
it("returns run with stepRuns (with scenarioStep) and logs array", async () => {
|
||||
const sc = await createScenario();
|
||||
await createStep(sc.id, { order: 0 });
|
||||
const runRes = await request(app.getHttpServer())
|
||||
.post(`/scenarios/${sc.id}/run`)
|
||||
.expect(201);
|
||||
const runId = runRes.body.id;
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}/run/${runId}`)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.id).toBe(runId);
|
||||
expect(res.body.status).toBe("pending");
|
||||
expect(Array.isArray(res.body.stepRuns)).toBe(true);
|
||||
expect(res.body.stepRuns[0]).toHaveProperty("scenarioStep");
|
||||
expect(Array.isArray(res.body.logs)).toBe(true);
|
||||
});
|
||||
|
||||
it("stepRuns are ordered by order ASC", async () => {
|
||||
const sc = await createScenario();
|
||||
await createStep(sc.id, { order: 0 });
|
||||
await createStep(sc.id, { order: 1 });
|
||||
await createStep(sc.id, { order: 2 });
|
||||
const runRes = await request(app.getHttpServer())
|
||||
.post(`/scenarios/${sc.id}/run`)
|
||||
.expect(201);
|
||||
const runId = runRes.body.id;
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}/run/${runId}`)
|
||||
.expect(200);
|
||||
|
||||
const orders = res.body.stepRuns.map((s: { order: number }) => s.order);
|
||||
expect(orders).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it("returns 404 for unknown run", async () => {
|
||||
const sc = await createScenario();
|
||||
await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}/run/99999`)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("returns 404 when run belongs to a different scenario", async () => {
|
||||
const sc1 = await createScenario();
|
||||
const sc2 = await createScenario();
|
||||
await createStep(sc1.id, { order: 0 });
|
||||
const runRes = await request(app.getHttpServer())
|
||||
.post(`/scenarios/${sc1.id}/run`)
|
||||
.expect(201);
|
||||
const runId = runRes.body.id;
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc2.id}/run/${runId}`)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("returns 404 for unknown scenario", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get("/scenarios/99999/run/1")
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /scenarios/:id/run/:runId/wait ───────────────────────────────────
|
||||
|
||||
describe("POST /scenarios/:id/run/:runId/wait", () => {
|
||||
it("returns 200 with run data immediately when run is already terminal", async () => {
|
||||
const sc = await createScenario();
|
||||
await createStep(sc.id, { order: 0 });
|
||||
const runRes = await request(app.getHttpServer())
|
||||
.post(`/scenarios/${sc.id}/run`)
|
||||
.expect(201);
|
||||
const runId = runRes.body.id;
|
||||
|
||||
// Manually mark run as pass so wait resolves immediately
|
||||
const dataSource = app.get(DataSource);
|
||||
await dataSource.query(
|
||||
`UPDATE scenario_runs SET status='pass' WHERE id=${runId}`,
|
||||
);
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(`/scenarios/${sc.id}/run/${runId}/wait`)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.id).toBe(runId);
|
||||
expect(res.body.status).toBe("pass");
|
||||
expect(Array.isArray(res.body.logs)).toBe(true);
|
||||
expect(Array.isArray(res.body.stepRuns)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns the run in fail state when it has failed", async () => {
|
||||
const sc = await createScenario();
|
||||
await createStep(sc.id, { order: 0 });
|
||||
const runRes = await request(app.getHttpServer())
|
||||
.post(`/scenarios/${sc.id}/run`)
|
||||
.expect(201);
|
||||
const runId = runRes.body.id;
|
||||
|
||||
const dataSource = app.get(DataSource);
|
||||
await dataSource.query(
|
||||
`UPDATE scenario_runs SET status='fail' WHERE id=${runId}`,
|
||||
);
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(`/scenarios/${sc.id}/run/${runId}/wait`)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.status).toBe("fail");
|
||||
});
|
||||
|
||||
it("returns 404 for unknown run", async () => {
|
||||
const sc = await createScenario();
|
||||
await request(app.getHttpServer())
|
||||
.post(`/scenarios/${sc.id}/run/99999/wait`)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("returns 404 for unknown scenario", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/scenarios/99999/run/1/wait")
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { getRepositoryToken } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import request from "supertest";
|
||||
import { buildTestApp } from "./app.harness";
|
||||
import { SessionEntity } from "../src/session/session.entity";
|
||||
|
||||
describe("SessionController", () => {
|
||||
let app: INestApplication;
|
||||
let repo: Repository<SessionEntity>;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildTestApp();
|
||||
repo = app.get<Repository<SessionEntity>>(
|
||||
getRepositoryToken(SessionEntity),
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
async function seedSession(name: string) {
|
||||
return repo.save(
|
||||
repo.create({
|
||||
sessionName: name,
|
||||
token: "tok",
|
||||
cookies: "[]",
|
||||
localStorage: "{}",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── GET /sessions ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("GET /sessions", () => {
|
||||
it("returns 200 with a paginated result", async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get("/sessions")
|
||||
.expect(200);
|
||||
expect(Array.isArray(res.body.data)).toBe(true);
|
||||
expect(typeof res.body.total).toBe("number");
|
||||
expect(res.body).toHaveProperty("page");
|
||||
expect(res.body).toHaveProperty("limit");
|
||||
});
|
||||
|
||||
it("includes seeded sessions", async () => {
|
||||
await seedSession("visible-session");
|
||||
const res = await request(app.getHttpServer())
|
||||
.get("/sessions")
|
||||
.expect(200);
|
||||
const names = res.body.data.map(
|
||||
(s: { sessionName: string }) => s.sessionName,
|
||||
);
|
||||
expect(names).toContain("visible-session");
|
||||
});
|
||||
|
||||
it("does not expose token, cookies or localStorage fields", async () => {
|
||||
await seedSession("private-session");
|
||||
const res = await request(app.getHttpServer())
|
||||
.get("/sessions")
|
||||
.expect(200);
|
||||
const item = res.body.data.find(
|
||||
(s: { sessionName: string }) => s.sessionName === "private-session",
|
||||
) as Record<string, unknown>;
|
||||
expect(item).toBeDefined();
|
||||
expect(item.token).toBeUndefined();
|
||||
expect(item.cookies).toBeUndefined();
|
||||
expect(item.localStorage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("respects page and limit params", async () => {
|
||||
await seedSession("paged-session-a");
|
||||
await seedSession("paged-session-b");
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get("/sessions?page=1&limit=1")
|
||||
.expect(200);
|
||||
expect(res.body.data).toHaveLength(1);
|
||||
expect(res.body.page).toBe(1);
|
||||
expect(res.body.limit).toBe(1);
|
||||
});
|
||||
|
||||
it("returns empty data array for out-of-range page", async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get("/sessions?page=9999&limit=20")
|
||||
.expect(200);
|
||||
expect(res.body.data).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns 400 for invalid page param", async () => {
|
||||
await request(app.getHttpServer()).get("/sessions?page=0").expect(400);
|
||||
});
|
||||
|
||||
it("orders by sessionName ASC", async () => {
|
||||
await seedSession("zzz-sort-session");
|
||||
await seedSession("aaa-sort-session");
|
||||
|
||||
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,
|
||||
);
|
||||
expect(names).toEqual([...names].sort());
|
||||
});
|
||||
|
||||
it("orders by sessionName DESC", async () => {
|
||||
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,
|
||||
);
|
||||
expect(names).toEqual([...names].sort().reverse());
|
||||
});
|
||||
|
||||
it("returns 400 for invalid orderDir", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get("/sessions?orderDir=SIDEWAYS")
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ── DELETE /sessions/:id ───────────────────────────────────────────────────
|
||||
|
||||
describe("DELETE /sessions/:id", () => {
|
||||
it("deletes an existing session and returns 200", async () => {
|
||||
const s = await seedSession("delete-me-session");
|
||||
await request(app.getHttpServer())
|
||||
.delete(`/sessions/${s.id}`)
|
||||
.expect(200);
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get("/sessions")
|
||||
.expect(200);
|
||||
const names = res.body.data.map(
|
||||
(sess: { sessionName: string }) => sess.sessionName,
|
||||
);
|
||||
expect(names).not.toContain("delete-me-session");
|
||||
});
|
||||
|
||||
it("returns 404 for unknown id", async () => {
|
||||
await request(app.getHttpServer()).delete("/sessions/99999").expect(404);
|
||||
});
|
||||
|
||||
it("returns 400 for non-numeric id", async () => {
|
||||
await request(app.getHttpServer()).delete("/sessions/abc").expect(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "..",
|
||||
"noEmit": true,
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"exclude": []
|
||||
}
|
||||
Reference in New Issue
Block a user