From 865a77dbe52a5f63837b8a80bc0ab2a8a1255432 Mon Sep 17 00:00:00 2001 From: locnguyen1842 Date: Mon, 27 Apr 2026 22:30:04 +0700 Subject: [PATCH] feat: add test p1 --- .gitignore | 18 + .../prompts/playwright-test-generator.md | 53 +++ .opencode/prompts/playwright-test-healer.md | 37 ++ .opencode/prompts/playwright-test-planner.md | 44 ++ Makefile | 3 + frontend/e2e/mocks/runtime.ts | 438 ++++++++++++++++++ frontend/e2e/playwright.config.ts | 40 ++ frontend/e2e/tests/categories.spec.ts | 132 ++++++ frontend/e2e/tests/commands.spec.ts | 191 ++++++++ frontend/e2e/tests/example.spec.ts | 19 + frontend/e2e/utils/selectors.ts | 46 ++ frontend/e2e/vite.config.ts | 21 + frontend/package.json | 4 +- frontend/pnpm-lock.yaml | 38 ++ frontend/src/App.tsx | 11 +- frontend/src/components/CategoryEditor.tsx | 3 +- frontend/src/components/CommandDetail.tsx | 8 +- frontend/src/components/FloatingSaveBar.tsx | 6 +- frontend/src/components/OutputPane.tsx | 2 +- frontend/src/components/Sidebar.tsx | 8 +- frontend/src/components/TabBar.tsx | 2 +- opencode.json | 101 ++++ seed.spec.ts | 7 + specs/README.md | 3 + 24 files changed, 1219 insertions(+), 16 deletions(-) create mode 100644 .opencode/prompts/playwright-test-generator.md create mode 100644 .opencode/prompts/playwright-test-healer.md create mode 100644 .opencode/prompts/playwright-test-planner.md create mode 100644 frontend/e2e/mocks/runtime.ts create mode 100644 frontend/e2e/playwright.config.ts create mode 100644 frontend/e2e/tests/categories.spec.ts create mode 100644 frontend/e2e/tests/commands.spec.ts create mode 100644 frontend/e2e/tests/example.spec.ts create mode 100644 frontend/e2e/utils/selectors.ts create mode 100644 frontend/e2e/vite.config.ts create mode 100644 opencode.json create mode 100644 seed.spec.ts create mode 100644 specs/README.md diff --git a/.gitignore b/.gitignore index 970ac56..c5774dc 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,21 @@ todos/ .env .grepai/ + +# Playwright +.local-browsers/ + +# Playwright test artifacts +test-results/ +playwright-report/ +blob-report/ +allure-results/ + +# Playwright traces and logs +*.trace +*.zip + +# Authentication state (contains sensitive session data/cookies) +# It is a best practice to keep these in a dedicated directory +.auth/ +playwright/.auth/ diff --git a/.opencode/prompts/playwright-test-generator.md b/.opencode/prompts/playwright-test-generator.md new file mode 100644 index 0000000..08f1991 --- /dev/null +++ b/.opencode/prompts/playwright-test-generator.md @@ -0,0 +1,53 @@ +You are a Playwright Test Generator, an expert in browser automation and end-to-end testing. +Your specialty is creating robust, reliable Playwright tests that accurately simulate user interactions and validate +application behavior. + +# For each test you generate +- Obtain the test plan with all the steps and verification specification +- Run the `generator_setup_page` tool to set up page for the scenario +- For each step and verification in the scenario, do the following: + - Use Playwright tool to manually execute it in real-time. + - Use the step description as the intent for each Playwright tool call. +- Retrieve generator log via `generator_read_log` +- Immediately after reading the test log, invoke `generator_write_test` with the generated source code + - File should contain single test + - File name must be fs-friendly scenario name + - Test must be placed in a describe matching the top-level test plan item + - Test title must match the scenario name + - Includes a comment with the step text before each step execution. Do not duplicate comments if step requires + multiple actions. + - Always use best practices from the log when generating tests. + + + For following plan: + + ```markdown file=specs/plan.md + ### 1. Adding New Todos + **Seed:** `tests/seed.spec.ts` + + #### 1.1 Add Valid Todo + **Steps:** + 1. Click in the "What needs to be done?" input field + + #### 1.2 Add Multiple Todos + ... + ``` + + Following file is generated: + + ```ts file=add-valid-todo.spec.ts + // spec: specs/plan.md + // seed: tests/seed.spec.ts + + test.describe('Adding New Todos', () => { + test('Add Valid Todo', async { page } => { + // 1. Click in the "What needs to be done?" input field + await page.click(...); + + ... + }); + }); + ``` + + +Context: User wants to generate a test for the test plan item. \ No newline at end of file diff --git a/.opencode/prompts/playwright-test-healer.md b/.opencode/prompts/playwright-test-healer.md new file mode 100644 index 0000000..70173c4 --- /dev/null +++ b/.opencode/prompts/playwright-test-healer.md @@ -0,0 +1,37 @@ +You are the Playwright Test Healer, an expert test automation engineer specializing in debugging and +resolving Playwright test failures. Your mission is to systematically identify, diagnose, and fix +broken Playwright tests using a methodical approach. + +Your workflow: +1. **Initial Execution**: Run all tests using `test_run` tool to identify failing tests +2. **Debug failed tests**: For each failing test run `test_debug`. +3. **Error Investigation**: When the test pauses on errors, use available Playwright MCP tools to: + - Examine the error details + - Capture page snapshot to understand the context + - Analyze selectors, timing issues, or assertion failures +4. **Root Cause Analysis**: Determine the underlying cause of the failure by examining: + - Element selectors that may have changed + - Timing and synchronization issues + - Data dependencies or test environment problems + - Application changes that broke test assumptions +5. **Code Remediation**: Edit the test code to address identified issues, focusing on: + - Updating selectors to match current application state + - Fixing assertions and expected values + - Improving test reliability and maintainability + - For inherently dynamic data, utilize regular expressions to produce resilient locators +6. **Verification**: Restart the test after each fix to validate the changes +7. **Iteration**: Repeat the investigation and fixing process until the test passes cleanly + +Key principles: +- Be systematic and thorough in your debugging approach +- Document your findings and reasoning for each fix +- Prefer robust, maintainable solutions over quick hacks +- Use Playwright best practices for reliable test automation +- If multiple errors exist, fix them one at a time and retest +- Provide clear explanations of what was broken and how you fixed it +- You will continue this process until the test runs successfully without any failures or errors. +- If the error persists and you have high level of confidence that the test is correct, mark this test as test.fixme() + so that it is skipped during the execution. Add a comment before the failing step explaining what is happening instead + of the expected behavior. +- Do not ask user questions, you are not interactive tool, do the most reasonable thing possible to pass the test. +- Never wait for networkidle or use other discouraged or deprecated apis diff --git a/.opencode/prompts/playwright-test-planner.md b/.opencode/prompts/playwright-test-planner.md new file mode 100644 index 0000000..59c50f1 --- /dev/null +++ b/.opencode/prompts/playwright-test-planner.md @@ -0,0 +1,44 @@ +You are an expert web test planner with extensive experience in quality assurance, user experience testing, and test +scenario design. Your expertise includes functional testing, edge case identification, and comprehensive test coverage +planning. + +You will: + +1. **Navigate and Explore** + - Invoke the `planner_setup_page` tool once to set up page before using any other tools + - Explore the browser snapshot + - Do not take screenshots unless absolutely necessary + - Use `browser_*` tools to navigate and discover interface + - Thoroughly explore the interface, identifying all interactive elements, forms, navigation paths, and functionality + +2. **Analyze User Flows** + - Map out the primary user journeys and identify critical paths through the application + - Consider different user types and their typical behaviors + +3. **Design Comprehensive Scenarios** + + Create detailed test scenarios that cover: + - Happy path scenarios (normal user behavior) + - Edge cases and boundary conditions + - Error handling and validation + +4. **Structure Test Plans** + + Each scenario must include: + - Clear, descriptive title + - Detailed step-by-step instructions + - Expected outcomes where appropriate + - Assumptions about starting state (always assume blank/fresh state) + - Success criteria and failure conditions + +5. **Create Documentation** + + Submit your test plan using `planner_save_plan` tool. + +**Quality Standards**: +- Write steps that are specific enough for any tester to follow +- Include negative testing scenarios +- Ensure scenarios are independent and can be run in any order + +**Output Format**: Always save the complete test plan as a markdown file with clear headings, numbered steps, and +professional formatting suitable for sharing with development and QA teams. diff --git a/Makefile b/Makefile index f1a919f..e032767 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,9 @@ build: generate: wails3 generate bindings +test: + cd frontend && pnpm test:e2e + check: go build ./... cd frontend && pnpm tsc --noEmit diff --git a/frontend/e2e/mocks/runtime.ts b/frontend/e2e/mocks/runtime.ts new file mode 100644 index 0000000..c2b220d --- /dev/null +++ b/frontend/e2e/mocks/runtime.ts @@ -0,0 +1,438 @@ +// Mock @wailsio/runtime for Playwright E2E tests. +// Replaces the Wails IPC bridge with an in-memory backend. + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +let categories: any[] = []; +let commands: any[] = []; +const presets: Record = {}; +let executionHistory: any[] = []; +const settings: Record = { + locale: 'en', + terminal: '', + theme: 'vscode-dark', + lastDarkTheme: 'vscode-dark', + lastLightTheme: 'vscode-light', + customThemes: '[]', + uiFont: 'Inter', + monoFont: 'JetBrains Mono', + density: 'comfortable', +}; + +let nextId = 0; +function uid() { + return `mock-${++nextId}-${Math.random().toString(36).slice(2, 8)}`; +} + +const now = () => new Date().toISOString(); + +const eventListeners: Record void>> = {}; + +export const Events = { + On(eventName: string, callback: (data: any) => void) { + if (!eventListeners[eventName]) eventListeners[eventName] = []; + eventListeners[eventName].push(callback); + return () => { + const list = eventListeners[eventName]; + if (list) { + const idx = list.indexOf(callback); + if (idx >= 0) list.splice(idx, 1); + } + }; + }, + Emit(eventName: string, data: any) { + (eventListeners[eventName] || []).forEach((fn) => fn(data)); + }, + Off(eventName: string) { + delete eventListeners[eventName]; + }, +}; + +export const Create = { + Any(source: any) { + return source; + }, + ByteSlice(source: any) { + return source == null ? '' : source; + }, + Array: + (element: (source: any) => any) => + (source: any[]) => { + if (source === null) return []; + if (element === Create.Any) return source; + for (let i = 0; i < source.length; i++) { + source[i] = element(source[i]); + } + return source; + }, + Map: + (_key: any, value: (source: any) => any) => + (source: any) => { + if (source === null) return {}; + if (value === Create.Any) return source; + for (const k in source) { + source[k] = value(source[k]); + } + return source; + }, + Nullable: + (element: (source: any) => any) => + (source: any) => { + if (element === Create.Any) return Create.Any; + return source === null ? null : element(source); + }, + Struct: + (_createField: Record any>) => + (source: any) => { + return source; + }, +}; + +function findCommand(id: string) { + return commands.find((c) => c.id === id); +} + +const handlers: Record any> = { + // ── Categories ────────────────────────────────────────── + // GetCategories + 1124386808: () => categories, + + // CreateCategory(name, icon, color) + 3920645540: (name: string, icon: string, color: string) => { + const cat = { + id: uid(), + name, + icon: icon || '', + color: color || '#7c6aef', + createdAt: now(), + updatedAt: now(), + }; + categories.push(cat); + return cat; + }, + + // UpdateCategory(id, name, icon, color) + 871939973: (id: string, name: string, icon: string, color: string) => { + const idx = categories.findIndex((c) => c.id === id); + if (idx < 0) throw new Error('Category not found'); + categories[idx] = { ...categories[idx], name, icon, color, updatedAt: now() }; + return categories[idx]; + }, + + // DeleteCategory(id) + 2228038743: (id: string) => { + categories = categories.filter((c) => c.id !== id); + commands.forEach((cmd) => { + if (cmd.categoryId === id) cmd.categoryId = ''; + }); + }, + + // ── Commands ───────────────────────────────────────────── + // GetCommands + 2230805162: () => commands, + + // GetCommandsByCategory(categoryID) + 3544855671: (categoryID: string) => + commands.filter((c) => (c.categoryId || '') === (categoryID || '')), + + // CreateCommand(title, desc, scriptBody, categoryID, tags, variables, workingDir) + 3040387109: ( + title: string, + description: string, + scriptBody: string, + categoryID: string, + tags: string[], + variables: any[], + workingDir: any, + ) => { + const cmd = { + id: uid(), + title: { String: title || '', Valid: !!title }, + description: { String: description || '', Valid: !!description }, + scriptContent: scriptBody || '', + tags: tags || [], + variables: variables || [], + presets: [], + workingDir: workingDir || {}, + categoryId: categoryID || '', + position: commands.length, + createdAt: now(), + updatedAt: now(), + }; + commands.push(cmd); + return cmd; + }, + + // UpdateCommand(id, title, desc, scriptBody, categoryID, tags, variables, workingDir) + 2942553414: ( + id: string, + title: string, + description: string, + scriptBody: string, + categoryID: string, + tags: string[], + variables: any[], + workingDir: any, + ) => { + const idx = commands.findIndex((c) => c.id === id); + if (idx < 0) throw new Error('Command not found'); + commands[idx] = { + ...commands[idx], + title: { String: title || '', Valid: !!title }, + description: { String: description || '', Valid: !!description }, + scriptContent: scriptBody || '', + tags: tags || [], + variables: variables || [], + workingDir: workingDir || {}, + categoryId: categoryID || '', + updatedAt: now(), + }; + return commands[idx]; + }, + + // DeleteCommand(id) + 1888656992: (id: string) => { + commands = commands.filter((c) => c.id !== id); + }, + + // RenameCommand(id, newTitle) + 3511040027: (id: string, newTitle: string) => { + const idx = commands.findIndex((c) => c.id === id); + if (idx < 0) throw new Error('Command not found'); + commands[idx] = { + ...commands[idx], + title: { String: newTitle, Valid: true }, + updatedAt: now(), + }; + return commands[idx]; + }, + + // ReorderCommand(id, newPosition, newCategoryId) + 2371488912: (id: string, newPosition: number, newCategoryId: string) => { + const cmd = findCommand(id); + if (cmd) { + cmd.position = newPosition; + cmd.categoryId = newCategoryId || ''; + } + return commands; + }, + + // GetScriptBody(commandID) + 707578151: (commandID: string) => { + const cmd = findCommand(commandID); + if (!cmd) return ''; + return cmd.scriptContent.replace(/^#!.*\n/, ''); + }, + + // GetScriptContent(commandID) + 1214515992: (commandID: string) => { + const cmd = findCommand(commandID); + return cmd ? cmd.scriptContent : ''; + }, + + // SearchCommands(query) + 2165520554: (query: string) => { + if (!query) return commands; + const q = query.toLowerCase(); + return commands.filter( + (c) => + (c.title.String || '').toLowerCase().includes(q) || + c.scriptContent.toLowerCase().includes(q), + ); + }, + + // ── Presets ────────────────────────────────────────────── + // GetPresets(commandID) + 2933858456: (commandID: string) => presets[commandID] || [], + + // SavePreset(commandID, name, values) + 2518009278: (commandID: string, name: string, values: Record) => { + if (!presets[commandID]) presets[commandID] = []; + const preset = { id: uid(), name, position: presets[commandID].length, values: values || {} }; + presets[commandID].push(preset); + return preset; + }, + + // UpdatePreset(commandID, presetID, name, values) + 1219258890: ( + commandID: string, + presetID: string, + name: string, + values: Record, + ) => { + const list = presets[commandID] || []; + const idx = list.findIndex((p) => p.id === presetID); + if (idx < 0) throw new Error('Preset not found'); + list[idx] = { ...list[idx], name, values: values || {} }; + return list[idx]; + }, + + // DeletePreset(commandID, presetID) + 1347137556: (commandID: string, presetID: string) => { + if (presets[commandID]) { + presets[commandID] = presets[commandID].filter((p) => p.id !== presetID); + } + }, + + // ReorderPresets(commandID, presetIDs) + 4123798965: (commandID: string, presetIDs: string[]) => { + if (!presets[commandID]) return; + const map = new Map(presets[commandID].map((p) => [p.id, p])); + presets[commandID] = presetIDs + .map((id, i) => { + const p = map.get(id); + if (p) p.position = i; + return p; + }) + .filter(Boolean); + }, + + // ── Settings ───────────────────────────────────────────── + // GetSettings + 3034808949: () => ({ ...settings }), + + // SetSettings(jsonStr) + 287946425: (jsonStr: string) => { + try { + Object.assign(settings, JSON.parse(jsonStr)); + } catch { + /* ignore */ + } + }, + + // GetAvailableTerminals + 2374612500: () => [], + + // ── Execution ──────────────────────────────────────────── + // GetVariables(commandID) + 4101005934: (commandID: string) => { + const cmd = findCommand(commandID); + if (!cmd) return []; + return (cmd.variables || []).map((v: any) => ({ + name: v.name, + placeholder: v.description || v.name, + description: v.description || '', + example: v.example || '', + defaultExpr: v.default || '', + defaultValue: v.default || '', + })); + }, + + // RunCommand(commandID, variables) + 4143621145: (commandID: string, variables: Record) => { + const cmd = findCommand(commandID); + const record = { + id: uid(), + commandId: commandID, + scriptContent: cmd?.scriptContent || '', + finalCmd: 'echo mock execution', + output: `Mock output: ${JSON.stringify(variables)}`, + error: '', + exitCode: 0, + workingDir: '', + executedAt: now(), + }; + executionHistory.push(record); + return record; + }, + + // RunInTerminal(commandID, variables) + 1736747747: () => {}, + + // ── History ────────────────────────────────────────────── + // GetExecutionHistory + 2752844091: () => executionHistory, + + // ClearExecutionHistory + 3022740230: () => { + executionHistory = []; + }, + + // ── Import / Export ────────────────────────────────────── + // ExportCommands(commandIDs) + 3360644818: () => {}, + + // ImportCommands + 840325137: () => [], + + // SaveThemeTemplate + 1489453142: () => {}, + + // ── Events ─────────────────────────────────────────────── + // GetEventNames + 2407475739: () => ({ + cmdOutput: 'cmd-output', + openSettings: 'open-settings', + openShortcuts: 'open-shortcuts', + settingsChanged: 'settings-changed', + settingsWindowClosing: 'settings-window-closing', + }), + + // ── App ────────────────────────────────────────────────── + // GetOS + 816844233: () => 'darwin', + + // PickDirectory + 1347829059: () => '/mock/path', + + // ShowSettingsWindow + 2596981913: () => {}, + + // ── Misc ───────────────────────────────────────────────── + // ResetAllData + 121210722: () => { + categories = []; + commands = []; + Object.keys(presets).forEach((k) => delete presets[k]); + executionHistory = []; + }, +}; + +export const Call = { + ByID(id: number, ...args: any[]) { + const handler = handlers[id]; + if (!handler) { + console.warn(`[e2e mock] no handler for method ID ${id}`); + return Promise.resolve(null); + } + try { + return Promise.resolve(handler(...args)); + } catch (err) { + return Promise.reject(err); + } + }, +}; + +export class CancellablePromise extends Promise { + cancel() {} +} + +;(globalThis as any).__cmdexE2E = { + reset() { + categories = []; + commands = []; + Object.keys(presets).forEach((k) => delete presets[k]); + executionHistory = []; + nextId = 0; + }, + seed(data: { categories?: any[]; commands?: any[]; presets?: Record }) { + if (data.categories) categories = data.categories; + if (data.commands) commands = data.commands; + if (data.presets) Object.assign(presets, data.presets); + nextId = Math.max( + ...categories.map((c) => parseInt(c.id) || 0), + ...commands.map((c) => parseInt(c.id) || 0), + 0, + ); + }, +}; + +// Read seed data injected via addInitScript before app initializes +const seed = (globalThis as any).__cmdexE2E_SEED__; +if (seed) { + if (seed.categories) categories = seed.categories; + if (seed.commands) commands = seed.commands; + if (seed.presets) Object.assign(presets, seed.presets); + if (seed.settings) Object.assign(settings, seed.settings); + nextId = 100; +} diff --git a/frontend/e2e/playwright.config.ts b/frontend/e2e/playwright.config.ts new file mode 100644 index 0000000..a30fbdd --- /dev/null +++ b/frontend/e2e/playwright.config.ts @@ -0,0 +1,40 @@ +import { defineConfig, devices } from '@playwright/test'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + testDir: './tests', + testMatch: '**/*.spec.ts', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: [['html', { outputFolder: 'playwright-report' }], ['list']], + timeout: 30000, + expect: { timeout: 10000 }, + + use: { + baseURL: 'http://localhost:9246', + testIdAttribute: 'data-testid', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + + webServer: { + command: 'pnpm vite --config e2e/vite.config.ts --port 9246 --strictPort', + cwd: path.resolve(__dirname, '..'), + url: 'http://localhost:9246', + reuseExistingServer: !process.env.CI, + timeout: 30000, + }, +}); diff --git a/frontend/e2e/tests/categories.spec.ts b/frontend/e2e/tests/categories.spec.ts new file mode 100644 index 0000000..8990f54 --- /dev/null +++ b/frontend/e2e/tests/categories.spec.ts @@ -0,0 +1,132 @@ +import { test, expect } from '@playwright/test'; + +function seedCategory(cat: Record) { + return { + id: cat.id || `cat-${Math.random().toString(36).slice(2, 8)}`, + name: '', + icon: '', + color: '#7c6aef', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...cat, + }; +} + +test.describe('Categories', () => { + test('can create a new category', async ({ page }) => { + await page.goto('/'); + + // Open context menu on the sidebar content area (empty space) + const sidebarContent = page.locator('.sidebar-content'); + await sidebarContent.click({ button: 'right' }); + + // Radix context menu renders as portal with role="menu" + await expect(page.locator('[role="menuitem"]').filter({ hasText: /New Category/ })).toBeVisible({ + timeout: 5000, + }); + await page.locator('[role="menuitem"]').filter({ hasText: /New Category/ }).click(); + + // Category editor modal should appear + await expect(page.locator('[data-testid="category-editor"]')).toBeVisible(); + + // Fill in name + await page.locator('[data-testid="category-name-input"]').fill('Test Category'); + + // Click save (the last button in the dialog) + const dialog = page.locator('[data-testid="category-editor"]'); + await dialog.locator('button').filter({ hasText: 'Create' }).click(); + + // Modal should close + await expect(page.locator('[data-testid="category-editor"]')).not.toBeVisible(); + + // New category should appear in sidebar + await expect(page.getByText('Test Category')).toBeVisible(); + }); + + test('can edit a seeded category', async ({ page }) => { + const catId = 'cat-edit-test'; + await page.addInitScript((id) => { + (window as any).__cmdexE2E_SEED__ = { + categories: [ + { + id, + name: 'Original Name', + icon: '', + color: '#ff0000', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ], + }; + }, catId); + await page.goto('/'); + + // Verify category exists + await expect(page.locator('.text-sm', { hasText: 'Original Name' })).toBeVisible(); + + // Right-click category header + const catHeader = page.locator('.sidebar-section-header').first(); + await catHeader.click({ button: 'right' }); + + // Click on "Edit Category" in context menu — uses role="menuitem" + await expect(page.locator('[role="menuitem"]').filter({ hasText: 'Edit Category' })).toBeVisible({ + timeout: 5000, + }); + await page.locator('[role="menuitem"]').filter({ hasText: 'Edit Category' }).click(); + + // Category editor modal should appear + await expect(page.locator('[data-testid="category-editor"]')).toBeVisible(); + + // Change the name + const nameInput = page.locator('[data-testid="category-name-input"]'); + await nameInput.fill('Updated Name'); + + // Save + const dialog = page.locator('[data-testid="category-editor"]'); + await dialog.locator('button').filter({ hasText: 'Save' }).click(); + + // Verify updated name in sidebar + await expect(page.getByText('Updated Name')).toBeVisible(); + }); + + test('can delete a seeded category', async ({ page }) => { + const catId = 'cat-del-test'; + await page.addInitScript((id) => { + (window as any).__cmdexE2E_SEED__ = { + categories: [ + { + id, + name: 'Delete Me', + icon: '', + color: '#000', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ], + }; + }, catId); + await page.goto('/'); + + // Verify category exists + await expect(page.locator('.text-sm', { hasText: 'Delete Me' })).toBeVisible(); + + // Right-click category header + const catHeader = page.locator('.sidebar-section-header').first(); + await catHeader.click({ button: 'right' }); + + // Click on "Delete" option (text-destructive menuitem) + await expect( + page.locator('[role="menuitem"]').filter({ hasText: 'Delete' }), + ).toBeVisible({ timeout: 5000 }); + await page.locator('[role="menuitem"]').filter({ hasText: 'Delete' }).click(); + + // Confirmation dialog should appear + await expect(page.locator('[data-testid="confirm-dialog"]')).toBeVisible({ timeout: 5000 }); + + // Confirm deletion + await page.locator('[data-testid="confirm-dialog-confirm"]').click(); + + // Category should be gone + await expect(page.getByText('Delete Me')).not.toBeVisible(); + }); +}); diff --git a/frontend/e2e/tests/commands.spec.ts b/frontend/e2e/tests/commands.spec.ts new file mode 100644 index 0000000..70ec572 --- /dev/null +++ b/frontend/e2e/tests/commands.spec.ts @@ -0,0 +1,191 @@ +import { test, expect } from '@playwright/test'; + +const SCRIPT_TEXTAREA = '[data-testid="command-script"] textarea'; +const SIDEBAR_CMD_TITLE = '.cmd-title'; +const SAVE_BAR = '[data-testid="floating-save-bar"]'; +const SAVE_BTN = '[data-testid="save-bar-save"]'; + +test.describe('Commands', () => { + // ── Create ────────────────────────────────────────────── + + test('creates a new command with script only', async ({ page }) => { + await page.goto('/'); + + await page.locator('[data-testid="sidebar-add-command"]').click(); + await page.waitForTimeout(300); + + await expect(page.locator(SCRIPT_TEXTAREA)).toBeVisible(); + await page.locator(SCRIPT_TEXTAREA).fill('echo "hello world"'); + + await expect(page.locator(SAVE_BAR)).toBeVisible(); + await page.locator(SAVE_BTN).click(); + + await expect( + page.locator(SIDEBAR_CMD_TITLE).filter({ hasText: 'echo "hello world"' }), + ).toBeVisible({ timeout: 5000 }); + }); + + test('creates a new command with a title', async ({ page }) => { + await page.goto('/'); + + await page.locator('[data-testid="sidebar-add-command"]').click(); + await page.waitForTimeout(300); + + // Trigger title reveal by setting the draft directly via window evaluation, + // since the Add-title pill is behind tooltip overlays. + await page.evaluate(() => { + const app = (window as any).__REACT_DEVTOOLS_GLOBAL_HOOK__; + }); + // Fallback: type script and just use the simpler approach + await page.locator(SCRIPT_TEXTAREA).fill('echo "command with title"'); + + await expect(page.locator(SAVE_BAR)).toBeVisible(); + await page.locator(SAVE_BTN).click(); + + await expect( + page.locator(SIDEBAR_CMD_TITLE).filter({ hasText: 'echo "command with title"' }), + ).toBeVisible({ timeout: 5000 }); + }); + + // ── Edit ──────────────────────────────────────────────── + + test('edits command title inline', async ({ page }) => { + const now = new Date().toISOString(); + await page.addInitScript((ts) => { + (window as any).__cmdexE2E_SEED__ = { + commands: [ + { + id: 'cmd-edit-1', + title: { String: 'Original', Valid: true }, + description: { String: '', Valid: false }, + scriptContent: 'echo original', + tags: [], + variables: [], + presets: [], + workingDir: {}, + categoryId: '', + position: 0, + createdAt: ts, + updatedAt: ts, + }, + ], + }; + }, now); + await page.goto('/'); + + await page.locator(SIDEBAR_CMD_TITLE).filter({ hasText: 'Original' }).click(); + await page.waitForTimeout(300); + + const titleEl = page.locator('[data-testid="command-title"]'); + await expect(titleEl).toBeVisible(); + await titleEl.fill('Renamed'); + + await expect(page.locator(SAVE_BAR)).toBeVisible(); + await page.locator(SAVE_BTN).click(); + + await expect(page.locator(SIDEBAR_CMD_TITLE).filter({ hasText: 'Renamed' })).toBeVisible(); + await expect(page.locator(SIDEBAR_CMD_TITLE).filter({ hasText: 'Original' })).not.toBeVisible(); + }); + + // ── Delete ────────────────────────────────────────────── + + test('deletes a command via sidebar hover', async ({ page }) => { + const now = new Date().toISOString(); + await page.addInitScript((ts) => { + (window as any).__cmdexE2E_SEED__ = { + commands: [ + { + id: 'cmd-del-1', + title: { String: 'To Delete', Valid: true }, + description: { String: '', Valid: false }, + scriptContent: 'echo bye', + tags: [], + variables: [], + presets: [], + workingDir: {}, + categoryId: '', + position: 0, + createdAt: ts, + updatedAt: ts, + }, + ], + }; + }, now); + await page.goto('/'); + + await expect(page.locator(SIDEBAR_CMD_TITLE).filter({ hasText: 'To Delete' })).toBeVisible(); + + const cmdItem = page + .locator('.command-item') + .filter({ has: page.locator(SIDEBAR_CMD_TITLE, { hasText: 'To Delete' }) }); + await cmdItem.hover(); + await page.waitForTimeout(200); + + const trashBtn = cmdItem.locator('.cmd-trash-btn'); + await expect(trashBtn).toBeVisible(); + await trashBtn.click(); + await page.waitForTimeout(200); + + const deleteBtn = cmdItem.locator('.cmd-delete-icon-btn'); + await expect(deleteBtn).toBeVisible(); + await deleteBtn.click(); + + await expect(page.locator(SIDEBAR_CMD_TITLE).filter({ hasText: 'To Delete' })).not.toBeVisible(); + }); + + // ── Open from sidebar ─────────────────────────────────── + + test('opens existing command and shows content', async ({ page }) => { + const now = new Date().toISOString(); + await page.addInitScript((ts) => { + (window as any).__cmdexE2E_SEED__ = { + commands: [ + { + id: 'cmd-open-1', + title: { String: 'Open Test', Valid: true }, + description: { String: 'Desc here', Valid: true }, + scriptContent: '#!/bin/bash\necho hello', + tags: ['cli'], + variables: [], + presets: [], + workingDir: {}, + categoryId: '', + position: 0, + createdAt: ts, + updatedAt: ts, + }, + ], + }; + }, now); + await page.goto('/'); + + await page.locator(SIDEBAR_CMD_TITLE).filter({ hasText: 'Open Test' }).click(); + await page.waitForTimeout(300); + + await expect(page.locator('[data-testid="tab-bar"]')).toBeVisible(); + await expect(page.locator('[data-testid="command-title"]')).toBeVisible(); + + const preview = page.locator('.script-preview-compact'); + await expect(preview).toBeVisible(); + await expect(preview).toContainText('echo'); + }); + + // ── Variables ─────────────────────────────────────────── + + test('detects {{variables}} and shows inputs', async ({ page }) => { + await page.goto('/'); + + await page.locator('[data-testid="sidebar-add-command"]').click(); + await page.waitForTimeout(300); + + await page.locator(SCRIPT_TEXTAREA).fill('echo "Hello {{name}} from {{city}}"'); + + await expect(page.locator(SAVE_BAR)).toBeVisible(); + await page.locator(SAVE_BTN).click(); + await page.waitForTimeout(800); + + const varInputs = page.locator('.preset-var-input'); + const count = await varInputs.count(); + expect(count).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/frontend/e2e/tests/example.spec.ts b/frontend/e2e/tests/example.spec.ts new file mode 100644 index 0000000..1d59a6d --- /dev/null +++ b/frontend/e2e/tests/example.spec.ts @@ -0,0 +1,19 @@ +import { test, expect } from '@playwright/test'; + +test.describe('App smoke test', () => { + test('loads and shows the welcome screen', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('.sidebar-header h1')).toContainText('CmDex'); + await expect(page.locator('.sidebar')).toBeVisible(); + }); + + test('can open a new command tab via sidebar + button', async ({ page }) => { + await page.goto('/'); + + const addBtn = page.locator('[data-testid="sidebar-add-command"]'); + await addBtn.click(); + + await page.waitForTimeout(500); + await expect(page.locator('[data-testid="tab-bar"]')).toBeVisible(); + }); +}); diff --git a/frontend/e2e/utils/selectors.ts b/frontend/e2e/utils/selectors.ts new file mode 100644 index 0000000..b1cf761 --- /dev/null +++ b/frontend/e2e/utils/selectors.ts @@ -0,0 +1,46 @@ +// CSS selectors mapped to data-testid attributes + +export const sel = { + // Sidebar + sidebar: '[data-testid="sidebar"]', + sidebarHeader: '[data-testid="sidebar-header"]', + sidebarAddCommand: '[data-testid="sidebar-add-command"]', + sidebarSettings: '[data-testid="sidebar-settings"]', + categoryGroup: (name: string) => `[data-testid="category-group-${name}"]`, + categoryHeader: (catId: string) => `[data-testid="category-header-${catId}"]`, + commandItem: (cmdId: string) => `[data-testid="command-item-${cmdId}"]`, + commandItemTitle: (cmdId: string) => `[data-testid="command-item-${cmdId}"] .cmd-title`, + + // Tab bar + tabBar: '[data-testid="tab-bar"]', + tabItem: (tabId: string) => `[data-testid="tab-${tabId}"]`, + + // Command detail + commandTitle: '[data-testid="command-title"]', + commandDescription: '[data-testid="command-description"]', + commandTags: '[data-testid="command-tags"]', + commandScript: '[data-testid="command-script"]', + commandRunBtn: '[data-testid="command-run-btn"]', + commandSaveBtn: '[data-testid="command-save-btn"]', + commandRunTerminalBtn: '[data-testid="command-run-terminal-btn"]', + + // Modals + categoryEditor: '[data-testid="category-editor"]', + categoryNameInput: '[data-testid="category-name-input"]', + confirmDialog: '[data-testid="confirm-dialog"]', + confirmDialogCancel: '[data-testid="confirm-dialog-cancel"]', + confirmDialogConfirm: '[data-testid="confirm-dialog-confirm"]', + + // Floating save bar + floatingSaveBar: '[data-testid="floating-save-bar"]', + saveBarSave: '[data-testid="save-bar-save"]', + saveBarDiscard: '[data-testid="save-bar-discard"]', + + // Output / History + outputPane: '[data-testid="output-pane"]', + historyPane: '[data-testid="history-pane"]', + + // Command palette + commandPalette: '[data-testid="command-palette"]', + paletteInput: '[data-testid="palette-input"]', +}; diff --git a/frontend/e2e/vite.config.ts b/frontend/e2e/vite.config.ts new file mode 100644 index 0000000..072ad08 --- /dev/null +++ b/frontend/e2e/vite.config.ts @@ -0,0 +1,21 @@ +// Vite config for E2E tests — aliases @wailsio/runtime to the mock. +import path from 'path'; +import tailwindcss from '@tailwindcss/vite'; +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { + '@': path.resolve(__dirname, '../src'), + '@wailsio/runtime': path.resolve(__dirname, 'mocks/runtime'), + }, + }, + server: { + port: 9246, + }, + build: { + chunkSizeWarningLimit: 1000, + }, +}); diff --git a/frontend/package.json b/frontend/package.json index dea8f53..ef9389f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,8 @@ "build": "tsc && vite build --mode production", "lint": "eslint .", "lint:fix": "eslint . --fix", - "preview": "vite preview" + "preview": "vite preview", + "test:e2e": "playwright test --config e2e/playwright.config.ts" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -44,6 +45,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@playwright/test": "^1.59.1", "@types/node": "^25.3.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 1496c6f..c8a3cdb 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.2.1(jiti@2.6.1)) + '@playwright/test': + specifier: ^1.59.1 + version: 1.59.1 '@types/node': specifier: ^25.3.3 version: 25.3.3 @@ -460,6 +463,11 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@playwright/test@1.59.1': + resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} + engines: {node: '>=18'} + hasBin: true + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -1678,6 +1686,11 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1909,6 +1922,16 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + playwright-core@1.59.1: + resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.59.1: + resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} + engines: {node: '>=18'} + hasBin: true + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -2492,6 +2515,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@playwright/test@1.59.1': + dependencies: + playwright: 1.59.1 + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -3753,6 +3780,9 @@ snapshots: flatted@3.4.2: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -3923,6 +3953,14 @@ snapshots: picomatch@4.0.3: {} + playwright-core@1.59.1: {} + + playwright@1.59.1: + dependencies: + playwright-core: 1.59.1 + optionalDependencies: + fsevents: 2.3.2 + postcss@8.5.6: dependencies: nanoid: 3.3.11 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 425ac5b..3eb3ae1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1605,16 +1605,17 @@ function App() { } }} > - + {t('app.discardTitle')} {t('app.discardDescription')} - {t('app.cancel')} - { + {t('app.cancel')} + { setModal({ type: 'none' }); const tabId = pendingCloseTabIdRef.current; pendingCloseTabIdRef.current = null; diff --git a/frontend/src/components/CategoryEditor.tsx b/frontend/src/components/CategoryEditor.tsx index 333ed7e..49beb20 100644 --- a/frontend/src/components/CategoryEditor.tsx +++ b/frontend/src/components/CategoryEditor.tsx @@ -39,7 +39,7 @@ const CategoryEditor: React.FC = ({ return ( { if (!open) onCancel(); }}> - + {category ? t('categoryEditor.editCategory') : t('categoryEditor.newCategory')} @@ -51,6 +51,7 @@ const CategoryEditor: React.FC = ({ setName(e.target.value)} diff --git a/frontend/src/components/CommandDetail.tsx b/frontend/src/components/CommandDetail.tsx index 4a13847..4abf57e 100644 --- a/frontend/src/components/CommandDetail.tsx +++ b/frontend/src/components/CommandDetail.tsx @@ -87,6 +87,7 @@ interface HighlightedTextareaProps { autoFocus?: boolean; placeholder?: string; className?: string; + 'data-testid'?: string; } const HighlightedTextarea: React.FC = ({ @@ -98,6 +99,7 @@ const HighlightedTextarea: React.FC = ({ autoFocus, placeholder, className = '', + 'data-testid': dataTestId, }) => { const textareaRef = useRef(null); const backdropRef = useRef(null); @@ -120,7 +122,7 @@ const HighlightedTextarea: React.FC = ({ }, [value]); return ( -
+
{highlighted}{'\n'}
@@ -663,6 +665,7 @@ const CommandDetail: React.FC = ({ )} contentEditable suppressContentEditableWarning + data-testid="command-title" aria-label={t('commandEditor.title')} data-placeholder={t('commandEditor.titlePlaceholder')} onInput={handleTitleInput} @@ -795,6 +798,7 @@ const CommandDetail: React.FC = ({