Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README-ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
60 changes: 60 additions & 0 deletions docs/testing/playwright.md
Original file line number Diff line number Diff line change
@@ -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
<div data-testid="workflow-graph">
<GraphCanvas graph={graph} renderBlock={renderBlock} />
</div>
```

```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.
72 changes: 72 additions & 0 deletions e2e/tests/playwright-page-object.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
2 changes: 1 addition & 1 deletion jest.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Config } from "jest";

const jestConfig: Config = {
testPathIgnorePatterns: ["/node_modules/", "/e2e/"],
testPathIgnorePatterns: ["/node_modules/", "/build/", "/e2e/"],
testEnvironment: "jsdom",
setupFiles: ["<rootDir>/setupJest.js", "jest-canvas-mock"],
transformIgnorePatterns: [],
Expand Down
6 changes: 6 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
"*": {
"react": [
"build/react-components/index.d.ts"
],
"playwright": [
"build/playwright/index.d.ts"
]
}
},
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
30 changes: 30 additions & 0 deletions src/graph.test.ts
Original file line number Diff line number Diff line change
@@ -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<symbol, Graph | undefined>;

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, Graph | undefined>)[symbol]).toBeUndefined();
expect((secondRoot as unknown as Record<symbol, Graph | undefined>)[symbol]).toBe(graph);
graph.detach();
});
});

describe("Graph export/import and updateBlock integration", () => {
function createBlock(): TBlock {
Expand Down
11 changes: 10 additions & 1 deletion src/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
59 changes: 59 additions & 0 deletions src/playwright/GraphBlockPO.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
return this.graph.evaluate((graph, blockId) => Boolean(graph.blocks.getBlockState(blockId)), this.id);
}

public async getState(): Promise<TBlock | null> {
return this.graph.evaluate((graph, blockId) => graph.blocks.getBlockState(blockId)?.asTBlock() ?? null, this.id);
}

public async getGeometry(): Promise<GraphRect> {
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<GraphPoint> {
const geometry = await this.getGeometry();
return {
x: geometry.x + geometry.width / 2,
y: geometry.y + geometry.height / 2,
};
}

public async isSelected(): Promise<boolean> {
return this.graph.evaluate((graph, blockId) => graph.blocks.getBlockState(blockId)?.selected ?? false, this.id);
}

public async click(options?: GraphClickOptions): Promise<void> {
await this.graph.clickAt(await this.getCenter(), options);
}

public async doubleClick(options?: GraphClickOptions): Promise<void> {
await this.graph.doubleClickAt(await this.getCenter(), options);
}

public async hover(options?: GraphHoverOptions): Promise<void> {
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<void> {
await this.graph.drag(await this.getCenter(), point, options);
}
}
59 changes: 59 additions & 0 deletions src/playwright/GraphCameraPO.ts
Original file line number Diff line number Diff line change
@@ -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<GraphCameraState> {
return this.graph.evaluate((graph) => {
const camera = graph.cameraService.getCameraState();
return { x: camera.x, y: camera.y, scale: camera.scale };
});
}

public async getBounds(): Promise<GraphRect> {
return this.graph.getBounds();
}

public async zoomToScale(scale: number): Promise<void> {
await this.graph.evaluate((graph, nextScale) => graph.zoom({ scale: nextScale }), scale);
await this.graph.waitForFrames(3);
}

public async zoomToCenter(): Promise<void> {
await this.graph.evaluate((graph) => graph.zoomTo("center"));
await this.graph.waitForFrames(3);
}

public async zoomToBlocks(blockIds: TBlockId[]): Promise<void> {
await this.graph.evaluate((graph, ids) => graph.api.zoomToBlocks(ids), blockIds);
await this.graph.waitForFrames(3);
}

public async panBy(dx: number, dy: number): Promise<void> {
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<void> {
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);
}
}
Loading
Loading