PRA-only-demo/src/main/capabilities/browser-extended.test.ts

186 lines
8.0 KiB
TypeScript

import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import { getHandler } from "../engine/registry";
import { RuntimeError, type BrowserLike, type RunContext } from "../engine/types";
import "./browser-extended";
const EXTENDED_IDS = [
"Element_Click_V1", "Element_Click_V2", "Element_Click_V4", "Element_Click_V5",
"Element_SetValue_V1", "Element_SetValue_V2", "Element_SetValue_V3", "Element_SetValue_V5",
"Element_SetValuePassword", "Element_SetValuePassword_V1", "Element_SetValuePassword_V5",
"Element_SetValuePassword_V6", "Element_GetText_V1", "Element_GetText_V2", "Element_GetText_V3",
"Element_GetText_V4", "Element_GetValue", "Element_WaitElement", "Element_WaitElement_v1",
"Element_WaitElement_v2", "Element_WaitElement_v3", "Element_WaitElement_v4",
"Element_SelectOperation_v2", "Element_SelectOperation_v3"
] as const;
const component = {} as CatalogComponent;
function node(componentId: string): FlowNode {
return { id: `extended_${componentId}`, componentId, position: { x: 0, y: 0 }, values: {}, options: {} };
}
function fakeBrowser(): BrowserLike {
return {
navigate: vi.fn(async (url: string) => ({ url, title: "Fake page" })),
click: vi.fn(async (selector: unknown) => ({ selector })),
type: vi.fn(async (selector: unknown, text: string, clear?: boolean) => ({ selector, text, clear })),
getText: vi.fn(async (_selector: unknown, attribute?: string) => attribute ? `attr:${attribute}` : "fake text"),
exists: vi.fn(async () => true),
select: vi.fn(async (selector: unknown, value: string) => ({ selector, value })),
runJs: vi.fn(async () => true),
download: vi.fn(async () => "download.bin"),
screenshot: vi.fn(async (target: string) => target),
currentUrl: vi.fn(async () => "about:blank"),
close: vi.fn(async () => undefined)
};
}
function context(browser: BrowserLike): RunContext {
return {
runId: "browser-extended-run",
flowId: "browser-extended-flow",
flow: {} as RunContext["flow"],
vars: {},
secrets: {},
signal: new AbortController().signal,
evidenceDir: "C:/temp/evidence",
browser,
emit: vi.fn(),
log: vi.fn(),
componentOf: vi.fn()
};
}
async function invoke(ctx: RunContext, componentId: string, args: Record<string, unknown>): Promise<unknown> {
const handler = getHandler(componentId);
expect(handler, `${componentId} should be registered`).toBeTypeOf("function");
return handler!(ctx, node(componentId), args, component);
}
async function expectValidationError(promise: Promise<unknown>): Promise<void> {
await expect(promise).rejects.toSatisfy((error: unknown) =>
error instanceof RuntimeError && error.code === "validation_error"
);
}
describe("extended browser component registration", () => {
it("registers only component IDs that exist in the catalog", () => {
const catalog = JSON.parse(readFileSync(resolve("resources/catalog.json"), "utf8")) as {
components: Array<{ id: string }>;
};
const catalogIds = new Set(catalog.components.map((item) => item.id));
expect(EXTENDED_IDS).toHaveLength(24);
expect(EXTENDED_IDS.filter((id) => !catalogIds.has(id))).toEqual([]);
expect(EXTENDED_IDS.every((id) => typeof getHandler(id) === "function")).toBe(true);
});
});
describe("extended browser component routing", () => {
let browser: BrowserLike;
let ctx: RunContext;
const selector = { kind: "css", value: "#target" };
beforeEach(() => {
browser = fakeBrowser();
ctx = context(browser);
});
it.each(["Element_Click_V1", "Element_Click_V2", "Element_Click_V4", "Element_Click_V5"])(
"%s routes a default left click",
async (componentId) => {
await invoke(ctx, componentId, { uiSelector: selector });
expect(browser.click).toHaveBeenCalledWith(selector);
}
);
it("rejects unsupported click modes instead of silently changing their meaning", async () => {
await expectValidationError(invoke(ctx, "Element_Click_V2", { uiSelector: selector, clickfunc: "1" }));
expect(browser.click).not.toHaveBeenCalled();
});
it.each([
["Element_SetValue_V1", { value: "append", input: "1" }, "append", false],
["Element_SetValue_V2", { text: "replace", input_type: "0" }, "replace", true],
["Element_SetValue_V3", { text: "append", input_type: "1" }, "append", false],
["Element_SetValue_V5", { text: "replace", input_type: "0" }, "replace", true]
])("%s routes text and replacement mode", async (componentId, args, expectedText, expectedClear) => {
await invoke(ctx, componentId, { uiSelector: selector, ...args });
expect(browser.type).toHaveBeenCalledWith(selector, expectedText, expectedClear);
});
it.each([
"Element_SetValuePassword", "Element_SetValuePassword_V1",
"Element_SetValuePassword_V5", "Element_SetValuePassword_V6"
])("%s types a password without logging its value", async (componentId) => {
await invoke(ctx, componentId, { uiSelector: selector, text: "secret-value" });
expect(browser.type).toHaveBeenCalledWith(selector, "secret-value", true);
expect(ctx.log).toHaveBeenCalledWith("已输入密码", "info", `extended_${componentId}`);
expect(JSON.stringify(vi.mocked(ctx.log).mock.calls)).not.toContain("secret-value");
});
it("submits the active input when Enter is requested", async () => {
await invoke(ctx, "Element_SetValue_V3", { uiSelector: selector, text: "query", isEnter: "1" });
expect(browser.runJs).toHaveBeenCalledOnce();
expect(vi.mocked(browser.runJs).mock.calls[0][0]).toContain("requestSubmit");
});
it.each(["Element_GetText_V1", "Element_GetText_V2", "Element_GetText_V3", "Element_GetText_V4"])(
"%s reads element text",
async (componentId) => {
await expect(invoke(ctx, componentId, { uiSelector: selector })).resolves.toBe("fake text");
expect(browser.getText).toHaveBeenCalledWith(selector, undefined);
}
);
it("routes requested attributes and the GetValue variant", async () => {
await expect(invoke(ctx, "Element_GetText_V3", {
uiSelector: selector, attr_type: "attribute", attr_name: "data-state"
})).resolves.toBe("attr:data-state");
await expect(invoke(ctx, "Element_GetValue", { uiSelector: selector })).resolves.toBe("fake text");
});
it.each([
"Element_WaitElement", "Element_WaitElement_v1", "Element_WaitElement_v2",
"Element_WaitElement_v3", "Element_WaitElement_v4"
])("%s resolves when the requested presence condition is met", async (componentId) => {
await expect(invoke(ctx, componentId, { uiSelector: selector, timeout: 0, time_out: 0 })).resolves.toBe(true);
expect(browser.exists).toHaveBeenCalledWith(selector);
});
it("supports waiting for disappearance and reports timeout as false", async () => {
vi.mocked(browser.exists).mockResolvedValueOnce(false);
await expect(invoke(ctx, "Element_WaitElement_v4", {
uiSelector: selector, choose: "3", time_out: 0
})).resolves.toBe(true);
vi.mocked(browser.exists).mockResolvedValue(true);
await expect(invoke(ctx, "Element_WaitElement", {
uiSelector: selector, choose: "3", timeout: 0
})).resolves.toBe(false);
});
it("routes v2 and v3 single-option selections", async () => {
await invoke(ctx, "Element_SelectOperation_v2", {
uiSelector: selector, itemsToSelect: '["approved"]'
});
expect(browser.select).toHaveBeenCalledWith(selector, "approved");
await invoke(ctx, "Element_SelectOperation_v3", {
uiSelector: selector, select_value_str: "pending"
});
expect(browser.select).toHaveBeenCalledWith(selector, "pending");
});
it("rejects missing selectors, image text mode and unsupported select queries", async () => {
await expectValidationError(invoke(ctx, "Element_Click_V1", {}));
await expectValidationError(invoke(ctx, "Element_GetText_V2", { uiSelector: selector, choose: "1" }));
await expectValidationError(invoke(ctx, "Element_SelectOperation_v2", {
uiSelector: selector, operation_type: "2", itemsToSelect: '["approved"]'
}));
});
});