- add output column to ScenarioRunStepEntity; exec step return value is JSON-serialised and stored - expose helpers.getStepOutput(order) in exec code; negative order is relative to current step - add get_scenario_run, wait_for_scenario_run, export_scenario, import_scenario MCP tools - move Architecture, Key descriptors, MCP tools, Scenario, Development docs to docs/ - delete CONTRIBUTING.md (replaced by docs/development.md)
424 lines
14 KiB
TypeScript
424 lines
14 KiB
TypeScript
/**
|
|
* Unit tests for dumpDom.
|
|
*
|
|
* We use a lightweight fake DOM rather than jsdom to avoid ESM-dependency
|
|
* transform issues. Each fake element is a plain JS object that duck-types
|
|
* the DOM API used by the dumpDom evaluate payload. The fake `page`
|
|
* captures the evaluate callback and runs it via new Function() with the
|
|
* fake globals injected as named parameters.
|
|
*/
|
|
|
|
import { dumpDom, DomNode } from "../src/code-executor/dom-helpers";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fake DOM builder
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface FakeText {
|
|
nodeType: 3;
|
|
textContent: string;
|
|
}
|
|
|
|
interface FakeEl {
|
|
tagName: string;
|
|
children: FakeEl[];
|
|
childNodes: FakeText[];
|
|
offsetParent: object | null;
|
|
id: string;
|
|
type: string;
|
|
name: string;
|
|
href: string;
|
|
checked: boolean;
|
|
disabled: boolean;
|
|
innerText: string;
|
|
textContent: string;
|
|
getAttribute(name: string): string | null;
|
|
_display: string;
|
|
_visibility: string;
|
|
}
|
|
|
|
type ElAttrs = Partial<{
|
|
role: string;
|
|
"data-testid": string;
|
|
"data-qa": string;
|
|
"data-action": string;
|
|
"data-element-id": string;
|
|
id: string;
|
|
type: string;
|
|
name: string;
|
|
href: string;
|
|
checked: boolean;
|
|
disabled: boolean;
|
|
style: string;
|
|
}>;
|
|
|
|
function el(
|
|
tag: string,
|
|
attrs: ElAttrs = {},
|
|
...children: (FakeEl | string)[]
|
|
): FakeEl {
|
|
const ownTextNodes: FakeText[] = children
|
|
.filter((c): c is string => typeof c === "string")
|
|
.map((t) => ({ nodeType: 3, textContent: t }));
|
|
const childEls = children.filter((c): c is FakeEl => typeof c !== "string");
|
|
const deepText = children
|
|
.map((c) => (typeof c === "string" ? c : c.innerText))
|
|
.join("");
|
|
|
|
const style = attrs.style ?? "";
|
|
const display = /display\s*:\s*none/.test(style) ? "none" : "";
|
|
const visibility = /visibility\s*:\s*hidden/.test(style) ? "hidden" : "";
|
|
|
|
const attrMap: Record<string, string | null> = {};
|
|
for (const key of [
|
|
"role",
|
|
"data-testid",
|
|
"data-qa",
|
|
"data-action",
|
|
"data-element-id",
|
|
] as const) {
|
|
if (attrs[key] != null) attrMap[key] = attrs[key] as string;
|
|
}
|
|
|
|
return {
|
|
tagName: tag.toUpperCase(),
|
|
children: childEls,
|
|
childNodes: ownTextNodes,
|
|
offsetParent: display || visibility ? null : {},
|
|
id: attrs.id ?? "",
|
|
type: attrs.type ?? "",
|
|
name: attrs.name ?? "",
|
|
href: attrs.href
|
|
? attrs.href.startsWith("http")
|
|
? attrs.href
|
|
: `https://example.com${attrs.href}`
|
|
: "",
|
|
checked: !!attrs.checked,
|
|
disabled: !!attrs.disabled,
|
|
innerText: deepText,
|
|
textContent: deepText,
|
|
getAttribute(name: string) {
|
|
return attrMap[name] ?? null;
|
|
},
|
|
_display: display,
|
|
_visibility: visibility,
|
|
};
|
|
}
|
|
|
|
function body(...children: (FakeEl | string)[]): FakeEl {
|
|
return el("body", {}, ...children);
|
|
}
|
|
|
|
// ── Fake page ──────────────────────────────────────────────────────────────
|
|
|
|
function findByTag(root: FakeEl, tag: string): FakeEl | null {
|
|
if (root.tagName.toLowerCase() === tag.toLowerCase()) return root;
|
|
for (const child of root.children) {
|
|
const found = findByTag(child, tag);
|
|
if (found) return found;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function makePage(rootEl: FakeEl) {
|
|
const fakeDocument = {
|
|
querySelector(sel: string): FakeEl | null {
|
|
if (sel.startsWith("#") || sel.startsWith("[") || sel.startsWith("."))
|
|
return null;
|
|
return findByTag(rootEl, sel);
|
|
},
|
|
};
|
|
const fakeWindow = {
|
|
getComputedStyle: (e: FakeEl) => ({
|
|
display: e._display,
|
|
visibility: e._visibility,
|
|
}),
|
|
location: { origin: "https://example.com" },
|
|
};
|
|
const fakeNode = { TEXT_NODE: 3 };
|
|
|
|
const evaluate = jest
|
|
.fn()
|
|
.mockImplementation(
|
|
(fn: (...args: unknown[]) => unknown, args: unknown) => {
|
|
const exec = new Function(
|
|
"document",
|
|
"window",
|
|
"Node",
|
|
"__args__",
|
|
`return (${fn.toString()})(__args__)`,
|
|
);
|
|
return Promise.resolve(exec(fakeDocument, fakeWindow, fakeNode, args));
|
|
},
|
|
);
|
|
|
|
return { evaluate } as unknown as import("playwright").Page;
|
|
}
|
|
|
|
const dump = (rootEl: FakeEl, sel?: string): Promise<DomNode> =>
|
|
dumpDom(makePage(rootEl), sel);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("dumpDom", () => {
|
|
// ── Error handling ────────────────────────────────────────────────────────
|
|
|
|
it("returns an ERROR node when the root selector is not found", async () => {
|
|
const result = await dump(body(el("p", {}, "hello")), "#does-not-exist");
|
|
expect(result.tag).toBe("ERROR");
|
|
expect(result.text).toContain("#does-not-exist");
|
|
});
|
|
|
|
// ── Scope & defaults ──────────────────────────────────────────────────────
|
|
|
|
it("defaults to body scope", async () => {
|
|
const result = await dump(body(el("button", {}, "Go")));
|
|
expect(result.tag).toBe("body");
|
|
});
|
|
|
|
it("scopes to an arbitrary sub-selector", async () => {
|
|
const root = body(
|
|
el("header", {}, el("a", { href: "/nav" }, "Nav")),
|
|
el("main", {}, el("button", {}, "Action")),
|
|
);
|
|
const result = await dump(root, "main");
|
|
expect(result.tag).toBe("main");
|
|
expect(result.children.find((c) => c.tag === "header")).toBeUndefined();
|
|
});
|
|
|
|
// ── Visibility filtering ──────────────────────────────────────────────────
|
|
|
|
it("skips elements with display:none", async () => {
|
|
const root = body(
|
|
el("button", { style: "display:none" }, "Hidden"),
|
|
el("button", {}, "Visible"),
|
|
);
|
|
const result = await dump(root);
|
|
const btns = result.children.filter((c) => c.tag === "button");
|
|
expect(btns).toHaveLength(1);
|
|
expect(btns[0].text).toBe("Visible");
|
|
});
|
|
|
|
it("skips elements with visibility:hidden", async () => {
|
|
const root = body(
|
|
el("button", { style: "visibility:hidden" }, "Hidden"),
|
|
el("button", {}, "Visible"),
|
|
);
|
|
const result = await dump(root);
|
|
const btns = result.children.filter((c) => c.tag === "button");
|
|
expect(btns).toHaveLength(1);
|
|
expect(btns[0].text).toBe("Visible");
|
|
});
|
|
|
|
// ── Ignored tag types ─────────────────────────────────────────────────────
|
|
|
|
it("skips SVG elements", async () => {
|
|
const svgEl = el("svg", {}, el("path", {}));
|
|
const result = await dump(body(el("button", {}, svgEl, "Click")));
|
|
const btn = result.children.find((c) => c.tag === "button");
|
|
expect(btn).toBeDefined();
|
|
expect(btn!.children.some((c) => c.tag === "svg")).toBe(false);
|
|
});
|
|
|
|
it("skips SCRIPT elements", async () => {
|
|
const result = await dump(
|
|
body(el("script", {}, "alert(1)"), el("button", {}, "OK")),
|
|
);
|
|
expect(result.children.some((c) => c.tag === "script")).toBe(false);
|
|
});
|
|
|
|
// ── Semantic attributes ───────────────────────────────────────────────────
|
|
|
|
it("captures data-testid", async () => {
|
|
const result = await dump(
|
|
body(el("button", { "data-testid": "save-btn" }, "Save")),
|
|
);
|
|
expect(result.children.find((c) => c.tag === "button")?.testid).toBe(
|
|
"save-btn",
|
|
);
|
|
});
|
|
|
|
it("captures data-qa", async () => {
|
|
const result = await dump(
|
|
body(
|
|
el("div", { "data-qa": "process-name" }, el("input", { type: "text" })),
|
|
),
|
|
);
|
|
expect(result.children.find((c) => c.qa === "process-name")).toBeDefined();
|
|
});
|
|
|
|
it("captures data-action", async () => {
|
|
const result = await dump(
|
|
body(el("div", { "data-action": "append.append-task" })),
|
|
);
|
|
expect(
|
|
result.children.find((c) => c.action === "append.append-task"),
|
|
).toBeDefined();
|
|
});
|
|
|
|
it("captures data-element-id", async () => {
|
|
const result = await dump(
|
|
body(el("div", { "data-element-id": "Activity_1abc" })),
|
|
);
|
|
expect(
|
|
result.children.find((c) => c.elementId === "Activity_1abc"),
|
|
).toBeDefined();
|
|
});
|
|
|
|
it("captures role attribute", async () => {
|
|
const result = await dump(
|
|
body(el("div", { role: "dialog" }, el("button", {}, "OK"))),
|
|
);
|
|
const dialog = result.children.find((c) => c.role === "dialog");
|
|
expect(dialog).toBeDefined();
|
|
expect(dialog!.tag).toBe("div");
|
|
});
|
|
|
|
// ── Interactive element attributes ────────────────────────────────────────
|
|
|
|
it("captures input id, type, and name", async () => {
|
|
const result = await dump(
|
|
body(el("input", { id: "email", type: "email", name: "userEmail" })),
|
|
);
|
|
const input = result.children.find((c) => c.tag === "input");
|
|
expect(input?.id).toBe("email");
|
|
expect(input?.type).toBe("email");
|
|
expect(input?.name).toBe("userEmail");
|
|
});
|
|
|
|
it("captures checked:true on a checked checkbox", async () => {
|
|
const result = await dump(
|
|
body(el("input", { type: "checkbox", checked: true })),
|
|
);
|
|
expect(result.children.find((c) => c.tag === "input")?.checked).toBe(true);
|
|
});
|
|
|
|
it("captures checked:false on an unchecked checkbox", async () => {
|
|
const result = await dump(body(el("input", { type: "checkbox" })));
|
|
expect(result.children.find((c) => c.tag === "input")?.checked).toBe(false);
|
|
});
|
|
|
|
it("captures disabled:true on a disabled button", async () => {
|
|
const result = await dump(body(el("button", { disabled: true }, "Nope")));
|
|
expect(result.children.find((c) => c.tag === "button")?.disabled).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("does not set disabled for a non-disabled button", async () => {
|
|
const result = await dump(body(el("button", {}, "OK")));
|
|
expect(
|
|
result.children.find((c) => c.tag === "button")?.disabled,
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("does not capture type for button elements", async () => {
|
|
const result = await dump(body(el("button", { type: "submit" }, "Go")));
|
|
expect(
|
|
result.children.find((c) => c.tag === "button")?.type,
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("relativizes same-origin anchor href", async () => {
|
|
const result = await dump(body(el("a", { href: "/workflow/123" }, "Link")));
|
|
expect(result.children.find((c) => c.tag === "a")?.href).toBe(
|
|
"/workflow/123",
|
|
);
|
|
});
|
|
|
|
it("keeps full href for cross-origin anchors", async () => {
|
|
const result = await dump(
|
|
body(el("a", { href: "https://other.com/page" }, "Ext")),
|
|
);
|
|
expect(result.children.find((c) => c.tag === "a")?.href).toContain(
|
|
"other.com",
|
|
);
|
|
});
|
|
|
|
// ── Text content ──────────────────────────────────────────────────────────
|
|
|
|
it("captures own text content of a button", async () => {
|
|
const result = await dump(body(el("button", {}, "Save")));
|
|
expect(result.children.find((c) => c.tag === "button")?.text).toBe("Save");
|
|
});
|
|
|
|
it("truncates text to 80 characters", async () => {
|
|
const long = "x".repeat(100);
|
|
const result = await dump(body(el("button", {}, long)));
|
|
expect(result.children.find((c) => c.tag === "button")?.text?.length).toBe(
|
|
80,
|
|
);
|
|
});
|
|
|
|
it("falls back to innerText when element has no direct text nodes", async () => {
|
|
// button wraps a span — no direct text node on button, innerText = span text
|
|
const result = await dump(body(el("button", {}, el("span", {}, "Nested"))));
|
|
expect(result.children.find((c) => c.tag === "button")?.text).toBe(
|
|
"Nested",
|
|
);
|
|
});
|
|
|
|
// ── Tree pruning / unwrapping ─────────────────────────────────────────────
|
|
|
|
it("unwraps a non-significant div that has exactly one significant child", async () => {
|
|
const result = await dump(body(el("div", {}, el("button", {}, "Click"))));
|
|
const btn = result.children.find((c) => c.tag === "button");
|
|
expect(btn).toBeDefined();
|
|
expect(
|
|
result.children.some(
|
|
(c) => c.tag === "div" && !c.role && !c.testid && !c.qa,
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("keeps a non-significant div that has more than one significant child", async () => {
|
|
const result = await dump(
|
|
body(el("div", {}, el("button", {}, "A"), el("button", {}, "B"))),
|
|
);
|
|
const wrapper = result.children.find((c) => c.tag === "div");
|
|
expect(wrapper).toBeDefined();
|
|
expect(wrapper!.children).toHaveLength(2);
|
|
});
|
|
|
|
it("discards non-significant childless elements", async () => {
|
|
const result = await dump(body(el("div", {}), el("button", {}, "Keep")));
|
|
expect(
|
|
result.children.find((c) => c.tag === "div" && c.children.length === 0),
|
|
).toBeUndefined();
|
|
expect(result.children.some((c) => c.tag === "button")).toBe(true);
|
|
});
|
|
|
|
// ── Structural tags ───────────────────────────────────────────────────────
|
|
|
|
it("preserves nested structure inside a form", async () => {
|
|
const result = await dump(
|
|
body(
|
|
el(
|
|
"form",
|
|
{},
|
|
el("input", { id: "n", type: "text", name: "name" }),
|
|
el("button", {}, "Send"),
|
|
),
|
|
),
|
|
);
|
|
const form = result.children.find((c) => c.tag === "form");
|
|
expect(form).toBeDefined();
|
|
expect(form!.children.find((c) => c.tag === "input")).toBeDefined();
|
|
expect(form!.children.find((c) => c.tag === "button")).toBeDefined();
|
|
});
|
|
|
|
it("preserves dialog element", async () => {
|
|
const result = await dump(
|
|
body(el("dialog", { role: "dialog" }, el("button", {}, "Close"))),
|
|
);
|
|
expect(result.children.find((c) => c.tag === "dialog")).toBeDefined();
|
|
});
|
|
|
|
it("returns empty node when root has no visible significant content", async () => {
|
|
const result = await dumpDom(makePage(el("div", {})), "div");
|
|
expect(result.tag).toBe("empty");
|
|
});
|
|
});
|