Преглед на файлове

test(webui): Playwright login→collection→document→logout smoke

Add @playwright/test devDep + e2e script; playwright.config.ts (chromium,
no managed server, baseURL from SVAPI_BASE env); webui/e2e/smoke.spec.ts
driving the full flow: / → /login → fill key → dashboard → Collections →
create e2e_docs → Documents → new doc {"name":"e2e"} → delete collection
(cleanup) → logout. Add data-testid to Login, AppShell logout, Collections
name input + create btn + row cell, ConfirmDialog confirm btn. tsconfig.e2e.json
keeps e2e out of the app tsc build. npm run build passes (0 TS errors).
Fszontagh преди 1 месец
родител
ревизия
6fd70a7cd9

+ 108 - 0
webui/e2e/smoke.spec.ts

@@ -0,0 +1,108 @@
+/**
+ * Playwright end-to-end smoke test
+ *
+ * Preconditions (operator must supply):
+ *   - The backend is running on port 8080 with the built UI served via --webui-dir
+ *   - SVAPI_ADMIN_KEY env var contains the admin API key (e.g. "e2eadmin")
+ *   - SVAPI_BASE env var (optional) overrides the base URL (default http://localhost:8080)
+ *
+ * Run sequence (example):
+ *   cmake --build build -j"$(nproc)"
+ *   SMARTBOTIC_VECTORAPI_KEY=e2eadmin ./build/src/smartbotic-vectorapi \
+ *     --config config/config.json --share-dir api --webui-dir build/webui/dist &
+ *   cd webui && SVAPI_ADMIN_KEY=e2eadmin SVAPI_BASE=http://localhost:8080 npx playwright test
+ *
+ * The test creates a collection "e2e_docs" and a document inside it, then
+ * deletes the collection (cleanup) before logging out.
+ */
+
+import { test, expect } from '@playwright/test'
+
+const ADMIN_KEY = process.env['SVAPI_ADMIN_KEY'] ?? 'e2eadmin'
+
+test('login → collections → documents → logout smoke', async ({ page }) => {
+  // ── 1. Navigate to / → should redirect to /login ─────────────────────────
+  await page.goto('/')
+  await expect(page).toHaveURL(/\/login/)
+
+  // ── 2. Fill API key and submit ────────────────────────────────────────────
+  await page.locator('[data-testid="api-key-input"]').fill(ADMIN_KEY)
+  await page.locator('[data-testid="login-submit-btn"]').click()
+
+  // After login, should land on dashboard (root path)
+  await expect(page).toHaveURL(/\/$/)
+
+  // Project selector should be visible and show "default"
+  const projectSelector = page.getByRole('combobox', { name: 'Select project' })
+  await expect(projectSelector).toBeVisible()
+  await expect(projectSelector).toHaveValue('default')
+
+  // ── 3. Navigate to Collections ────────────────────────────────────────────
+  await page.getByRole('link', { name: 'Collections' }).click()
+  await expect(page).toHaveURL(/\/collections/)
+
+  // Open "New collection" modal
+  await page.locator('[data-testid="new-collection-btn"]').click()
+
+  // Fill collection name
+  const nameInput = page.locator('[data-testid="collection-name-input"]')
+  await expect(nameInput).toBeVisible()
+  await nameInput.fill('e2e_docs')
+
+  // Kind is already "json" by default — just submit
+  await page.locator('[data-testid="collection-create-btn"]').click()
+
+  // Wait for modal to close and collection to appear
+  await expect(page.locator('[data-testid="collection-row-e2e_docs"]')).toBeVisible({ timeout: 10_000 })
+
+  // ── 4. Navigate to Documents ──────────────────────────────────────────────
+  await page.getByRole('link', { name: 'Documents' }).click()
+  await expect(page).toHaveURL(/\/documents/)
+
+  // Collection picker should auto-select e2e_docs (it will be in the list)
+  const collPicker = page.locator('[data-testid="collection-picker"]')
+  await expect(collPicker).toBeVisible()
+
+  // Select e2e_docs in the collection picker
+  await collPicker.selectOption('e2e_docs')
+
+  // Click "New document"
+  const newDocBtn = page.locator('[data-testid="new-doc-btn"]')
+  await expect(newDocBtn).toBeEnabled({ timeout: 5_000 })
+  await newDocBtn.click()
+
+  // Monaco editor: wait for it to load and type the document
+  // Monaco renders a contenteditable div; we use keyboard to replace the default "{}"
+  const monacoEditor = page.locator('.monaco-editor').first()
+  await expect(monacoEditor).toBeVisible({ timeout: 15_000 })
+
+  // Click into the editor, select all and type our JSON
+  await monacoEditor.click()
+  await page.keyboard.press('Control+a')
+  await page.keyboard.type('{"name":"e2e"}')
+
+  // Save button
+  const saveBtn = page.locator('[data-testid="doc-save-btn"]')
+  await expect(saveBtn).toBeEnabled({ timeout: 5_000 })
+  await saveBtn.click()
+
+  // Document should appear in the list
+  await expect(page.locator('[data-testid="doc-row"]').first()).toBeVisible({ timeout: 10_000 })
+
+  // ── 5. Delete the collection (cleanup) ────────────────────────────────────
+  await page.getByRole('link', { name: 'Collections' }).click()
+  await expect(page).toHaveURL(/\/collections/)
+
+  // Click Delete button for e2e_docs row
+  await page.getByRole('button', { name: 'Delete e2e_docs' }).click()
+
+  // Confirm deletion
+  await page.locator('[data-testid="confirm-dialog-confirm-btn"]').click()
+
+  // Collection should be gone
+  await expect(page.locator('[data-testid="collection-row-e2e_docs"]')).not.toBeVisible({ timeout: 10_000 })
+
+  // ── 6. Logout ──────────────────────────────────────────────────────────────
+  await page.locator('[data-testid="logout-btn"]').click()
+  await expect(page).toHaveURL(/\/login/)
+})

+ 60 - 0
webui/package-lock.json

@@ -20,6 +20,7 @@
         "zustand": "^5.0.10"
       },
       "devDependencies": {
+        "@playwright/test": "^1.60.0",
         "@tailwindcss/vite": "^4.1.18",
         "@types/node": "^24.0.0",
         "@types/react": "^19.2.5",
@@ -970,6 +971,21 @@
         "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
       }
     },
+    "node_modules/@playwright/test": {
+      "version": "1.60.0",
+      "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
+      "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
+      "dev": true,
+      "dependencies": {
+        "playwright": "1.60.0"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
     "node_modules/@rolldown/pluginutils": {
       "version": "1.0.0-rc.3",
       "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
@@ -2951,6 +2967,50 @@
         "url": "https://github.com/sponsors/jonschlinkert"
       }
     },
+    "node_modules/playwright": {
+      "version": "1.60.0",
+      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
+      "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
+      "dev": true,
+      "dependencies": {
+        "playwright-core": "1.60.0"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "fsevents": "2.3.2"
+      }
+    },
+    "node_modules/playwright-core": {
+      "version": "1.60.0",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
+      "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
+      "dev": true,
+      "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,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
     "node_modules/postcss": {
       "version": "8.5.15",
       "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",

+ 3 - 1
webui/package.json

@@ -7,7 +7,8 @@
     "dev": "vite",
     "build": "tsc -b && vite build",
     "lint": "eslint .",
-    "preview": "vite preview"
+    "preview": "vite preview",
+    "e2e": "playwright test"
   },
   "dependencies": {
     "@monaco-editor/react": "^4.7.0",
@@ -22,6 +23,7 @@
     "zustand": "^5.0.10"
   },
   "devDependencies": {
+    "@playwright/test": "^1.60.0",
     "@tailwindcss/vite": "^4.1.18",
     "@types/node": "^24.0.0",
     "@types/react": "^19.2.5",

+ 19 - 0
webui/playwright.config.ts

@@ -0,0 +1,19 @@
+import { defineConfig, devices } from '@playwright/test'
+
+export default defineConfig({
+  testDir: './e2e',
+  timeout: 30_000,
+  retries: 0,
+  reporter: 'list',
+  use: {
+    baseURL: process.env['SVAPI_BASE'] ?? 'http://localhost:8080',
+    screenshot: 'only-on-failure',
+    trace: 'retain-on-failure',
+  },
+  projects: [
+    {
+      name: 'chromium',
+      use: { ...devices['Desktop Chrome'] },
+    },
+  ],
+})

+ 1 - 0
webui/src/components/AppShell.tsx

@@ -100,6 +100,7 @@ export function AppShell() {
           <ProjectSelector />
           <button
             onClick={handleLogout}
+            data-testid="logout-btn"
             className="flex items-center gap-2 text-sm text-slate-400 hover:text-slate-200 transition"
           >
             <LogOut size={16} />

+ 1 - 0
webui/src/components/ConfirmDialog.tsx

@@ -32,6 +32,7 @@ export function ConfirmDialog({
             onConfirm()
             onClose()
           }}
+          data-testid="confirm-dialog-confirm-btn"
           className="px-4 py-2 rounded-md text-sm font-medium text-white bg-red-600 hover:bg-red-500 transition"
         >
           {confirmLabel}

+ 3 - 1
webui/src/pages/Collections.tsx

@@ -91,6 +91,7 @@ function NewCollectionForm({ project, onSuccess, onCancel }: NewCollectionFormPr
           onChange={(e) => setName(e.target.value)}
           required
           placeholder="my_collection"
+          data-testid="collection-name-input"
           className="w-full rounded-md bg-slate-800 border border-slate-600 text-slate-100 px-3 py-2 text-sm placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/40"
         />
       </div>
@@ -163,6 +164,7 @@ function NewCollectionForm({ project, onSuccess, onCancel }: NewCollectionFormPr
         <button
           type="submit"
           disabled={submitting}
+          data-testid="collection-create-btn"
           className="px-4 py-2 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-500 transition disabled:opacity-50"
         >
           {submitting ? 'Creating…' : 'Create'}
@@ -242,7 +244,7 @@ function CollectionsTable({ collections, project }: CollectionsTableProps) {
                   idx % 2 === 0 ? 'bg-slate-900/30' : 'bg-slate-800/20',
                 ].join(' ')}
               >
-                <td className="px-4 py-3 font-mono text-slate-200 text-xs">{col.name}</td>
+                <td className="px-4 py-3 font-mono text-slate-200 text-xs" data-testid={`collection-row-${col.name}`}>{col.name}</td>
                 <td className="px-4 py-3">
                   <KindBadge kind={col.kind} />
                 </td>

+ 2 - 0
webui/src/pages/Login.tsx

@@ -44,6 +44,7 @@ export default function Login() {
               onChange={(e) => setKey(e.target.value)}
               placeholder="sk-…"
               autoComplete="current-password"
+              data-testid="api-key-input"
               className="w-full rounded-lg border border-slate-600 bg-slate-800 px-3.5 py-2.5 text-sm text-slate-100 placeholder-slate-500 outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition"
             />
           </div>
@@ -57,6 +58,7 @@ export default function Login() {
           <button
             type="submit"
             disabled={loading || !key.trim()}
+            data-testid="login-submit-btn"
             className="w-full rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed transition"
           >
             {loading ? 'Signing in…' : 'Sign in'}

+ 15 - 0
webui/tsconfig.e2e.json

@@ -0,0 +1,15 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "lib": ["ES2022"],
+    "module": "CommonJS",
+    "moduleResolution": "node",
+    "skipLibCheck": true,
+    "strict": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "esModuleInterop": true,
+    "outDir": "./node_modules/.tmp/e2e"
+  },
+  "include": ["e2e", "playwright.config.ts"]
+}