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
+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");
});
});