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): Promise { const handler = getHandler(componentId); expect(handler, `${componentId} should be registered`).toBeTypeOf("function"); return handler!(ctx, node(componentId), args, component); } async function expectValidationError(promise: Promise): Promise { 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"]' })); }); });