From 6338bd0c579ad1ea0b38afe00e4fbb1f64a7fb26 Mon Sep 17 00:00:00 2001 From: draedful Date: Sun, 25 Jan 2026 22:33:56 +0300 Subject: [PATCH] chore: introduce e2e --- .eslintignore | 7 + .gitignore | 3 + e2e/README.md | 320 ++++++++++ e2e/build-bundle.js | 39 ++ e2e/entry.ts | 8 + e2e/global.d.ts | 15 + e2e/page-objects/BlockPageObject.ts | 180 ++++++ e2e/page-objects/CameraPageObject.ts | 167 +++++ e2e/page-objects/ConnectionPageObject.ts | 61 ++ e2e/page-objects/GraphPageObject.ts | 182 ++++++ e2e/page-objects/ReactGraphPageObject.ts | 59 ++ e2e/pages/base.html | 26 + e2e/server.js | 100 +++ e2e/tests/block-click.spec.ts | 97 +++ e2e/tests/camera-control.spec.ts | 150 +++++ e2e/tests/debug.spec.ts | 79 +++ e2e/tests/drag-and-drop.spec.ts | 148 +++++ e2e/tests/selection-test.spec.ts | 42 ++ e2e/tsconfig.json | 19 + e2e/utils/CoordinateTransformer.ts | 62 ++ package-lock.json | 763 +++++++++++++++++++---- package.json | 11 +- playwright.config.ts | 27 + src/services/camera/Camera.ts | 7 + 24 files changed, 2464 insertions(+), 108 deletions(-) create mode 100644 .eslintignore create mode 100644 e2e/README.md create mode 100644 e2e/build-bundle.js create mode 100644 e2e/entry.ts create mode 100644 e2e/global.d.ts create mode 100644 e2e/page-objects/BlockPageObject.ts create mode 100644 e2e/page-objects/CameraPageObject.ts create mode 100644 e2e/page-objects/ConnectionPageObject.ts create mode 100644 e2e/page-objects/GraphPageObject.ts create mode 100644 e2e/page-objects/ReactGraphPageObject.ts create mode 100644 e2e/pages/base.html create mode 100644 e2e/server.js create mode 100644 e2e/tests/block-click.spec.ts create mode 100644 e2e/tests/camera-control.spec.ts create mode 100644 e2e/tests/debug.spec.ts create mode 100644 e2e/tests/drag-and-drop.spec.ts create mode 100644 e2e/tests/selection-test.spec.ts create mode 100644 e2e/tsconfig.json create mode 100644 e2e/utils/CoordinateTransformer.ts create mode 100644 playwright.config.ts diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000..c26da4ca --- /dev/null +++ b/.eslintignore @@ -0,0 +1,7 @@ +node_modules +build +dist +storybook-static +e2e +playwright-report +test-results diff --git a/.gitignore b/.gitignore index af7bce26..d81bc6a4 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ dist storybook-static .tsbuildinfo build +playwright-report +test-results +e2e/dist diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 00000000..d45fca47 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,320 @@ +# E2E Testing with Playwright + +This directory contains end-to-end tests for the @gravity-ui/graph library using Playwright and the PageObject pattern. + +## Key Features + +- **RAF-based Waiting**: Tests use `requestAnimationFrame` for synchronization instead of arbitrary timeouts, ensuring reliable tests that wait for the actual rendering cycle +- **PageObject Pattern**: Clean API for interacting with graph components +- **Coordinate Transformations**: Automatic conversion between world and screen coordinates +- **Full Event Emulation**: Proper mouse event sequences for realistic interactions + +## Structure + +``` +e2e/ +├── page-objects/ # PageObject classes for interacting with graph +│ ├── GraphPageObject.ts # Base PageObject for Canvas graph +│ ├── ReactGraphPageObject.ts # PageObject for React version +│ ├── BlockPageObject.ts # Block interactions +│ ├── CameraPageObject.ts # Camera controls +│ └── ConnectionPageObject.ts # Connection operations +├── utils/ # Utility classes +│ └── CoordinateTransformer.ts # World ↔ Screen coordinate transformations +├── tests/ # Test files +│ ├── block-click.spec.ts # Block click and selection tests +│ ├── camera-control.spec.ts # Camera zoom and pan tests +│ └── drag-and-drop.spec.ts # Drag and drop tests +├── pages/ # HTML pages for tests +│ └── base.html # Minimal base page +├── server.js # HTTP server for serving test pages +├── tsconfig.json # TypeScript configuration +└── global.d.ts # TypeScript type definitions + +## Prerequisites + +The project will be automatically built before running tests. No manual build step is required. + +## Running Tests + +### Run all tests (with automatic build) + +```bash +npm run e2e:test +``` + +This will automatically: +1. Build the project (`npm run build`) +2. Start the test server +3. Run all tests +4. Stop the server when done + +### Run tests in UI mode (interactive) + +```bash +npm run e2e:test:ui +``` + +### Run tests in debug mode + +```bash +npm run e2e:test:debug +``` + +### Development mode with watch + +For active development with auto-rebuild on changes: + +```bash +npm run e2e:dev +``` + +Then in another terminal: + +```bash +npx playwright test --ui +``` + +This will: +- Watch for source file changes and rebuild automatically +- Keep the test server running +- Allow you to run tests multiple times without restarting + +### Run specific test file + +```bash +npx playwright test block-click +``` + +## PageObject Pattern + +Tests use the PageObject pattern to provide a clean API for interacting with the graph. + +### Example Usage + +```typescript +import { test, expect } from "@playwright/test"; +import { GraphPageObject } from "../page-objects/GraphPageObject"; + +test("should select block on click", async ({ page }) => { + const graphPO = new GraphPageObject(page); + + // Initialize graph with configuration + await graphPO.initialize({ + blocks: [ + { + id: "block-1", + x: 100, + y: 100, + width: 200, + height: 100, + // ... other properties + }, + ], + connections: [], + }); + + // Interact with blocks + await graphPO.blocks.click("block-1"); + + // Verify selection + const selectedBlocks = await graphPO.blocks.getSelected(); + expect(selectedBlocks).toContain("block-1"); +}); +``` + +## PageObject API + +### GraphPageObject + +Main PageObject providing access to all graph functionality: + +- `initialize(config)` - Initialize graph with configuration +- `waitForReady()` - Wait for graph to be ready +- `camera` - Access to CameraPageObject +- `blocks` - Access to BlockPageObject +- `connections` - Access to ConnectionPageObject +- `event(type, xy)` - Trigger mouse event at coordinates +- `waitForEvent(eventName)` - Wait for specific graph event + +### BlockPageObject + +Methods for interacting with blocks: + +- `getGeometry(blockId)` - Get block position and size +- `getScreenCenter(blockId)` - Get screen coordinates of block center +- `click(blockId)` - Click on block +- `doubleClick(blockId)` - Double click on block +- `dragTo(blockId, worldPos)` - Drag block to new world position +- `isSelected(blockId)` - Check if block is selected +- `getSelected()` - Get all selected block IDs + +### CameraPageObject + +Methods for camera control: + +- `getState()` - Get current camera state (x, y, scale) +- `zoomToScale(scale)` - Zoom to specific scale +- `pan(dx, dy)` - Pan camera by offset +- `zoomToCenter()` - Zoom to center +- `zoomToBlocks(blockIds)` - Zoom to specific blocks +- `getCanvasBounds()` - Get canvas bounding box + +### ConnectionPageObject + +Methods for working with connections: + +- `getAll()` - Get all connections +- `getById(connectionId)` - Get connection by ID +- `existsBetween(sourceId, targetId)` - Check if connection exists +- `getCount()` - Get number of connections + +## Coordinate Transformation + +The library uses three coordinate spaces: + +1. **World Space** - Graph coordinates where blocks are positioned +2. **Camera Space** - Coordinates accounting for camera scale +3. **Screen Space** - Pixel coordinates on the canvas element + +The `CoordinateTransformer` utility handles transformations: + +```typescript +import { CoordinateTransformer } from "../utils/CoordinateTransformer"; + +// World to Screen +const screenPos = CoordinateTransformer.worldToScreen( + { x: 100, y: 100 }, + cameraState +); + +// Screen to World +const worldPos = CoordinateTransformer.screenToWorld(screenPos, cameraState); +``` + +## RAF-based Synchronization + +The library uses a Scheduler with `requestAnimationFrame` (RAF) for rendering. Tests must wait for animation frames instead of using arbitrary timeouts for reliable synchronization. + +### Available Waiting Methods + +**`waitForFrames(count)`** - Wait for N animation frames to complete: + +```typescript +// Wait for 2 frames after clicking +await graphPO.blocks.click("block-1"); +// Click method already waits internally, no manual wait needed + +// Manual wait if needed +await graphPO.waitForFrames(2); +``` + +**`waitForSchedulerIdle()`** - Wait until scheduler has no pending tasks: + +```typescript +await graphPO.waitForSchedulerIdle(); +``` + +### Automatic Waiting + +PageObject methods automatically wait for frames where appropriate: + +- `blocks.click()` - Waits 2 frames after click +- `blocks.dragTo()` - Waits between drag phases +- `camera.zoomToScale()` - Waits 3 frames for zoom animation +- `camera.pan()` - Waits 2 frames after pan + +### Why RAF over Timeouts? + +❌ **Don't use timeouts**: +```typescript +// Unreliable - may be too fast or too slow +await page.waitForTimeout(100); +``` + +✅ **Use RAF-based waiting**: +```typescript +// Reliable - syncs with actual render cycle +await graphPO.waitForFrames(2); +``` + +Benefits: +- **Reliable**: Tests wait for actual rendering, not arbitrary time +- **Fast**: No unnecessary delays +- **Deterministic**: Tests behave consistently across different machines + +// Screen to World +const worldPos = CoordinateTransformer.screenToWorld( + { x: 500, y: 300 }, + cameraState +); +``` + +## Writing New Tests + +1. Create a new test file in `e2e/tests/` +2. Import `GraphPageObject` or `ReactGraphPageObject` +3. Initialize graph in `beforeEach` hook +4. Use PageObject methods to interact with graph +5. Use Playwright's `expect` for assertions + +## Debugging Tests + +### Visual Debugging + +Use UI mode to see tests running in real-time: + +```bash +npm run e2e:test:ui +``` + +### Debug Mode + +Run with debugging enabled: + +```bash +npm run e2e:test:debug +``` + +### Screenshots + +Failed tests automatically capture screenshots in `test-results/` + +### Videos + +Configure video recording in `playwright.config.ts` + +## Best Practices + +1. **Use PageObjects** - Never interact with page directly in tests +2. **Wait for Updates** - Use `page.waitForTimeout()` after actions that trigger updates +3. **Coordinate Transformations** - Always use world coordinates for positioning +4. **Clean State** - Each test should initialize its own graph configuration +5. **Descriptive Names** - Use clear test and variable names + +## CI/CD Integration + +Tests can be integrated into CI/CD pipelines. See `playwright.config.ts` for CI-specific configuration. + +Example GitHub Actions workflow: + +```yaml +- name: Install dependencies + run: npm ci + +- name: Install Playwright + run: npx playwright install --with-deps chromium + +- name: Build project + run: npm run build + +- name: Run E2E tests + run: npm run e2e:test + +- name: Upload test results + if: failure() + uses: actions/upload-artifact@v3 + with: + name: playwright-report + path: playwright-report/ +``` diff --git a/e2e/build-bundle.js b/e2e/build-bundle.js new file mode 100644 index 00000000..bc21672a --- /dev/null +++ b/e2e/build-bundle.js @@ -0,0 +1,39 @@ +const esbuild = require("esbuild"); +const path = require("path"); +const fs = require("fs"); + +// Plugin to inject CSS into the bundle as a style tag +const cssPlugin = { + name: "css", + setup(build) { + build.onLoad({ filter: /\.css$/ }, async (args) => { + const css = await fs.promises.readFile(args.path, "utf8"); + const contents = ` + const style = document.createElement('style'); + style.textContent = ${JSON.stringify(css)}; + document.head.appendChild(style); + `; + return { contents, loader: "js" }; + }); + }, +}; + +esbuild + .build({ + entryPoints: [path.join(__dirname, "entry.ts")], + bundle: true, + outfile: path.join(__dirname, "dist/graph.bundle.js"), + format: "iife", + globalName: "GraphModule", + platform: "browser", + target: ["es2020"], + sourcemap: true, + plugins: [cssPlugin], + }) + .then(() => { + console.log("E2E bundle created successfully with CSS"); + }) + .catch((err) => { + console.error("E2E bundle failed:", err); + process.exit(1); + }); diff --git a/e2e/entry.ts b/e2e/entry.ts new file mode 100644 index 00000000..d94f51cf --- /dev/null +++ b/e2e/entry.ts @@ -0,0 +1,8 @@ +// E2E bundle entry point with CSS imports +import "../src/services/Layer.css"; +import "../src/react-components/graph-canvas.css"; +import "../src/react-components/Block.css"; +import "../src/react-components/Anchor.css"; + +// Re-export everything from main index +export * from "../src/index"; diff --git a/e2e/global.d.ts b/e2e/global.d.ts new file mode 100644 index 00000000..647823f1 --- /dev/null +++ b/e2e/global.d.ts @@ -0,0 +1,15 @@ +import type { Graph } from "../src/graph"; + +declare global { + interface Window { + GraphModule: any; + graph: Graph; + graphInitialized: boolean; + graphStarted?: boolean; + graphLibraryLoaded?: boolean; + React?: any; + ReactDOM?: any; + } +} + +export {}; diff --git a/e2e/page-objects/BlockPageObject.ts b/e2e/page-objects/BlockPageObject.ts new file mode 100644 index 00000000..37ca098f --- /dev/null +++ b/e2e/page-objects/BlockPageObject.ts @@ -0,0 +1,180 @@ +import { Page } from "@playwright/test"; +import { + CoordinateTransformer, + ScreenCoordinates, + WorldCoordinates, + Rect, +} from "../utils/CoordinateTransformer"; +import { CameraPageObject } from "./CameraPageObject"; + +export class BlockPageObject { + constructor( + private page: Page, + private camera: CameraPageObject, + private getGraphPO?: () => { waitForFrames: (count: number) => Promise } + ) {} + + /** + * Wait for animation frames + */ + private async waitForFrames(count: number = 1): Promise { + await this.page.evaluate((frameCount) => { + return new Promise((resolve) => { + let remaining = frameCount; + const tick = () => { + remaining--; + if (remaining <= 0) { + resolve(); + } else { + requestAnimationFrame(tick); + } + }; + requestAnimationFrame(tick); + }); + }, count); + } + + /** + * Get block geometry by ID + */ + async getGeometry(blockId: string): Promise { + return await this.page.evaluate((id) => { + const blockState = window.graph.blocks.getBlockState(id); + if (!blockState) { + throw new Error(`Block ${id} not found`); + } + return blockState.$geometry.value; + }, blockId); + } + + /** + * Get screen coordinates for a block center + * Returns coordinates relative to canvas element (not viewport) + */ + async getScreenCenter(blockId: string): Promise { + const geometry = await this.getGeometry(blockId); + const cameraState = await this.camera.getState(); + + const worldCenter = CoordinateTransformer.getRectCenter(geometry); + return CoordinateTransformer.worldToScreen(worldCenter, cameraState); + } + + /** + * Click on block by ID + */ + async click( + blockId: string, + options?: { shift?: boolean; ctrl?: boolean; meta?: boolean } + ): Promise { + // Get screen position relative to canvas (not viewport) + const canvasPos = await this.getScreenCenter(blockId); + + // Get canvas bounds to convert to viewport coordinates + const canvasBounds = await this.page.evaluate(() => { + const canvas = window.graph.getGraphCanvas(); + const rect = canvas.getBoundingClientRect(); + return { + left: rect.left, + top: rect.top, + }; + }); + + // Calculate viewport coordinates + const viewportX = canvasPos.x + canvasBounds.left; + const viewportY = canvasPos.y + canvasBounds.top; + + // Emulate full click sequence with correct coordinates and modifiers + await this.page.evaluate( + ({ canvasX, canvasY, viewportX, viewportY, modifiers }) => { + const canvas = window.graph.getGraphCanvas(); + + const createMouseEvent = (type: string) => { + return new MouseEvent(type, { + bubbles: true, + cancelable: true, + view: window, + clientX: viewportX, + clientY: viewportY, + screenX: viewportX, + screenY: viewportY, + pageX: viewportX + window.scrollX, + pageY: viewportY + window.scrollY, + button: 0, + shiftKey: modifiers.shift || false, + ctrlKey: modifiers.ctrl || false, + metaKey: modifiers.meta || false, + }); + }; + + // Full sequence for proper click emulation + canvas.dispatchEvent(createMouseEvent("mousemove")); + canvas.dispatchEvent(createMouseEvent("mousedown")); + canvas.dispatchEvent(createMouseEvent("mouseup")); + canvas.dispatchEvent(createMouseEvent("click")); + }, + { + canvasX: canvasPos.x, + canvasY: canvasPos.y, + viewportX, + viewportY, + modifiers: options || {}, + } + ); + + // Wait for scheduler to process the click + await this.waitForFrames(2); + } + + /** + * Double click on block by ID + */ + async doubleClick(blockId: string): Promise { + const screenPos = await this.getScreenCenter(blockId); + await this.page.mouse.dblclick(screenPos.x, screenPos.y); + } + + /** + * Drag block to new world position + */ + async dragTo(blockId: string, toWorldPos: WorldCoordinates): Promise { + const fromScreen = await this.getScreenCenter(blockId); + const cameraState = await this.camera.getState(); + const toScreen = CoordinateTransformer.worldToScreen( + toWorldPos, + cameraState + ); + + await this.page.mouse.move(fromScreen.x, fromScreen.y); + await this.waitForFrames(1); + + await this.page.mouse.down(); + await this.waitForFrames(1); + + await this.page.mouse.move(toScreen.x, toScreen.y, { steps: 10 }); + await this.waitForFrames(2); + + await this.page.mouse.up(); + await this.waitForFrames(2); + } + + /** + * Check if block is selected + */ + async isSelected(blockId: string): Promise { + return await this.page.evaluate((id) => { + const blockState = window.graph.blocks.getBlockState(id); + return blockState?.selected || false; + }, blockId); + } + + /** + * Get all selected block IDs + */ + async getSelected(): Promise { + return await this.page.evaluate(() => { + const selection = window.graph.selectionService.$selection.value; + const blockSelection = selection.get("block"); + return blockSelection ? Array.from(blockSelection) : []; + }); + } +} diff --git a/e2e/page-objects/CameraPageObject.ts b/e2e/page-objects/CameraPageObject.ts new file mode 100644 index 00000000..1584925e --- /dev/null +++ b/e2e/page-objects/CameraPageObject.ts @@ -0,0 +1,167 @@ +import { Page } from "@playwright/test"; +import { CameraState } from "../utils/CoordinateTransformer"; + +export class CameraPageObject { + constructor(private page: Page) {} + + /** + * Wait for animation frames + */ + private async waitForFrames(count: number = 1): Promise { + await this.page.evaluate((frameCount) => { + return new Promise((resolve) => { + let remaining = frameCount; + const tick = () => { + remaining--; + if (remaining <= 0) { + resolve(); + } else { + requestAnimationFrame(tick); + } + }; + requestAnimationFrame(tick); + }); + }, count); + } + + /** + * Get camera state from the graph + */ + async getState(): Promise { + return await this.page.evaluate(() => { + const camera = window.graph.cameraService.getCameraState(); + return { + x: camera.x, + y: camera.y, + scale: camera.scale, + }; + }); + } + + /** + * Zoom camera to a specific scale + */ + async zoomToScale(scale: number): Promise { + + await this.page.evaluate((s) => { + window.graph.zoom({ scale: s }); + }, scale); + + // Wait for zoom animation to complete + await this.waitForFrames(3); + } + + /** + * Pan camera by offset (in screen pixels) + */ + async pan(dx: number, dy: number): Promise { + await this.page.evaluate( + ({ dx, dy }) => { + window.graph.cameraService.move(dx, dy); + }, + { dx, dy } + ); + + // Wait for pan to be processed + await this.waitForFrames(2); + } + + /** + * Zoom to center + */ + async zoomToCenter(): Promise { + await this.page.evaluate(() => { + window.graph.zoomTo("center"); + }); + + // Wait for zoom animation + await this.waitForFrames(3); + } + + /** + * Zoom to specific blocks + */ + async zoomToBlocks(blockIds: string[]): Promise { + await this.page.evaluate((ids) => { + window.graph.zoomTo(ids); + }, blockIds); + + // Wait for zoom animation + await this.waitForFrames(3); + } + + /** + * Get canvas bounding box + */ + async getCanvasBounds(): Promise<{ + x: number; + y: number; + width: number; + height: number; + }> { + return await this.page.evaluate(() => { + const canvas = window.graph.getGraphCanvas(); + const rect = canvas.getBoundingClientRect(); + return { + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, + }; + }); + } + + /** + * Emulate zoom with mouse wheel + * @param deltaY - Positive = zoom out, Negative = zoom in + * @param position - Optional position to zoom at (defaults to canvas center) + */ + async emulateZoom( + deltaY: number, + position?: { x: number; y: number } + ): Promise { + const canvasBounds = await this.getCanvasBounds(); + + // Use provided position or default to canvas center + const mouseX = position?.x ?? canvasBounds.x + canvasBounds.width / 2; + const mouseY = position?.y ?? canvasBounds.y + canvasBounds.height / 2; + + // Move mouse to position and perform wheel event + await this.page.mouse.move(mouseX, mouseY); + await this.page.mouse.wheel(0, deltaY); + + // Wait for zoom to be processed + await this.waitForFrames(3); + } + + /** + * Emulate camera pan with mouse drag + * @param deltaX - Horizontal drag distance in pixels + * @param deltaY - Vertical drag distance in pixels + * @param startPosition - Optional start position (defaults to canvas center) + */ + async emulatePan( + deltaX: number, + deltaY: number, + startPosition?: { x: number; y: number } + ): Promise { + const canvasBounds = await this.getCanvasBounds(); + + // Use provided position or default to canvas center + const startX = startPosition?.x ?? canvasBounds.x + canvasBounds.width / 2; + const startY = startPosition?.y ?? canvasBounds.y + canvasBounds.height / 2; + + // Perform drag operation + await this.page.mouse.move(startX, startY); + await this.waitForFrames(1); + + await this.page.mouse.down(); + await this.waitForFrames(1); + + await this.page.mouse.move(startX + deltaX, startY + deltaY, { steps: 10 }); + await this.waitForFrames(2); + + await this.page.mouse.up(); + await this.waitForFrames(2); + } +} diff --git a/e2e/page-objects/ConnectionPageObject.ts b/e2e/page-objects/ConnectionPageObject.ts new file mode 100644 index 00000000..5084c924 --- /dev/null +++ b/e2e/page-objects/ConnectionPageObject.ts @@ -0,0 +1,61 @@ +import { Page } from "@playwright/test"; + +export interface ConnectionData { + id: string | number; + sourceBlockId: string; + sourceAnchorId?: string; + targetBlockId: string; + targetAnchorId?: string; +} + +export class ConnectionPageObject { + constructor(private page: Page) {} + + /** + * Get all connections + */ + async getAll(): Promise { + return await this.page.evaluate(() => { + return window.graph.connections.toJSON(); + }); + } + + /** + * Get connection by ID + */ + async getById(connectionId: string): Promise { + return await this.page.evaluate((id) => { + const conn = window.graph.connections.getConnection(id); + return conn || undefined; + }, connectionId); + } + + /** + * Check if connection exists between two blocks + */ + async existsBetween( + sourceBlockId: string, + targetBlockId: string + ): Promise { + return await this.page.evaluate( + ({ sourceBlockId, targetBlockId }) => { + const connections = window.graph.connections.toJSON(); + return connections.some( + (conn: any) => + conn.sourceBlockId === sourceBlockId && + conn.targetBlockId === targetBlockId + ); + }, + { sourceBlockId, targetBlockId } + ); + } + + /** + * Get number of connections + */ + async getCount(): Promise { + return await this.page.evaluate(() => { + return window.graph.connections.toJSON().length; + }); + } +} diff --git a/e2e/page-objects/GraphPageObject.ts b/e2e/page-objects/GraphPageObject.ts new file mode 100644 index 00000000..2d81ac07 --- /dev/null +++ b/e2e/page-objects/GraphPageObject.ts @@ -0,0 +1,182 @@ +import { Page } from "@playwright/test"; +import { TBlock } from "../../src/components/canvas/blocks/Block"; +import { TConnection } from "../../src/store/connection/ConnectionState"; +import { BlockPageObject } from "./BlockPageObject"; +import { CameraPageObject } from "./CameraPageObject"; +import { ConnectionPageObject } from "./ConnectionPageObject"; + +export interface GraphConfig { + blocks?: TBlock[]; + connections?: TConnection[]; + settings?: any; +} + +export class GraphPageObject { + public readonly camera: CameraPageObject; + public readonly blocks: BlockPageObject; + public readonly connections: ConnectionPageObject; + public readonly page: Page; + + constructor(page: Page) { + this.page = page; + this.camera = new CameraPageObject(page); + this.blocks = new BlockPageObject(page, this.camera); + this.connections = new ConnectionPageObject(page); + } + + /** + * Initialize graph with config + */ + async initialize(config: GraphConfig): Promise { + await this.page.goto("/base.html"); + + // Wait for Graph library to load from the HTML page + await this.page.waitForFunction(() => { + return (window as any).graphLibraryLoaded === true; + }); + + // Initialize graph using the loaded module + await this.page.evaluate((cfg) => { + const rootEl = document.getElementById("root"); + if (!rootEl) { + throw new Error("Root element not found"); + } + + // GraphModule contains all exports from /build/index.js + const { Graph } = (window as any).GraphModule; + const graph = new Graph(cfg, rootEl); + + if (cfg.blocks || cfg.connections) { + graph.setEntities({ + blocks: cfg.blocks, + connections: cfg.connections, + }); + } + + // Start graph and zoom to center + graph.start(); + graph.zoomTo("center"); + + // Expose to window for tests + window.graph = graph; + window.graphInitialized = true; + }, config); + + // Wait for graph to be ready + await this.page.waitForFunction( + () => window.graphInitialized === true, + { timeout: 5000 } + ); + + // Wait for initial render frames + await this.waitForFrames(3); + } + + /** + * Wait for N animation frames to complete + * This is necessary because the library uses Scheduler with requestAnimationFrame + */ + async waitForFrames(count: number = 1): Promise { + await this.page.evaluate((frameCount) => { + return new Promise((resolve) => { + let remaining = frameCount; + const tick = () => { + remaining--; + if (remaining <= 0) { + resolve(); + } else { + requestAnimationFrame(tick); + } + }; + requestAnimationFrame(tick); + }); + }, count); + } + + /** + * Wait for scheduler to be idle + * Waits until there are no more scheduled tasks + */ + async waitForSchedulerIdle(timeout: number = 5000): Promise { + await this.page.evaluate((timeoutMs) => { + return new Promise((resolve, reject) => { + const startTime = Date.now(); + + const check = () => { + if (Date.now() - startTime > timeoutMs) { + reject(new Error(`Scheduler did not become idle within ${timeoutMs}ms`)); + return; + } + + // Wait for RAF to ensure all scheduled tasks are processed + requestAnimationFrame(() => { + // Give scheduler one more frame to be sure + requestAnimationFrame(() => { + resolve(); + }); + }); + }; + + check(); + }); + }, timeout); + } + + /** + * Wait for a graph event + */ + async waitForEvent(eventName: string, timeout = 5000): Promise { + return await this.page.evaluate( + ({ eventName, timeout }) => { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject( + new Error( + `Event ${eventName} did not fire within ${timeout}ms` + ) + ); + }, timeout); + + const handler = (event: any) => { + clearTimeout(timer); + resolve(event.detail); + }; + + window.graph.on(eventName as any, handler); + }); + }, + { eventName, timeout } + ); + } + + /** + * Emit custom event on graph + */ + async event( + type: "click" | "mouseover" | "mousedown" | "mouseup", + xy: { x: number; y: number } + ): Promise { + await this.page.mouse.move(xy.x, xy.y); + if (type === "click") { + await this.page.mouse.click(xy.x, xy.y); + } else if (type === "mousedown") { + await this.page.mouse.down(); + } else if (type === "mouseup") { + await this.page.mouse.up(); + } + } + + /** + * Get blocks store + */ + get blocksStore() { + return this.blocks; + } + + /** + * Get connections store + */ + get connectionsStore() { + return this.connections; + } +} diff --git a/e2e/page-objects/ReactGraphPageObject.ts b/e2e/page-objects/ReactGraphPageObject.ts new file mode 100644 index 00000000..96f45938 --- /dev/null +++ b/e2e/page-objects/ReactGraphPageObject.ts @@ -0,0 +1,59 @@ +import { Page } from "@playwright/test"; +import { GraphPageObject, GraphConfig } from "./GraphPageObject"; + +export class ReactGraphPageObject extends GraphPageObject { + constructor(page: Page) { + super(page); + } + + /** + * Initialize React graph with config + */ + async initialize(config: GraphConfig): Promise { + await this.page.goto("/base.html"); + + // Inject React and Graph library from build + await this.page.addScriptTag({ + path: "./node_modules/react/umd/react.development.js", + }); + await this.page.addScriptTag({ + path: "./node_modules/react-dom/umd/react-dom.development.js", + }); + await this.page.addScriptTag({ + path: "./build/index.js", + type: "module", + }); + await this.page.addScriptTag({ + path: "./build/react-components/index.js", + type: "module", + }); + + // Wait for libraries to load + await this.page.waitForFunction(() => { + return ( + typeof window.React !== "undefined" && + typeof window.ReactDOM !== "undefined" && + typeof window.Graph !== "undefined" + ); + }); + + // Initialize React graph + await this.page.evaluate((cfg) => { + const rootEl = document.getElementById("root"); + if (!rootEl || !(rootEl instanceof HTMLDivElement)) { + throw new Error("Root element not found or is not a div"); + } + + // Create graph using useGraph hook pattern + const graph = new window.Graph(cfg, rootEl); + graph.start(); + graph.zoomTo("center"); + + window.graph = graph; + window.graphInitialized = true; + }, config); + + // Wait for graph to be ready + await this.waitForReady(); + } +} diff --git a/e2e/pages/base.html b/e2e/pages/base.html new file mode 100644 index 00000000..d1890c92 --- /dev/null +++ b/e2e/pages/base.html @@ -0,0 +1,26 @@ + + + + + Graph E2E Test + + + +
+ + + + diff --git a/e2e/server.js b/e2e/server.js new file mode 100644 index 00000000..c87abb5a --- /dev/null +++ b/e2e/server.js @@ -0,0 +1,100 @@ +const http = require("http"); +const fs = require("fs"); +const path = require("path"); + +const PORT = 6006; +const PAGES_DIR = path.join(__dirname, "pages"); +const BUILD_DIR = path.join(__dirname, "..", "build"); +const E2E_DIST = path.join(__dirname, "dist"); + +const server = http.createServer((req, res) => { + // Serve e2e dist files (bundle) + if (req.url.startsWith("/e2e/dist/")) { + const filePath = path.join(__dirname, "..", req.url); + if (fs.existsSync(filePath)) { + const stat = fs.statSync(filePath); + + if (stat.isDirectory()) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + + const ext = path.extname(filePath); + const contentType = { + ".js": "application/javascript; charset=utf-8", + ".map": "application/json", + }[ext] || "text/plain"; + + res.writeHead(200, { + "Content-Type": contentType, + "Access-Control-Allow-Origin": "*", + }); + fs.createReadStream(filePath).pipe(res); + return; + } + } + + // Serve build files + if (req.url.startsWith("/build/")) { + const filePath = path.join(__dirname, "..", req.url); + if (fs.existsSync(filePath)) { + const stat = fs.statSync(filePath); + + // Skip if it's a directory + if (stat.isDirectory()) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + + const ext = path.extname(filePath); + const contentType = { + ".js": "application/javascript; charset=utf-8", + ".css": "text/css", + ".map": "application/json", + }[ext] || "text/plain"; + + res.writeHead(200, { + "Content-Type": contentType, + "Access-Control-Allow-Origin": "*", + }); + fs.createReadStream(filePath).pipe(res); + return; + } + } + + // Serve HTML pages + const filePath = path.join( + PAGES_DIR, + req.url === "/" ? "base.html" : req.url + ); + + if (fs.existsSync(filePath)) { + const stat = fs.statSync(filePath); + + // Skip if it's a directory + if (stat.isDirectory()) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + + const ext = path.extname(filePath); + const contentType = { + ".html": "text/html", + ".js": "text/javascript", + ".css": "text/css", + }[ext] || "text/plain"; + + res.writeHead(200, { "Content-Type": contentType }); + fs.createReadStream(filePath).pipe(res); + } else { + res.writeHead(404); + res.end("Not found"); + } +}); + +server.listen(PORT, () => { + console.log(`E2E test server running at http://localhost:${PORT}`); +}); diff --git a/e2e/tests/block-click.spec.ts b/e2e/tests/block-click.spec.ts new file mode 100644 index 00000000..36fc1cd1 --- /dev/null +++ b/e2e/tests/block-click.spec.ts @@ -0,0 +1,97 @@ +import { test, expect } from "@playwright/test"; +import { GraphPageObject } from "../page-objects/GraphPageObject"; + +test.describe("Block Click Selection", () => { + let graphPO: GraphPageObject; + + test.beforeEach(async ({ page }) => { + graphPO = new GraphPageObject(page); + + // Initialize graph with test blocks + await graphPO.initialize({ + blocks: [ + { + id: "block-1", + is: "Block", + x: 100, + y: 100, + width: 200, + height: 100, + name: "Block 1", + anchors: [], + selected: false, + }, + { + id: "block-2", + is: "Block", + x: 400, + y: 200, + width: 200, + height: 100, + name: "Block 2", + anchors: [], + selected: false, + }, + { + id: "block-3", + is: "Block", + x: 100, + y: 400, + width: 200, + height: 100, + name: "Block 3", + anchors: [], + selected: false, + }, + ], + connections: [], + }); + }); + + test("should select block on click", async () => { + // Click on block-1 + await graphPO.blocks.click("block-1"); + + // Check if block is selected + const isSelected = await graphPO.blocks.isSelected("block-1"); + await graphPO.page.pause(); + expect(isSelected).toBe(true); + + // Check selected blocks list + const selectedBlocks = await graphPO.blocks.getSelected(); + expect(selectedBlocks).toContain("block-1"); + expect(selectedBlocks).toHaveLength(1); + }); + + test("should deselect block when clicking on empty space", async () => { + // Select block first + await graphPO.blocks.click("block-1"); + + // Verify it's selected + let selectedBlocks = await graphPO.blocks.getSelected(); + expect(selectedBlocks).toContain("block-1"); + + // Click on empty space (top-left corner) + await graphPO.page.mouse.click(10, 10); + await graphPO.waitForFrames(2); + + // Check that no blocks are selected + selectedBlocks = await graphPO.blocks.getSelected(); + expect(selectedBlocks).toHaveLength(0); + }); + + test("should select multiple blocks with Shift+Click", async () => { + // Select first block + await graphPO.blocks.click("block-1"); + await graphPO.waitForFrames(2); + + // Click second block with Shift modifier + await graphPO.blocks.click("block-2", { shift: true }); + + // Check that both blocks are selected + const selectedBlocks = await graphPO.blocks.getSelected(); + expect(selectedBlocks).toHaveLength(2); + expect(selectedBlocks).toContain("block-1"); + expect(selectedBlocks).toContain("block-2"); + }); +}); diff --git a/e2e/tests/camera-control.spec.ts b/e2e/tests/camera-control.spec.ts new file mode 100644 index 00000000..eaddf6be --- /dev/null +++ b/e2e/tests/camera-control.spec.ts @@ -0,0 +1,150 @@ +import { test, expect } from "@playwright/test"; +import { GraphPageObject } from "../page-objects/GraphPageObject"; + +test.describe("Camera Control", () => { + let graphPO: GraphPageObject; + + test.beforeEach(async ({ page }) => { + graphPO = new GraphPageObject(page); + + // Initialize graph with test blocks and camera settings + await graphPO.initialize({ + blocks: [ + { + id: "block-1", + is: "Block", + x: 100, + y: 100, + width: 200, + height: 100, + name: "Block 1", + anchors: [], + selected: false, + }, + { + id: "block-2", + is: "Block", + x: 400, + y: 200, + width: 200, + height: 100, + name: "Block 2", + anchors: [], + selected: false, + }, + ], + connections: [], + settings: { + canDragCamera: true, + canZoomCamera: true, + }, + }); + }); + + test("should zoom in with mouse wheel and update camera scale", async () => { + // First zoom out to make room for zoom in + await graphPO.camera.emulateZoom(300); // Zoom out + + const initialCamera = await graphPO.camera.getState(); + const initialScale = initialCamera.scale; + + // Now zoom in + await graphPO.camera.emulateZoom(-100); // Zoom in + + const newCamera = await graphPO.camera.getState(); + + // Scale should have increased + expect(newCamera.scale).toBeGreaterThan(initialScale); + }); + + test("should zoom out with mouse wheel and update camera scale", async () => { + // First zoom in to make room for zoom out + await graphPO.camera.emulateZoom(-300); // Zoom in + + const initialCamera = await graphPO.camera.getState(); + const initialScale = initialCamera.scale; + + // Now zoom out + await graphPO.camera.emulateZoom(100); // Zoom out + + const newCamera = await graphPO.camera.getState(); + + // Scale should have decreased + expect(newCamera.scale).toBeLessThan(initialScale); + }); + + test("should pan camera with mouse drag", async () => { + const initialCamera = await graphPO.camera.getState(); + + // Pan camera by dragging + await graphPO.camera.emulatePan(100, 50); + + const newCamera = await graphPO.camera.getState(); + + // Camera position should have changed + expect(newCamera.x).not.toBe(initialCamera.x); + expect(newCamera.y).not.toBe(initialCamera.y); + }); + + test("should maintain world coordinates after zoom", async () => { + const initialGeometry = await graphPO.blocks.getGeometry("block-1"); + + // Zoom in multiple times using helper + for (let i = 0; i < 3; i++) { + await graphPO.camera.emulateZoom(-100); + } + + // Zoom out back + for (let i = 0; i < 3; i++) { + await graphPO.camera.emulateZoom(100); + } + + const finalGeometry = await graphPO.blocks.getGeometry("block-1"); + + // World coordinates should remain the same + expect(finalGeometry.x).toBe(initialGeometry.x); + expect(finalGeometry.y).toBe(initialGeometry.y); + expect(finalGeometry.width).toBe(initialGeometry.width); + expect(finalGeometry.height).toBe(initialGeometry.height); + }); + + test("should transform screen coordinates correctly after zoom", async () => { + // First zoom out to have room for zoom in + await graphPO.camera.emulateZoom(300); + + const initialCamera = await graphPO.camera.getState(); + const blockGeometry = await graphPO.blocks.getGeometry("block-1"); + + // Zoom in + await graphPO.camera.emulateZoom(-100); + + const newCamera = await graphPO.camera.getState(); + const sameBlockGeometry = await graphPO.blocks.getGeometry("block-1"); + + // World coordinates should be unchanged + expect(sameBlockGeometry.x).toBe(blockGeometry.x); + expect(sameBlockGeometry.y).toBe(blockGeometry.y); + + // Scale should have changed + expect(newCamera.scale).toBeGreaterThan(initialCamera.scale); + }); + + test("should pan camera and maintain block world positions", async () => { + const initialGeometry = await graphPO.blocks.getGeometry("block-1"); + const initialCamera = await graphPO.camera.getState(); + + // Pan by dragging using helper + await graphPO.camera.emulatePan(150, 100); + + const newCamera = await graphPO.camera.getState(); + const finalGeometry = await graphPO.blocks.getGeometry("block-1"); + + // Camera position should have changed + expect(newCamera.x).not.toBe(initialCamera.x); + expect(newCamera.y).not.toBe(initialCamera.y); + + // But block world position should remain the same + expect(finalGeometry.x).toBe(initialGeometry.x); + expect(finalGeometry.y).toBe(initialGeometry.y); + }); +}); diff --git a/e2e/tests/debug.spec.ts b/e2e/tests/debug.spec.ts new file mode 100644 index 00000000..83f704a7 --- /dev/null +++ b/e2e/tests/debug.spec.ts @@ -0,0 +1,79 @@ +import { test } from "@playwright/test"; +import { GraphPageObject } from "../page-objects/GraphPageObject"; + +test.describe("Debug Coordinates", () => { + test("debug block coordinates and hit test", async ({ page }) => { + const graphPO = new GraphPageObject(page); + + await graphPO.initialize({ + blocks: [ + { + id: "block-1", + is: "Block", + x: 100, + y: 100, + width: 200, + height: 100, + name: "Block 1", + anchors: [], + selected: false, + }, + ], + connections: [], + }); + + // Get debug info + const debugInfo = await page.evaluate(() => { + const blockState = window.graph.blocks.getBlockState("block-1"); + const geometry = blockState.$geometry.value; + const camera = window.graph.cameraService.getCameraState(); + + const worldCenterX = geometry.x + geometry.width / 2; + const worldCenterY = geometry.y + geometry.height / 2; + + const screenX = worldCenterX * camera.scale + camera.x; + const screenY = worldCenterY * camera.scale + camera.y; + + // Test hit detection + const elementsAtPoint = window.graph.getElementsOverPoint({ x: worldCenterX, y: worldCenterY }); + + return { + blockGeometry: geometry, + cameraState: { x: camera.x, y: camera.y, scale: camera.scale }, + worldCenter: { x: worldCenterX, y: worldCenterY }, + screenCenter: { x: screenX, y: screenY }, + canvasBounds: { + width: camera.width, + height: camera.height, + }, + elementsAtWorldCenter: elementsAtPoint.map((el: any) => el.id || el.constructor.name), + }; + }); + + console.log("Debug Info:", JSON.stringify(debugInfo, null, 2)); + + // Try clicking + await page.mouse.click(debugInfo.screenCenter.x, debugInfo.screenCenter.y); + await page.waitForTimeout(200); + + const isSelected = await graphPO.blocks.isSelected("block-1"); + console.log("Is block selected:", isSelected); + + // Check what element is at click position + const elementAtClick = await page.evaluate((coords) => { + const point = window.graph.getPointInCameraSpace({ + pageX: coords.screenX + window.scrollX, + pageY: coords.screenY + window.scrollY, + target: window.graph.getGraphCanvas(), + } as any); + + const elements = window.graph.getElementsOverPoint(point); + return elements.map((el: any) => el.id || el.constructor.name); + }, { screenX: debugInfo.screenCenter.x, screenY: debugInfo.screenCenter.y }); + + console.log("Elements at click position:", elementAtClick); + + // Take screenshot + await page.screenshot({ path: "test-results/debug-screenshot.png" }); + }); +}); diff --git a/e2e/tests/drag-and-drop.spec.ts b/e2e/tests/drag-and-drop.spec.ts new file mode 100644 index 00000000..1e13fcbe --- /dev/null +++ b/e2e/tests/drag-and-drop.spec.ts @@ -0,0 +1,148 @@ +import { test, expect } from "@playwright/test"; +import { GraphPageObject } from "../page-objects/GraphPageObject"; + +test.describe("Drag and Drop", () => { + let graphPO: GraphPageObject; + + test.beforeEach(async ({ page }) => { + graphPO = new GraphPageObject(page); + + // Initialize graph with test blocks + await graphPO.initialize({ + blocks: [ + { + id: "block-1", + is: "Block", + x: 100, + y: 100, + width: 200, + height: 100, + name: "Block 1", + anchors: [], + selected: false, + }, + { + id: "block-2", + is: "Block", + x: 400, + y: 200, + width: 200, + height: 100, + name: "Block 2", + anchors: [], + selected: false, + }, + { + id: "block-3", + is: "Block", + x: 100, + y: 400, + width: 200, + height: 100, + name: "Block 3", + anchors: [], + selected: false, + }, + ], + connections: [], + }); + }); + + test("should drag block to new position", async () => { + const initialGeometry = await graphPO.blocks.getGeometry("block-1"); + + // Drag block to new world position + const newWorldPos = { x: 500, y: 300 }; + await graphPO.blocks.dragTo("block-1", newWorldPos); + + // Wait for update + await graphPO.page.waitForTimeout(100); + + const finalGeometry = await graphPO.blocks.getGeometry("block-1"); + + // Position should have changed + expect(finalGeometry.x).not.toBe(initialGeometry.x); + expect(finalGeometry.y).not.toBe(initialGeometry.y); + + // Should be close to target position (within 10 pixels tolerance) + expect(Math.abs(finalGeometry.x - newWorldPos.x)).toBeLessThan(10); + expect(Math.abs(finalGeometry.y - newWorldPos.y)).toBeLessThan(10); + }); + + test("should drag multiple selected blocks together", async ({ page }) => { + // Select two blocks + await graphPO.blocks.click("block-1"); + await graphPO.waitForFrames(2); + + await graphPO.blocks.click("block-2", { shift: true }); + await graphPO.waitForFrames(2); + + // Verify both are selected + let selectedBlocks = await graphPO.blocks.getSelected(); + expect(selectedBlocks).toHaveLength(2); + + const initialGeometry1 = await graphPO.blocks.getGeometry("block-1"); + const initialGeometry2 = await graphPO.blocks.getGeometry("block-2"); + + // Calculate initial relative distance + const initialDeltaX = initialGeometry2.x - initialGeometry1.x; + const initialDeltaY = initialGeometry2.y - initialGeometry1.y; + + // Drag first block + await graphPO.blocks.dragTo("block-1", { x: 200, y: 200 }); + await page.waitForTimeout(100); + + const finalGeometry1 = await graphPO.blocks.getGeometry("block-1"); + const finalGeometry2 = await graphPO.blocks.getGeometry("block-2"); + + // Both blocks should have moved + expect(finalGeometry1.x).not.toBe(initialGeometry1.x); + expect(finalGeometry1.y).not.toBe(initialGeometry1.y); + expect(finalGeometry2.x).not.toBe(initialGeometry2.x); + expect(finalGeometry2.y).not.toBe(initialGeometry2.y); + + // Relative distance should be preserved (within tolerance) + const finalDeltaX = finalGeometry2.x - finalGeometry1.x; + const finalDeltaY = finalGeometry2.y - finalGeometry1.y; + + expect(Math.abs(finalDeltaX - initialDeltaX)).toBeLessThan(5); + expect(Math.abs(finalDeltaY - initialDeltaY)).toBeLessThan(5); + }); + + test("should maintain block selection after drag", async () => { + // Select a block + await graphPO.blocks.click("block-1"); + await graphPO.page.waitForTimeout(50); + + // Verify it's selected + let selectedBlocks = await graphPO.blocks.getSelected(); + expect(selectedBlocks).toContain("block-1"); + + // Drag the block + await graphPO.blocks.dragTo("block-1", { x: 300, y: 250 }); + await graphPO.page.waitForTimeout(100); + + // Block should still be selected after drag + selectedBlocks = await graphPO.blocks.getSelected(); + expect(selectedBlocks).toContain("block-1"); + }); + + test("should drag block with different zoom levels", async () => { + // Zoom in + await graphPO.camera.zoomToScale(0.8); + await graphPO.page.waitForTimeout(100); + + const initialGeometry = await graphPO.blocks.getGeometry("block-1"); + const targetPos = { x: 350, y: 250 }; + + // Drag block at this zoom level + await graphPO.blocks.dragTo("block-1", targetPos); + await graphPO.page.waitForTimeout(100); + + const finalGeometry = await graphPO.blocks.getGeometry("block-1"); + + // Should reach approximately target position + expect(Math.abs(finalGeometry.x - targetPos.x)).toBeLessThan(10); + expect(Math.abs(finalGeometry.y - targetPos.y)).toBeLessThan(10); + }); +}); diff --git a/e2e/tests/selection-test.spec.ts b/e2e/tests/selection-test.spec.ts new file mode 100644 index 00000000..043fb95d --- /dev/null +++ b/e2e/tests/selection-test.spec.ts @@ -0,0 +1,42 @@ +import { test, expect } from "@playwright/test"; +import { GraphPageObject } from "../page-objects/GraphPageObject"; + +test.describe("Selection Test", () => { + test("test selection programmatically", async ({ page }) => { + const graphPO = new GraphPageObject(page); + + await graphPO.initialize({ + blocks: [ + { + id: "block-1", + is: "Block", + x: 100, + y: 100, + width: 200, + height: 100, + name: "Block 1", + anchors: [], + selected: false, + }, + ], + connections: [], + }); + + // Try selecting programmatically first + await page.evaluate(() => { + const blockState = window.graph.blocks.getBlockState("block-1"); + console.log("BlockState exists:", !!blockState); + console.log("SelectionService:", window.graph.selectionService); + + // Try to select the block using correct API + window.graph.selectionService.select("block", ["block-1"], 0); // ESelectionStrategy.REPLACE = 0 + }); + + await page.waitForTimeout(200); + + const isSelected = await graphPO.blocks.isSelected("block-1"); + console.log("Is block selected after programmatic selection:", isSelected); + + expect(isSelected).toBe(true); + }); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 00000000..5a5dc73d --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "outDir": "./build/", + "moduleResolution": "node", + "module": "esnext", + "target": "es2020", + "noEmitOnError": false, + "allowJs": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "resolveJsonModule": true, + "lib": ["es2020", "dom"], + "types": ["@playwright/test", "node"] + }, + "include": ["**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/e2e/utils/CoordinateTransformer.ts b/e2e/utils/CoordinateTransformer.ts new file mode 100644 index 00000000..2319e789 --- /dev/null +++ b/e2e/utils/CoordinateTransformer.ts @@ -0,0 +1,62 @@ +export interface WorldCoordinates { + x: number; + y: number; +} + +export interface ScreenCoordinates { + x: number; + y: number; +} + +export interface CameraState { + x: number; + y: number; + scale: number; +} + +export interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +export class CoordinateTransformer { + /** + * Transforms world coordinates to screen coordinates + * Formula: screenX = worldX * scale + cameraX + */ + static worldToScreen( + world: WorldCoordinates, + camera: CameraState + ): ScreenCoordinates { + return { + x: world.x * camera.scale + camera.x, + y: world.y * camera.scale + camera.y, + }; + } + + /** + * Transforms screen coordinates to world coordinates + * Formula: worldX = (screenX - cameraX) / scale + */ + static screenToWorld( + screen: ScreenCoordinates, + camera: CameraState + ): WorldCoordinates { + return { + x: (screen.x - camera.x) / camera.scale, + y: (screen.y - camera.y) / camera.scale, + }; + } + + /** + * Get center of a rect in world coordinates + */ + static getRectCenter(rect: Rect): WorldCoordinates { + return { + x: rect.x + rect.width / 2, + y: rect.y + rect.height / 2, + }; + } +} diff --git a/package-lock.json b/package-lock.json index 546036b6..5ce5a6e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "@gravity-ui/tsconfig": "^1.0.0", "@gravity-ui/uikit": "^7.19.0", "@monaco-editor/react": "^4.6.0", + "@playwright/test": "^1.58.0", "@storybook/addon-docs": "^9.1.2", "@storybook/addon-links": "^9.1.2", "@storybook/addon-styling-webpack": "^2.0.0", @@ -51,6 +52,7 @@ "copyfiles": "^2.4.1", "cross-env": "^7.0.3", "elkjs": "^0.9.3", + "esbuild": "^0.27.2", "eslint": "^8.0.0", "eslint-config-prettier": "^8.10.0", "eslint-import-resolver-typescript": "2.5.0", @@ -2303,9 +2305,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz", - "integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", "cpu": [ "ppc64" ], @@ -2320,9 +2322,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.8.tgz", - "integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", "cpu": [ "arm" ], @@ -2337,9 +2339,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz", - "integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", "cpu": [ "arm64" ], @@ -2354,9 +2356,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.8.tgz", - "integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", "cpu": [ "x64" ], @@ -2371,9 +2373,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz", - "integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", "cpu": [ "arm64" ], @@ -2388,9 +2390,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz", - "integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", "cpu": [ "x64" ], @@ -2405,9 +2407,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz", - "integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", "cpu": [ "arm64" ], @@ -2422,9 +2424,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz", - "integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", "cpu": [ "x64" ], @@ -2439,9 +2441,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz", - "integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", "cpu": [ "arm" ], @@ -2456,9 +2458,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz", - "integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", "cpu": [ "arm64" ], @@ -2473,9 +2475,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz", - "integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", "cpu": [ "ia32" ], @@ -2490,9 +2492,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz", - "integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", "cpu": [ "loong64" ], @@ -2507,9 +2509,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz", - "integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", "cpu": [ "mips64el" ], @@ -2524,9 +2526,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz", - "integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", "cpu": [ "ppc64" ], @@ -2541,9 +2543,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz", - "integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", "cpu": [ "riscv64" ], @@ -2558,9 +2560,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz", - "integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", "cpu": [ "s390x" ], @@ -2575,9 +2577,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz", - "integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", "cpu": [ "x64" ], @@ -2592,9 +2594,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz", - "integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", "cpu": [ "arm64" ], @@ -2609,9 +2611,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz", - "integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", "cpu": [ "x64" ], @@ -2626,9 +2628,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz", - "integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", "cpu": [ "arm64" ], @@ -2643,9 +2645,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz", - "integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", "cpu": [ "x64" ], @@ -2660,9 +2662,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz", - "integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", "cpu": [ "arm64" ], @@ -2677,9 +2679,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz", - "integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", "cpu": [ "x64" ], @@ -2694,9 +2696,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz", - "integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", "cpu": [ "arm64" ], @@ -2711,9 +2713,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz", - "integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", "cpu": [ "ia32" ], @@ -2728,9 +2730,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz", - "integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", "cpu": [ "x64" ], @@ -4489,6 +4491,22 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@playwright/test": { + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.0.tgz", + "integrity": "sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@preact/signals-core": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.12.2.tgz", @@ -8746,9 +8764,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz", - "integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -8759,32 +8777,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.8", - "@esbuild/android-arm": "0.25.8", - "@esbuild/android-arm64": "0.25.8", - "@esbuild/android-x64": "0.25.8", - "@esbuild/darwin-arm64": "0.25.8", - "@esbuild/darwin-x64": "0.25.8", - "@esbuild/freebsd-arm64": "0.25.8", - "@esbuild/freebsd-x64": "0.25.8", - "@esbuild/linux-arm": "0.25.8", - "@esbuild/linux-arm64": "0.25.8", - "@esbuild/linux-ia32": "0.25.8", - "@esbuild/linux-loong64": "0.25.8", - "@esbuild/linux-mips64el": "0.25.8", - "@esbuild/linux-ppc64": "0.25.8", - "@esbuild/linux-riscv64": "0.25.8", - "@esbuild/linux-s390x": "0.25.8", - "@esbuild/linux-x64": "0.25.8", - "@esbuild/netbsd-arm64": "0.25.8", - "@esbuild/netbsd-x64": "0.25.8", - "@esbuild/openbsd-arm64": "0.25.8", - "@esbuild/openbsd-x64": "0.25.8", - "@esbuild/openharmony-arm64": "0.25.8", - "@esbuild/sunos-x64": "0.25.8", - "@esbuild/win32-arm64": "0.25.8", - "@esbuild/win32-ia32": "0.25.8", - "@esbuild/win32-x64": "0.25.8" + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" } }, "node_modules/esbuild-register": { @@ -14709,6 +14727,53 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz", + "integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz", + "integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", @@ -15984,6 +16049,448 @@ } } }, + "node_modules/storybook/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/storybook/node_modules/@vitest/mocker": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", @@ -16011,6 +16518,48 @@ } } }, + "node_modules/storybook/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, "node_modules/storybook/node_modules/semver": { "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", diff --git a/package.json b/package.json index c9e3ff1e..a17f4f61 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,14 @@ "build:publish": "tsc -p tsconfig.publish.json && npm run copy-styles", "build": "tsc --declaration && npm run copy-styles", "prepublishOnly": "npm run typecheck && npm run test && npm run build:publish", - "dev": "concurrently \"tsc --watch --declaration\" \"chokidar 'src/**/*.css' -c 'npm run copy-styles'\"" + "dev": "concurrently \"tsc --watch --declaration\" \"chokidar 'src/**/*.css' -c 'npm run copy-styles'\"", + "e2e:bundle": "node e2e/build-bundle.js", + "e2e:serve": "node e2e/server.js", + "e2e:build": "npm run build && npm run e2e:bundle", + "e2e:test": "playwright test", + "e2e:test:ui": "playwright test --ui", + "e2e:test:debug": "playwright test --debug", + "e2e:dev": "concurrently \"tsc --watch --declaration\" \"chokidar 'src/**/*.css' -c 'npm run copy-styles'\" \"npm run e2e:serve\"" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0", @@ -89,6 +96,7 @@ "@gravity-ui/tsconfig": "^1.0.0", "@gravity-ui/uikit": "^7.19.0", "@monaco-editor/react": "^4.6.0", + "@playwright/test": "^1.58.0", "@storybook/addon-docs": "^9.1.2", "@storybook/addon-links": "^9.1.2", "@storybook/addon-styling-webpack": "^2.0.0", @@ -108,6 +116,7 @@ "copyfiles": "^2.4.1", "cross-env": "^7.0.3", "elkjs": "^0.9.3", + "esbuild": "^0.27.2", "eslint": "^8.0.0", "eslint-config-prettier": "^8.10.0", "eslint-import-resolver-typescript": "2.5.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..36c802eb --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,27 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e/tests", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: "html", + use: { + baseURL: "http://localhost:6006", + trace: "on-first-retry", + screenshot: "only-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + webServer: { + command: "npm run e2e:build && npm run e2e:serve", + url: "http://localhost:6006", + reuseExistingServer: !process.env.CI, + timeout: 120000, + }, +}); diff --git a/src/services/camera/Camera.ts b/src/services/camera/Camera.ts index 1aa91c53..6b3a3ad7 100644 --- a/src/services/camera/Camera.ts +++ b/src/services/camera/Camera.ts @@ -216,6 +216,7 @@ export class Camera extends EventedComponent { + console.log("handleWheelEvent", event); if (!this.context.graph.rootStore.settings.getConfigFlag("canZoomCamera")) { + console.log("canZoomCamera is false"); return; } + console.log("canZoomCamera is true"); + event.stopPropagation(); event.preventDefault(); const isTrackpad = isTrackpadWheelEvent(event); const isTrackpadMove = isTrackpad && !isMetaKeyEvent(event); + console.log("isTrackpadMove", isTrackpadMove); // Trackpad swipe gesture - always moves camera if (isTrackpadMove) { this.handleTrackpadMove(event); @@ -260,6 +266,7 @@ export class Camera extends EventedComponent