feat(scenario): run logs, lint/format tooling, CONTRIBUTING

- add ScenarioRunLogEntity to persist step script output to DB
- stepLogger dual-writes to NestJS logger and DB (fire-and-forget)
- add GET /scenarios/:id/run/:runId returning run, stepRuns and logs
- add POST /scenarios/:id/run/:runId/wait (polls until terminal state)
- 9 new integration tests for the two endpoints (136 total)
- add eslint with typescript-eslint and eslint-config-prettier
- add npm scripts: format, lint, lint:fix
- resolve all lint errors across src and test (no any types)
- add CONTRIBUTING.md covering dev workflow
This commit is contained in:
2026-04-08 18:10:42 +03:00
parent b0c02a1cdc
commit 9a9ccbfe37
67 changed files with 3809 additions and 1412 deletions
+6 -3
View File
@@ -1,14 +1,17 @@
const mockElement = {
outerHTML: '<div id="mock">mock content</div>',
textContent: 'mock content',
textContent: "mock content",
};
export class JSDOM {
constructor(public html: string, public options?: any) {}
constructor(
public html: string,
public options?: Record<string, unknown>,
) {}
get window() {
return {
document: {
querySelector: (_selector: string) => mockElement,
querySelector: () => mockElement,
},
};
}
+3 -3
View File
@@ -3,9 +3,9 @@ export const chromium = {
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(''),
content: jest.fn().mockResolvedValue("<html></html>"),
title: jest.fn().mockReturnValue(""),
url: jest.fn().mockReturnValue(""),
evaluate: jest.fn(),
close: jest.fn(),
}),
+2 -2
View File
@@ -1,6 +1,6 @@
export class Readability {
constructor(private doc: any) {}
constructor(private doc: unknown) {}
parse() {
return { textContent: '' };
return { textContent: "" };
}
}
+39 -33
View File
@@ -1,23 +1,27 @@
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';
import debug from 'debug';
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");
jest.mock('@nestjs/common', () => {
const actual = jest.requireActual('@nestjs/common');
const log = require('debug')('test');
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] : []));
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) {
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';
const ctx = context ?? this.context ?? "App";
log(`[${ctx}]`, level, message);
};
}
@@ -25,29 +29,30 @@ jest.mock('@nestjs/common', () => {
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 { HealthController } from '../src/health/health.controller';
import { HttpExceptionFilter } from '../src/filters/http-exception.filter';
import { LoggingInterceptor } from '../src/interceptors/logging.interceptor';
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";
export async function buildTestApp(): Promise<INestApplication> {
const module: TestingModule = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true }),
TypeOrmModule.forRoot({
type: 'better-sqlite3',
database: ':memory:',
type: "better-sqlite3",
database: ":memory:",
entities: [
SessionEntity,
EnvironmentEntity,
@@ -55,6 +60,7 @@ export async function buildTestApp(): Promise<INestApplication> {
ScenarioStepEntity,
ScenarioRunEntity,
ScenarioRunStepEntity,
ScenarioRunLogEntity,
],
synchronize: true,
}),
+20 -20
View File
@@ -1,6 +1,6 @@
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { buildTestApp } from './app.harness';
import { INestApplication } from "@nestjs/common";
import request from "supertest";
import { buildTestApp } from "./app.harness";
/**
* Auth controller integration tests.
@@ -9,7 +9,7 @@ import { buildTestApp } from './app.harness';
* exercised here (those belong to e2e tests with real credentials).
* We cover the parts that can be tested without external dependencies.
*/
describe('AuthController', () => {
describe("AuthController", () => {
let app: INestApplication;
beforeAll(async () => {
@@ -22,39 +22,39 @@ describe('AuthController', () => {
// ── 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');
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);
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 () => {
it("returns 400 when key is missing", async () => {
await request(app.getHttpServer())
.post('/login')
.send({ environmentName: 'test-env' })
.post("/login")
.send({ environmentName: "test-env" })
.expect(400);
});
it('returns 400 when environmentName is missing', async () => {
it("returns 400 when environmentName is missing", async () => {
await request(app.getHttpServer())
.post('/login')
.send({ key: 'some-key' })
.post("/login")
.send({ key: "some-key" })
.expect(400);
});
it('returns 400 when key file does not exist', async () => {
it("returns 400 when key file does not exist", async () => {
await request(app.getHttpServer())
.post('/login')
.send({ key: 'nonexistent-key', environmentName: 'test-env' })
.post("/login")
.send({ key: "nonexistent-key", environmentName: "test-env" })
.expect(404); // NotFoundException for missing environment
});
});
+50 -44
View File
@@ -1,9 +1,9 @@
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';
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.
@@ -12,24 +12,26 @@ import { Repository } from 'typeorm';
* validation rejections (no browser launched) and session-not-found paths,
* which are safe to run in a headless CI environment.
*/
describe('BrowserController', () => {
describe("BrowserController", () => {
let app: INestApplication;
let sessionRepo: Repository<SessionEntity>;
const FAKE_SESSION = 'test-browser-session';
const FAKE_SESSION = "test-browser-session";
beforeAll(async () => {
app = await buildTestApp();
sessionRepo = app.get<Repository<SessionEntity>>(getRepositoryToken(SessionEntity));
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: '{}',
token: "fake-token",
cookies: "[]",
localStorage: "{}",
}),
);
});
@@ -40,29 +42,29 @@ describe('BrowserController', () => {
// ── POST /open ─────────────────────────────────────────────────────────────
describe('POST /open', () => {
it('returns 400 when body is empty', async () => {
await request(app.getHttpServer()).post('/open').send({}).expect(400);
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 () => {
it("returns 400 when url is missing", async () => {
await request(app.getHttpServer())
.post('/open')
.post("/open")
.send({ sessionName: FAKE_SESSION })
.expect(400);
});
it('returns 404 when session does not exist', async () => {
it("returns 404 when session does not exist", async () => {
await request(app.getHttpServer())
.post('/open')
.send({ sessionName: 'no-such-session', url: 'https://example.com' })
.post("/open")
.send({ sessionName: "no-such-session", url: "https://example.com" })
.expect(404);
});
it('succeeds without a session (sessionless open)', async () => {
it("succeeds without a session (sessionless open)", async () => {
const res = await request(app.getHttpServer())
.post('/open')
.send({ url: 'https://example.com' })
.post("/open")
.send({ url: "https://example.com" })
.expect(201);
expect(res.body).toMatchObject({
url: expect.any(String),
@@ -71,55 +73,59 @@ describe('BrowserController', () => {
});
});
it('returns only selector content when selector is provided', async () => {
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' })
.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 () => {
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 })
.post("/open")
.send({
url: "https://example.com",
selector: "#mock",
readerMode: true,
})
.expect(201);
expect(res.body.content).toBe('mock content');
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);
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 () => {
it("returns 400 when code is missing", async () => {
await request(app.getHttpServer())
.post('/exec')
.post("/exec")
.send({ sessionName: FAKE_SESSION })
.expect(400);
});
it('returns 400 when code has a syntax error', async () => {
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 {{{' })
.post("/exec")
.send({ sessionName: FAKE_SESSION, code: "this is not valid {{{" })
.expect(400);
});
it('returns 404 when session does not exist', async () => {
it("returns 404 when session does not exist", async () => {
await request(app.getHttpServer())
.post('/exec')
.send({ sessionName: 'no-such-session', code: 'return 1;' })
.post("/exec")
.send({ sessionName: "no-such-session", code: "return 1;" })
.expect(404);
});
it('succeeds without a session (sessionless exec)', async () => {
it("succeeds without a session (sessionless exec)", async () => {
const res = await request(app.getHttpServer())
.post('/exec')
.send({ code: 'return 42;' })
.post("/exec")
.send({ code: "return 42;" })
.expect(201);
expect(res.body).toEqual({ result: 42 });
});
+219 -140
View File
@@ -8,7 +8,7 @@
* fake globals injected as named parameters.
*/
import { dumpDom, DomNode } from '../src/code-executor/dom-helpers';
import { dumpDom, DomNode } from "../src/code-executor/dom-helpers";
// ---------------------------------------------------------------------------
// Fake DOM builder
@@ -39,10 +39,10 @@ interface FakeEl {
type ElAttrs = Partial<{
role: string;
'data-testid': string;
'data-qa': string;
'data-action': string;
'data-element-id': string;
"data-testid": string;
"data-qa": string;
"data-action": string;
"data-element-id": string;
id: string;
type: string;
name: string;
@@ -52,19 +52,31 @@ type ElAttrs = Partial<{
style: string;
}>;
function el(tag: string, attrs: ElAttrs = {}, ...children: (FakeEl | string)[]): FakeEl {
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('');
.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 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) {
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;
}
@@ -73,26 +85,28 @@ function el(tag: string, attrs: ElAttrs = {}, ...children: (FakeEl | string)[]):
children: childEls,
childNodes: ownTextNodes,
offsetParent: display || visibility ? null : {},
id: attrs.id ?? '',
type: attrs.type ?? '',
name: attrs.name ?? '',
id: attrs.id ?? "",
type: attrs.type ?? "",
name: attrs.name ?? "",
href: attrs.href
? attrs.href.startsWith('http')
? 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; },
getAttribute(name: string) {
return attrMap[name] ?? null;
},
_display: display,
_visibility: visibility,
};
}
function body(...children: (FakeEl | string)[]): FakeEl {
return el('body', {}, ...children);
return el("body", {}, ...children);
}
// ── Fake page ──────────────────────────────────────────────────────────────
@@ -109,26 +123,34 @@ function findByTag(root: FakeEl, tag: string): FakeEl | null {
function makePage(rootEl: FakeEl) {
const fakeDocument = {
querySelector(sel: string): FakeEl | null {
if (sel.startsWith('#') || sel.startsWith('[') || sel.startsWith('.')) return 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' },
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: Function, args: unknown) => {
// eslint-disable-next-line no-new-func
const exec = new Function(
'document', 'window', 'Node', '__args__',
`return (${fn.toString()})(__args__)`,
);
return Promise.resolve(exec(fakeDocument, fakeWindow, fakeNode, args));
});
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;
return { evaluate } as unknown as import("playwright").Page;
}
const dump = (rootEl: FakeEl, sel?: string): Promise<DomNode> =>
@@ -138,205 +160,262 @@ const dump = (rootEl: FakeEl, sel?: string): Promise<DomNode> =>
// Tests
// ---------------------------------------------------------------------------
describe('dumpDom', () => {
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');
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("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 () => {
it("scopes to an arbitrary sub-selector", async () => {
const root = body(
el('header', {}, el('a', { href: '/nav' }, 'Nav')),
el('main', {}, el('button', {}, 'Action')),
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();
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 () => {
it("skips elements with display:none", async () => {
const root = body(
el('button', { style: 'display:none' }, 'Hidden'),
el('button', {}, 'Visible'),
el("button", { style: "display:none" }, "Hidden"),
el("button", {}, "Visible"),
);
const result = await dump(root);
const btns = result.children.filter(c => c.tag === 'button');
const btns = result.children.filter((c) => c.tag === "button");
expect(btns).toHaveLength(1);
expect(btns[0].text).toBe('Visible');
expect(btns[0].text).toBe("Visible");
});
it('skips elements with visibility:hidden', async () => {
it("skips elements with visibility:hidden", async () => {
const root = body(
el('button', { style: 'visibility:hidden' }, 'Hidden'),
el('button', {}, 'Visible'),
el("button", { style: "visibility:hidden" }, "Hidden"),
el("button", {}, "Visible"),
);
const result = await dump(root);
const btns = result.children.filter(c => c.tag === 'button');
const btns = result.children.filter((c) => c.tag === "button");
expect(btns).toHaveLength(1);
expect(btns[0].text).toBe('Visible');
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');
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);
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);
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-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-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-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 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');
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');
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 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: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 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("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 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("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("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');
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("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("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 () => {
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');
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');
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);
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');
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);
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 () => {
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'))),
body(
el(
"form",
{},
el("input", { id: "n", type: "text", name: "name" }),
el("button", {}, "Send"),
),
),
);
const form = result.children.find(c => c.tag === 'form');
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();
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("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');
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");
});
});
+101 -72
View File
@@ -1,8 +1,8 @@
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { buildTestApp } from './app.harness';
import { INestApplication } from "@nestjs/common";
import request from "supertest";
import { buildTestApp } from "./app.harness";
describe('EnvironmentController', () => {
describe("EnvironmentController", () => {
let app: INestApplication;
beforeAll(async () => {
@@ -15,160 +15,187 @@ describe('EnvironmentController', () => {
// ── POST /environments ─────────────────────────────────────────────────────
describe('POST /environments', () => {
it('creates an environment and returns 201', async () => {
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' } })
.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');
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 () => {
it("returns 400 when name is missing", async () => {
await request(app.getHttpServer())
.post('/environments')
.send({ urls: { id_url: 'https://id.example.com' } })
.post("/environments")
.send({ urls: { id_url: "https://id.example.com" } })
.expect(400);
});
it('returns 400 when urls is missing', async () => {
it("returns 400 when urls is missing", async () => {
await request(app.getHttpServer())
.post('/environments')
.send({ name: 'env-no-urls' })
.post("/environments")
.send({ name: "env-no-urls" })
.expect(400);
});
it('returns 400 when urls is not an object', async () => {
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' })
.post("/environments")
.send({ name: "env-bad-urls", urls: "not-an-object" })
.expect(400);
});
it('returns 409 when name already exists', async () => {
it("returns 409 when name already exists", async () => {
await request(app.getHttpServer())
.post('/environments')
.send({ name: 'env-duplicate', urls: {} })
.post("/environments")
.send({ name: "env-duplicate", urls: {} })
.expect(201);
await request(app.getHttpServer())
.post('/environments')
.send({ name: 'env-duplicate', urls: {} })
.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);
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');
expect(typeof res.body.total).toBe("number");
expect(res.body).toHaveProperty("page");
expect(res.body).toHaveProperty("limit");
});
it('respects page and limit params', async () => {
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);
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);
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);
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("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: {} });
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: any) => e.name);
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: any) => e.name);
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);
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 () => {
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' } })
.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');
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 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);
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 () => {
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: {} })
.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' })
.send({ name: "env-patched" })
.expect(200);
expect(res.body.name).toBe('env-patched');
expect(res.body.name).toBe("env-patched");
});
it('returns 404 for unknown id', async () => {
it("returns 404 for unknown id", async () => {
await request(app.getHttpServer())
.patch('/environments/99999')
.send({ name: 'x' })
.patch("/environments/99999")
.send({ name: "x" })
.expect(404);
});
});
// ── DELETE /environments/:id ───────────────────────────────────────────────
describe('DELETE /environments/:id', () => {
it('deletes and returns 204', async () => {
describe("DELETE /environments/:id", () => {
it("deletes and returns 204", async () => {
const created = await request(app.getHttpServer())
.post('/environments')
.send({ name: 'env-delete-me', urls: {} })
.post("/environments")
.send({ name: "env-delete-me", urls: {} })
.expect(201);
await request(app.getHttpServer())
@@ -180,8 +207,10 @@ describe('EnvironmentController', () => {
.expect(404);
});
it('returns 404 for unknown id', async () => {
await request(app.getHttpServer()).delete('/environments/99999').expect(404);
it("returns 404 for unknown id", async () => {
await request(app.getHttpServer())
.delete("/environments/99999")
.expect(404);
});
});
});
+46 -40
View File
@@ -1,6 +1,6 @@
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { buildTestApp } from './app.harness';
import { INestApplication } from "@nestjs/common";
import request from "supertest";
import { buildTestApp } from "./app.harness";
/**
* MCP controller integration tests.
@@ -14,7 +14,7 @@ import { buildTestApp } from './app.harness';
* Browser-dependent tools (open_url, exec_code) require a live Playwright
* session and are not covered here.
*/
describe('McpController', () => {
describe("McpController", () => {
let app: INestApplication;
beforeAll(async () => {
@@ -35,13 +35,13 @@ describe('McpController', () => {
/** 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')
.post("/mcp")
.set("Content-Type", "application/json")
.set("Accept", "application/json, text/event-stream")
.send({
jsonrpc: '2.0',
jsonrpc: "2.0",
id: 1,
method: 'tools/call',
method: "tools/call",
params: { name: toolName, arguments: args },
});
return { status: res.status, rpc: parseSse(res.text) };
@@ -49,20 +49,20 @@ describe('McpController', () => {
// ── Connectivity ───────────────────────────────────────────────────────────
describe('POST /mcp — connectivity', () => {
it('is reachable and returns a non-5xx status', async () => {
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')
.post("/mcp")
.set("Content-Type", "application/json")
.set("Accept", "application/json, text/event-stream")
.send({
jsonrpc: '2.0',
jsonrpc: "2.0",
id: 1,
method: 'initialize',
method: "initialize",
params: {
protocolVersion: '2024-11-05',
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: 'test', version: '0' },
clientInfo: { name: "test", version: "0" },
},
});
expect(res.status).toBe(200);
@@ -71,9 +71,9 @@ describe('McpController', () => {
// ── 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');
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);
@@ -82,50 +82,56 @@ describe('McpController', () => {
// ── list_sessions tool ─────────────────────────────────────────────────────
describe('list_sessions', () => {
it('returns a paginated result with a data array', async () => {
const { status, rpc } = await mcpCall('list_sessions');
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 };
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');
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');
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 };
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');
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' },
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');
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 });
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 };
+378 -169
View File
@@ -1,8 +1,9 @@
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { buildTestApp } from './app.harness';
import { INestApplication } from "@nestjs/common";
import request from "supertest";
import { DataSource } from "typeorm";
import { buildTestApp } from "./app.harness";
describe('ScenarioController', () => {
describe("ScenarioController", () => {
let app: INestApplication;
beforeAll(async () => {
@@ -15,22 +16,25 @@ describe('ScenarioController', () => {
// ── helpers ────────────────────────────────────────────────────────────────
async function createScenario(name = 'test scenario') {
async function createScenario(name = "test scenario") {
const res = await request(app.getHttpServer())
.post('/scenarios')
.post("/scenarios")
.send({ name })
.expect(201);
return res.body as { id: number; name: string };
}
async function createStep(scenarioId: number, overrides: Record<string, unknown> = {}) {
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;',
type: "exec",
sessionName: "test-session",
execCode: "return 1;",
...overrides,
})
.expect(201);
@@ -39,80 +43,93 @@ describe('ScenarioController', () => {
// ── POST /scenarios ────────────────────────────────────────────────────────
describe('POST /scenarios', () => {
it('creates a scenario and returns 201', async () => {
describe("POST /scenarios", () => {
it("creates a scenario and returns 201", async () => {
const res = await request(app.getHttpServer())
.post('/scenarios')
.send({ name: 'my scenario' })
.post("/scenarios")
.send({ name: "my scenario" })
.expect(201);
expect(res.body.id).toBeDefined();
expect(res.body.name).toBe('my scenario');
expect(res.body.name).toBe("my scenario");
});
it('returns 400 when name is missing', async () => {
await request(app.getHttpServer()).post('/scenarios').send({}).expect(400);
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');
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');
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')
.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);
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("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');
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: any) => s.name);
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: any) => s.name);
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);
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');
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);
@@ -120,121 +137,128 @@ describe('ScenarioController', () => {
expect(Array.isArray(res.body.steps)).toBe(true);
});
it('returns 404 for unknown id', async () => {
await request(app.getHttpServer()).get('/scenarios/99999').expect(404);
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');
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' })
.send({ name: "patched" })
.expect(200);
expect(res.body.name).toBe('patched');
expect(res.body.name).toBe("patched");
});
it('returns 404 for unknown id', async () => {
it("returns 404 for unknown id", async () => {
await request(app.getHttpServer())
.patch('/scenarios/99999')
.send({ name: 'x' })
.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);
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);
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 () => {
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;' })
.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');
expect(res.body.type).toBe("exec");
expect(res.body.sessionName).toBe("my-session");
});
it('creates a login step', async () => {
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' })
.send({ order: 0, type: "login", sessionName: "session-x" })
.expect(201);
expect(res.body.type).toBe('login');
expect(res.body.type).toBe("login");
});
it('returns 400 when order is missing', async () => {
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' })
.send({ type: "exec", sessionName: "x" })
.expect(400);
});
it('returns 400 when type is invalid', async () => {
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' })
.send({ order: 0, type: "unknown", sessionName: "x" })
.expect(400);
});
it('returns 400 when sessionName is missing', async () => {
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' })
.send({ order: 0, type: "exec" })
.expect(400);
});
it('returns 404 for unknown scenario', async () => {
it("returns 404 for unknown scenario", async () => {
await request(app.getHttpServer())
.post('/scenarios/99999/steps')
.send({ order: 0, type: 'exec', sessionName: 'x' })
.post("/scenarios/99999/steps")
.send({ order: 0, type: "exec", sessionName: "x" })
.expect(404);
});
it('returns steps ordered by order field', async () => {
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' });
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: any) => s.order);
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 () => {
describe("GET /scenarios/:id/steps/:stepId", () => {
it("returns the step", async () => {
const sc = await createScenario();
const step = await createStep(sc.id);
@@ -245,7 +269,7 @@ describe('ScenarioController', () => {
expect(res.body.id).toBe(step.id);
});
it('returns 404 for unknown step', async () => {
it("returns 404 for unknown step", async () => {
const sc = await createScenario();
await request(app.getHttpServer())
.get(`/scenarios/${sc.id}/steps/99999`)
@@ -255,21 +279,21 @@ describe('ScenarioController', () => {
// ── PATCH /scenarios/:id/steps/:stepId ────────────────────────────────────
describe('PATCH /scenarios/:id/steps/:stepId', () => {
it('updates step fields', async () => {
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 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;' })
.send({ order: 5, execCode: "return 99;" })
.expect(200);
expect(res.body.order).toBe(5);
expect(res.body.execCode).toBe('return 99;');
expect(res.body.execCode).toBe("return 99;");
});
it('returns 404 for unknown step', async () => {
it("returns 404 for unknown step", async () => {
const sc = await createScenario();
await request(app.getHttpServer())
.patch(`/scenarios/${sc.id}/steps/99999`)
@@ -280,8 +304,8 @@ describe('ScenarioController', () => {
// ── DELETE /scenarios/:id/steps/:stepId ───────────────────────────────────
describe('DELETE /scenarios/:id/steps/:stepId', () => {
it('deletes the step and returns 204', async () => {
describe("DELETE /scenarios/:id/steps/:stepId", () => {
it("deletes the step and returns 204", async () => {
const sc = await createScenario();
const step = await createStep(sc.id);
@@ -297,8 +321,8 @@ describe('ScenarioController', () => {
// ── POST /scenarios/:id/run ────────────────────────────────────────────────
describe('POST /scenarios/:id/run', () => {
it('creates a run with stepRuns in correct initial states', async () => {
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 });
@@ -308,30 +332,32 @@ describe('ScenarioController', () => {
.post(`/scenarios/${sc.id}/run`)
.expect(201);
expect(res.body.status).toBe('pending');
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: any) => s.status);
expect(statuses[0]).toBe('pending');
expect(statuses[1]).toBe('waiting');
expect(statuses[2]).toBe('waiting');
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 () => {
it("returns 404 for unknown scenario", async () => {
await request(app.getHttpServer())
.post('/scenarios/99999/run')
.post("/scenarios/99999/run")
.expect(404);
});
});
// ── GET /scenarios/:id/runs ────────────────────────────────────────────────
describe('GET /scenarios/:id/runs', () => {
it('returns paginated runs with stepRuns embedded', async () => {
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);
await request(app.getHttpServer())
.post(`/scenarios/${sc.id}/run`)
.expect(201);
const res = await request(app.getHttpServer())
.get(`/scenarios/${sc.id}/runs`)
@@ -341,17 +367,21 @@ describe('ScenarioController', () => {
expect(Array.isArray(res.body.data[0].stepRuns)).toBe(true);
});
it('filters by status', async () => {
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);
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: any) => expect(r.status).toBe('pending'));
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`)
@@ -360,67 +390,84 @@ describe('ScenarioController', () => {
expect(passRes.body.total).toBe(0);
});
it('returns 400 for invalid status filter', async () => {
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);
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;' });
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(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' });
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: any) => s.order);
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' });
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');
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;' });
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`)
@@ -429,99 +476,261 @@ describe('ScenarioController', () => {
expect(res.body.steps[0].validateCode).toBeNull();
});
it('returns 404 for unknown scenario', async () => {
await request(app.getHttpServer()).get('/scenarios/99999/export').expect(404);
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 () => {
describe("POST /scenarios/import", () => {
it("creates a new scenario with all steps", async () => {
const payload = {
name: 'imported scenario',
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 },
{
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')
.post("/scenarios/import")
.send(payload)
.expect(201);
expect(res.body.id).toBeDefined();
expect(res.body.name).toBe('imported scenario');
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');
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');
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')
.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;' });
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')
.post("/scenarios/import")
.send(exportRes.body)
.expect(201);
expect(importRes.body.name).toBe('roundtrip');
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);
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 () => {
it("imports with empty steps array", async () => {
const res = await request(app.getHttpServer())
.post('/scenarios/import')
.send({ name: 'empty-import', steps: [] })
.post("/scenarios/import")
.send({ name: "empty-import", steps: [] })
.expect(201);
expect(res.body.name).toBe('empty-import');
expect(res.body.name).toBe("empty-import");
expect(res.body.steps).toHaveLength(0);
});
it('returns 400 when name is missing', async () => {
it("returns 400 when name is missing", async () => {
await request(app.getHttpServer())
.post('/scenarios/import')
.post("/scenarios/import")
.send({ steps: [] })
.expect(400);
});
it('returns 400 when steps is not an array', async () => {
it("returns 400 when steps is not an array", async () => {
await request(app.getHttpServer())
.post('/scenarios/import')
.send({ name: 'bad', steps: 'oops' })
.post("/scenarios/import")
.send({ name: "bad", steps: "oops" })
.expect(400);
});
it('returns 400 when a step has an invalid type', async () => {
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' }] })
.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);
});
});
});
+79 -55
View File
@@ -1,17 +1,19 @@
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';
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', () => {
describe("SessionController", () => {
let app: INestApplication;
let repo: Repository<SessionEntity>;
beforeAll(async () => {
app = await buildTestApp();
repo = app.get<Repository<SessionEntity>>(getRepositoryToken(SessionEntity));
repo = app.get<Repository<SessionEntity>>(
getRepositoryToken(SessionEntity),
);
});
afterAll(async () => {
@@ -22,98 +24,120 @@ describe('SessionController', () => {
return repo.save(
repo.create({
sessionName: name,
token: 'tok',
cookies: '[]',
localStorage: '{}',
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);
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');
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: any) => s.sessionName);
expect(names).toContain('visible-session');
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: any) => s.sessionName === 'private-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');
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);
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);
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("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');
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: any) => s.sessionName);
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: any) => s.sessionName);
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);
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);
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: any) => sess.sessionName);
expect(names).not.toContain('delete-me-session');
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 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);
it("returns 400 for non-numeric id", async () => {
await request(app.getHttpServer()).delete("/sessions/abc").expect(400);
});
});
});