From c6c63ad50a1d626ce0088bef66243a894086d20f Mon Sep 17 00:00:00 2001 From: tymurbaniak Date: Fri, 6 Mar 2026 05:07:59 +0100 Subject: [PATCH] added playwright tests --- .gitignore | 3 + e2e/app.spec.ts | 136 +++++++++++++++++++++++++++++++++++++++++++ package-lock.json | 64 ++++++++++++++++++++ package.json | 5 +- playwright.config.ts | 24 ++++++++ 5 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 e2e/app.spec.ts create mode 100644 playwright.config.ts diff --git a/.gitignore b/.gitignore index a547bf3..e0b2705 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ dist-ssr *.njsproj *.sln *.sw? + +playwright-report +test-results \ No newline at end of file diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts new file mode 100644 index 0000000..e56eee6 --- /dev/null +++ b/e2e/app.spec.ts @@ -0,0 +1,136 @@ +import { test, expect } from '@playwright/test'; + +// Helper: wait for the SVG graph to fully render (viz.js is async) +async function waitForGraph(page: import('@playwright/test').Page) { + await page.waitForSelector('svg g.node', { timeout: 15000 }); +} + +// Helper: find a graph node by its label text +function nodeByLabel(page: import('@playwright/test').Page, label: string) { + return page.locator('g.node').filter({ hasText: label }); +} + +test.describe('ConceptSketch', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await waitForGraph(page); + }); + + test('renders the initial graph with Start and End nodes', async ({ page }) => { + await expect(page.locator('svg g.graph')).toBeVisible(); + await expect(nodeByLabel(page, 'Start')).toBeVisible(); + await expect(nodeByLabel(page, 'End')).toBeVisible(); + // There is exactly one edge connecting them + await expect(page.locator('g.edge')).toHaveCount(1); + }); + + test('toggles the sidebar open and closed', async ({ page }) => { + const sider = page.locator('.ant-layout-sider'); + const toggleBtn = page.locator('header button').first(); + + // Initially collapsed (collapsedWidth=0 → ant-layout-sider-collapsed) + await expect(sider).toHaveClass(/ant-layout-sider-collapsed/); + + // Expand + await toggleBtn.click(); + await expect(sider).not.toHaveClass(/ant-layout-sider-collapsed/); + + // Collapse again + await toggleBtn.click(); + await expect(sider).toHaveClass(/ant-layout-sider-collapsed/); + }); + + test('creates a child node when clicking a node', async ({ page }) => { + const initialNodeCount = await page.locator('g.node').count(); + const initialEdgeCount = await page.locator('g.edge').count(); + + await nodeByLabel(page, 'Start').click(); + + // One more node and one more edge appear after re-render + await expect(page.locator('g.node')).toHaveCount(initialNodeCount + 1, { timeout: 5000 }); + await expect(page.locator('g.edge')).toHaveCount(initialEdgeCount + 1, { timeout: 5000 }); + }); + + test('removes a node via the context menu', async ({ page }) => { + const initialNodeCount = await page.locator('g.node').count(); + + 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(initialNodeCount - 1, { timeout: 5000 }); + await expect(nodeByLabel(page, 'End')).not.toBeAttached(); + }); + + test('renames a node via the context menu', 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 }); + + const input = modal.locator('.ant-input'); + await input.clear(); + await input.fill('Concept'); + + await modal.locator('.ant-btn-primary').click(); + + await expect(modal).not.toBeVisible(); + await expect(nodeByLabel(page, 'Concept')).toBeVisible({ timeout: 5000 }); + await expect(nodeByLabel(page, 'Start')).not.toBeAttached(); + }); + + test('navigates into a subgraph via the context menu', 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: 'Subgraph' }).click(); + + // Breadcrumb shows "Start" as the new path segment + await expect(page.locator('.ant-breadcrumb')).toContainText('Start', { timeout: 3000 }); + + // A fresh subgraph is rendered with its own default Start/End nodes + await waitForGraph(page); + await expect(nodeByLabel(page, 'Start')).toBeVisible(); + await expect(nodeByLabel(page, 'End')).toBeVisible(); + }); + + test('navigating back via breadcrumb restores the parent graph', async ({ page }) => { + // Go into Start's subgraph + 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: 'Subgraph' }).click(); + await expect(page.locator('.ant-breadcrumb')).toContainText('Start', { timeout: 3000 }); + await waitForGraph(page); + + // Click the first breadcrumb item (Main) to go back + await page.locator('.ant-breadcrumb-link').first().click(); + + // Breadcrumb should only show the root segment again + await expect(page.locator('.ant-breadcrumb-link')).toHaveCount(1, { timeout: 3000 }); + await waitForGraph(page); + await expect(nodeByLabel(page, 'Start')).toBeVisible(); + }); + + test('links nodes using Ctrl+click selection', async ({ page }) => { + // Create a third node to link to + await nodeByLabel(page, 'Start').click(); + await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 }); + + const edgeCountBefore = await page.locator('g.edge').count(); + + // Ctrl+click End to select it as a parent + await page.keyboard.down('Control'); + await nodeByLabel(page, 'End').click(); + await page.keyboard.up('Control'); + + // Click Start (without Ctrl) — links selected End as a parent of Start + await nodeByLabel(page, 'Start').click(); + + // One new edge End→Start should have been created + await expect(page.locator('g.edge')).toHaveCount(edgeCountBefore + 1, { timeout: 5000 }); + }); +}); diff --git a/package-lock.json b/package-lock.json index 2edf164..a0d5701 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ }, "devDependencies": { "@eslint/js": "^9.36.0", + "@playwright/test": "^1.58.2", "@types/d3": "^7.4.3", "@types/lodash": "^4.17.20", "@types/node": "^24.6.0", @@ -1174,6 +1175,22 @@ "node": ">= 8" } }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rc-component/async-validator": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.0.4.tgz", @@ -3967,6 +3984,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", diff --git a/package.json b/package.json index 78cac2c..efded7f 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" }, "dependencies": { "antd": "^5.28.0", @@ -20,6 +22,7 @@ }, "devDependencies": { "@eslint/js": "^9.36.0", + "@playwright/test": "^1.58.2", "@types/d3": "^7.4.3", "@types/lodash": "^4.17.20", "@types/node": "^24.6.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..0da324f --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,24 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: 'html', + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'npm run dev', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + }, +});