import { test, expect, type Page } from '@playwright/test'; async function waitForGraph(page: Page) { await page.waitForSelector('svg g.node', { timeout: 15000 }); } function nodeByLabel(page: Page, label: string) { return page.locator('g.node').filter({ hasText: label }); } async function openSidebar(page: Page) { const sider = page.locator('.ant-layout-sider'); const isCollapsed = await sider.evaluate(el => el.classList.contains('ant-layout-sider-collapsed')); if (isCollapsed) { await page.locator('header button').first().click(); await expect(sider).not.toHaveClass(/ant-layout-sider-collapsed/, { timeout: 3000 }); } } async function openSubgraph(page: Page, nodeLabel: string) { await nodeByLabel(page, nodeLabel).click({ button: 'right' }); await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 }); await page.waitForTimeout(300); await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Subgraph' }).click(); await waitForGraph(page); } function treeNodeByLabel(page: Page, label: string) { return page.locator('.ant-tree-node-content-wrapper').filter({ hasText: label }); } const breadcrumbLinks = (page: Page) => page.locator('.ant-breadcrumb-link'); test.describe('Tree navigation', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); await waitForGraph(page); }); test('sidebar tree is empty before any subgraphs are created', async ({ page }) => { await openSidebar(page); await expect(page.locator('.ant-tree-title')).toHaveCount(0); }); test('creating a subgraph adds its node to the sidebar tree', async ({ page }) => { await openSubgraph(page, 'Start'); await openSidebar(page); await expect(treeNodeByLabel(page, 'Start')).toBeVisible({ timeout: 3000 }); }); test('clicking a tree node navigates to its subgraph', async ({ page }) => { await openSubgraph(page, 'Start'); // Go back to main await breadcrumbLinks(page).first().click(); await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 }); await waitForGraph(page); // Navigate via tree await openSidebar(page); await treeNodeByLabel(page, 'Start').click(); await expect(page.locator('.ant-breadcrumb')).toContainText('Start', { timeout: 3000 }); await waitForGraph(page); await expect(nodeByLabel(page, 'Start')).toBeVisible(); await expect(nodeByLabel(page, 'End')).toBeVisible(); }); test('clicking a tree node updates the breadcrumb to show the full path', async ({ page }) => { await openSubgraph(page, 'Start'); // Go back to main await breadcrumbLinks(page).first().click(); await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 }); await openSidebar(page); await treeNodeByLabel(page, 'Start').click(); await expect(breadcrumbLinks(page)).toHaveCount(2, { timeout: 3000 }); await expect(page.locator('.ant-breadcrumb')).toContainText('Main'); await expect(page.locator('.ant-breadcrumb')).toContainText('Start'); }); test('clicking a nested tree node navigates directly with the full breadcrumb path', async ({ page }) => { // Create level 1: Start → Start's subgraph await openSubgraph(page, 'Start'); // Create level 2: Start → Start's sub-subgraph await openSubgraph(page, 'Start'); // Go back all the way to main await breadcrumbLinks(page).first().click(); await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 }); await waitForGraph(page); // Navigate to level 2 directly via tree await openSidebar(page); // Expand the root Start node to reveal its child await page.locator('.ant-tree-switcher').first().click(); const treeNodes = page.locator('.ant-tree-node-content-wrapper').filter({ hasText: 'Start' }); // The deepest node is the last one in the tree await treeNodes.last().click(); // Breadcrumb should show 3 levels: Main / Start / Start await expect(breadcrumbLinks(page)).toHaveCount(3, { timeout: 3000 }); await waitForGraph(page); }); test('tree navigation from inside one subgraph jumps directly to another', async ({ page }) => { // Create subgraph for Start await openSubgraph(page, 'Start'); // Go back, create subgraph for End await breadcrumbLinks(page).first().click(); await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 }); await openSubgraph(page, 'End'); // Now we are inside End's subgraph — navigate to Start's subgraph via tree await openSidebar(page); await treeNodeByLabel(page, 'Start').click(); await expect(page.locator('.ant-breadcrumb')).toContainText('Start', { timeout: 3000 }); await expect(page.locator('.ant-breadcrumb')).not.toContainText('End'); await expect(breadcrumbLinks(page)).toHaveCount(2, { timeout: 3000 }); await waitForGraph(page); }); test('subgraph content is preserved when navigating away and back via tree', async ({ page }) => { await openSubgraph(page, 'Start'); // Add a node in Start's subgraph await nodeByLabel(page, 'End').click(); await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 }); // Go back to main await breadcrumbLinks(page).first().click(); await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 }); await waitForGraph(page); // Navigate back via tree await openSidebar(page); await treeNodeByLabel(page, 'Start').click(); await waitForGraph(page); await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 }); }); }); test.describe('Tree highlighting', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); await waitForGraph(page); }); test('tree node is highlighted when entering its subgraph via context menu', async ({ page }) => { await openSubgraph(page, 'Start'); await openSidebar(page); await expect(treeNodeByLabel(page, 'Start')).toHaveClass(/ant-tree-node-selected/, { timeout: 3000 }); }); test('tree node is highlighted when navigating to it via tree click', async ({ page }) => { await openSubgraph(page, 'Start'); await breadcrumbLinks(page).first().click(); await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 }); await openSidebar(page); await treeNodeByLabel(page, 'Start').click(); await expect(treeNodeByLabel(page, 'Start')).toHaveClass(/ant-tree-node-selected/, { timeout: 3000 }); }); test('tree node is deselected when navigating back to main via breadcrumb', async ({ page }) => { await openSubgraph(page, 'Start'); await openSidebar(page); await expect(treeNodeByLabel(page, 'Start')).toHaveClass(/ant-tree-node-selected/, { timeout: 3000 }); await breadcrumbLinks(page).first().click(); await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 }); await expect(treeNodeByLabel(page, 'Start')).not.toHaveClass(/ant-tree-node-selected/, { timeout: 3000 }); }); test('highlight switches between tree nodes when navigating between sibling subgraphs', async ({ page }) => { await openSubgraph(page, 'Start'); await breadcrumbLinks(page).first().click(); await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 }); await openSubgraph(page, 'End'); await breadcrumbLinks(page).first().click(); await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 }); await waitForGraph(page); await openSidebar(page); // Navigate to Start's subgraph via tree await treeNodeByLabel(page, 'Start').click(); await expect(treeNodeByLabel(page, 'Start')).toHaveClass(/ant-tree-node-selected/, { timeout: 3000 }); await expect(treeNodeByLabel(page, 'End')).not.toHaveClass(/ant-tree-node-selected/); // Navigate to End's subgraph via tree await treeNodeByLabel(page, 'End').click(); await expect(treeNodeByLabel(page, 'End')).toHaveClass(/ant-tree-node-selected/, { timeout: 3000 }); await expect(treeNodeByLabel(page, 'Start')).not.toHaveClass(/ant-tree-node-selected/); }); }); test.describe('Tree node removal', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); await waitForGraph(page); }); test('removing a node with a subgraph removes it from the tree', async ({ page }) => { await openSubgraph(page, 'Start'); await breadcrumbLinks(page).first().click(); await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 }); await waitForGraph(page); await openSidebar(page); await expect(treeNodeByLabel(page, 'Start')).toBeVisible(); await nodeByLabel(page, 'Start').click({ button: 'right' }); await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 }); await page.waitForTimeout(300); await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Remove' }).click(); await expect(treeNodeByLabel(page, 'Start')).not.toBeAttached({ timeout: 3000 }); }); test('removing a node without a subgraph does not affect the tree', async ({ page }) => { await openSubgraph(page, 'Start'); await breadcrumbLinks(page).first().click(); await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 }); await waitForGraph(page); await openSidebar(page); await expect(page.locator('.ant-tree-title')).toHaveCount(1); // Remove End (no subgraph) await nodeByLabel(page, 'End').click({ button: 'right' }); await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 }); await page.waitForTimeout(300); await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Remove' }).click(); // Tree still has exactly one node (Start) await expect(page.locator('.ant-tree-title')).toHaveCount(1); await expect(treeNodeByLabel(page, 'Start')).toBeVisible(); }); });