diff --git a/core/nut.js/lib/window.class.spec.ts b/core/nut.js/lib/window.class.spec.ts index 45e6bb6..4a4056f 100644 --- a/core/nut.js/lib/window.class.spec.ts +++ b/core/nut.js/lib/window.class.spec.ts @@ -1,7 +1,15 @@ import { Window } from "./window.class"; -import { ProviderRegistry, ScreenProviderInterface, WindowProviderInterface } from "@nut-tree/provider-interfaces"; +import { + ElementInspectionProviderInterface, + LogProviderInterface, + ProviderRegistry, + ScreenProviderInterface, + WindowProviderInterface +} from "@nut-tree/provider-interfaces"; import { mockPartial } from "sneer"; -import { Region } from "@nut-tree/shared"; +import { Region, WindowElement, WindowElementDescription } from "@nut-tree/shared"; +import { windowElementDescribedBy } from "../index"; +import { NoopLogProvider } from "./provider/log/noop-log-provider.class"; describe("Window class", () => { it("should retrieve the window region via provider", async () => { @@ -54,4 +62,166 @@ describe("Window class", () => { expect(windowMock).toHaveBeenCalledTimes(1); expect(windowMock).toHaveBeenCalledWith(mockWindowHandle); }); + + describe("element-inspection", () => { + it("should retrieve the window elements via provider", async () => { + // GIVEN + const elementInspectorMock = jest.fn(); + const providerRegistryMock = mockPartial({ + getWindowElementInspector(): ElementInspectionProviderInterface { + return mockPartial({ + getElements: elementInspectorMock + }); + } + }); + const mockWindowHandle = 123; + const maxElements = 1000; + const SUT = new Window(providerRegistryMock, mockWindowHandle); + + // WHEN + await SUT.getElements(maxElements); + + // THEN + expect(elementInspectorMock).toHaveBeenCalledTimes(1); + expect(elementInspectorMock).toHaveBeenCalledWith(mockWindowHandle, maxElements); + }); + + it("should search for window elements via provider", async () => { + // GIVEN + const elementInspectorMock = jest.fn(); + const providerRegistryMock = mockPartial({ + getWindowElementInspector(): ElementInspectionProviderInterface { + return mockPartial({ + findElement: elementInspectorMock, + findElements: elementInspectorMock + }); + }, + getLogProvider(): LogProviderInterface { + return new NoopLogProvider(); + } + }); + const mockWindowHandle = 123; + const description: WindowElementDescription = { + type: "test" + }; + const SUT = new Window(providerRegistryMock, mockWindowHandle); + + // WHEN + await SUT.find(windowElementDescribedBy(description)); + + // THEN + expect(elementInspectorMock).toHaveBeenCalledTimes(1); + expect(elementInspectorMock).toHaveBeenCalledWith(mockWindowHandle, description); + }); + + it("should search for multiple elements via provider", async () => { + // GIVEN + const elementInspectorMock = jest.fn(); + const providerRegistryMock = mockPartial({ + getWindowElementInspector(): ElementInspectionProviderInterface { + return mockPartial({ + findElement: elementInspectorMock, + findElements: elementInspectorMock + }); + }, + getLogProvider(): LogProviderInterface { + return new NoopLogProvider(); + } + }); + const mockWindowHandle = 123; + const description: WindowElementDescription = { + type: "test" + }; + const SUT = new Window(providerRegistryMock, mockWindowHandle); + + // WHEN + await SUT.findAll(windowElementDescribedBy(description)); + + // THEN + expect(elementInspectorMock).toHaveBeenCalledTimes(1); + expect(elementInspectorMock).toHaveBeenCalledWith(mockWindowHandle, description); + }); + + describe("hooks", () => { + it("should trigger registered hooks", async () => { + // GIVEN + const windowElementType = { type: "testElement" }; + const windowElement = mockPartial(windowElementType); + const hookMock = jest.fn(); + const elementInspectorMock = jest.fn(() => Promise.resolve(windowElement)); + const secondHookMock = jest.fn(); + const providerRegistryMock = mockPartial({ + getWindowElementInspector(): ElementInspectionProviderInterface { + return mockPartial({ + findElement: elementInspectorMock + }); + }, + getLogProvider(): LogProviderInterface { + return new NoopLogProvider(); + } + }); + const mockWindowHandle = 123; + const description: WindowElementDescription = { + type: "test" + }; + const query = windowElementDescribedBy(description); + const SUT = new Window(providerRegistryMock, mockWindowHandle); + SUT.on(query, hookMock); + SUT.on(query, secondHookMock); + + // WHEN + await SUT.find(query); + + // THEN + expect(elementInspectorMock).toHaveBeenCalledTimes(1); + expect(elementInspectorMock).toHaveBeenCalledWith(mockWindowHandle, description); + expect(hookMock).toHaveBeenCalledTimes(1); + expect(hookMock).toHaveBeenCalledWith(windowElement); + expect(secondHookMock).toHaveBeenCalledTimes(1); + expect(secondHookMock).toHaveBeenCalledWith(windowElement); + }); + + it("should trigger registered hooks for all matches", async () => { + // GIVEN + const windowElementType = { type: "testElement" }; + const secondElementType = { type: "secondElement" }; + const windowElement = mockPartial(windowElementType); + const secondElement = mockPartial(secondElementType); + const mockMatches = [windowElement, secondElement]; + const hookMock = jest.fn(); + const elementInspectorMock = jest.fn(() => Promise.resolve(mockMatches)); + const secondHookMock = jest.fn(); + const providerRegistryMock = mockPartial({ + getWindowElementInspector(): ElementInspectionProviderInterface { + return mockPartial({ + findElements: elementInspectorMock + }); + }, + getLogProvider(): LogProviderInterface { + return new NoopLogProvider(); + } + }); + const mockWindowHandle = 123; + const description: WindowElementDescription = { + type: "test" + }; + const query = windowElementDescribedBy(description); + const SUT = new Window(providerRegistryMock, mockWindowHandle); + SUT.on(query, hookMock); + SUT.on(query, secondHookMock); + + // WHEN + await SUT.findAll(query); + + // THEN + expect(elementInspectorMock).toHaveBeenCalledTimes(1); + expect(elementInspectorMock).toHaveBeenCalledWith(mockWindowHandle, description); + expect(hookMock).toHaveBeenCalledTimes(mockMatches.length); + expect(hookMock).toHaveBeenCalledWith(windowElement); + expect(hookMock).toHaveBeenCalledWith(secondElement); + expect(secondHookMock).toHaveBeenCalledTimes(mockMatches.length); + expect(secondHookMock).toHaveBeenCalledWith(secondElement); + }); + }); + }); }); diff --git a/core/nut.js/lib/window.class.ts b/core/nut.js/lib/window.class.ts index 66ca7ba..24bc3cf 100644 --- a/core/nut.js/lib/window.class.ts +++ b/core/nut.js/lib/window.class.ts @@ -1,11 +1,31 @@ -import { Point, Region, Size, WindowInterface } from "@nut-tree/shared"; +import { + FindHookCallback, + isWindowElementQuery, + OptionalSearchParameters, + Point, + Region, + Size, + WindowedFindInput, + WindowElement, + WindowElementCallback, + WindowElementQuery, + WindowElementResultFindInput, + WindowInterface +} from "@nut-tree/shared"; import { ProviderRegistry } from "@nut-tree/provider-interfaces"; +import { timeout } from "./util/timeout.function"; export class Window implements WindowInterface { + private findHooks: Map; + constructor( private providerRegistry: ProviderRegistry, private windowHandle: number ) { + this.findHooks = new Map< + WindowElementQuery, + WindowElementCallback[] + >(); } get title(): Promise { @@ -65,4 +85,147 @@ export class Window implements WindowInterface { async focus() { return this.providerRegistry.getWindow().focusWindow(this.windowHandle); } + + async getElements(maxElements?: number): Promise { + return this.providerRegistry.getWindowElementInspector().getElements(this.windowHandle, maxElements); + } + + /** + * {@link find} will search for a single occurrence of a given search input in the current window. + * @param searchInput A {@link WindowedFindInput} instance + */ + public async find( + searchInput: WindowElementResultFindInput | Promise + ): Promise { + const needle = await searchInput; + this.providerRegistry.getLogProvider().info(`Searching for ${needle} in window ${this.windowHandle}`); + + try { + if (isWindowElementQuery(needle)) { + this.providerRegistry.getLogProvider().debug(`Running a window element search`); + const windowElement = await this.providerRegistry + .getWindowElementInspector() + .findElement(this.windowHandle, needle.by.description); + const possibleHooks = this.getHooksForInput(needle) || []; + this.providerRegistry + .getLogProvider() + .debug(`${possibleHooks.length} hooks triggered for match`); + for (const hook of possibleHooks) { + this.providerRegistry.getLogProvider().debug(`Executing hook`); + await hook(windowElement); + } + return windowElement; + } + throw new Error( + `Search input is not supported. Please use a valid search input type.` + ); + } catch (e) { + const error = new Error( + `Searching for ${needle.id} failed. Reason: '${e}'` + ); + this.providerRegistry.getLogProvider().error(error); + throw error; + } + } + + /** + * {@link findAll} will search for multiple occurrence of a given search input in the current window. + * @param searchInput A {@link WindowedFindInput} instance + */ + public async findAll( + searchInput: WindowElementResultFindInput | Promise + ): Promise { + // return this.providerRegistry.getWindowElementInspector().findElement(this.windowHandle, description); + const needle = await searchInput; + this.providerRegistry.getLogProvider().info(`Searching for ${needle} in window ${this.windowHandle}`); + + try { + if (isWindowElementQuery(needle)) { + this.providerRegistry.getLogProvider().debug(`Running a window element search`); + const windowElements = await this.providerRegistry + .getWindowElementInspector() + .findElements(this.windowHandle, needle.by.description); + const possibleHooks = this.getHooksForInput(needle) || []; + this.providerRegistry + .getLogProvider() + .debug(`${possibleHooks.length} hooks triggered for match`); + for (const hook of possibleHooks) { + for (const windowElement of windowElements) { + this.providerRegistry.getLogProvider().debug(`Executing hook`); + await hook(windowElement); + } + } + return windowElements; + } + throw new Error( + `Search input is not supported. Please use a valid search input type.` + ); + } catch (e) { + const error = new Error( + `Searching for ${needle.id} failed. Reason: '${e}'` + ); + this.providerRegistry.getLogProvider().error(error); + throw error; + } + } + + /** + * {@link waitFor} repeatedly searches for a query to appear in the window until it is found or the timeout is reached + * @param searchInput A {@link WindowElementQuery} instance + * @param timeoutMs Timeout in milliseconds after which {@link waitFor} fails + * @param updateInterval Update interval in milliseconds to retry search + * @param params {@link OptionalSearchParameters} which are used to fine tune search + */ + public async waitFor( + searchInput: WindowElementQuery | Promise, + timeoutMs?: number, + updateInterval?: number, + params?: OptionalSearchParameters + ): Promise { + const needle = await searchInput; + + const timeoutValue = timeoutMs ?? 5000; + const updateIntervalValue = updateInterval ?? 500; + + this.providerRegistry + .getLogProvider() + .info( + `Waiting for ${needle.id} to appear in window. Timeout: ${ + timeoutValue / 1000 + } seconds, interval: ${updateIntervalValue} ms` + ); + return timeout( + updateIntervalValue, + timeoutValue, + () => { + return this.find(needle); + }, + { + signal: params?.abort + } + ); + } + + /** + * {@link on} registers a callback which is triggered once a certain searchInput image is found + * @param searchInput to trigger the callback on + * @param callback The {@link FindHookCallback} function to trigger + */ + public on(searchInput: WindowElementQuery, callback: WindowElementCallback): void { + const existingHooks = this.getHooksForInput(searchInput); + this.findHooks.set(searchInput, [...existingHooks, callback]); + this.providerRegistry + .getLogProvider() + .info( + `Registered callback for image ${searchInput.id}. There are currently ${ + existingHooks.length + 1 + } hooks registered` + ); + } + + private getHooksForInput( + input: WindowElementQuery + ): WindowElementCallback[] { + return this.findHooks.get(input) ?? []; + } } diff --git a/core/shared/lib/types/window.interface.ts b/core/shared/lib/types/window.interface.ts index 2e56c4e..e06aa68 100644 --- a/core/shared/lib/types/window.interface.ts +++ b/core/shared/lib/types/window.interface.ts @@ -1,4 +1,4 @@ -import { Point, Region, Size } from "../objects"; +import { OptionalSearchParameters, Point, Region, Size, WindowElementQuery } from "../objects"; import { WindowElement } from "./window-element.interface"; import { PointResultFindInput, RegionResultFindInput, WindowElementResultFindInput } from "./index"; @@ -22,6 +22,15 @@ export interface WindowInterface { findAll( searchInput: WindowElementResultFindInput | Promise ): Promise; + + waitFor( + searchInput: WindowElementQuery | Promise, + timeoutMs?: number, + updateInterval?: number, + params?: OptionalSearchParameters + ): Promise; + + on(searchInput: WindowElementQuery, callback: WindowElementCallback): void; } export type WindowedFindInput =