import { test, expect, type Page, type Download } from '@playwright/test'; import * as fs from 'fs'; // ── types mirroring saveGraph.ts ────────────────────────────────────────────── interface SavedEdge { id: string; from: string; to: string; } interface SavedNode { id: string; label?: string; subgraph?: SavedGraph; } interface SavedGraph { id: string; nodes: SavedNode[]; edges: SavedEdge[]; } // ── 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 saveButton(page: Page) { return page.getByTitle('Save as JSON'); } async function triggerSave(page: Page): Promise { const [download] = await Promise.all([ page.waitForEvent('download'), saveButton(page).click(), ]); return download; } async function getSavedJson(page: Page): Promise { const download = await triggerSave(page); const filePath = await download.path(); expect(filePath).not.toBeNull(); return JSON.parse(fs.readFileSync(filePath!, 'utf-8')) as SavedGraph; } // ── tests ───────────────────────────────────────────────────────────────────── test.describe('Graph saving', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); await waitForGraph(page); }); // ── button presence ───────────────────────────────────────────────────────── test('save button is visible in the header', async ({ page }) => { await expect(saveButton(page)).toBeVisible(); }); // ── download mechanics ────────────────────────────────────────────────────── test('clicking save downloads a file named concept-sketch.json', async ({ page }) => { const download = await triggerSave(page); expect(download.suggestedFilename()).toBe('concept-sketch.json'); }); test('downloaded file is valid JSON', async ({ page }) => { const download = await triggerSave(page); const filePath = await download.path(); expect(filePath).not.toBeNull(); expect(() => JSON.parse(fs.readFileSync(filePath!, 'utf-8'))).not.toThrow(); }); // ── root graph structure ──────────────────────────────────────────────────── test('saved JSON has id, nodes and edges at the root', async ({ page }) => { const data = await getSavedJson(page); expect(data.id).toBe('main'); expect(Array.isArray(data.nodes)).toBe(true); expect(Array.isArray(data.edges)).toBe(true); }); test('default graph saves Start and End nodes with one connecting edge', async ({ page }) => { const data = await getSavedJson(page); expect(data.nodes).toHaveLength(2); const labels = data.nodes.map(n => n.label); expect(labels).toContain('Start'); expect(labels).toContain('End'); expect(data.edges).toHaveLength(1); }); test('edge from/to ids reference nodes that exist in the graph', async ({ page }) => { const data = await getSavedJson(page); const nodeIds = new Set(data.nodes.map(n => n.id)); for (const edge of data.edges) { expect(nodeIds.has(edge.from)).toBe(true); expect(nodeIds.has(edge.to)).toBe(true); } }); // ── edits reflected in the save ───────────────────────────────────────────── test('newly added node appears in the saved JSON', async ({ page }) => { await nodeByLabel(page, 'Start').click(); await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 }); const data = await getSavedJson(page); expect(data.nodes).toHaveLength(3); expect(data.edges).toHaveLength(2); }); test('renamed node label appears correctly in the saved JSON', async ({ page }) => { await nodeByLabel(page, 'Start').click({ button: 'right' }); await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 }); await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Rename' }).click(); const modal = page.locator('.ant-modal'); await expect(modal).toBeVisible({ timeout: 3000 }); await modal.locator('.ant-input').clear(); await modal.locator('.ant-input').fill('Concept'); await modal.locator('.ant-btn-primary').click(); await expect(modal).not.toBeVisible(); await expect(nodeByLabel(page, 'Concept')).toBeVisible({ timeout: 5000 }); const data = await getSavedJson(page); const labels = data.nodes.map(n => n.label); expect(labels).toContain('Concept'); expect(labels).not.toContain('Start'); }); test('removed node is absent from the saved JSON', async ({ page }) => { await nodeByLabel(page, 'End').click({ button: 'right' }); await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 }); await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Remove' }).click(); await expect(page.locator('g.node')).toHaveCount(1, { timeout: 5000 }); const data = await getSavedJson(page); expect(data.nodes).toHaveLength(1); expect(data.nodes.map(n => n.label)).not.toContain('End'); }); // ── subgraph serialisation ────────────────────────────────────────────────── test('entering a subgraph and returning adds it nested inside the parent node', async ({ page }) => { await openSubgraph(page, 'Start'); await page.locator('.ant-breadcrumb-link').first().click(); await waitForGraph(page); const data = await getSavedJson(page); const startNode = data.nodes.find(n => n.label === 'Start')!; expect(startNode.subgraph).toBeDefined(); expect(startNode.subgraph!.nodes).toHaveLength(2); expect(startNode.subgraph!.edges).toHaveLength(1); }); test('subgraph id matches the id of the node that contains it', async ({ page }) => { await openSubgraph(page, 'Start'); await page.locator('.ant-breadcrumb-link').first().click(); await waitForGraph(page); const data = await getSavedJson(page); const startNode = data.nodes.find(n => n.label === 'Start')!; expect(startNode.subgraph!.id).toBe(startNode.id); }); test('node without a subgraph has no subgraph key in the JSON', async ({ page }) => { await openSubgraph(page, 'Start'); await page.locator('.ant-breadcrumb-link').first().click(); await waitForGraph(page); const data = await getSavedJson(page); const endNode = data.nodes.find(n => n.label === 'End')!; expect(endNode.subgraph).toBeUndefined(); }); test('nodes added inside a subgraph are serialised in the nested subgraph', async ({ page }) => { await openSubgraph(page, 'Start'); // Add one extra node inside the subgraph await nodeByLabel(page, 'End').click(); await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 }); await page.locator('.ant-breadcrumb-link').first().click(); await waitForGraph(page); const data = await getSavedJson(page); const startNode = data.nodes.find(n => n.label === 'Start')!; expect(startNode.subgraph!.nodes).toHaveLength(3); expect(startNode.subgraph!.edges).toHaveLength(2); }); test('saving from inside a subgraph includes the full hierarchy from root', async ({ page }) => { await openSubgraph(page, 'Start'); // Still inside the subgraph when saving const data = await getSavedJson(page); // Root must still be present expect(data.id).toBe('main'); expect(data.nodes.map(n => n.label)).toContain('Start'); // And the subgraph must be nested const startNode = data.nodes.find(n => n.label === 'Start')!; expect(startNode.subgraph).toBeDefined(); }); });