diff --git a/services/dashboard-ui/.gitignore b/services/dashboard-ui/.gitignore index 153d591ca9..15e725dbf4 100644 --- a/services/dashboard-ui/.gitignore +++ b/services/dashboard-ui/.gitignore @@ -40,3 +40,9 @@ src/types/nuon-oapi-v3.d.ts NOTES.md compilation-analysis.json analyze.js + +# Playwright E2E testing +/test-results/ +/playwright-report/ +/e2e-results/ +playwright/.cache/ diff --git a/services/dashboard-ui/AGENTS.md b/services/dashboard-ui/AGENTS.md index 0edf13c7b9..630a451fc3 100644 --- a/services/dashboard-ui/AGENTS.md +++ b/services/dashboard-ui/AGENTS.md @@ -1350,6 +1350,111 @@ export type TEnhancedInstall = components['schemas']['app.Install'] & { **Component Interfaces** (`I` prefix): Manually defined in component files or shared in `/src/types/` +## E2E User Flow Documentation System + +The dashboard uses a structured approach to documenting and testing user flows. This system enables non-technical team members (Product, Design, QA) to document critical user journeys that can be automated with Playwright. + +### Directory Structure + +- `/e2e/flows/` - User flow documentation (markdown files) +- `/e2e/tests/` - Playwright test implementations +- `/e2e/fixtures/` - Reusable test utilities (auth, helpers) + +### For Non-Technical Contributors + +**Writing a User Flow**: + +1. **Copy the template**: `/e2e/flows/TEMPLATE.md` +2. **Name your flow**: Use kebab-case (e.g., `create-install.md`, `deploy-workflow.md`) +3. **Fill in the sections**: + - **Prerequisites**: What needs to be true before starting + - **User Flow**: Step-by-step actions + - **Test Data**: Example data used in the flow + - **Edge Cases**: What could go wrong + +4. **For each step, document**: + - What the user does (click, type, select) + - What should happen (expected result) + - Visible text on buttons/links + - Form field names (if known) + - **Screenshots are optional** - only add if they help clarify complex UI + +5. **Submit for review**: Create PR with the markdown file + +### For Developers (Writing Tests) + +**Converting Flow to Test**: + +1. **Read the flow documentation**: `/e2e/flows/[flow-name].md` +2. **Create test file**: `/e2e/tests/[flow-name].spec.ts` +3. **Follow the documented steps**: Each step becomes a test action +4. **Use documented selectors**: Form fields, buttons, text from the flow doc +5. **Update flow status**: Mark as ✅ Automated in `/e2e/flows/README.md` + +**Test Patterns**: +- Use `page.getByRole()` for accessibility-friendly selectors +- Use `page.locator('[name="field"]')` for form fields +- Use `page.getByText()` for visible text matching +- Add `data-testid` attributes to components if needed + +**Using the Authentication Fixture**: +```typescript +import { test, expect } from '../fixtures/auth.fixture'; + +test('my test', async ({ authenticatedPage }) => { + await authenticatedPage.goto('/installs'); + // Already authenticated - no manual cookie setup needed! +}); +``` + +### Using Claude to Write Flows + +**Prompt Template for Claude**: +``` +I want to document a user flow for E2E testing. + +Flow: [Name of the flow] +Description: [What the user is trying to accomplish] + +Steps: +1. [First action] +2. [Second action] +3. [etc...] + +Please create a user flow document following the template at /e2e/flows/TEMPLATE.md. +For each step, describe: +- What the user does +- What they see on screen +- Expected results +- Any form fields, buttons, or interactive elements + +Also write a corresponding Playwright test in /e2e/tests/. + +[Optional: Attach screenshots if complex UI needs clarification] +``` + +**Claude can**: +- Write flow documentation from step descriptions +- Read screenshots to identify UI elements (if provided) +- Explore component code to find selectors +- Generate Playwright tests from flow docs +- Suggest edge cases based on flow complexity + +### Example Workflows + +**Adding a New Flow**: +1. Copy TEMPLATE.md → `your-flow-name.md` +2. Fill in prerequisites, steps, test data, edge cases +3. Add entry to `/e2e/flows/README.md` under appropriate priority +4. Submit PR for review +5. Developer writes corresponding test +6. Update status from ❌ to ✅ when automated + +**Navigation Pattern**: +- Tests use `/installs`, `/apps`, etc. (no org-id needed) +- Dashboard automatically redirects to first org +- Use `authenticatedPage` fixture to skip auth setup + ## Important Notes - **Ignore `/old/` directories**: Contains deprecated components and utilities diff --git a/services/dashboard-ui/e2e/README.md b/services/dashboard-ui/e2e/README.md new file mode 100644 index 0000000000..71e495c031 --- /dev/null +++ b/services/dashboard-ui/e2e/README.md @@ -0,0 +1,89 @@ +# Dashboard UI E2E Tests + +End-to-end tests using Playwright for browser-based testing against the real API. + +## Prerequisites + +1. **Install dependencies**: `npm install` +2. **Install browsers**: `npx playwright install chromium` +3. **Running services**: + - Dashboard: Running on port 4000 + - API: Running on port 8081 +4. **Authentication token**: `E2E_AUTH_TOKEN` environment variable set (created by nuonctl script) + +## Running Tests + +```bash +# Run all E2E tests +npm run test:e2e + +# Interactive UI mode (best for debugging) +npm run test:e2e:ui + +# Watch browser execution +npm run test:e2e:headed + +# Step-through debugger +npm run test:e2e:debug + +# Generate tests from browser actions +npm run test:e2e:codegen +``` + +## Test Organization + +- `/e2e/tests/` - Test spec files (`.spec.ts`) +- `/e2e/flows/` - User flow documentation (markdown files) +- `/e2e/fixtures/` - Reusable test fixtures (auth helpers, utilities) + +## User Flows + +User flows are documented in the `/e2e/flows/` directory. These documents describe critical user journeys and serve as the source of truth for E2E tests. + +**Flow Documentation**: +- See `/e2e/flows/README.md` for catalog of all flows +- Each flow corresponds to a test file in `/e2e/tests/` +- Flows can be written by non-technical team members using the template + +**Writing User Flows**: +1. Copy `/e2e/flows/TEMPLATE.md` to create a new flow +2. Fill in the prerequisites, steps, test data, and edge cases +3. Submit PR with the markdown file +4. Developer writes corresponding test in `/e2e/tests/` + +**Using the Authentication Fixture**: +```typescript +import { test, expect } from '../fixtures/auth.fixture'; + +test('my test', async ({ authenticatedPage }) => { + await authenticatedPage.goto('/installs'); + // Already authenticated - no need to set cookies manually! +}); +``` + +## Writing Tests + +Tests are written using Playwright's test API. Example: + +```typescript +import { test, expect } from '@playwright/test'; + +test('should do something', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('h1')).toHaveText('Welcome'); +}); +``` + +## Debugging Tips + +1. **Use UI mode**: `npm run test:e2e:ui` - Interactive debugging interface +2. **Run headed**: `npm run test:e2e:headed` - See browser window +3. **Use debug mode**: `npm run test:e2e:debug` - Step through tests +4. **Screenshots**: Automatically captured on failure +5. **Videos**: Recorded when tests fail + +## Authentication + +Tests use the Nuon auth service with token from `E2E_AUTH_TOKEN` environment variable. The token is set as an `X-Nuon-Auth` cookie for authenticated requests. + +To run authenticated tests, ensure `E2E_AUTH_TOKEN` is set in your environment before running the tests. diff --git a/services/dashboard-ui/e2e/fixtures/auth.fixture.ts b/services/dashboard-ui/e2e/fixtures/auth.fixture.ts new file mode 100644 index 0000000000..e314a62c24 --- /dev/null +++ b/services/dashboard-ui/e2e/fixtures/auth.fixture.ts @@ -0,0 +1,54 @@ +import { test as base, expect, type Page } from '@playwright/test'; + +/** + * Authentication fixture for Playwright E2E tests + * + * Provides an authenticated page context with the X-Nuon-Auth cookie already set. + * This eliminates the need to manually set authentication in every test. + * + * Usage: + * ```typescript + * import { test, expect } from '../fixtures/auth.fixture'; + * + * test('my test', async ({ authenticatedPage }) => { + * await authenticatedPage.goto('/installs'); + * // Page is already authenticated! + * }); + * ``` + * + * Requires E2E_AUTH_TOKEN environment variable to be set. + */ + +// Define the type for our custom fixtures +type AuthFixtures = { + authenticatedPage: Page; +}; + +export const test = base.extend({ + authenticatedPage: async ({ page }, use) => { + // Get auth token from environment + const authToken = process.env.E2E_AUTH_TOKEN; + + if (!authToken) { + throw new Error( + 'E2E_AUTH_TOKEN environment variable not set. ' + + 'Set it with: export E2E_AUTH_TOKEN="your-token-here"' + ); + } + + // Add authentication cookie + await page.context().addCookies([ + { + name: 'X-Nuon-Auth', + value: authToken, + domain: 'localhost', + path: '/', + }, + ]); + + // Provide the authenticated page to the test + await use(page); + }, +}); + +export { expect }; diff --git a/services/dashboard-ui/e2e/flows/README.md b/services/dashboard-ui/e2e/flows/README.md new file mode 100644 index 0000000000..058fd00548 --- /dev/null +++ b/services/dashboard-ui/e2e/flows/README.md @@ -0,0 +1,78 @@ +# E2E User Flow Documentation + +This directory contains documented user flows for the Nuon dashboard. Each flow describes a critical user journey and corresponds to automated E2E tests. + +## Flow Status Legend + +- ✅ **Automated** - Flow is documented and has corresponding Playwright test +- ⏳ **In Progress** - Flow is documented, test implementation in progress +- ❌ **Not Automated** - Flow is documented but not yet automated + +## Critical Flows (Priority: High) + +- ✅ **[Create Install](./create-install.md)** - Provision a new install for an app + - Status: Automated with Playwright test + - Test File: [`tests/create-install.spec.ts`](../tests/create-install.spec.ts) + +## Secondary Flows (Priority: Medium) + +*No flows documented yet.* + +## How to Use This Documentation + +### For Non-Technical Contributors (Product, Design, QA) + +1. **Copy the template**: `cp TEMPLATE.md [your-flow-name].md` +2. **Fill in the flow details**: Describe what the user does step-by-step +3. **Submit for review**: Create a PR with your markdown file +4. **Screenshots optional**: Only add if the UI is complex + +### For Developers + +1. **Read the flow doc**: `/e2e/flows/[flow-name].md` +2. **Write the test**: `/e2e/tests/[flow-name].spec.ts` +3. **Update this README**: Change status from ❌ to ✅ Automated +4. **Link the test**: Add test file path to the flow entry below + +### For Everyone + +**Using Claude to write flows**: You can ask Claude to document a flow and write the test: + +``` +I want to document a user flow for [feature name]. + +Steps: +1. [User does X] +2. [User does Y] +3. [etc...] + +Please create a flow doc using /e2e/flows/TEMPLATE.md +and write a Playwright test in /e2e/tests/. +``` + +Claude can: +- Read component code to find selectors +- Suggest edge cases +- Generate test code from flow descriptions + +## Adding a New Flow + +1. Copy `TEMPLATE.md` → `your-flow-name.md` +2. Fill in prerequisites, steps, test data, edge cases +3. Add entry to this README under appropriate priority section +4. Submit PR for review +5. Once approved, developer writes corresponding test +6. Update status from ❌ to ✅ when automated + +--- + +## Upcoming Flows + +Flows to consider documenting: + +- Deploy workflow approval +- Component configuration +- Runner health monitoring +- VCS connection setup +- Build execution +- App configuration sync diff --git a/services/dashboard-ui/e2e/flows/TEMPLATE.md b/services/dashboard-ui/e2e/flows/TEMPLATE.md new file mode 100644 index 0000000000..f8e59cb7f5 --- /dev/null +++ b/services/dashboard-ui/e2e/flows/TEMPLATE.md @@ -0,0 +1,101 @@ +# [Flow Name] Flow + +**Priority**: [High | Medium | Low] +**Status**: ❌ Not Automated +**Test File**: `tests/[flow-name].spec.ts` + +## Prerequisites + +Before starting this flow, ensure: +- [ ] User is authenticated +- [ ] [What data needs to exist - e.g., "User has at least one app configured"] +- [ ] [What page/state user starts from - e.g., "User is on the dashboard home page"] + +## User Flow + +### Step 1: [Action Description] + +*(Optional screenshot: `screenshots/[flow-name]-1.png`)* + +- **User Action**: [What the user does - e.g., "Click the 'Create Install' button"] +- **Expected Result**: [What should happen - e.g., "Modal opens with install creation form"] +- **Key Selectors**: + - Button/Link: `[visible text or data-testid]` + - Form field: `[name or id attribute]` + +### Step 2: [Next Action] + +*(Optional screenshot: `screenshots/[flow-name]-2.png`)* + +- **User Action**: [What happens next] +- **Expected Result**: [What should happen] +- **Key Selectors**: + - Element: `[selector]` + +### Step 3: [Continue...] + +*(Repeat for each step in the flow)* + +--- + +## Test Data + +Example data needed to run this flow: + +```typescript +const testData = { + fieldName: 'example-value', + // Add any test data here +} +``` + +## Edge Cases to Test + +Document scenarios that should be tested beyond the happy path: + +- [ ] Empty required fields show validation errors +- [ ] Submitting invalid data shows error message +- [ ] Canceling the flow closes modal/returns to previous page +- [ ] [Add more edge cases specific to this flow] + +## Notes + +Any additional context, gotchas, or important information: + +- [Note about special behavior] +- [Dependencies on other flows or features] +- [Known issues or limitations] + +--- + +## For Developers + +**Selector Recommendations**: +- Buttons: `page.getByRole('button', { name: 'Button Text' })` +- Form fields: `page.locator('[name="field-name"]')` or `page.locator('#field-id')` +- Headings: `page.getByRole('heading', { name: 'Heading Text' })` +- Links: `page.getByRole('link', { name: 'Link Text' })` +- Text content: `page.getByText('Exact text')` + +**Test Structure Example**: + +```typescript +import { test, expect } from '../fixtures/auth.fixture'; + +test.describe('[Flow Name]', () => { + test('should complete happy path', async ({ authenticatedPage }) => { + // Step 1: [Action] + await authenticatedPage.goto('/start-page'); + + // Step 2: [Action] + await authenticatedPage.getByRole('button', { name: 'Button' }).click(); + + // Step 3: Verify result + await expect(authenticatedPage).toHaveURL(/expected-url/); + }); + + test('should handle edge case', async ({ authenticatedPage }) => { + // Test edge case scenario + }); +}); +``` diff --git a/services/dashboard-ui/e2e/flows/create-install.md b/services/dashboard-ui/e2e/flows/create-install.md new file mode 100644 index 0000000000..6b4b301d2e --- /dev/null +++ b/services/dashboard-ui/e2e/flows/create-install.md @@ -0,0 +1,174 @@ +# Create Install Flow + +**Priority**: High +**Status**: ✅ Automated +**Test File**: [`tests/create-install.spec.ts`](../tests/create-install.spec.ts) + +## Prerequisites + +Before starting this flow, ensure: +- [ ] User is authenticated +- [ ] User has an org created +- [ ] User has at least one app with valid app config +- [ ] User starts at root path `/` (dashboard will redirect to `/:org_id/apps`) + +## User Flow + +### Step 1: View Apps Page + +*(Optional screenshot: `screenshots/apps-page.png`)* + +- **User Action**: Navigate to root path `/` - dashboard automatically redirects to `/:org_id/apps` +- **Expected Result**: User is redirected to apps page with apps table visible (at least one app listed) +- **Key Selectors**: + - Apps table with columns: App name, Config version, Sandbox, Platform + - Each app row has a "View" link on the right side +- **URL Pattern**: `/:org_id/apps` (org_id is dynamic, determined by dashboard redirect) + +### Step 2: Click App Name to Navigate to App Details + +*(Optional screenshot: `screenshots/apps-page.png`)* + +- **User Action**: Click the first app name link in the apps table (e.g., "httpbin") +- **Expected Result**: Navigates to app detail page `/:org_id/apps/:app_id` for that app +- **Key Selectors**: + - App name link (first link in first table row): `table tbody tr:first a:first` + - Alternative: Can also click "View >" link on the right side of the row +- **Note**: Uses generic selector targeting first app in table - works for any app name + +### Step 3: Open Create Install Modal + +*(Optional screenshot: `screenshots/app-details-page.png`)* + +- **User Action**: Click the purple "Create install" button in the top right corner of the page +- **Expected Result**: Modal opens with heading "Create install" +- **Key Selectors**: + - Button: `page.getByRole('button', { name: 'Create install' })` + +### Step 4: Fill Install Form + +*(Optional screenshot: `screenshots/create-install-form.png`)* + +- **User Action**: Enter install name and select AWS region + - Enter install name in "Install name *" text field (placeholder: "Enter install name") + - Select AWS region from "Select AWS region *" dropdown (placeholder: "Choose AWS region") +- **Expected Result**: Form accepts input values +- **Key Selectors**: + - Install name field: Input with label "Install name *" + - Region dropdown: Dropdown with label "Select AWS region *" +- **Form Structure**: + - Section header: "Set AWS settings (required)" + - Both fields are required (marked with *) + +### Step 5: Submit Form + +*(Optional screenshot: `screenshots/create-install-form.png`)* + +- **User Action**: Click the purple "Create install" button at the bottom of the modal +- **Expected Result**: Form submits successfully, modal closes +- **Alternative Actions**: + - Click "Cancel" button to cancel operation without creating install + - Click X close button in top right to close modal without creating install +- **Key Selectors**: + - Submit button: `page.getByRole('button', { name: 'Create install' })` (in modal) + - Cancel button: `page.getByRole('button', { name: 'Cancel' })` + +### Step 6: Verify Redirect to Workflow + +- **System Action**: Automatically redirects to provision workflow page +- **Expected Result**: User lands on workflow page (URL contains workflow route) +- **Verification**: `await expect(page).toHaveURL(/workflow/)` +- **Important**: Test stops here - workflow execution is a separate test + +--- + +## Test Data + +Example data needed to run this flow: + +```typescript +const testInstall = { + name: `test-install-${Date.now()}`, // Unique name using timestamp + region: 'us-west-2', // For AWS apps + // OR location: 'eastus' for Azure apps (depending on platform) +} +``` + +## Edge Cases to Test + +Document scenarios that should be tested beyond the happy path: + +- [ ] Empty install name shows validation error +- [ ] Empty region shows validation error +- [ ] Form submission with invalid data shows error message +- [ ] Escape key closes modal without creating install +- [ ] Clicking outside modal closes it without creating install +- [ ] Cancel button closes modal without creating install + +## Notes + +Any additional context, gotchas, or important information: + +- **Screenshots**: Stored in `/services/dashboard-ui/e2e/flows/screenshots/` + - Screenshots are optional - only add if they clarify complex UI + - Current screenshots: apps-page.png, app-details-page.png, create-install-form.png + - Screenshots show "httpbin" app as example, but flow uses generic selectors +- **Prerequisites**: Test assumes valid app config exists (must be set up before test runs) +- **Region field**: Dropdown with AWS region options (e.g., us-east-1, us-west-2) +- **Test boundary**: Test stops at workflow page redirect - workflow execution is a separate test +- **Generic approach**: Flow works for any app in the table (uses first "View" link, not specific app names) + +--- + +## For Developers + +**Selector Recommendations**: +- Buttons: `page.getByRole('button', { name: 'Button Text' })` +- Form fields: `page.locator('input[type="text"]')` or target by label +- Headings: `page.getByRole('heading', { name: 'Heading Text' })` +- Links: `page.getByRole('link', { name: 'Link Text' })` +- Dropdowns: `page.locator('select')` or target by label + +**Test Structure Example**: + +```typescript +import { test, expect } from '../fixtures/auth.fixture'; + +test.describe('Create Install Flow', () => { + test('should complete happy path', async ({ authenticatedPage }) => { + // Step 1: Navigate to root - dashboard redirects to /:org_id/apps + await authenticatedPage.goto('/'); // Dashboard handles org redirect automatically + + // Step 2: Click first app name link in table + const appTable = authenticatedPage.locator('table').first(); + const firstAppNameLink = appTable.locator('tbody tr').first().locator('a').first(); + await firstAppNameLink.click(); + + // Step 3: Open create install modal + await authenticatedPage.getByRole('button', { name: 'Create install' }).click(); + + // Step 4: Fill form + await authenticatedPage.locator('input[placeholder="Enter install name"]').fill(`test-install-${Date.now()}`); + await authenticatedPage.locator('select').selectOption('us-west-2'); // Or appropriate selector + + // Step 5: Submit form + await authenticatedPage.getByRole('button', { name: 'Create install' }).last().click(); // Use .last() to target modal button + + // Step 6: Verify redirect to workflow + await expect(authenticatedPage).toHaveURL(/workflow/); + }); + + test('should show validation error for empty install name', async ({ authenticatedPage }) => { + await authenticatedPage.goto('/'); // Navigate to root + const appTable = authenticatedPage.locator('table').first(); + await appTable.locator('tbody tr').first().locator('a').first().click(); + await authenticatedPage.getByRole('button', { name: 'Create install' }).click(); + + // Try to submit without filling required fields + await authenticatedPage.getByRole('button', { name: 'Create install' }).last().click(); + + // Expect validation error (adjust selector based on actual error display) + await expect(authenticatedPage.getByText(/required/i)).toBeVisible(); + }); +}); +``` diff --git a/services/dashboard-ui/e2e/flows/screenshots/app-details-page.png b/services/dashboard-ui/e2e/flows/screenshots/app-details-page.png new file mode 100644 index 0000000000..61bc1b4d45 Binary files /dev/null and b/services/dashboard-ui/e2e/flows/screenshots/app-details-page.png differ diff --git a/services/dashboard-ui/e2e/flows/screenshots/apps-page.png b/services/dashboard-ui/e2e/flows/screenshots/apps-page.png new file mode 100644 index 0000000000..62b74dc580 Binary files /dev/null and b/services/dashboard-ui/e2e/flows/screenshots/apps-page.png differ diff --git a/services/dashboard-ui/e2e/flows/screenshots/create-install-form.png b/services/dashboard-ui/e2e/flows/screenshots/create-install-form.png new file mode 100644 index 0000000000..30ae42813d Binary files /dev/null and b/services/dashboard-ui/e2e/flows/screenshots/create-install-form.png differ diff --git a/services/dashboard-ui/e2e/tests/create-install.spec.ts b/services/dashboard-ui/e2e/tests/create-install.spec.ts new file mode 100644 index 0000000000..c22e68d77a --- /dev/null +++ b/services/dashboard-ui/e2e/tests/create-install.spec.ts @@ -0,0 +1,138 @@ +import { test, expect } from '../fixtures/auth.fixture'; + +test.describe('Create Install Flow', () => { + test('should complete happy path - create install for first app', async ({ authenticatedPage }) => { + // Step 1: Navigate to root - dashboard will redirect to /:org-id/apps automatically + await authenticatedPage.goto('/'); + // Wait for URL to change instead of networkidle (app has polling) + await authenticatedPage.waitForURL(/\/org[a-zA-Z0-9]+\/apps/, { timeout: 10000 }); + + // Verify we're redirected to an org's apps page + await expect(authenticatedPage).toHaveURL(/\/org[a-zA-Z0-9]+\/apps/); + + // Step 2: Click first app in table to navigate to app details + // Get the href from the first app link and navigate directly + const appTable = authenticatedPage.locator('table').first(); + const firstAppNameLink = appTable.locator('tbody tr').first().locator('a').first(); + await expect(firstAppNameLink).toBeVisible(); + + // Get the href and navigate to it (more reliable than clicking for Next.js apps) + const appHref = await firstAppNameLink.getAttribute('href'); + if (!appHref) { + throw new Error('App link does not have href attribute'); + } + + await authenticatedPage.goto(appHref); + // Wait for URL to change instead of networkidle (app has polling) + await authenticatedPage.waitForURL(/\/org[a-zA-Z0-9]+\/apps\/app[a-zA-Z0-9]+/, { timeout: 10000 }); + + // Verify we're on an app detail page + await expect(authenticatedPage).toHaveURL(/\/org[a-zA-Z0-9]+\/apps\/app[a-zA-Z0-9]+/); + + // Step 3: Open create install modal + const createInstallButton = authenticatedPage.getByRole('button', { name: 'Create install' }); + await expect(createInstallButton).toBeVisible(); + await createInstallButton.click(); + + // Verify modal opened by checking for the form field (wait up to 15s for loading to complete) + await expect(authenticatedPage.locator('input[placeholder="Enter install name"]')).toBeVisible({ timeout: 15000 }); + + // Step 4: Fill install form + const installName = `test-install-${Date.now()}`; + + // Fill install name field + const installNameInput = authenticatedPage.locator('input[placeholder="Enter install name"]'); + await expect(installNameInput).toBeVisible(); + await installNameInput.fill(installName); + + // Select AWS region from dropdown using keyboard navigation + // Find the dropdown by its text content + const regionDropdown = authenticatedPage.getByText('Choose AWS region'); + await expect(regionDropdown).toBeVisible(); + + // Focus on the dropdown and use keyboard to select first option + await regionDropdown.click(); // Click to focus + await authenticatedPage.keyboard.press('Space'); // Open dropdown + await authenticatedPage.keyboard.press('Tab'); // Navigate to first option + await authenticatedPage.keyboard.press('Tab'); // Navigate to second item (first actual option) + await authenticatedPage.keyboard.press('Enter'); // Select the option + + // Brief wait for selection to register + await authenticatedPage.waitForTimeout(500); + + // Step 5: Submit form + // Use .last() to target the modal's submit button (not the page button) + const submitButton = authenticatedPage.getByRole('button', { name: 'Create install' }).last(); + await submitButton.click(); + + // Wait for navigation after form submission + await authenticatedPage.waitForTimeout(1000); + + // Step 6: Verify redirect to workflow page + // Test stops here - workflow execution is separate test + await expect(authenticatedPage).toHaveURL(/workflow/, { timeout: 10000 }); + + // Take screenshot for verification + await authenticatedPage.screenshot({ + path: 'e2e-results/create-install-success.png', + fullPage: true + }); + }); + + test('should show validation error for empty install name', async ({ authenticatedPage }) => { + // Navigate to root - dashboard will redirect to /:org-id/apps + await authenticatedPage.goto('/'); + await authenticatedPage.waitForURL(/\/org[a-zA-Z0-9]+\/apps/, { timeout: 10000 }); + + // Get first app href and navigate to it + const appTable = authenticatedPage.locator('table').first(); + const firstAppNameLink = appTable.locator('tbody tr').first().locator('a').first(); + const appHref = await firstAppNameLink.getAttribute('href'); + if (appHref) { + await authenticatedPage.goto(appHref); + await authenticatedPage.waitForURL(/\/org[a-zA-Z0-9]+\/apps\/app[a-zA-Z0-9]+/, { timeout: 10000 }); + } + + // Open create install modal + await authenticatedPage.getByRole('button', { name: 'Create install' }).click(); + // Verify modal opened by checking for form field (wait up to 15s for loading to complete) + await expect(authenticatedPage.locator('input[placeholder="Enter install name"]')).toBeVisible({ timeout: 15000 }); + + // Try to submit without filling required fields + const submitButton = authenticatedPage.getByRole('button', { name: 'Create install' }).last(); + await submitButton.click(); + + // Expect validation error to appear (adjust selector based on actual error display) + // Common patterns: error message, required field indicator, disabled submit + const errorMessage = authenticatedPage.getByText(/required/i); + await expect(errorMessage).toBeVisible({ timeout: 5000 }); + }); + + test('should close modal when clicking Cancel button', async ({ authenticatedPage }) => { + // Navigate to root - dashboard will redirect to /:org-id/apps + await authenticatedPage.goto('/'); + await authenticatedPage.waitForURL(/\/org[a-zA-Z0-9]+\/apps/, { timeout: 10000 }); + + // Get first app href and navigate to it + const appTable = authenticatedPage.locator('table').first(); + const firstAppNameLink = appTable.locator('tbody tr').first().locator('a').first(); + const appHref = await firstAppNameLink.getAttribute('href'); + if (appHref) { + await authenticatedPage.goto(appHref); + await authenticatedPage.waitForURL(/\/org[a-zA-Z0-9]+\/apps\/app[a-zA-Z0-9]+/, { timeout: 10000 }); + } + + // Open create install modal + await authenticatedPage.getByRole('button', { name: 'Create install' }).click(); + // Verify modal opened by checking for form field (wait up to 15s for loading to complete) + await expect(authenticatedPage.locator('input[placeholder="Enter install name"]')).toBeVisible({ timeout: 15000 }); + + // Click Cancel button + const cancelButton = authenticatedPage.getByRole('button', { name: 'Cancel' }); + await expect(cancelButton).toBeVisible(); + await cancelButton.click(); + + // Verify modal is closed (form field should not be visible) + await expect(authenticatedPage.locator('input[placeholder="Enter install name"]')).not.toBeVisible(); + }); +}); diff --git a/services/dashboard-ui/e2e/tests/smoke.spec.ts b/services/dashboard-ui/e2e/tests/smoke.spec.ts new file mode 100644 index 0000000000..104dffdea2 --- /dev/null +++ b/services/dashboard-ui/e2e/tests/smoke.spec.ts @@ -0,0 +1,45 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Dashboard Smoke Tests', () => { + test('should load the dashboard home page', async ({ page }) => { + // Navigate to the dashboard home page + await page.goto('/'); + + // Wait for the page to finish loading + await page.waitForLoadState('networkidle'); + + // Verify we successfully loaded a page (not an error) + const url = page.url(); + expect(url).toContain('localhost:4000'); + + // Take a screenshot for verification + await page.screenshot({ path: 'e2e-results/dashboard-home.png' }); + }); + + test('should load with authentication token', async ({ page }) => { + // Get auth token from environment variable (created by nuonctl script) + const authToken = process.env.E2E_AUTH_TOKEN; + + if (!authToken) { + test.skip(true, 'E2E_AUTH_TOKEN not set - skipping authenticated test'); + } + + // Set authentication token as cookie + await page.context().addCookies([ + { + name: 'X-Nuon-Auth', + value: authToken!, + domain: 'localhost', + path: '/', + }, + ]); + + // Navigate to dashboard + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + // Verify authenticated state - adjust selector based on actual UI + // For example, check for user menu, org selector, or dashboard content + await expect(page).toHaveURL(/localhost:4000/); + }); +}); diff --git a/services/dashboard-ui/package-lock.json b/services/dashboard-ui/package-lock.json index 572d548234..ba3edbb6b6 100644 --- a/services/dashboard-ui/package-lock.json +++ b/services/dashboard-ui/package-lock.json @@ -44,6 +44,7 @@ "@faker-js/faker": "^8.4.1", "@ladle/react": "^5.0.3", "@mswjs/http-middleware": "^0.10.2", + "@playwright/test": "^1.57.0", "@testing-library/jest-dom": "^6.4.2", "@testing-library/react": "^15.0.2", "@types/babel__generator": "^7.27.0", @@ -1935,6 +1936,22 @@ "react-dom": ">= 16.8" } }, + "node_modules/@playwright/test": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -19774,6 +19791,53 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "devOptional": 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/points-on-curve": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", diff --git a/services/dashboard-ui/package.json b/services/dashboard-ui/package.json index 1671706ac0..aea956a5e5 100644 --- a/services/dashboard-ui/package.json +++ b/services/dashboard-ui/package.json @@ -16,6 +16,11 @@ "fmt": "prettier src", "pretest": "npm run generate-api-mocks", "test": "vitest run --testTimeout 40000", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:headed": "playwright test --headed", + "test:e2e:debug": "playwright test --debug", + "test:e2e:codegen": "playwright codegen http://localhost:4000", "pretsc": "npm run generate-api-types", "tsc": "tsc --noEmit" }, @@ -57,6 +62,7 @@ "@faker-js/faker": "^8.4.1", "@ladle/react": "^5.0.3", "@mswjs/http-middleware": "^0.10.2", + "@playwright/test": "^1.57.0", "@testing-library/jest-dom": "^6.4.2", "@testing-library/react": "^15.0.2", "@types/babel__generator": "^7.27.0", diff --git a/services/dashboard-ui/playwright.config.ts b/services/dashboard-ui/playwright.config.ts new file mode 100644 index 0000000000..d57ad9da4e --- /dev/null +++ b/services/dashboard-ui/playwright.config.ts @@ -0,0 +1,64 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Playwright E2E Test Configuration for Nuon Dashboard + * + * Prerequisites: + * - Next.js dev server running on localhost:4000 + * - ctl-api service running on localhost:8081 + * - E2E_AUTH_TOKEN set in environment (created by nuonctl script) + */ +export default defineConfig({ + // Directory containing E2E test files + testDir: './e2e/tests', + + // Run tests sequentially to avoid auth conflicts + fullyParallel: false, + + // Fail the build on CI if you accidentally left test.only + forbidOnly: !!process.env.CI, + + // Retry on CI only + retries: process.env.CI ? 2 : 0, + + // Single worker to avoid conflicts + workers: 1, + + // Reporter to use + reporter: [ + ['html', { outputFolder: 'playwright-report' }], + ['list'], + ], + + // Shared settings for all projects + use: { + baseURL: 'http://localhost:4000', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + actionTimeout: 15000, + navigationTimeout: 30000, + }, + + // Test timeout + timeout: 60000, + + // Expect timeout + expect: { + timeout: 10000, + }, + + // Configure projects for major browsers + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1920, height: 1080 }, + }, + }, + ], + + // Assume services already running + webServer: undefined, +}); diff --git a/services/dashboard-ui/src/app/[org-id]/apps/[app-id]/inputs-config.tsx b/services/dashboard-ui/src/app/[org-id]/apps/[app-id]/inputs-config.tsx index 9e6586f43a..23b3d67621 100644 --- a/services/dashboard-ui/src/app/[org-id]/apps/[app-id]/inputs-config.tsx +++ b/services/dashboard-ui/src/app/[org-id]/apps/[app-id]/inputs-config.tsx @@ -25,16 +25,17 @@ export async function AppInputs({ recurse: true, }) - return !error && config?.input && config?.input?.input_groups?.length ? ( + return (
Inputs config - - + {!error && config?.input && config?.input?.input_groups?.length ? ( + + ) : ( + + )}
- ) : ( - ) }