import { test, expect, type Page } from '@playwright/test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; // ── fixtures ────────────────────────────────────────────────────────────────── const SIMPLE_GRAPH = { id: 'main', nodes: [ { id: 'node-a', label: 'Alpha' }, { id: 'node-b', label: 'Beta' }, ], edges: [{ id: 'edge-1', from: 'node-a', to: 'node-b' }], }; const GRAPH_WITH_SUBGRAPH = { id: 'main', nodes: [ { id: 'node-a', label: 'Alpha', subgraph: { id: 'node-a', nodes: [ { id: 'sub-1', label: 'SubAlpha' }, { id: 'sub-2', label: 'SubBeta' }, ], edges: [{ id: 'sub-edge-1', from: 'sub-1', to: 'sub-2' }], }, }, { id: 'node-b', label: 'Beta' }, ], edges: [{ id: 'edge-1', from: 'node-a', to: 'node-b' }], }; function writeTempJson(data: object): string { const name = `concept-sketch-test-${Date.now()}-${Math.random().toString(36).slice(2)}.json`; const filePath = path.join(os.tmpdir(), name); fs.writeFileSync(filePath, JSON.stringify(data)); return filePath; } // ── helpers ─────────────────────────────────────────────────────────────────── 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 }); await page.waitForTimeout(300); await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Subgraph' }).click(); await waitForGraph(page); } function loadButton(page: Page) { return page.getByTitle('Load JSON'); } // Triggers load and waits until a specific node label appears in the graph — // that is the reliable signal that the full async load pipeline has finished. async function loadFile(page: Page, filePath: string, waitForLabel: string) { const [fileChooser] = await Promise.all([ page.waitForEvent('filechooser'), loadButton(page).click(), ]); await fileChooser.setFiles(filePath); await expect(nodeByLabel(page, waitForLabel)).toBeVisible({ timeout: 10000 }); } // ── tests ───────────────────────────────────────────────────────────────────── test.describe('Graph loading', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); await waitForGraph(page); }); // ── button presence ────────────────────────────────────────────────────────── test('load button is visible in the header', async ({ page }) => { await expect(loadButton(page)).toBeVisible(); }); test('clicking the load button opens a file chooser', async ({ page }) => { const [fileChooser] = await Promise.all([ page.waitForEvent('filechooser'), loadButton(page).click(), ]); expect(fileChooser).toBeDefined(); }); // ── basic load ─────────────────────────────────────────────────────────────── test('loaded nodes and edges replace the default graph', async ({ page }) => { const filePath = writeTempJson(SIMPLE_GRAPH); await loadFile(page, filePath, 'Alpha'); await expect(nodeByLabel(page, 'Alpha')).toBeVisible(); await expect(nodeByLabel(page, 'Beta')).toBeVisible(); await expect(page.locator('g.node')).toHaveCount(2); await expect(page.locator('g.edge')).toHaveCount(1); }); test('loading removes nodes that existed before the load', async ({ page }) => { await nodeByLabel(page, 'Start').click(); await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 }); const filePath = writeTempJson(SIMPLE_GRAPH); await loadFile(page, filePath, 'Alpha'); await expect(page.locator('g.node')).toHaveCount(2, { timeout: 5000 }); await expect(nodeByLabel(page, 'Start')).not.toBeAttached(); await expect(nodeByLabel(page, 'End')).not.toBeAttached(); }); test('a second load completely replaces the previously loaded graph', async ({ page }) => { const firstFile = writeTempJson(SIMPLE_GRAPH); await loadFile(page, firstFile, 'Alpha'); const secondFile = writeTempJson({ id: 'main', nodes: [ { id: 'n1', label: 'Gamma' }, { id: 'n2', label: 'Delta' }, ], edges: [{ id: 'e1', from: 'n1', to: 'n2' }], }); await loadFile(page, secondFile, 'Gamma'); await expect(nodeByLabel(page, 'Gamma')).toBeVisible(); await expect(nodeByLabel(page, 'Delta')).toBeVisible(); await expect(nodeByLabel(page, 'Alpha')).not.toBeAttached(); await expect(page.locator('g.node')).toHaveCount(2); }); // ── navigation reset ────────────────────────────────────────────────────────── test('loading resets the breadcrumb to "Main" only', async ({ page }) => { await openSubgraph(page, 'Start'); await expect(page.locator('.ant-breadcrumb-link')).toHaveCount(2); const filePath = writeTempJson(SIMPLE_GRAPH); await loadFile(page, filePath, 'Alpha'); await expect(page.locator('.ant-breadcrumb-link')).toHaveCount(1, { timeout: 3000 }); await expect(page.locator('.ant-breadcrumb')).toContainText('Main'); await expect(page.locator('.ant-breadcrumb')).not.toContainText('Start'); }); test('loading while inside a subgraph shows the loaded main graph', async ({ page }) => { await openSubgraph(page, 'Start'); const filePath = writeTempJson(SIMPLE_GRAPH); await loadFile(page, filePath, 'Alpha'); await expect(nodeByLabel(page, 'Alpha')).toBeVisible(); await expect(nodeByLabel(page, 'Beta')).toBeVisible(); await expect(nodeByLabel(page, 'Start')).not.toBeAttached(); await expect(nodeByLabel(page, 'End')).not.toBeAttached(); }); // ── subgraph restoration ────────────────────────────────────────────────────── test('subgraph from loaded JSON is accessible via the context menu', async ({ page }) => { const filePath = writeTempJson(GRAPH_WITH_SUBGRAPH); await loadFile(page, filePath, 'Alpha'); await openSubgraph(page, 'Alpha'); await expect(nodeByLabel(page, 'SubAlpha')).toBeVisible(); await expect(nodeByLabel(page, 'SubBeta')).toBeVisible(); await expect(page.locator('g.node')).toHaveCount(2); await expect(page.locator('g.edge')).toHaveCount(1); }); test('breadcrumb shows the loaded node label when navigated into its subgraph', async ({ page }) => { const filePath = writeTempJson(GRAPH_WITH_SUBGRAPH); await loadFile(page, filePath, 'Alpha'); await openSubgraph(page, 'Alpha'); await expect(page.locator('.ant-breadcrumb-link')).toHaveCount(2); await expect(page.locator('.ant-breadcrumb')).toContainText('Alpha'); }); test('navigating back from a loaded subgraph restores the loaded main graph', async ({ page }) => { const filePath = writeTempJson(GRAPH_WITH_SUBGRAPH); await loadFile(page, filePath, 'Alpha'); await openSubgraph(page, 'Alpha'); await page.locator('.ant-breadcrumb-link').first().click(); await waitForGraph(page); await expect(nodeByLabel(page, 'Alpha')).toBeVisible(); await expect(nodeByLabel(page, 'Beta')).toBeVisible(); await expect(page.locator('g.node')).toHaveCount(2); }); test('node without a subgraph in the loaded JSON does not expose a pre-existing subgraph', async ({ page }) => { // First load graph with subgraph, then load a graph without const withSubgraph = writeTempJson(GRAPH_WITH_SUBGRAPH); await loadFile(page, withSubgraph, 'Alpha'); await openSubgraph(page, 'Alpha'); await expect(nodeByLabel(page, 'SubAlpha')).toBeVisible(); // Go back and load a plain graph await page.locator('.ant-breadcrumb-link').first().click(); await waitForGraph(page); const plain = writeTempJson(SIMPLE_GRAPH); await loadFile(page, plain, 'Alpha'); // Alpha no longer has a subgraph — entering it should create a fresh default graph await openSubgraph(page, 'Alpha'); await expect(nodeByLabel(page, 'Start')).toBeVisible(); await expect(nodeByLabel(page, 'End')).toBeVisible(); await expect(nodeByLabel(page, 'SubAlpha')).not.toBeAttached(); }); // ── sidebar tree ────────────────────────────────────────────────────────────── test('sidebar tree reflects subgraph structure from loaded JSON', async ({ page }) => { const filePath = writeTempJson(GRAPH_WITH_SUBGRAPH); await loadFile(page, filePath, 'Alpha'); // Expand sidebar await page.locator('header button').first().click(); await expect(page.locator('.ant-layout-sider')).not.toHaveClass(/ant-layout-sider-collapsed/, { timeout: 3000 }); // Alpha has a subgraph so it appears in the tree await expect(page.locator('.ant-tree-title').filter({ hasText: 'Alpha' })).toBeVisible({ timeout: 3000 }); }); });