186 lines
8.0 KiB
TypeScript
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"]'
|
|
}));
|
|
});
|
|
});
|
|
|