diff --git a/README-ru.md b/README-ru.md index baab433d..6c4f5ba6 100644 --- a/README-ru.md +++ b/README-ru.md @@ -252,3 +252,7 @@ graph.zoomTo("center", { padding: 100 }); |------|-----------|--------------| | Настройки графа | Параметры конфигурации | [Подробнее](docs/system/graph-settings.md) | | API | Методы для управления графом | [Подробнее](docs/system/public_api.md) | + +### Тестирование + +- [Page object-ы для Playwright](docs/testing/playwright.md) diff --git a/README.md b/README.md index c30194e6..875b645c 100644 --- a/README.md +++ b/README.md @@ -262,3 +262,6 @@ graph.zoomTo("center", { padding: 100 }); 4. Blocks and Connections - [Block Groups](docs/blocks/groups.md) - [Canvas Connection System](docs/connections/canvas-connection-system.md) + +5. Testing + - [Playwright page objects](docs/testing/playwright.md) diff --git a/docs/testing/playwright.md b/docs/testing/playwright.md new file mode 100644 index 00000000..7e708001 --- /dev/null +++ b/docs/testing/playwright.md @@ -0,0 +1,60 @@ +# Testing graph applications with Playwright + +The package exposes Playwright page objects through a separate entry point. They let tests interact with canvas-rendered blocks and connections without depending on internal DOM structure. + +```bash +npm install --save-dev @playwright/test +``` + +## Setup + +Give the graph an application-owned locator. The locator may be the element passed to `graph.attach()` or an ancestor containing exactly one graph. + +```tsx +
+ +
+``` + +```ts +import { expect, test } from "@playwright/test"; +import { GraphPO } from "@gravity-ui/graph/playwright"; + +test("selects a graph block", async ({ page }) => { + await page.goto("/workflow"); + + const graph = new GraphPO(page.getByTestId("workflow-graph")); + await graph.waitForReady(); + + const block = graph.block("block-1"); + await block.click(); + + await expect.poll(() => block.isSelected()).toBe(true); +}); +``` + +`waitForReady()` waits for the application to attach and start the graph. `GraphPO` does not create a graph or navigate to an application page. + +## Page objects + +- `graph.block(id)` returns a `GraphBlockPO` for state, geometry, click, hover, double-click, and drag operations. +- `graph.connection(id)` returns a `GraphConnectionPO` for connection state and curve interactions. +- `graph.camera()` returns a `GraphCameraPO` for camera state, zoom, pan, and wheel gestures. +- `graph.clickAt()`, `hoverAt()`, and `drag()` interact with arbitrary world coordinates. + +Use `ControlOrMeta` for portable multi-selection tests: + +```ts +await graph.block("block-1").click(); +await graph.block("block-2").click({ modifiers: ["ControlOrMeta"] }); +``` + +## Browser evaluation escape hatch + +For application-specific assertions, `evaluate()` runs a serializable callback in the browser against the matching `Graph` instance: + +```ts +const blockName = await graph.evaluate((instance, blockId) => instance.blocks.getBlock(blockId)?.name, "block-1"); +``` + +Callbacks cannot capture variables from the Node.js test context. Pass values through the second argument. diff --git a/e2e/tests/playwright-page-object.spec.ts b/e2e/tests/playwright-page-object.spec.ts new file mode 100644 index 00000000..52121bbb --- /dev/null +++ b/e2e/tests/playwright-page-object.spec.ts @@ -0,0 +1,72 @@ +import { expect, test } from "@playwright/test"; +import { GraphPO } from "@gravity-ui/graph/playwright"; + +import { GraphPageObject } from "../page-objects/GraphPageObject"; + +test.describe("Public Playwright page objects", () => { + let graph: GraphPO; + + test.beforeEach(async ({ page }) => { + const harness = new GraphPageObject(page); + await harness.initialize({ + blocks: [ + { + id: "block-1", + is: "Block", + x: 100, + y: 100, + width: 200, + height: 100, + name: "Block 1", + anchors: [], + }, + { + id: "block-2", + is: "Block", + x: 400, + y: 200, + width: 200, + height: 100, + name: "Block 2", + anchors: [], + }, + ], + connections: [ + { + id: "connection-1", + sourceBlockId: "block-1", + targetBlockId: "block-2", + }, + ], + }); + + // The public PO intentionally receives an application-owned wrapper, + // rather than relying on the graph's internal CSS classes. + graph = new GraphPO(page.locator("body")); + await graph.waitForReady(); + }); + + test("finds and interacts with blocks through an ancestor locator", async () => { + const block = graph.block("block-1"); + + expect(await block.exists()).toBe(true); + expect(await block.getState()).toMatchObject({ id: "block-1", name: "Block 1" }); + + await block.click(); + + expect(await block.isSelected()).toBe(true); + expect(await graph.getSelectedBlockIds()).toEqual(["block-1"]); + }); + + test("exposes connection and camera page objects", async () => { + const connection = graph.connection("connection-1"); + expect(await connection.exists()).toBe(true); + expect(await connection.getState()).toMatchObject({ + sourceBlockId: "block-1", + targetBlockId: "block-2", + }); + + await graph.camera().zoomToScale(0.75); + expect((await graph.camera().getState()).scale).toBeCloseTo(0.75); + }); +}); diff --git a/jest.config.ts b/jest.config.ts index 0b430d0f..c6533c47 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,7 +1,7 @@ import type { Config } from "jest"; const jestConfig: Config = { - testPathIgnorePatterns: ["/node_modules/", "/e2e/"], + testPathIgnorePatterns: ["/node_modules/", "/build/", "/e2e/"], testEnvironment: "jsdom", setupFiles: ["/setupJest.js", "jest-canvas-mock"], transformIgnorePatterns: [], diff --git a/package-lock.json b/package-lock.json index 67d7d31e..518b31a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,8 +80,14 @@ "yarn": "Please use npm instead of yarn to install dependencies" }, "peerDependencies": { + "@playwright/test": ">=1.58.0", "react": "^17.0.0 || ^18.0.0", "react-dom": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@playwright/test": { + "optional": true + } } }, "node_modules/@adobe/css-tools": { diff --git a/package.json b/package.json index acd914e0..81e61854 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,9 @@ "*": { "react": [ "build/react-components/index.d.ts" + ], + "playwright": [ + "build/playwright/index.d.ts" ] } }, @@ -33,6 +36,10 @@ "./react": { "types": "./build/react-components/index.d.ts", "default": "./build/react-components/index.js" + }, + "./playwright": { + "types": "./build/playwright/index.d.ts", + "default": "./build/playwright/index.js" } }, "license": "MIT", @@ -72,9 +79,15 @@ "e2e:dev": "concurrently \"tsc --watch --declaration\" \"chokidar 'src/**/*.css' -c 'npm run copy-styles'\" \"npm run e2e:serve\"" }, "peerDependencies": { + "@playwright/test": ">=1.58.0", "react": "^17.0.0 || ^18.0.0", "react-dom": "^17.0.0 || ^18.0.0" }, + "peerDependenciesMeta": { + "@playwright/test": { + "optional": true + } + }, "dependencies": { "@preact/signals-core": "^1.12.2", "intersects": "^2.7.2", diff --git a/src/graph.test.ts b/src/graph.test.ts index 80c8c3f5..91f223b8 100644 --- a/src/graph.test.ts +++ b/src/graph.test.ts @@ -1,5 +1,35 @@ import { TBlock } from "./components/canvas/blocks/Block"; import { Graph } from "./graph"; +import { GRAPH_INSTANCE_SYMBOL_KEY, LEGACY_GRAPH_INSTANCE_SYMBOL_KEY } from "./utils/graphInstance"; + +describe("Graph DOM instance bridge", () => { + it("registers the graph on attach and clears it on detach", () => { + const root = document.createElement("div"); + const graph = new Graph({}, root); + const record = root as unknown as Record; + + expect(record[Symbol.for(GRAPH_INSTANCE_SYMBOL_KEY)]).toBe(graph); + expect(record[Symbol.for(LEGACY_GRAPH_INSTANCE_SYMBOL_KEY)]).toBe(graph); + + graph.detach(); + + expect(record[Symbol.for(GRAPH_INSTANCE_SYMBOL_KEY)]).toBeUndefined(); + expect(record[Symbol.for(LEGACY_GRAPH_INSTANCE_SYMBOL_KEY)]).toBeUndefined(); + }); + + it("moves the bridge when an attached graph receives a new root", () => { + const firstRoot = document.createElement("div"); + const secondRoot = document.createElement("div"); + const graph = new Graph({}, firstRoot); + const symbol = Symbol.for(GRAPH_INSTANCE_SYMBOL_KEY); + + graph.attach(secondRoot); + + expect((firstRoot as unknown as Record)[symbol]).toBeUndefined(); + expect((secondRoot as unknown as Record)[symbol]).toBe(graph); + graph.detach(); + }); +}); describe("Graph export/import and updateBlock integration", () => { function createBlock(): TBlock { diff --git a/src/graph.ts b/src/graph.ts index 469bed89..3604caf5 100644 --- a/src/graph.ts +++ b/src/graph.ts @@ -23,6 +23,7 @@ import { TBlockId } from "./store/block/Block"; import { TConnection } from "./store/connection/ConnectionState"; import { TGraphSettingsConfig } from "./store/settings"; import { clearColorCache, getXY } from "./utils/functions"; +import { clearGraphInstance, setGraphInstance } from "./utils/graphInstance"; import { clearTextCache } from "./utils/renderers/text"; import { RecursivePartial } from "./utils/types/helpers"; import { IPoint, IRect, Point, TPoint, TRect, isTRect } from "./utils/types/shapes"; @@ -432,7 +433,11 @@ export class Graph { if (this.state === GraphState.READY) { return; } - rootEl[Symbol.for("graph")] = this; + const previousRoot = this.layers.$root; + if (previousRoot && previousRoot !== rootEl) { + clearGraphInstance(previousRoot, this); + } + setGraphInstance(rootEl, this); this.layers.attach(rootEl); const { width: rootWidth, height: rootHeight } = this.layers.getRootSize(); @@ -482,7 +487,11 @@ export class Graph { } public stop(full = false) { + const root = this.layers.$root; this.layers.detach(full); + if (full && root) { + clearGraphInstance(root, this); + } clearTextCache(); this.scheduler.stop(); this.setGraphState(this.layers.$root ? GraphState.ATTACHED : GraphState.INIT); diff --git a/src/playwright/GraphBlockPO.ts b/src/playwright/GraphBlockPO.ts new file mode 100644 index 00000000..51fd8b4e --- /dev/null +++ b/src/playwright/GraphBlockPO.ts @@ -0,0 +1,59 @@ +import type { TBlock } from "../components/canvas/blocks/Block"; +import type { TBlockId } from "../store/block/Block"; + +import type { GraphPO } from "./GraphPO"; +import type { GraphClickOptions, GraphDragOptions, GraphHoverOptions, GraphPoint, GraphRect } from "./types"; + +export class GraphBlockPO { + constructor( + private readonly graph: GraphPO, + public readonly id: TBlockId + ) {} + + public async exists(): Promise { + return this.graph.evaluate((graph, blockId) => Boolean(graph.blocks.getBlockState(blockId)), this.id); + } + + public async getState(): Promise { + return this.graph.evaluate((graph, blockId) => graph.blocks.getBlockState(blockId)?.asTBlock() ?? null, this.id); + } + + public async getGeometry(): Promise { + return this.graph.evaluate((graph, blockId) => { + const block = graph.blocks.getBlockState(blockId); + if (!block) { + throw new Error(`Block ${blockId} was not found.`); + } + return block.$geometry.value; + }, this.id); + } + + public async getCenter(): Promise { + const geometry = await this.getGeometry(); + return { + x: geometry.x + geometry.width / 2, + y: geometry.y + geometry.height / 2, + }; + } + + public async isSelected(): Promise { + return this.graph.evaluate((graph, blockId) => graph.blocks.getBlockState(blockId)?.selected ?? false, this.id); + } + + public async click(options?: GraphClickOptions): Promise { + await this.graph.clickAt(await this.getCenter(), options); + } + + public async doubleClick(options?: GraphClickOptions): Promise { + await this.graph.doubleClickAt(await this.getCenter(), options); + } + + public async hover(options?: GraphHoverOptions): Promise { + await this.graph.hoverAt(await this.getCenter(), options); + } + + /** Drags the center of the block to the supplied world coordinate. */ + public async dragTo(point: GraphPoint, options?: GraphDragOptions): Promise { + await this.graph.drag(await this.getCenter(), point, options); + } +} diff --git a/src/playwright/GraphCameraPO.ts b/src/playwright/GraphCameraPO.ts new file mode 100644 index 00000000..4a987f91 --- /dev/null +++ b/src/playwright/GraphCameraPO.ts @@ -0,0 +1,59 @@ +import type { TBlockId } from "../store/block/Block"; + +import type { GraphPO } from "./GraphPO"; +import type { GraphPoint, GraphRect } from "./types"; + +export type GraphCameraState = { + x: number; + y: number; + scale: number; +}; + +export class GraphCameraPO { + constructor(private readonly graph: GraphPO) {} + + public async getState(): Promise { + return this.graph.evaluate((graph) => { + const camera = graph.cameraService.getCameraState(); + return { x: camera.x, y: camera.y, scale: camera.scale }; + }); + } + + public async getBounds(): Promise { + return this.graph.getBounds(); + } + + public async zoomToScale(scale: number): Promise { + await this.graph.evaluate((graph, nextScale) => graph.zoom({ scale: nextScale }), scale); + await this.graph.waitForFrames(3); + } + + public async zoomToCenter(): Promise { + await this.graph.evaluate((graph) => graph.zoomTo("center")); + await this.graph.waitForFrames(3); + } + + public async zoomToBlocks(blockIds: TBlockId[]): Promise { + await this.graph.evaluate((graph, ids) => graph.api.zoomToBlocks(ids), blockIds); + await this.graph.waitForFrames(3); + } + + public async panBy(dx: number, dy: number): Promise { + await this.graph.evaluate((graph, offset) => graph.cameraService.move(offset.dx, offset.dy), { dx, dy }); + await this.graph.waitForFrames(2); + } + + /** + * Emulates a user wheel gesture over the graph. + * `position` is expressed in viewport coordinates and defaults to the graph center. + */ + public async wheel(deltaX: number, deltaY: number, position?: GraphPoint): Promise { + const bounds = await this.getBounds(); + const x = position?.x ?? bounds.x + bounds.width / 2; + const y = position?.y ?? bounds.y + bounds.height / 2; + + await this.graph.page.mouse.move(x, y); + await this.graph.page.mouse.wheel(deltaX, deltaY); + await this.graph.waitForFrames(3); + } +} diff --git a/src/playwright/GraphConnectionPO.ts b/src/playwright/GraphConnectionPO.ts new file mode 100644 index 00000000..606486fa --- /dev/null +++ b/src/playwright/GraphConnectionPO.ts @@ -0,0 +1,97 @@ +import type { TConnection, TConnectionId } from "../store/connection/ConnectionState"; + +import type { GraphPO } from "./GraphPO"; +import type { GraphClickOptions, GraphHoverOptions, GraphPoint } from "./types"; + +export type GraphConnectionActionOptions = GraphClickOptions & { + curveTime?: number; +}; + +export class GraphConnectionPO { + constructor( + private readonly graph: GraphPO, + public readonly id: TConnectionId + ) {} + + public async exists(): Promise { + return this.graph.evaluate( + (graph, connectionId) => Boolean(graph.connections.getConnectionState(connectionId)), + this.id + ); + } + + public async getState(): Promise { + return this.graph.evaluate((graph, connectionId) => graph.connections.getConnection(connectionId) ?? null, this.id); + } + + public async isSelected(): Promise { + return this.graph.evaluate( + (graph, connectionId) => graph.connections.getConnectionState(connectionId)?.$selected.value ?? false, + this.id + ); + } + + public async getPointOnCurve(time = 0.5): Promise { + if (time < 0 || time > 1) { + throw new Error("curveTime must be between 0 and 1."); + } + + const geometry = await this.graph.evaluate((graph, connectionId) => { + const connection = graph.connections.getConnectionState(connectionId); + if (!connection) { + throw new Error(`Connection ${connectionId} was not found.`); + } + + const view = connection.getViewComponent(); + if (!view?.connectionPoints) { + throw new Error(`Connection ${connectionId} has no rendered geometry.`); + } + + return { + start: view.connectionPoints[0], + end: view.connectionPoints[1], + useBezier: graph.rootStore.settings.getConfigFlag("useBezierConnections"), + direction: graph.rootStore.settings.getConfigFlag("bezierConnectionDirection") ?? "horizontal", + }; + }, this.id); + + if (!geometry.useBezier) { + return { + x: geometry.start.x + (geometry.end.x - geometry.start.x) * time, + y: geometry.start.y + (geometry.end.y - geometry.start.y) * time, + }; + } + + const distance = Math.abs(geometry.end.x - geometry.start.x); + const coefficient = geometry.direction === "horizontal" ? Math.max(distance / 2, 25) : 0; + const coefficientY = geometry.direction === "vertical" ? Math.max(distance / 2, 25) : 0; + const p0 = geometry.start; + const p1 = { x: p0.x + coefficient, y: p0.y + coefficientY }; + const p3 = geometry.end; + const p2 = { x: p3.x - coefficient, y: p3.y - coefficientY }; + const inverseTime = 1 - time; + + return { + x: + inverseTime ** 3 * p0.x + + 3 * inverseTime ** 2 * time * p1.x + + 3 * inverseTime * time ** 2 * p2.x + + time ** 3 * p3.x, + y: + inverseTime ** 3 * p0.y + + 3 * inverseTime ** 2 * time * p1.y + + 3 * inverseTime * time ** 2 * p2.y + + time ** 3 * p3.y, + }; + } + + public async click(options: GraphConnectionActionOptions = {}): Promise { + const { curveTime = 0.5, ...clickOptions } = options; + await this.graph.clickAt(await this.getPointOnCurve(curveTime), clickOptions); + } + + public async hover(options: GraphHoverOptions & { curveTime?: number } = {}): Promise { + const { curveTime = 0.5, ...hoverOptions } = options; + await this.graph.hoverAt(await this.getPointOnCurve(curveTime), hoverOptions); + } +} diff --git a/src/playwright/GraphPO.ts b/src/playwright/GraphPO.ts new file mode 100644 index 00000000..6ec74636 --- /dev/null +++ b/src/playwright/GraphPO.ts @@ -0,0 +1,312 @@ +import type { ElementHandle, JSHandle, Locator, Page } from "@playwright/test"; + +import type { Graph } from "../graph"; +import type { TBlockId } from "../store/block/Block"; +import type { TConnection, TConnectionId } from "../store/connection/ConnectionState"; +import { GRAPH_INSTANCE_SYMBOL_KEY, LEGACY_GRAPH_INSTANCE_SYMBOL_KEY } from "../utils/graphInstance"; + +import { GraphBlockPO } from "./GraphBlockPO"; +import { GraphCameraPO } from "./GraphCameraPO"; +import { GraphConnectionPO } from "./GraphConnectionPO"; +import type { GraphClickOptions, GraphDragOptions, GraphHoverOptions, GraphPoint, GraphRect } from "./types"; + +const GRAPH_SYMBOL_KEYS = { + current: GRAPH_INSTANCE_SYMBOL_KEY, + legacy: LEGACY_GRAPH_INSTANCE_SYMBOL_KEY, +}; + +type BrowserGraphRecord = Record; + +/** + * Playwright page object for a graph rendered by `@gravity-ui/graph`. + * + * The locator may point either to the element passed to `graph.attach()` or to + * one of its ancestors. It must contain exactly one graph. + */ +export class GraphPO { + public readonly page: Page; + + public readonly root: Locator; + + private readonly cameraPO: GraphCameraPO; + + constructor(root: Locator) { + this.root = root; + this.page = root.page(); + this.cameraPO = new GraphCameraPO(this); + } + + public block(blockId: TBlockId): GraphBlockPO { + return new GraphBlockPO(this, blockId); + } + + public connection(connectionId: TConnectionId): GraphConnectionPO { + return new GraphConnectionPO(this, connectionId); + } + + public camera(): GraphCameraPO { + return this.cameraPO; + } + + /** + * Waits until the application attaches and starts the graph. + */ + public async waitForReady(options: { timeout?: number } = {}): Promise { + const timeout = options.timeout ?? 5000; + + await this.root.evaluate( + async (scope, { keys, timeoutMs }) => { + const deadline = performance.now() + timeoutMs; + const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); + + const findGraphs = () => { + const elements = [scope, ...Array.from(scope.querySelectorAll("*"))]; + const currentGraphs: Array<{ state?: number }> = []; + const legacyGraphs: Array<{ state?: number }> = []; + + for (const element of elements) { + const record = element as unknown as BrowserGraphRecord; + const currentGraph = record[Symbol.for(keys.current)]; + const legacyGraph = record[Symbol.for(keys.legacy)]; + + if (currentGraph && !currentGraphs.includes(currentGraph as { state?: number })) { + currentGraphs.push(currentGraph as { state?: number }); + } + if (legacyGraph && !legacyGraphs.includes(legacyGraph as { state?: number })) { + legacyGraphs.push(legacyGraph as { state?: number }); + } + } + + return currentGraphs.length > 0 ? currentGraphs : legacyGraphs; + }; + + while (performance.now() <= deadline) { + const graphs = findGraphs(); + if (graphs.length > 1) { + throw new Error("GraphPO locator contains multiple graphs. Pass a more specific locator."); + } + // GraphState.READY is currently the third enum member (2). + if (graphs[0]?.state === 2) { + await nextFrame(); + return; + } + await nextFrame(); + } + + throw new Error(`Graph did not become ready within ${timeoutMs}ms.`); + }, + { keys: GRAPH_SYMBOL_KEYS, timeoutMs: timeout }, + { timeout } + ); + } + + /** + * Runs a serializable callback in the browser against the graph instance. + * As with Playwright's evaluate APIs, the callback cannot capture variables + * from the Node.js test context; pass them through `arg` instead. + */ + public async evaluate( + pageFunction: (graph: Graph, arg: TArg) => TResult | Promise, + arg?: TArg + ): Promise { + const graphHandle = await this.getGraphHandle(); + try { + // Playwright internally unboxes serializable arguments. Keep the public + // callback type simple and let JSHandle validate the actual value. + return await graphHandle.evaluate(pageFunction as never, arg as TArg); + } finally { + await graphHandle.dispose(); + } + } + + public async waitForFrames(count = 1): Promise { + if (count <= 0) { + return; + } + + await this.root.evaluate( + (_scope, frameCount) => + new Promise((resolve) => { + let remaining = frameCount; + const onFrame = () => { + remaining -= 1; + if (remaining <= 0) { + resolve(); + } else { + requestAnimationFrame(onFrame); + } + }; + requestAnimationFrame(onFrame); + }), + count + ); + } + + public async getBounds(): Promise { + return this.evaluate((graph) => { + const rect = graph.layers.$root.getBoundingClientRect(); + return { + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, + }; + }); + } + + public async clickAt(point: GraphPoint, options: GraphClickOptions = {}): Promise { + const position = await this.toRootPoint(point); + const root = await this.getGraphRootHandle(); + const { waitForFrames = 2, ...clickOptions } = options; + + try { + await root.click({ position, ...clickOptions }); + } finally { + await root.dispose(); + } + + await this.waitForFrames(waitForFrames); + } + + public async doubleClickAt(point: GraphPoint, options: GraphClickOptions = {}): Promise { + const position = await this.toRootPoint(point); + const root = await this.getGraphRootHandle(); + const { waitForFrames = 2, ...clickOptions } = options; + + try { + await root.dblclick({ position, ...clickOptions }); + } finally { + await root.dispose(); + } + + await this.waitForFrames(waitForFrames); + } + + public async hoverAt(point: GraphPoint, options: GraphHoverOptions = {}): Promise { + const position = await this.toRootPoint(point); + const root = await this.getGraphRootHandle(); + const { waitForFrames = 1, ...hoverOptions } = options; + + try { + await root.hover({ position, ...hoverOptions }); + } finally { + await root.dispose(); + } + + await this.waitForFrames(waitForFrames); + } + + public async drag(from: GraphPoint, to: GraphPoint, options: GraphDragOptions = {}): Promise { + const [fromPosition, toPosition] = await Promise.all([this.toRootPoint(from), this.toRootPoint(to)]); + const root = await this.getGraphRootHandle(); + const { button = "left", steps = 10, waitForFrames = 2 } = options; + let pointerDown = false; + + try { + await root.hover({ position: fromPosition }); + const bounds = await root.boundingBox(); + if (!bounds) { + throw new Error("Graph root is not visible."); + } + + await this.page.mouse.down({ button }); + pointerDown = true; + await this.waitForFrames(1); + await this.page.mouse.move(bounds.x + toPosition.x, bounds.y + toPosition.y, { + steps, + }); + await this.waitForFrames(1); + await this.page.mouse.up({ button }); + pointerDown = false; + } finally { + if (pointerDown) { + await this.page.mouse.up({ button }); + } + await root.dispose(); + } + + await this.waitForFrames(waitForFrames); + } + + public async getSelectedBlockIds(): Promise { + return this.evaluate((graph) => Array.from(graph.blocks.blockSelectionBucket.$selected.value)); + } + + public async getConnections(): Promise { + return this.evaluate((graph) => graph.connections.toJSON()); + } + + public async hasConnectionBetween(sourceBlockId: TBlockId, targetBlockId: TBlockId): Promise { + return this.evaluate( + (graph, ids) => + graph.connections + .toJSON() + .some( + (connection) => + connection.sourceBlockId === ids.sourceBlockId && connection.targetBlockId === ids.targetBlockId + ), + { sourceBlockId, targetBlockId } + ); + } + + public async getCursor(): Promise { + return this.evaluate((graph) => window.getComputedStyle(graph.layers.$root).cursor); + } + + private async toRootPoint(point: GraphPoint): Promise { + return this.evaluate((graph, worldPoint) => { + const [x, y] = graph.cameraService.getAbsoluteXY(worldPoint.x, worldPoint.y); + return { x, y }; + }, point); + } + + private async getGraphHandle(): Promise> { + const handle = await this.root.evaluateHandle((scope, keys) => { + const elements = [scope, ...Array.from(scope.querySelectorAll("*"))]; + const currentGraphs: unknown[] = []; + const legacyGraphs: unknown[] = []; + + for (const element of elements) { + const record = element as unknown as BrowserGraphRecord; + const currentGraph = record[Symbol.for(keys.current)]; + const legacyGraph = record[Symbol.for(keys.legacy)]; + if (currentGraph && !currentGraphs.includes(currentGraph)) { + currentGraphs.push(currentGraph); + } + if (legacyGraph && !legacyGraphs.includes(legacyGraph)) { + legacyGraphs.push(legacyGraph); + } + } + const graphs = currentGraphs.length > 0 ? currentGraphs : legacyGraphs; + + if (graphs.length === 0) { + throw new Error( + "GraphPO could not find a graph under the supplied locator. " + + "Make sure the graph is attached and call waitForReady() first." + ); + } + if (graphs.length > 1) { + throw new Error("GraphPO locator contains multiple graphs. Pass a more specific locator."); + } + + return graphs[0]; + }, GRAPH_SYMBOL_KEYS); + + return handle as JSHandle; + } + + private async getGraphRootHandle(): Promise> { + const graphHandle = await this.getGraphHandle(); + try { + const rootHandle = await graphHandle.evaluateHandle((graph) => graph.layers.$root); + const element = rootHandle.asElement(); + if (!element) { + await rootHandle.dispose(); + throw new Error("Graph is not attached to a DOM element."); + } + return element as ElementHandle; + } finally { + await graphHandle.dispose(); + } + } +} diff --git a/src/playwright/index.ts b/src/playwright/index.ts new file mode 100644 index 00000000..5dc60e30 --- /dev/null +++ b/src/playwright/index.ts @@ -0,0 +1,14 @@ +export { GraphPO } from "./GraphPO"; +export { GraphBlockPO } from "./GraphBlockPO"; +export { GraphConnectionPO, type GraphConnectionActionOptions } from "./GraphConnectionPO"; +export { GraphCameraPO, type GraphCameraState } from "./GraphCameraPO"; +export type { + GraphActionOptions, + GraphClickOptions, + GraphDragOptions, + GraphHoverOptions, + GraphModifier, + GraphMouseButton, + GraphPoint, + GraphRect, +} from "./types"; diff --git a/src/playwright/types.ts b/src/playwright/types.ts new file mode 100644 index 00000000..2493c2ad --- /dev/null +++ b/src/playwright/types.ts @@ -0,0 +1,31 @@ +export type GraphPoint = { + x: number; + y: number; +}; + +export type GraphRect = GraphPoint & { + width: number; + height: number; +}; + +export type GraphModifier = "Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift"; +export type GraphMouseButton = "left" | "middle" | "right"; + +export type GraphActionOptions = { + force?: boolean; + modifiers?: GraphModifier[]; + timeout?: number; + waitForFrames?: number; +}; + +export type GraphClickOptions = GraphActionOptions & { + button?: GraphMouseButton; +}; + +export type GraphDragOptions = { + button?: GraphMouseButton; + steps?: number; + waitForFrames?: number; +}; + +export type GraphHoverOptions = GraphActionOptions; diff --git a/src/utils/graphInstance.ts b/src/utils/graphInstance.ts new file mode 100644 index 00000000..f817c7ac --- /dev/null +++ b/src/utils/graphInstance.ts @@ -0,0 +1,25 @@ +import type { Graph } from "../graph"; + +export const GRAPH_INSTANCE_SYMBOL_KEY = "@gravity-ui/graph/instance"; +export const LEGACY_GRAPH_INSTANCE_SYMBOL_KEY = "graph"; + +type GraphHostElement = HTMLElement & Record; + +export function setGraphInstance(element: HTMLElement, graph: Graph): void { + const host = element as GraphHostElement; + + host[Symbol.for(GRAPH_INSTANCE_SYMBOL_KEY)] = graph; + // Keep the old, undocumented key during the transition to the namespaced key. + host[Symbol.for(LEGACY_GRAPH_INSTANCE_SYMBOL_KEY)] = graph; +} + +export function clearGraphInstance(element: HTMLElement, graph: Graph): void { + const host = element as GraphHostElement; + + for (const key of [GRAPH_INSTANCE_SYMBOL_KEY, LEGACY_GRAPH_INSTANCE_SYMBOL_KEY]) { + const symbol = Symbol.for(key); + if (host[symbol] === graph) { + delete host[symbol]; + } + } +}