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];
+ }
+ }
+}