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 openSubgraph(page: Page, nodeLabel: string) { await nodeByLabel(page, nodeLabel).click({ button: 'right' }); await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 }); // Wait for the dropdown animation to fully settle before clicking await page.waitForTimeout(300); await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Subgraph' }).click(); await waitForGraph(page); } const breadcrumbLinks = (page: Page) => page.locator('.ant-breadcrumb-link'); test.describe('Breadcrumb navigation', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); await waitForGraph(page); }); test('shows "Main" as the only breadcrumb in the initial state', async ({ page }) => { await expect(breadcrumbLinks(page)).toHaveCount(1); await expect(page.locator('.ant-breadcrumb')).toContainText('Main'); }); test('adds a breadcrumb segment when entering a subgraph', async ({ page }) => { await openSubgraph(page, 'Start'); await expect(breadcrumbLinks(page)).toHaveCount(2); await expect(page.locator('.ant-breadcrumb')).toContainText('Main'); await expect(page.locator('.ant-breadcrumb')).toContainText('Start'); }); test('breadcrumb grows for each additional level of nesting', async ({ page }) => { // Level 1: enter Start's subgraph await openSubgraph(page, 'Start'); await expect(breadcrumbLinks(page)).toHaveCount(2); // Level 2: enter Start's subgraph again from within level 1 await openSubgraph(page, 'Start'); await expect(breadcrumbLinks(page)).toHaveCount(3); }); test('clicking the root breadcrumb from two levels deep returns to main graph', async ({ page }) => { // Navigate 2 levels deep await openSubgraph(page, 'Start'); await openSubgraph(page, 'Start'); await expect(breadcrumbLinks(page)).toHaveCount(3); // Click "Main" (first item) await breadcrumbLinks(page).first().click(); await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 }); await expect(page.locator('.ant-breadcrumb')).toContainText('Main'); await waitForGraph(page); await expect(nodeByLabel(page, 'Start')).toBeVisible(); await expect(nodeByLabel(page, 'End')).toBeVisible(); }); test('clicking a middle breadcrumb navigates to that level and removes subsequent segments', async ({ page }) => { // Navigate to level 1 (Start's subgraph) await openSubgraph(page, 'Start'); // Add a node at level 1 so we can recognise it when we return await nodeByLabel(page, 'End').click(); await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 }); // Navigate to level 2 await openSubgraph(page, 'Start'); await expect(breadcrumbLinks(page)).toHaveCount(3); // Click the level-1 breadcrumb (second item, "Start") await breadcrumbLinks(page).nth(1).click(); // Breadcrumb should be truncated to 2 items await expect(breadcrumbLinks(page)).toHaveCount(2, { timeout: 3000 }); // The level-1 graph still has the extra node we added await waitForGraph(page); await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 }); }); test('navigating forward after going back starts a fresh path from that level', async ({ page }) => { // Navigate to level 1 await openSubgraph(page, 'Start'); await expect(breadcrumbLinks(page)).toHaveCount(2); // Go back to main await breadcrumbLinks(page).first().click(); await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 }); // Navigate into End's subgraph (a different node than before) await openSubgraph(page, 'End'); // Breadcrumb should be Main / End — no leftover "Start" segment await expect(breadcrumbLinks(page)).toHaveCount(2, { timeout: 3000 }); await expect(page.locator('.ant-breadcrumb')).toContainText('End'); await expect(page.locator('.ant-breadcrumb')).not.toContainText('Start'); }); }); // Regression tests for issue #2: stale closure caused breadcrumbs to disappear on back-navigation. // // The bug: createPathSegment's onClick closed over `graphsPath` from the render in which // the segment was created. After further navigation the closure was stale, so findIndex // returned -1 and splice(0) wiped the entire breadcrumb array. // // The fix: use the functional updater form of setGraphsPath so findIndex always runs // against the *current* state rather than a captured snapshot. test.describe('Stale-closure regression (issue #2)', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); await waitForGraph(page); }); test('root breadcrumb remains functional after navigating three levels deep', async ({ page }) => { // Navigate 3 levels deep — each additional level re-renders breadcrumbs with new // closures; before the fix, clicking the root would hit a stale closure from level 1 // and wipe everything. await openSubgraph(page, 'Start'); // level 1 await openSubgraph(page, 'Start'); // level 2 await openSubgraph(page, 'Start'); // level 3 await expect(breadcrumbLinks(page)).toHaveCount(4); // Click the root ("Main") breadcrumb await breadcrumbLinks(page).first().click(); // Breadcrumbs must NOT disappear — exactly one "Main" segment remains await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 }); await expect(page.locator('.ant-breadcrumb')).toContainText('Main'); await waitForGraph(page); // The root graph is rendered correctly await expect(nodeByLabel(page, 'Start')).toBeVisible(); await expect(nodeByLabel(page, 'End')).toBeVisible(); }); test('level-1 breadcrumb remains functional when clicked from level 3', async ({ page }) => { // This is the core regression scenario: a breadcrumb segment created at level 1 // must still resolve correctly after two more navigations have updated the path state. await openSubgraph(page, 'Start'); // level 1 await openSubgraph(page, 'Start'); // level 2 await openSubgraph(page, 'Start'); // level 3 await expect(breadcrumbLinks(page)).toHaveCount(4); // Click the level-1 breadcrumb (second item) await breadcrumbLinks(page).nth(1).click(); // Should trim to exactly 2 items — NOT clear everything await expect(breadcrumbLinks(page)).toHaveCount(2, { timeout: 3000 }); await waitForGraph(page); // The graph at level 1 is shown await expect(nodeByLabel(page, 'Start')).toBeVisible(); }); test('successive back-clicks each trim exactly one breadcrumb level', async ({ page }) => { await openSubgraph(page, 'Start'); // level 1 await openSubgraph(page, 'Start'); // level 2 await openSubgraph(page, 'Start'); // level 3 await expect(breadcrumbLinks(page)).toHaveCount(4); // Click level-2 breadcrumb await breadcrumbLinks(page).nth(2).click(); await expect(breadcrumbLinks(page)).toHaveCount(3, { timeout: 3000 }); // Then click level-1 breadcrumb await breadcrumbLinks(page).nth(1).click(); await expect(breadcrumbLinks(page)).toHaveCount(2, { timeout: 3000 }); // Then click root breadcrumb await breadcrumbLinks(page).first().click(); await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 }); await expect(page.locator('.ant-breadcrumb')).toContainText('Main'); }); test('clicking the active (last) breadcrumb does not alter the path', async ({ page }) => { // Clicking the segment you are already on should be a no-op — it must not clear // breadcrumbs by accidentally slicing at index -1. await openSubgraph(page, 'Start'); // level 1 await openSubgraph(page, 'Start'); // level 2 await expect(breadcrumbLinks(page)).toHaveCount(3); // The last breadcrumb link is the currently active level await breadcrumbLinks(page).last().click(); await expect(breadcrumbLinks(page)).toHaveCount(3, { timeout: 3000 }); }); });