Files
ConceptSketch/e2e/save.spec.ts

196 lines
8.7 KiB
TypeScript

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<Download> {
const [download] = await Promise.all([
page.waitForEvent('download'),
saveButton(page).click(),
]);
return download;
}
async function getSavedJson(page: Page): Promise<SavedGraph> {
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();
});
});