diff --git a/e2e/README.md b/e2e/README.md index fd136422b..44c743188 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -1,281 +1,93 @@ # E2E Tests for PlaceOS Backoffice -This directory contains end-to-end tests for the PlaceOS Backoffice application using Playwright. +These Playwright tests run the app against the mock backend in +`src/app/mocks/`. Each test gets a new browser context, so it starts with +fresh mock data. The mock backend keeps creates, edits and deletes in session +storage, so a change stays after `page.reload()` in the same test. -## Test Coverage +## Run the tests -| Metric | Count | -|--------|-------| -| Total Tests | 204 | -| Passing | 201 | -| Skipped | 3 | -| Browsers | Chromium, Firefox, WebKit | - -Tests cover 5 main feature areas: Systems, Users, Zones, Domains, and Admin. - -## Features - -- **Mock Mode (Default)**: Tests run against mock data using `@placeos/ts-client` mock mechanisms -- **Live Mode**: Tests can be configured to run against a live PlaceOS environment -- **User Story Coverage**: Tests are organized by user stories (US-SYS, US-USR, US-ZON, US-DOM, US-ADM) - -## Prerequisites - -- Bun 1.3+ -- Playwright browsers installed (`bunx playwright install`) - -## Running Tests - -### Quick Start (Mock Mode) - -```bash -# Run all E2E tests with mock data -bun run e2e -# or -bunx nx e2e e2e - -# Run tests in specific browser -bunx nx e2e backoffice --project=chromium - -# Run specific test file -bunx nx e2e backoffice --grep "Systems" - -# Run tests in headed mode (visible browser) -bunx nx e2e backoffice --headed - -# Run tests with UI mode -bunx nx e2e backoffice --ui - -# Run with debug output -DEBUG=pw:api bunx nx e2e backoffice -``` - -### Running Against Live Environment +Install the browsers one time with `bunx playwright install`. ```bash -# Set environment variables for live testing -USE_MOCK=false API_DOMAIN=placeos-dev.aca.im bunx nx e2e backoffice - -# Or create an .env file in the e2e directory -cp .env.example .env -# Edit .env with your settings -``` - -## Configuration - -### Environment Variables - -| Variable | Default | Description | -|----------|---------|-------------| -| `USE_MOCK` | `true` | Set to `false` to use live environment | -| `BASE_URL` | `http://localhost:4200` | Base URL for the application | -| `API_DOMAIN` | `localhost` | API domain for live environment | -| `API_PORT` | `4200` | API port for live environment | -| `API_PROTOCOL` | `http` | API protocol (`http` or `https`) | -| `TEST_USER_EMAIL` | `admin@place.tech` | Test user email for live auth | -| `TEST_USER_PASSWORD` | - | Test user password for live auth | - -### Using .env File - -Create a `.env` file in the `e2e` directory: - -```env -USE_MOCK=true -BASE_URL=http://localhost:4200 -# For live environment: -# USE_MOCK=false -# API_DOMAIN=placeos-dev.aca.im -# API_PORT=443 -# API_PROTOCOL=https -``` - -## Test Structure - -``` -e2e/ -├── src/ -│ ├── config/ -│ │ └── test-env.ts # Environment configuration -│ ├── fixtures/ -│ │ └── test-fixtures.ts # Test utilities and helpers -│ ├── pages/ -│ │ ├── base.page.ts # Base page object -│ │ ├── systems.page.ts # Systems page object -│ │ ├── users.page.ts # Users page object -│ │ ├── zones.page.ts # Zones page object -│ │ ├── domains.page.ts # Domains page object -│ │ ├── admin.page.ts # Admin page object -│ │ └── index.ts # Page exports -│ ├── systems.spec.ts # Systems tests (US-SYS-001 to US-SYS-012) -│ ├── users.spec.ts # Users tests (US-USR-001 to US-USR-012) -│ ├── zones.spec.ts # Zones tests (US-ZON-001 to US-ZON-011) -│ ├── domains.spec.ts # Domains tests (US-DOM-001 to US-DOM-012) -│ └── admin.spec.ts # Admin tests (US-ADM-001 to US-ADM-016) -├── playwright.config.ts # Playwright configuration -├── .env.example # Example environment file -└── README.md # This file +bun run e2e # all projects +bunx nx e2e e2e -- --project=chromium # one browser +bunx nx e2e e2e -- --project=chromium zones.spec # one spec file +bunx nx e2e e2e -- --ui # UI mode ``` -## User Stories Covered - -### Systems (US-SYS) -- US-SYS-001: View Systems List -- US-SYS-002: Create New System -- US-SYS-003: Edit System Details -- US-SYS-004: Delete System -- US-SYS-005: Bulk Import Systems -- US-SYS-006: Manage System Modules -- US-SYS-007: View System Zones -- US-SYS-008: Manage System Triggers -- US-SYS-009: Manage System Metadata -- US-SYS-010: View System History -- US-SYS-011: Start and Stop System -- US-SYS-012: View System Extensions - -### Users (US-USR) -- US-USR-001: View Users List -- US-USR-002: View User Details -- US-USR-003: Create New User -- US-USR-004: Edit User Details -- US-USR-005: Delete User -- US-USR-006: Bulk Import Users -- US-USR-007: Enable/Disable User -- US-USR-008: Manage User Roles -- US-USR-009: Manage User Metadata -- US-USR-010: View User History -- US-USR-011: Filter Users by Domain -- US-USR-012: View User Groups - -### Zones (US-ZON) -- US-ZON-001: View Zones List -- US-ZON-002: View Zone Details -- US-ZON-003: Create New Zone -- US-ZON-004: Edit Zone Details -- US-ZON-005: Delete Zone -- US-ZON-006: View Child Zones -- US-ZON-007: View Systems in Zone -- US-ZON-008: Manage Zone Triggers -- US-ZON-009: Manage Zone Metadata -- US-ZON-010: View Zone History -- US-ZON-011: Bulk Import Zones - -### Domains (US-DOM) -- US-DOM-001: View Domains List -- US-DOM-002: View Domain Details -- US-DOM-003: Create New Domain -- US-DOM-004: Edit Domain Configuration -- US-DOM-005: Delete Domain -- US-DOM-006: Manage OAuth Applications -- US-DOM-007: Configure OAuth Authentication -- US-DOM-008: Configure SAML Authentication -- US-DOM-009: Configure LDAP Authentication -- US-DOM-010: View Domain Users -- US-DOM-011: View Domain Extensions -- US-DOM-012: Manage Domain Authorization - -### Admin (US-ADM) -- US-ADM-001: View Cluster Status -- US-ADM-002: View Cluster Tasks -- US-ADM-003: View System Information -- US-ADM-008: Manage Backoffice Extensions -- US-ADM-009: Manage Tenants -- US-ADM-011: Manage API Keys -- US-ADM-012: Configure Upload Storage -- US-ADM-014: Manage Email Templates (skipped - requires live environment) -- US-ADM-015: Monitor Build Jobs +The config starts the dev server, or uses the server that runs on port 4200. -## Mock Mode Details +| Project | Specs | +| ---------------------------------- | ------------------------ | +| `chromium`, `firefox`, `webkit` | `*.spec.ts` | +| `mobile-chrome`, `mobile-safari` | `*.mobile.spec.ts` | -When running in mock mode (default), tests use the `@placeos/ts-client` mock mechanisms: - -1. **HTTP Mocking**: API calls are intercepted and handled by mock endpoints -2. **WebSocket Mocking**: Real-time updates are simulated -3. **Mock Data**: Pre-defined data from `src/app/mocks/data/` - -The mock mode is enabled by setting `localStorage.setItem('BACKOFFICE.mock', 'true')` before page load, which the application checks during initialization. - -## Writing New Tests - -### Using Page Objects - -```typescript -import { test, expect } from '@playwright/test'; -import { SystemsPage } from './pages'; +CI runs `chromium` and `mobile-chrome` for pull requests to `develop`, and +all projects on other branches. -test('should create a new system', async ({ page }) => { - // Set mock mode - await page.addInitScript(() => { - localStorage.setItem('BACKOFFICE.mock', 'true'); - }); +`USE_MOCK=false` stops the tests from switching on the mock backend. There is +no sign-in flow for a live server, and most tests expect the mock data, so +use this only to debug. - const systemsPage = new SystemsPage(page); - await systemsPage.goto(); +## Coverage - // Zone is required - will auto-select first available zone - await systemsPage.createSystem({ - name: 'Test System', - description: 'A test system', - }); +Tests use the ids from `user-stories/*.md` (e.g. `AC-ZON-004-5`) in their +names. - await systemsPage.expectSystemInList('Test System'); -}); -``` +| Spec | Covers | +| ------------------------ | -------------------------------------------------------------- | +| `systems.spec.ts` | List, search, create, edit, delete, tabs, start and stop | +| `zones.spec.ts` | List, search, create, edit, delete, parent link, child zones | +| `cascade-delete.spec.ts` | Optional removal of the systems inside a deleted zone | +| `modules.spec.ts` | List, create from a driver, edit, delete, start and stop | +| `drivers.spec.ts` | List, readme, modules, edit, delete, recompile and reload | +| `repositories.spec.ts` | List, details, edit, delete, add a driver from a repository | +| `triggers.spec.ts` | List, search, create, edit, delete, instances | +| `users.spec.ts` | List, search, create, edit, delete, metadata, history | +| `groups.spec.ts` | Group tree, create, edit, delete, members, zone access | +| `domains.spec.ts` | List, create, edit, delete, applications, auth methods, users | +| `admin.spec.ts` | Every admin section, brokers, extensions, tenants, API keys | +| `navigation.spec.ts` | Main menu, redirects, deep links, browser back | +| `mobile.mobile.spec.ts` | Hamburger menu, item picker, system details on phones | -### Creating Users +## Write a test ```typescript -import { test, expect } from '@playwright/test'; -import { UsersPage } from './pages'; +import { expect, seedMockData, test } from './fixtures/test-fixtures'; +import { FeaturePage } from './pages'; -test('should create a new user', async ({ page }) => { - await page.addInitScript(() => { - localStorage.setItem('BACKOFFICE.mock', 'true'); - }); +test('a renamed trigger keeps its new name', async ({ page }) => { + await seedMockData(page, 'triggers', [{ id: 'trigger-1', name: 'Alarm' }]); + const triggers = new FeaturePage(page, 'triggers'); - const usersPage = new UsersPage(page); - await usersPage.goto(); + await triggers.gotoItem('trigger-1'); + await triggers.openEditModal(); + await triggers.dialog.getByRole('textbox', { name: 'Name*' }).fill('Fire'); + await triggers.saveButton.click(); - await usersPage.createUser({ - first_name: 'John', - last_name: 'Doe', - email: 'john.doe@example.com', - password: 'SecurePassword123!', - }); - - await expect(usersPage.dialog).not.toBeVisible({ timeout: 10000 }); + await page.reload(); + await triggers.waitForItemDetails(); + await expect(triggers.itemName).toHaveText('Fire'); }); ``` -### Test Conventions - -1. Use descriptive test names that reference user stories -2. Use page objects for all page interactions -3. Set mock mode in `beforeEach` for all tests -4. Use `await` for all async operations -5. Add appropriate waits for dynamic content - -## Troubleshooting - -### Tests Failing in CI - -- Ensure `USE_MOCK=true` is set -- Check that the dev server is running and accessible -- Increase timeout values if needed - -### Mock Data Not Loading - -- Verify mock mode is enabled in localStorage -- Check that mock imports are included in `src/app/mocks/backend/index.ts` - -### Flaky Tests - -- Add explicit waits for dynamic content -- Use `toBeVisible()` with timeout for assertions -- Consider increasing action/navigation timeouts - -## Contributing - -1. Create tests following the user story structure -2. Use page objects for reusable interactions -3. Ensure tests pass in mock mode -4. Document any new environment variables +Rules: + +1. Import `test` and `expect` from `fixtures/test-fixtures`. This `test` + switches on the mock backend. +2. Use `FeaturePage` for a list/detail section. `BasePage` has the shared + helpers: `gotoItem`, `itemLink`, `itemName`, `openTab`, `createWith`, + `openEditModal`, `deleteSelected`, `tableRow` and `notification`. Add a + page object only for helpers that one section needs (e.g. `UsersPage`). +3. Seed the data a test needs. `seedMockData(page, 'triggers', rows)` replaces + the rows of an endpoint made with `generateBasicHandlers`. + `seedSession(page, key, rows)` replaces any other mock store, e.g. + `api_keys`. +4. Make each test fail when the feature breaks. Check the value that changed, + the URL, or that a change stays after a reload. Do not only check that an + element is visible. +5. Do not use `waitForTimeout` or `.catch()` on assertions. Web-first + assertions (`toHaveText`, `toHaveURL`, `toHaveCount`) wait for the app. +6. The item list is virtual and shows only part of a long list. Search for an + item before you look for it in a long list. diff --git a/e2e/src/admin.spec.ts b/e2e/src/admin.spec.ts index 9fa3e1e43..5c28e7b64 100644 --- a/e2e/src/admin.spec.ts +++ b/e2e/src/admin.spec.ts @@ -1,4 +1,4 @@ -import { Page, expect, test } from '@playwright/test'; +import { expect, seedSession, test } from './fixtures/test-fixtures'; import { AdminPage } from './pages'; /** @@ -7,312 +7,222 @@ import { AdminPage } from './pages'; * Tests for user stories US-ADM-001 through US-ADM-016 */ -async function seedAdminApiKeyData(page: Page): Promise { - await page.addInitScript(() => { - sessionStorage.setItem( - 'PLACEOS.mocks.api_engine_v2_domains', - JSON.stringify([ - { - id: 'place.tech', - name: 'Place Technology', - domain: 'place.tech', - }, - ]), - ); - sessionStorage.setItem( - 'PLACEOS.mocks.api_keys', - JSON.stringify([ - { - id: 'api_key-e2e', - name: 'E2E App Key', - description: 'Existing e2e API key', - authority_id: 'place.tech', - scopes: ['public'], - permissions: 'user', - user_id: 'current', - created_at: Date.now() / 1000, - updated_at: Date.now() / 1000, - }, - ]), - ); - }); -} +/** Admin menu entries, their route id and the heading of the section */ +const SECTIONS = [ + { name: 'About', id: 'about', heading: 'Backoffice & PlaceOS Details' }, + { name: 'Database', id: 'database', heading: 'PlaceOS Database' }, + { name: 'Clusters', id: 'clusters', heading: 'PlaceOS Clusters' }, + { name: 'Edges', id: 'edge', heading: 'PlaceOS Edge Nodes' }, + { name: 'Interfaces', id: 'interfaces', heading: 'PlaceOS Interfaces' }, + { name: 'MQTT Brokers', id: 'brokers', heading: 'MQTT Brokers' }, + { name: 'Staff Tenants', id: 'staff-api', heading: 'Staff API Tenants' }, + { + name: 'Resource Imports', + id: 'resource-imports', + heading: 'Resource Imports', + }, + { name: 'Extensions', id: 'extensions', heading: 'Extensions' }, + { + name: 'Application Keys', + id: 'api-keys', + heading: 'PlaceOS Application Keys', + }, + { name: 'Custom Schemas', id: 'schemas', heading: 'Custom Schemas' }, + { + name: 'Data Stores', + id: 'upload-storage', + heading: 'PlaceOS Data Stores', + }, + { + name: 'Uploads Library', + id: 'upload-library', + heading: 'Uploads Library', + }, + { name: 'Build Jobs', id: 'build-jobs', heading: 'Build Service Jobs' }, +]; + +const API_KEYS = [ + { + id: 'api_key-e2e', + name: 'E2E App Key', + description: 'Existing e2e API key', + authority_id: 'mock-authority', + scopes: ['public'], + permissions: 'user', + user_id: 'current', + created_at: Date.now() / 1000, + updated_at: Date.now() / 1000, + }, +]; test.describe('Admin', () => { - let adminPage: AdminPage; + let admin: AdminPage; test.beforeEach(async ({ page }) => { - // Set mock mode in localStorage - await page.addInitScript(() => { - localStorage.setItem('BACKOFFICE.mock', 'true'); - }); - adminPage = new AdminPage(page); + admin = new AdminPage(page); }); - /** - * US-ADM-001: View Cluster Status - */ - test.describe('US-ADM-001: View Cluster Status', () => { - test('AC-ADM-001-1: Display Cluster Nodes - should show cluster information', async () => { - await adminPage.goto(); + test('each admin menu entry opens its section', async ({ page }) => { + await admin.goto(); - // Navigate to clusters section - await adminPage.viewClusters(); + for (const section of SECTIONS) { + await test.step(section.name, async () => { + await admin.sectionLink(section.id).click(); - // Verify content is displayed - await expect(adminPage.mainContent).toBeVisible(); - }); + await expect(page).toHaveURL( + new RegExp(`#/admin/${section.id}$`), + ); + await expect(admin.section).toContainText(section.heading); + }); + } }); - /** - * US-ADM-003: View System Information - */ test.describe('US-ADM-003: View System Information', () => { - test('AC-ADM-003-1: Display Version Info - should show system version', async () => { - await adminPage.goto(); + test('AC-ADM-003-1: shows the backoffice version and commit', async () => { + await admin.gotoSection('about'); - // Navigate to about section - await adminPage.viewAbout(); - - // Verify content is displayed - await expect(adminPage.mainContent).toBeVisible(); + await expect(admin.section).toContainText('Version'); + await expect(admin.section).toContainText( + /Commit hash\s*g?[0-9a-f]{7,}/, + ); }); }); - /** - * US-ADM-004: View Database Status - */ test.describe('US-ADM-004: View Database Status', () => { - test('AC-ADM-004-4: Export Zone Tree - should open parent zone selection modal', async () => { - await adminPage.goto(); - - await adminPage.viewDatabase(); - - await expect(adminPage.exportZoneTreeButton).toBeVisible(); - await adminPage.openZoneTreeExportModal(); + test('AC-ADM-004-4: export asks for the parent zones first', async () => { + await admin.gotoSection('database'); + await admin.exportZoneTreeButton.click(); await expect( - adminPage.dialog.locator('h3:has-text("Export Zone Tree")'), + admin.dialog.getByRole('heading', { name: 'Export Zone Tree' }), ).toBeVisible(); await expect( - adminPage.dialog.locator('item-search-field'), + admin.dialog.locator('item-search-field'), ).toBeVisible(); await expect( - adminPage.dialog.locator('button:has-text("Export Selected")'), + admin.dialog.getByRole('button', { name: 'Export Selected' }), ).toBeDisabled(); }); - test('AC-ADM-004-5: Import Zone Tree - should expose CSV or TSV import input', async () => { - await adminPage.goto(); + test('AC-ADM-004-5: import accepts CSV files', async () => { + await admin.gotoSection('database'); - await adminPage.viewDatabase(); - - await expect(adminPage.importZoneTreeButton).toBeVisible(); - await expect(adminPage.zoneTreeImportInput).toHaveAttribute( + await expect(admin.importZoneTreeButton).toBeVisible(); + await expect(admin.zoneTreeImportInput).toHaveAttribute( 'accept', /text\/csv|\.csv/, ); }); }); - /** - * US-ADM-008: Manage Backoffice Extensions - */ - test.describe('US-ADM-008: Manage Backoffice Extensions', () => { - test('AC-ADM-008-1: View Extensions - should show extensions section', async () => { - await adminPage.goto(); - - // Navigate to extensions section - await adminPage.viewExtensions(); - - // Verify content is displayed - await expect(adminPage.mainContent).toBeVisible(); + test.describe('US-ADM-006: Manage MQTT Brokers', () => { + test('adds a broker to the list', async ({ page }) => { + await admin.gotoSection('brokers'); + await expect(admin.section).toContainText('No MQTT Brokers'); + + await page.getByRole('button', { name: 'Add MQTT Broker' }).click(); + await admin.dialog + .getByRole('textbox', { name: 'Name*' }) + .fill('Building Broker'); + await admin.dialog + .getByRole('textbox', { name: 'Host*' }) + .fill('mqtt.place.tech'); + await admin.dialog + .getByRole('textbox', { name: 'Username *' }) + .fill('placeos'); + await admin.dialog + .getByRole('textbox', { name: 'Password' }) + .fill('secret'); + await admin.saveButton.click(); + await expect(admin.dialog).toBeHidden(); + + await expect(admin.section).toContainText('Building Broker'); + await expect(admin.section).toContainText('mqtt.place.tech'); }); + }); - test('AC-ADM-008-2: Create Extension - should open extension create dialog', async ({ + test.describe('US-ADM-008: Manage Backoffice Extensions', () => { + test('AC-ADM-008-2: adds an extension to the domain', async ({ page, }) => { - await adminPage.goto(); - - // Navigate to extensions section - await adminPage.viewExtensions(); - - // Click add button - await adminPage.addButton.click().catch(() => { - // Add button might not be visible if extensions section is different - }); + await admin.gotoSection('extensions'); + await expect(admin.section).toContainText( + 'No extensions available for the selected domain', + ); - // Wait a moment for dialog - await page.waitForTimeout(500); + await page.getByRole('button', { name: 'Add Extension' }).click(); + await admin.dialog + .getByRole('textbox', { name: 'Name *' }) + .fill('Floor Plans'); + await admin.dialog + .getByRole('textbox', { name: 'URL *' }) + .fill('https://maps.place.tech'); + await admin.saveButton.click(); + await expect(admin.dialog).toBeHidden(); + + await expect(admin.section).toContainText('Floor Plans'); + await expect(admin.section).toContainText( + 'https://maps.place.tech', + ); }); }); - /** - * US-ADM-009: Manage Tenants - */ test.describe('US-ADM-009: Manage Tenants', () => { - test('AC-ADM-009-1: View Tenants - should show tenants section', async () => { - await adminPage.goto(); + test('AC-ADM-009-1: lists the tenants and their platform', async () => { + await admin.gotoSection('staff-api'); - // Navigate to staff API section - await adminPage.viewTenants(); - - // Verify content is displayed - await expect(adminPage.mainContent).toBeVisible(); + const row = await admin.tableRow('Default Tenant', admin.section); + await expect(row.nth(1)).toHaveText(/google/); }); }); - /** - * US-ADM-011: Manage API Keys - */ test.describe('US-ADM-011: Manage API Keys', () => { - test('AC-ADM-011-1: View API Keys - should show API keys section', async ({ - page, - }) => { - await seedAdminApiKeyData(page); - await adminPage.goto(); - - // Navigate to API keys section - await adminPage.viewApiKeys(); - await adminPage.selectApiKeyDomain(); - - // Verify content is displayed - await expect(adminPage.mainContent).toBeVisible(); + test.beforeEach(async ({ page }) => { + await seedSession(page, 'api_keys', API_KEYS); + await admin.gotoSection('api-keys'); + await admin.selectApiKeyDomain(); }); - test('AC-ADM-011-2: Generate New Key - should open key generation dialog', async ({ - page, - }) => { - await seedAdminApiKeyData(page); - await adminPage.goto(); - - // Navigate to API keys section - await adminPage.viewApiKeys(); - await adminPage.selectApiKeyDomain(); - - // Click add/generate button - await expect(adminPage.apiKeyAddButton).toBeEnabled({ - timeout: 10000, - }); - await adminPage.apiKeyAddButton.click(); - - await expect(adminPage.dialog).toBeVisible(); + test('AC-ADM-011-1: lists the keys of the domain', async () => { + await expect(admin.section).toContainText('E2E App Key'); + await expect(admin.section).toContainText('Existing e2e API key'); }); - test('AC-ADM-011-4: Assign to User - should search and select a user with identity details', async ({ - page, - }) => { - await seedAdminApiKeyData(page); - await adminPage.goto(); - await adminPage.viewApiKeys(); - await adminPage.selectApiKeyDomain(); + test('AC-ADM-011-2: opens the new key form', async () => { + await expect(admin.apiKeyAddButton).toBeEnabled(); + await admin.apiKeyAddButton.click(); - await expect(adminPage.apiKeyAddButton).toBeEnabled({ - timeout: 10000, - }); - await adminPage.apiKeyAddButton.click(); - await expect(adminPage.dialog).toBeVisible(); + await expect(admin.dialog).toBeVisible(); + await expect(admin.apiKeyUserSearchInput).toBeVisible(); + }); - await adminPage.searchApiKeyUser('Alex'); + test('AC-ADM-011-4: assigns a user with identity details', async () => { + await admin.apiKeyAddButton.click(); + await admin.searchApiKeyUser('Alex'); - const userOption = adminPage.apiKeyUserOption('Alex'); - await expect(userOption.locator('a-user-avatar')).toBeVisible(); - await expect( - userOption.locator('text=/@place\\.tech/i'), - ).toBeVisible(); + const option = admin.apiKeyUserOption('Alex'); + await expect(option.locator('a-user-avatar')).toBeVisible(); + await expect(option).toContainText(/@place\.tech/i); - await userOption.click(); + await option.click(); - await expect(adminPage.apiKeyUserSearchInput).toHaveValue(/Alex/); + await expect(admin.apiKeyUserSearchInput).toHaveValue(/Alex/); await expect( - adminPage.dialog.locator('a-user-avatar').first(), + admin.dialog.locator('a-user-avatar').first(), ).toBeVisible(); await expect( - adminPage.dialog + admin.dialog .locator('main button:has(icon:has-text("close"))') .first(), ).toBeVisible(); }); - test('AC-ADM-011-6: Search All Domain Users - should not filter user search by permission role', async ({ - page, - }) => { - await seedAdminApiKeyData(page); - await adminPage.goto(); - await adminPage.viewApiKeys(); - await adminPage.selectApiKeyDomain(); - - await expect(adminPage.apiKeyAddButton).toBeEnabled({ - timeout: 10000, - }); - await adminPage.apiKeyAddButton.click(); - await expect(adminPage.dialog).toBeVisible(); - - await adminPage.selectApiKeyPermissions('admin'); - await adminPage.searchApiKeyUser('Jeremy'); - - await expect(adminPage.apiKeyUserOption('Jeremy West')).toBeVisible( - { timeout: 10000 }, - ); - }); - }); - - /** - * US-ADM-012: Configure Upload Storage - */ - test.describe('US-ADM-012: Configure Upload Storage', () => { - test('AC-ADM-012-1: View Storage Providers - should show storage section', async () => { - await adminPage.goto(); - - // Navigate to upload storage section - await adminPage.viewUploadStorage(); - - // Verify content is displayed - await expect(adminPage.mainContent).toBeVisible(); - }); - }); - - /** - * US-ADM-015: Monitor Build Jobs - */ - test.describe('US-ADM-015: Monitor Build Jobs', () => { - test('AC-ADM-015-1: View Build Jobs - should show build jobs section', async () => { - await adminPage.goto(); - - // Navigate to build jobs section - await adminPage.viewBuildJobs(); + test('AC-ADM-011-6: user search is not filtered by permission', async () => { + await admin.apiKeyAddButton.click(); + await admin.selectApiKeyPermissions('admin'); + await admin.searchApiKeyUser('Jeremy'); - // Verify content is displayed - await expect(adminPage.mainContent).toBeVisible(); - }); - }); - - /** - * Admin Navigation Tests - */ - test.describe('Admin Navigation', () => { - test('should be able to navigate between admin sections', async ({ - page, - }) => { - await adminPage.goto(); - - // Test navigating to different sections - const sections = [ - { link: adminPage.aboutLink, name: 'About' }, - { link: adminPage.apiKeysLink, name: 'API Keys' }, - { link: adminPage.extensionsLink, name: 'Extensions' }, - ]; - - for (const section of sections) { - const isVisible = await section.link - .isVisible() - .catch(() => false); - if (isVisible) { - await section.link.click(); - await page.waitForTimeout(500); - await expect(adminPage.mainContent).toBeVisible(); - } - } + await expect(admin.apiKeyUserOption('Jeremy West')).toBeVisible(); }); }); }); diff --git a/e2e/src/cascade-delete.spec.ts b/e2e/src/cascade-delete.spec.ts index acbb8b61a..2ca851474 100644 --- a/e2e/src/cascade-delete.spec.ts +++ b/e2e/src/cascade-delete.spec.ts @@ -1,5 +1,6 @@ -import { expect, test } from '@playwright/test'; -import { ZonesPage } from './pages'; +import { buildAppUrl } from './config/test-env'; +import { expect, test } from './fixtures/test-fixtures'; +import { FeaturePage } from './pages'; /** * Optional cascade delete (PPT-1203) @@ -21,23 +22,14 @@ import { ZonesPage } from './pages'; * New Zone (root, no children, no systems) */ test.describe('Cascade delete', () => { - let zonesPage: ZonesPage; + let zonesPage: FeaturePage; test.beforeEach(async ({ page }) => { - await page.addInitScript(() => { - localStorage.setItem('BACKOFFICE.mock', 'true'); - }); - zonesPage = new ZonesPage(page); + zonesPage = new FeaturePage(page, 'zones'); }); - const openZone = async (page, zone_id: string) => { - await page.goto(`/?mock=true#/zones/${zone_id}/about`); - await zonesPage.waitForLoad(); - await page.waitForSelector('item-details', { timeout: 20000 }); - }; - - test('offers the option, disabled by default', async ({ page }) => { - await openZone(page, 'zone-lmhh_hVfz0'); + test('offers the option, disabled by default', async () => { + await zonesPage.gotoItem('zone-lmhh_hVfz0'); await zonesPage.openDeleteConfirmation(); await expect(zonesPage.cascadeCheckbox).toHaveCount(1); @@ -49,7 +41,7 @@ test.describe('Cascade delete', () => { test('no longer claims that systems are removed by default', async ({ page, }) => { - await openZone(page, 'zone-lmhh_hVfz0'); + await zonesPage.gotoItem('zone-lmhh_hVfz0'); await zonesPage.openDeleteConfirmation(); const content = await page @@ -58,8 +50,8 @@ test.describe('Cascade delete', () => { expect(content).toContain('are kept unless you also remove'); }); - test('reports nothing to remove for an empty zone', async ({ page }) => { - await openZone(page, 'zone-lmhh_hVfz0'); + test('reports nothing to remove for an empty zone', async () => { + await zonesPage.gotoItem('zone-lmhh_hVfz0'); await zonesPage.openDeleteConfirmation(); await zonesPage.enableCascade(); @@ -70,7 +62,7 @@ test.describe('Cascade delete', () => { test('lists the systems and modules that would be removed', async ({ page, }) => { - await openZone(page, 'zone-Kl0E0HmCJ3'); // Place Technology (org) + await zonesPage.gotoItem('zone-Kl0E0HmCJ3'); // Place Technology (org) await zonesPage.openDeleteConfirmation(); await zonesPage.enableCascade(); @@ -82,12 +74,10 @@ test.describe('Cascade delete', () => { expect(scope).toMatch(/Scope: this zone and \d+ zones? beneath it/); }); - test('keeps systems that also belong to a zone outside the subtree', async ({ - page, - }) => { + test('keeps systems that also belong to a zone outside the subtree', async () => { // Level 30's systems are also in Tower 2, which is above it — they // survive, so the cascade must remove nothing and say so. - await openZone(page, 'zone-LEHeo501Er'); + await zonesPage.gotoItem('zone-LEHeo501Er'); await zonesPage.openDeleteConfirmation(); await zonesPage.enableCascade(); @@ -99,7 +89,7 @@ test.describe('Cascade delete', () => { }); test('removes the orphaned systems when confirmed', async ({ page }) => { - await openZone(page, 'zone-Kl0E0HmCJ3'); + await zonesPage.gotoItem('zone-Kl0E0HmCJ3'); await zonesPage.openDeleteConfirmation(); await zonesPage.enableCascade(); @@ -114,16 +104,17 @@ test.describe('Cascade delete', () => { await page.locator('confirm-modal button[name="close"]').click(); // Every system lived inside this org zone, so the systems list empties - await page.goto('/?mock=true#/systems'); - await zonesPage.waitForLoad(); - await page.waitForTimeout(1000); + await page.goto(buildAppUrl('/#/systems')); + await expect(page.locator('item-sidebar')).toContainText( + 'No Systems available', + ); await expect(zonesPage.sidebarItems).toHaveCount(0); }); test('lists what was removed, with ids, once it has run', async ({ page, }) => { - await openZone(page, 'zone-Kl0E0HmCJ3'); + await zonesPage.gotoItem('zone-Kl0E0HmCJ3'); await zonesPage.openDeleteConfirmation(); await zonesPage.enableCascade(); @@ -159,7 +150,7 @@ test.describe('Cascade delete', () => { test('shows no receipt for a delete without the option', async ({ page, }) => { - await openZone(page, 'zone-lmhh_hVfz0'); + await zonesPage.gotoItem('zone-lmhh_hVfz0'); await zonesPage.openDeleteConfirmation(); await zonesPage.acceptButton.click(); @@ -171,20 +162,16 @@ test.describe('Cascade delete', () => { test('leaves systems alone when the option is left off', async ({ page, }) => { - await page.goto('/?mock=true#/systems'); - await zonesPage.waitForLoad(); - await page.waitForTimeout(1000); - const before = await zonesPage.sidebarItems.count(); - expect(before).toBeGreaterThan(0); + const system = zonesPage.itemLink('2.30.08 - Activity Space'); + await page.goto(buildAppUrl('/#/systems')); + await expect(system).toBeVisible(); - await openZone(page, 'zone-Kl0E0HmCJ3'); + await zonesPage.gotoItem('zone-Kl0E0HmCJ3'); await zonesPage.openDeleteConfirmation(); await zonesPage.acceptButton.click(); await page.waitForURL(/#\/zones\/-/, { timeout: 30000 }); - await page.goto('/?mock=true#/systems'); - await zonesPage.waitForLoad(); - await page.waitForTimeout(1000); - await expect(zonesPage.sidebarItems).toHaveCount(before); + await page.goto(buildAppUrl('/#/systems')); + await expect(system).toBeVisible(); }); }); diff --git a/e2e/src/domains.spec.ts b/e2e/src/domains.spec.ts index 4d79b4514..aaced9356 100644 --- a/e2e/src/domains.spec.ts +++ b/e2e/src/domains.spec.ts @@ -1,287 +1,202 @@ -import { expect, test } from '@playwright/test'; -import { DomainsPage } from './pages'; +import { expect, seedMockData, test } from './fixtures/test-fixtures'; +import { FeaturePage } from './pages'; /** * Domains E2E Tests * - * Tests for user stories US-DOM-001 through US-DOM-012 + * Tests for user stories US-DOM-001 through US-DOM-012. + * The mock backend has no domains, so each test seeds its own. */ +/** Authority of the mock users, from `src/app/mocks/backend/domains.mock.ts` */ +const PLACE_ID = 'mock-authority'; + +const DOMAINS = [ + { + id: PLACE_ID, + name: 'Place Technology', + domain: 'place.tech', + description: 'Main tenant', + email_domains: ['place.tech'], + }, + { + id: 'dom-acme', + name: 'Acme Corporation', + domain: 'acme.example.com', + description: 'Second tenant', + email_domains: ['acme.example.com'], + }, +]; + test.describe('Domains', () => { - let domainsPage: DomainsPage; + let domains: FeaturePage; test.beforeEach(async ({ page }) => { - // Set mock mode in localStorage - await page.addInitScript(() => { - localStorage.setItem('BACKOFFICE.mock', 'true'); - }); - domainsPage = new DomainsPage(page); + await seedMockData(page, 'domains', DOMAINS); + domains = new FeaturePage(page, 'domains'); }); - /** - * US-DOM-001: View Domains List - */ test.describe('US-DOM-001: View Domains List', () => { - test('AC-DOM-001-1: Display Domains List - should show sidebar with all domains', async ({ - page, - }) => { - await domainsPage.goto(); - - // Verify the sidebar is visible - await expect(domainsPage.sidebarList).toBeVisible(); + test('AC-DOM-001-1: lists every domain', async () => { + await domains.goto(); - // Wait for domains to load (might be empty in mock) - await page.waitForTimeout(2000); + await expect(domains.itemLink('Place Technology')).toBeVisible(); + await expect(domains.itemLink('Acme Corporation')).toBeVisible(); }); - test('AC-DOM-001-2: Search Domains - should filter domains by search term', async ({ - page, - }) => { - await domainsPage.goto(); - - // Wait for list to load - await page.waitForTimeout(1000); - - // Search for a domain - await domainsPage.search('place'); + test('AC-DOM-001-2: search filters the list', async () => { + await domains.goto(); + await domains.search('Acme'); - // Wait for search results - await page.waitForTimeout(500); - - // Verify content updates - await expect(domainsPage.sidebarList).toBeVisible(); + await expect(domains.itemLink('Acme Corporation')).toBeVisible(); + await expect(domains.itemLink('Place Technology')).toHaveCount(0); }); - test('AC-DOM-001-3: Domain Selection - should display domain details when selected', async ({ + test('AC-DOM-001-3: selecting a domain opens its details', async ({ page, }) => { - await domainsPage.goto(); - - // Wait for list to load - await page.waitForTimeout(1000); + await domains.goto(); + await domains.itemLink('Acme Corporation').click(); - // Check if there are any domains - const count = await domainsPage.domainsList.count(); - if (count > 0) { - // Select first domain - await domainsPage.domainsList.first().click(); - - // Verify details are displayed - await expect(domainsPage.mainContent).toBeVisible(); - } + await expect(page).toHaveURL(/#\/domains\/dom-acme\//); + await expect(domains.itemName).toHaveText('Acme Corporation'); }); }); - /** - * US-DOM-002: View Domain Details - */ test.describe('US-DOM-002: View Domain Details', () => { - test('AC-DOM-002-1: Display Basic Information - should show domain name', async ({ - page, - }) => { - await domainsPage.goto(); - await page.waitForTimeout(1000); - - // Check if there are any domains - const count = await domainsPage.domainsList.count(); - if (count > 0) { - // Select a domain - await domainsPage.domainsList.first().click(); - await page.waitForTimeout(500); - - // Verify domain details - await expect(domainsPage.mainContent).toBeVisible(); - } + test('AC-DOM-002-1: shows the domain, description and email domains', async () => { + await domains.gotoItem(PLACE_ID); + + await expect(domains.itemDetails).toContainText('place.tech'); + await expect(domains.tabContent).toContainText('Main tenant'); + await expect( + domains.tabContent.getByRole('button', { name: 'place.tech' }), + ).toBeVisible(); }); }); - /** - * US-DOM-003: Create New Domain - */ test.describe('US-DOM-003: Create New Domain', () => { - test('AC-DOM-003-1: Access Create Form - should open create modal', async () => { - await domainsPage.goto(); + test('AC-DOM-003-2: name and domain are required', async () => { + await domains.goto(); + await domains.openAddModal(); - // Click add button - await domainsPage.addButton.click(); + await domains.saveButton.click(); - // Verify modal appears - await expect(domainsPage.dialog).toBeVisible(); + await expect(domains.dialog).toBeVisible(); + await expect(domains.dialog.locator('mat-error')).toHaveCount(2); }); - test('AC-DOM-003-4: Successful Creation - should create domain', async ({ - page, - }) => { - await domainsPage.goto(); - - const timestamp = Date.now(); - const uniqueName = `Test Domain ${timestamp}`; - const uniqueDomain = `test${timestamp}.example.com`; + test('AC-DOM-003-4: saves the domain and opens it', async () => { + await domains.goto(); - await domainsPage.createDomain({ - name: uniqueName, - domain: uniqueDomain, - description: 'E2E test domain', + await domains.createWith(async () => { + await domains.dialog + .getByRole('textbox', { name: 'Name*' }) + .fill('Globex'); + await domains.dialog + .getByRole('textbox', { name: 'Domain Name' }) + .fill('globex.example.com'); }); - // Wait for creation to complete - await page.waitForTimeout(1000); - - // In mock mode, verify the dialog closed (creation succeeded) - // Mock mode doesn't persist items, so we verify the form submission worked - await expect(domainsPage.dialog).not.toBeVisible({ timeout: 5000 }); + await expect(domains.itemName).toHaveText('Globex'); + await expect(domains.itemDetails).toContainText( + 'globex.example.com', + ); + await expect(domains.itemLink('Globex')).toBeVisible(); }); }); - /** - * US-DOM-004: Edit Domain Configuration - */ test.describe('US-DOM-004: Edit Domain Configuration', () => { - test('AC-DOM-004-1: Access Edit Mode - should open edit modal', async ({ + test('AC-DOM-004-1: a changed description is kept', async ({ page, }) => { - await domainsPage.goto(); - - // Check if there are any domains - const count = await domainsPage.domainsList.count(); - if (count > 0) { - // Select first domain - await domainsPage.domainsList.first().click(); - await page.waitForTimeout(500); - - // Click edit - await domainsPage.openEditModal(); + await domains.gotoItem(PLACE_ID); + await domains.openEditModal(); - // Verify modal - await expect(domainsPage.dialog).toBeVisible(); - } + const description = domains.dialog.getByRole('textbox', { + name: 'Description', + }); + await expect(description).toHaveValue('Main tenant'); + await description.fill('Head office tenant'); + await domains.saveButton.click(); + await expect(domains.dialog).toBeHidden(); + + await expect(domains.tabContent).toContainText( + 'Head office tenant', + ); + await page.reload(); + await domains.waitForItemDetails(); + await expect(domains.tabContent).toContainText( + 'Head office tenant', + ); }); }); - /** - * US-DOM-005: Delete Domain - */ test.describe('US-DOM-005: Delete Domain', () => { - test('AC-DOM-005-1: Delete Confirmation - should show confirmation dialog', async ({ - page, - }) => { - await domainsPage.goto(); - - // Check if there are any domains - const count = await domainsPage.domainsList.count(); - if (count > 0) { - // Select a domain - await domainsPage.domainsList.first().click(); - await page.waitForTimeout(500); - - // Click delete - await domainsPage.deleteButton.click(); + test('AC-DOM-005-2: removes the domain from the list', async () => { + await domains.gotoItem('dom-acme'); + await domains.deleteSelected(); - // Verify confirmation dialog appears - await expect(domainsPage.dialog).toBeVisible(); - } + await expect(domains.itemLink('Acme Corporation')).toHaveCount(0); + await expect(domains.itemLink('Place Technology')).toBeVisible(); }); }); - /** - * US-DOM-006: Manage OAuth Applications - */ test.describe('US-DOM-006: Manage OAuth Applications', () => { - test('AC-DOM-006-1: View Applications List - should show applications tab', async ({ - page, - }) => { - await domainsPage.goto(); - - // Check if there are any domains - const count = await domainsPage.domainsList.count(); - if (count > 0) { - // Select a domain - await domainsPage.domainsList.first().click(); - await page.waitForTimeout(500); - - // Navigate to applications tab - await domainsPage.viewApplications(); - - // Verify applications tab content is visible - await expect(domainsPage.mainContent).toBeVisible(); - } + test('AC-DOM-006-1: shows the empty applications list', async () => { + await domains.gotoItem(PLACE_ID); + await domains.openTab('applications'); + + await expect(domains.tabContent).toContainText( + 'No applications for this domain', + ); + await expect( + domains.tabContent.getByRole('button', { + name: 'New Application', + }), + ).toBeVisible(); }); }); - /** - * US-DOM-007: Configure OAuth Authentication - */ - test.describe('US-DOM-007: Configure OAuth Authentication', () => { - test('AC-DOM-007-1: View Auth Sources - should show authentication tab', async ({ - page, - }) => { - await domainsPage.goto(); - - // Check if there are any domains - const count = await domainsPage.domainsList.count(); - if (count > 0) { - // Select a domain - await domainsPage.domainsList.first().click(); - await page.waitForTimeout(500); - - // Navigate to auth sources tab - await domainsPage.viewAuthSources(); - - // Verify auth sources tab content is visible - await expect(domainsPage.mainContent).toBeVisible(); - } + test.describe('US-DOM-007: Configure Authentication', () => { + test('AC-DOM-007-1: shows the empty authentication list', async () => { + await domains.gotoItem(PLACE_ID); + await domains.openTab('authentication'); + + await expect(domains.tabContent).toContainText( + 'No authentication methods for this domain', + ); + await expect( + domains.tabContent.getByRole('button', { + name: 'New Auth Method', + }), + ).toBeVisible(); }); }); - /** - * US-DOM-010: View Domain Users - */ test.describe('US-DOM-010: View Domain Users', () => { - test('AC-DOM-010-1: Display Users List - should show users in domain', async ({ - page, - }) => { - await domainsPage.goto(); - - // Check if there are any domains - const count = await domainsPage.domainsList.count(); - if (count > 0) { - // Select a domain - await domainsPage.domainsList.first().click(); - await page.waitForTimeout(500); + test('AC-DOM-010-1: lists the users of the domain with their role', async () => { + await domains.gotoItem(PLACE_ID); + await domains.openTab('users'); - // Navigate to users tab - await domainsPage.viewUsers(); - - // Verify users tab content is visible - await expect(domainsPage.mainContent).toBeVisible(); - } + const admin = domains.tabContent.getByRole('row', { + name: /Place Admin/, + }); + await expect(admin).toContainText('admin@place.tech'); + await expect(admin.locator('code')).toHaveText('Admin'); }); - }); - /** - * US-DOM-011: View Domain Extensions - */ - test.describe('US-DOM-011: View Domain Extensions', () => { - test('AC-DOM-011-1: View Extensions - should show extensions tab', async ({ - page, - }) => { - await domainsPage.goto(); - - // Check if there are any domains - const count = await domainsPage.domainsList.count(); - if (count > 0) { - // Select a domain - await domainsPage.domainsList.first().click(); - await page.waitForTimeout(500); - - // Navigate to extensions tab - await domainsPage.viewExtensions().catch(() => { - // Extensions tab might not be available - }); - - // Verify content is visible - await expect(domainsPage.mainContent).toBeVisible(); - } + test('AC-DOM-010-1: leaves out users of other domains', async () => { + await domains.gotoItem('dom-acme'); + await domains.openTab('users'); + + // The list is empty while it loads, so wait for the load to end + await expect( + domains.tabContent.locator('mat-progress-bar'), + ).toHaveClass(/opacity-0/); + await expect(domains.tabContent).toContainText( + 'No users associated with this domain', + ); }); }); }); diff --git a/e2e/src/drivers.spec.ts b/e2e/src/drivers.spec.ts new file mode 100644 index 000000000..42e3acf8d --- /dev/null +++ b/e2e/src/drivers.spec.ts @@ -0,0 +1,245 @@ +import { expect, seedMockData, test } from './fixtures/test-fixtures'; +import { FeaturePage } from './pages'; + +/** + * Drivers E2E Tests + * + * Tests for user stories in `user-stories/drivers.md` (US-DRV-*) + */ + +const REPOSITORY_ID = 'repo-drivers'; + +/** The edit form needs a known repository to load the driver's commit */ +const REPOSITORIES = [ + { + id: REPOSITORY_ID, + name: 'PlaceOS Drivers', + folder_name: 'drivers/placeos', + uri: 'https://github.com/PlaceOS/drivers', + commit_hash: 'HEAD', + type: 'driver', + }, +]; + +const PROBE_ID = 'dep-WaXdMkUcHg'; +/** Same id as the default mock modules, so this driver has six modules */ +const KNX_ID = 'dep-WjCyo9xO8H'; + +const DRIVERS = [ + { + id: PROBE_ID, + name: 'ACA Device Probe', + role: 1, + description: 'Passthrough / comms logger for probing device protocols', + module_name: 'Probe', + class_name: '::Aca::DeviceProbe', + repository_id: REPOSITORY_ID, + file_name: 'disc-::Aca::DeviceProbe', + commit: 'a1b2c3', + default_port: 23, + settings: {}, + created_at: 1519091316, + updated_at: 1519091316, + }, + { + id: KNX_ID, + name: 'KNX BAOS Lighting', + role: 1, + description: null, + module_name: 'Lighting', + class_name: '::Knx::BaosLighting', + repository_id: REPOSITORY_ID, + file_name: 'disc-::Knx::BaosLighting', + commit: 'd4e5f6', + default_port: 12004, + settings: {}, + created_at: 1519367963, + updated_at: 1519367963, + }, +]; + +test.describe('Drivers', () => { + let drivers: FeaturePage; + + test.beforeEach(async ({ page }) => { + await seedMockData(page, 'repositories', REPOSITORIES); + await seedMockData(page, 'drivers', DRIVERS); + drivers = new FeaturePage(page, 'drivers'); + }); + + test.describe('US-DRV-001: View Drivers List', () => { + test('AC-DRV-001-1: lists every driver', async () => { + await drivers.goto(); + + await expect(drivers.itemLink('ACA Device Probe')).toBeVisible(); + await expect(drivers.itemLink('KNX BAOS Lighting')).toBeVisible(); + }); + + test('AC-DRV-001-2: search filters the list', async () => { + await drivers.goto(); + await drivers.search('KNX'); + + await expect(drivers.itemLink('KNX BAOS Lighting')).toBeVisible(); + await expect(drivers.itemLink('ACA Device Probe')).toHaveCount(0); + }); + + test('AC-DRV-001-3: selecting a driver opens its details', async ({ + page, + }) => { + await drivers.goto(); + await drivers.itemLink('ACA Device Probe').click(); + + await expect(page).toHaveURL(new RegExp(`#/drivers/${PROBE_ID}/`)); + await expect(drivers.itemName).toHaveText('ACA Device Probe'); + }); + }); + + test.describe('US-DRV-002: View Driver Details', () => { + test('AC-DRV-002-1: shows the module name and description', async () => { + await drivers.gotoItem(PROBE_ID); + + await expect(drivers.tabContent).toContainText('Module Name Probe'); + await expect(drivers.tabContent).toContainText( + 'Passthrough / comms logger for probing device protocols', + ); + }); + }); + + test.describe('US-DRV-003: View Driver Documentation', () => { + test('AC-DRV-003-2: renders the readme as markdown', async () => { + await drivers.gotoItem(PROBE_ID); + await drivers.openTab('docs'); + + await expect( + drivers.tabContent.getByRole('heading', { + level: 1, + name: 'Mock Driver', + }), + ).toBeVisible(); + await expect(drivers.tabContent.getByRole('listitem')).toHaveText([ + 'Feature 1', + 'Feature 2', + ]); + }); + }); + + test.describe('US-DRV-004: View Modules Using Driver', () => { + test('AC-DRV-004-1: lists the modules of the driver', async () => { + await drivers.gotoItem(KNX_ID); + await drivers.openTab('modules'); + + await expect( + drivers.tabContent.getByRole('link', { + name: 'KNX BAOS Lighting', + }), + ).toHaveCount(6); + }); + + test('AC-DRV-004-3: a module link opens that module', async ({ + page, + }) => { + await drivers.gotoItem(KNX_ID); + await drivers.openTab('modules'); + await drivers.tabContent + .getByRole('link', { name: 'KNX BAOS Lighting' }) + .first() + .click(); + + await expect(page).toHaveURL(/#\/modules\/mod-m2dku__78J/); + await expect(drivers.itemName).toHaveText('KNX BAOS Lighting'); + }); + }); + + test.describe('US-DRV-005: Edit Driver Configuration', () => { + test('AC-DRV-005-3: saved changes persist', async ({ page }) => { + await drivers.gotoItem(PROBE_ID); + await drivers.openEditModal(); + + const name = drivers.dialog.getByRole('textbox', { + name: 'Name *', + exact: true, + }); + await expect(name).toHaveValue('ACA Device Probe'); + await name.fill('Protocol Probe'); + await drivers.dialog + .getByRole('textbox', { name: 'Description' }) + .fill('Logs raw device traffic'); + await drivers.saveButton.click(); + await expect(drivers.dialog).toBeHidden(); + + await expect(drivers.itemName).toHaveText('Protocol Probe'); + await page.reload(); + await drivers.waitForItemDetails(); + await expect(drivers.itemName).toHaveText('Protocol Probe'); + await expect(drivers.tabContent).toContainText( + 'Logs raw device traffic', + ); + }); + }); + + test.describe('US-DRV-006: Recompile Driver', () => { + test('AC-DRV-006-1: recompiles after confirmation', async ({ + page, + }) => { + await drivers.gotoItem(PROBE_ID); + await drivers.tabContent + .getByRole('button', { name: 'Recompile' }) + .click(); + await expect(page.locator('confirm-modal')).toContainText( + 'Recompile Driver', + ); + await drivers.acceptButton.click(); + + await expect( + drivers.notification('Successfully recompiled the driver.'), + ).toBeVisible(); + }); + }); + + test.describe('US-DRV-007: Reload Driver', () => { + test('AC-DRV-007-2: confirms the reload', async ({ page }) => { + await drivers.gotoItem(PROBE_ID); + await drivers.tabContent + .getByRole('button', { name: 'Reload' }) + .click(); + await expect(page.locator('confirm-modal')).toContainText( + 'Reload Driver', + ); + await drivers.acceptButton.click(); + + await expect( + drivers.notification('Successfully reloaded driver.'), + ).toBeVisible(); + }); + }); + + test.describe('US-DRV-010: Delete Driver', () => { + test('AC-DRV-010-2: dismissing the confirmation keeps the driver', async ({ + page, + }) => { + await drivers.gotoItem(PROBE_ID); + await drivers.openDeleteConfirmation(); + await expect(page.locator('confirm-modal')).toContainText( + 'Delete driver', + ); + await page.keyboard.press('Escape'); + + await expect(page.locator('confirm-modal')).toHaveCount(0); + await expect(drivers.itemName).toHaveText('ACA Device Probe'); + await expect(drivers.itemLink('ACA Device Probe')).toBeVisible(); + }); + + test('AC-DRV-010-3: removes the driver from the list', async ({ + page, + }) => { + await drivers.gotoItem(PROBE_ID); + await drivers.deleteSelected(); + + await expect(drivers.itemLink('ACA Device Probe')).toHaveCount(0); + await page.reload(); + await drivers.waitForLoad(); + await expect(drivers.itemLink('KNX BAOS Lighting')).toBeVisible(); + await expect(drivers.itemLink('ACA Device Probe')).toHaveCount(0); + }); + }); +}); diff --git a/e2e/src/fixtures/test-fixtures.ts b/e2e/src/fixtures/test-fixtures.ts index 75bc14aad..4852e9130 100644 --- a/e2e/src/fixtures/test-fixtures.ts +++ b/e2e/src/fixtures/test-fixtures.ts @@ -1,73 +1,19 @@ import { test as base, expect, Page } from '@playwright/test'; -import { buildAppUrl, getTestEnvConfig } from '../config/test-env'; +import { isMockMode } from '../config/test-env'; /** - * Extended test fixtures for PlaceOS Backoffice E2E tests + * Playwright `test` that switches on the mock backend for every page, + * unless `USE_MOCK=false`. + * + * Specs import `test` and `expect` from here instead of `@playwright/test`. */ - -export interface TestFixtures { - /** Navigate to app with proper mock/live configuration */ - authenticatedPage: Page; -} - -/** - * Wait for the application to fully load - */ -async function waitForAppLoad(page: Page): Promise { - // Wait for the loading indicator to disappear - await page - .waitForSelector('[loader]', { state: 'detached', timeout: 30000 }) - .catch(() => { - // Loader might not exist if app loads quickly - }); - - // Wait for router outlet to be present - await page.waitForSelector('router-outlet', { timeout: 30000 }); - - // Wait for any loading spinners to disappear - await page - .waitForFunction( - () => { - const spinners = document.querySelectorAll( - 'mat-spinner, mat-progress-bar[mode="indeterminate"]', - ); - return spinners.length === 0; - }, - { timeout: 30000 }, - ) - .catch(() => { - // Spinners might not exist - }); -} - -/** - * Setup authentication for tests - * In mock mode, this sets the mock flag in localStorage - * In live mode, this would handle actual authentication - */ -async function setupAuth(page: Page): Promise { - const config = getTestEnvConfig(); - - if (config.use_mock) { - // Set mock mode in localStorage before navigating - await page.addInitScript(() => { - localStorage.setItem('BACKOFFICE.mock', 'true'); - }); - } else { - // For live environment, handle OAuth authentication - // This would need to be customized based on the actual auth flow - console.log( - 'Live authentication mode - ensure proper credentials are configured', - ); - } -} - -/** - * Extended test with authenticated page fixture - */ -export const test = base.extend({ - authenticatedPage: async ({ page }, use) => { - await setupAuth(page); +export const test = base.extend({ + page: async ({ page }, use) => { + if (isMockMode()) { + await page.addInitScript(() => { + localStorage.setItem('BACKOFFICE.mock', 'true'); + }); + } await use(page); }, }); @@ -75,214 +21,42 @@ export const test = base.extend({ export { expect }; /** - * Navigate to a page with proper mock configuration - */ -export async function navigateTo(page: Page, path: string): Promise { - const url = buildAppUrl(path); - await page.goto(url); - await waitForAppLoad(page); -} - -/** - * Wait for sidebar items to load - */ -export async function waitForSidebarLoad(page: Page): Promise { - await page - .waitForSelector('app-sidebar-menu', { timeout: 15000 }) - .catch(() => undefined); - // Wait for list items to appear - await page - .waitForFunction( - () => { - const items = document.querySelectorAll( - 'app-sidebar-menu .item, [sidebar] .item', - ); - return items.length > 0; - }, - { timeout: 15000 }, - ) - .catch(() => { - // List might be empty - }); -} - -/** - * Search in the sidebar - */ -export async function searchSidebar(page: Page, query: string): Promise { - const search_input = page - .locator('input[placeholder*="Search"], input[placeholder*="search"]') - .first(); - await search_input.fill(query); - // Wait for debounce and results - await page.waitForTimeout(500); -} - -/** - * Click on a sidebar item by name - */ -export async function selectSidebarItem( - page: Page, - name: string, -): Promise { - await page - .locator( - `[sidebar] .item:has-text("${name}"), app-sidebar-menu .item:has-text("${name}")`, - ) - .first() - .click(); - await page.waitForTimeout(500); -} - -/** - * Open the add/create modal - */ -export async function openAddModal(page: Page): Promise { - const add_button = page - .locator( - 'button:has-text("Add"), button[mattooltip*="Add"], button:has(mat-icon:has-text("add"))', - ) - .first(); - await add_button.click(); - await page.waitForSelector('mat-dialog-container, [mat-dialog-container]', { - timeout: 5000, - }); -} - -/** - * Open the edit modal for current item - */ -export async function openEditModal(page: Page): Promise { - const edit_button = page - .locator( - 'button:has-text("Edit"), button[mattooltip*="Edit"], button:has(mat-icon:has-text("edit"))', - ) - .first(); - await edit_button.click(); - await page.waitForSelector('mat-dialog-container, [mat-dialog-container]', { - timeout: 5000, - }); -} - -/** - * Close a modal by clicking save - */ -export async function saveModal(page: Page): Promise { - const save_button = page - .locator( - 'mat-dialog-container button:has-text("Save"), mat-dialog-container button:has-text("Create")', - ) - .first(); - await save_button.click(); - await page - .waitForSelector('mat-dialog-container', { - state: 'detached', - timeout: 10000, - }) - .catch(() => undefined); -} - -/** - * Close a modal by clicking cancel + * Replace the rows of a mock data store before the app loads. + * + * The mock backend keeps its data in session storage, so the seed is only + * written once. Changes the app makes survive later reloads in the same test. + * + * @param key Session storage key without the `PLACEOS.mocks.` prefix */ -export async function cancelModal(page: Page): Promise { - const cancel_button = page - .locator('mat-dialog-container button:has-text("Cancel")') - .first(); - await cancel_button.click(); - await page - .waitForSelector('mat-dialog-container', { - state: 'detached', - timeout: 5000, - }) - .catch(() => undefined); -} - -/** - * Confirm a delete dialog - */ -export async function confirmDelete(page: Page): Promise { - const confirm_button = page - .locator( - 'mat-dialog-container button:has-text("Ok"), mat-dialog-container button:has-text("Confirm"), mat-dialog-container button:has-text("Delete")', - ) - .first(); - await confirm_button.click(); - await page - .waitForSelector('mat-dialog-container', { - state: 'detached', - timeout: 5000, - }) - .catch(() => undefined); -} - -/** - * Navigate to a specific tab - */ -export async function navigateToTab( +export async function seedSession( page: Page, - tab_name: string, + key: string, + rows: Record[], ): Promise { - const tab = page - .locator( - `mat-tab-header [role="tab"]:has-text("${tab_name}"), .mat-mdc-tab:has-text("${tab_name}")`, - ) - .first(); - await tab.click(); - await page.waitForTimeout(500); + await page.addInitScript( + ([key, value]) => { + if (sessionStorage.getItem(key) === null) { + sessionStorage.setItem(key, value); + } + }, + [`PLACEOS.mocks.${key}`, JSON.stringify(rows)], + ); } /** - * Fill a form field by label + * Replace the mock rows behind an engine endpoint made with + * `generateBasicHandlers`, e.g. `domains` or `triggers` + * + * @param endpoint Path under `/api/engine/v2` */ -export async function fillFormField( +export async function seedMockData( page: Page, - label: string, - value: string, + endpoint: string, + rows: Record[], ): Promise { - const field = page - .locator( - `mat-form-field:has-text("${label}") input, mat-form-field:has-text("${label}") textarea`, - ) - .first(); - await field.fill(value); -} - -/** - * Select an option from a mat-select by label - */ -export async function selectOption( - page: Page, - label: string, - option: string, -): Promise { - const select = page - .locator(`mat-form-field:has-text("${label}") mat-select`) - .first(); - await select.click(); - await page.locator(`mat-option:has-text("${option}")`).first().click(); -} - -/** - * Check if a snackbar notification appears - */ -export async function expectNotification( - page: Page, - text: string, -): Promise { - await expect( - page.locator(`mat-snack-bar-container:has-text("${text}")`), - ).toBeVisible({ timeout: 5000 }); -} - -/** - * Check if the page has loaded successfully (no error state) - */ -export async function expectPageLoaded(page: Page): Promise { - // Check that there's no error message - const error_visible = await page - .locator('.error, [class*="error"]:has-text("Error")') - .isVisible() - .catch(() => false); - expect(error_visible).toBeFalsy(); + await seedSession( + page, + `api_engine_v2_${endpoint.replace(/\//g, '_')}`, + rows, + ); } diff --git a/e2e/src/groups.spec.ts b/e2e/src/groups.spec.ts new file mode 100644 index 000000000..ba738f5be --- /dev/null +++ b/e2e/src/groups.spec.ts @@ -0,0 +1,305 @@ +import { Locator, Page } from '@playwright/test'; +import { expect, seedMockData, test } from './fixtures/test-fixtures'; +import { FeaturePage } from './pages'; + +/** + * Groups E2E Tests + * + * Tests for user stories in `user-stories/groups.md` (US-GRP-*) + */ + +/** Authority the ts-client mock signs in to */ +const AUTHORITY_ID = 'mock-authority'; + +const GROUPS = [ + { + id: 'group-facilities', + name: 'Facilities', + description: 'Building and facilities staff', + authority_id: AUTHORITY_ID, + subsystems: ['bookings'], + default_permissions: 1, + }, + { + id: 'group-av-technicians', + name: 'AV Technicians', + description: 'Maintains the meeting room AV equipment', + authority_id: AUTHORITY_ID, + parent_id: 'group-facilities', + subsystems: ['bookings', 'control'], + default_permissions: 1 | 16, + }, + { + id: 'group-reception', + name: 'Reception', + description: 'Front desk and visitor management', + authority_id: AUTHORITY_ID, + subsystems: ['visitors'], + default_permissions: 1, + }, +]; + +const GROUP_USERS = [ + { + user_id: 'user-alex-sorafumo', + group_id: 'group-facilities', + permissions: 1 | 4, + }, +]; + +const GROUP_ZONES = [ + { + group_id: 'group-av-technicians', + zone_id: 'zone-LEHeo501Er', // Level 30 + permissions: 1 | 16, + deny: false, + }, + { + group_id: 'group-av-technicians', + zone_id: 'zone-QjLXbYUxuC', // Level 31 + permissions: 0, + deny: true, + }, +]; + +/** Type `query` in an item search field and pick `option` from the results */ +async function searchAndPick( + page: Page, + input: Locator, + query: string, + option: string, +): Promise { + await input.fill(query); + await page.getByRole('option', { name: option }).click(); +} + +test.describe('Groups', () => { + let groups: FeaturePage; + + test.beforeEach(async ({ page }) => { + await seedMockData(page, 'groups', GROUPS); + await seedMockData(page, 'group_users', GROUP_USERS); + await seedMockData(page, 'group_zones', GROUP_ZONES); + groups = new FeaturePage(page, 'groups'); + }); + + test.describe('US-GRP-001: View Groups List', () => { + test('AC-GRP-001-1: shows child groups under their parent', async ({ + page, + }) => { + await groups.goto(); + + await expect(groups.itemLink('Facilities')).toBeVisible(); + await expect(groups.itemLink('Reception')).toBeVisible(); + await expect(groups.itemLink('AV Technicians')).toHaveCount(0); + + await page + .getByRole('button', { name: 'Expand group Facilities' }) + .click(); + + await expect(groups.itemLink('AV Technicians')).toBeVisible(); + }); + + test('AC-GRP-001-2: search filters the list', async () => { + await groups.goto(); + await groups.search('Tech'); + + await expect(groups.itemLink('AV Technicians')).toBeVisible(); + await expect(groups.itemLink('Facilities')).toHaveCount(0); + await expect(groups.itemLink('Reception')).toHaveCount(0); + }); + + test('AC-GRP-001-3: selecting a group opens its details', async ({ + page, + }) => { + await groups.goto(); + await groups.itemLink('Reception').click(); + + await expect(page).toHaveURL(/#\/groups\/group-reception\//); + await expect(groups.itemName).toHaveText('Reception'); + }); + }); + + test.describe('US-GRP-002: View Group Details', () => { + test('AC-GRP-002-1: about tab shows the parent, subsystems and permissions', async () => { + await groups.gotoItem('group-av-technicians'); + + const parent = groups.tabContent.getByRole('link', { + name: 'Facilities', + }); + await expect(parent).toHaveAttribute( + 'href', + /#\/groups\/group-facilities\/about/, + ); + await expect(groups.tabContent).toContainText('control'); + await expect(groups.tabContent).toContainText('Operate'); + await expect(groups.tabContent).toContainText( + 'Maintains the meeting room AV equipment', + ); + // The tree opens the parent of the selected group + await expect(groups.itemLink('AV Technicians')).toBeVisible(); + }); + }); + + test.describe('US-GRP-003: Create Group', () => { + test('AC-GRP-003-1: saves the group and opens it', async ({ page }) => { + await groups.goto(); + + const id = await groups.createWith(async () => { + await groups.dialog + .getByRole('textbox', { name: 'Name*' }) + .fill('Security'); + await groups.dialog + .getByRole('textbox', { name: 'Description' }) + .fill('Guards and access control'); + }); + + await expect(groups.itemName).toHaveText('Security'); + await expect(groups.tabContent).toContainText( + 'Guards and access control', + ); + await expect(groups.itemLink('Security')).toBeVisible(); + + await page.reload(); + await groups.waitForItemDetails(); + await expect(page).toHaveURL(new RegExp(`#/groups/${id}/about`)); + await expect(groups.itemName).toHaveText('Security'); + }); + + test('AC-GRP-003-2: a new group can be placed under a parent', async ({ + page, + }) => { + await groups.goto(); + + await groups.createWith(async () => { + await groups.dialog + .getByRole('textbox', { name: 'Name*' }) + .fill('Cleaners'); + await searchAndPick( + page, + groups.dialog + .getByRole('group', { name: 'Parent Group' }) + .getByRole('combobox'), + 'Facil', + 'Facilities', + ); + }); + + await expect(groups.itemName).toHaveText('Cleaners'); + await expect( + groups.tabContent.getByRole('link', { name: 'Facilities' }), + ).toBeVisible(); + // The child inherits the subsystems of its parent + await expect(groups.tabContent).toContainText('bookings'); + await expect( + page.getByRole('button', { name: 'Collapse group Facilities' }), + ).toBeVisible(); + }); + }); + + test.describe('US-GRP-004: Edit Group', () => { + test('AC-GRP-004-1: a renamed group keeps its new name', async ({ + page, + }) => { + await groups.gotoItem('group-reception'); + await groups.openEditModal(); + + const name = groups.dialog.getByRole('textbox', { name: 'Name*' }); + await expect(name).toHaveValue('Reception'); + await name.fill('Front Desk'); + await groups.saveButton.click(); + await expect(groups.dialog).toBeHidden(); + + await expect(groups.itemName).toHaveText('Front Desk'); + await page.reload(); + await groups.waitForItemDetails(); + await expect(groups.itemName).toHaveText('Front Desk'); + await expect(groups.itemLink('Front Desk')).toBeVisible(); + }); + }); + + test.describe('US-GRP-005: Delete Group', () => { + test('AC-GRP-005-1: removes the group from the list', async ({ + page, + }) => { + await groups.gotoItem('group-reception'); + await groups.deleteSelected(); + + await expect(groups.itemLink('Reception')).toHaveCount(0); + await page.reload(); + await groups.waitForLoad(); + await expect(groups.itemLink('Facilities')).toBeVisible(); + await expect(groups.itemLink('Reception')).toHaveCount(0); + }); + }); + + test.describe('US-GRP-006: Manage Group Users', () => { + test('AC-GRP-006-1: users tab lists members and their permissions', async () => { + await groups.gotoItem('group-facilities', 'users'); + + const row = await groups.tableRow('Alex Sorafumo'); + await expect(row.getByRole('link')).toHaveAttribute( + 'href', + /#\/users\/user-alex-sorafumo\/about/, + ); + await expect(row.nth(0)).toContainText('Alex.Sorafumo@place.tech'); + await expect(row.nth(1)).toHaveText(/^\s*Read\s+Update\s*$/); + await expect(groups.tabContent).not.toContainText('Jeremy West'); + }); + + test('AC-GRP-006-2: adds a user with the default permissions', async ({ + page, + }) => { + await groups.gotoItem('group-reception', 'users'); + await expect(groups.tabContent).toContainText( + 'No users associated with this group', + ); + + await searchAndPick( + page, + page.getByPlaceholder('Search for users...'), + 'Jeremy', + 'Jeremy West', + ); + + // Reception gives Read to members by default + const row = await groups.tableRow('Jeremy West'); + await expect(row.nth(1)).toHaveText(/^\s*Read\s*$/); + await page.reload(); + await groups.waitForItemDetails(); + await expect(groups.tabContent).toContainText('Jeremy West'); + }); + + test('AC-GRP-006-3: removes a user from the group', async ({ + page, + }) => { + await groups.gotoItem('group-facilities', 'users'); + + const row = await groups.tableRow('Alex Sorafumo'); + await row.locator('button:has(icon:has-text("delete"))').click(); + await groups.acceptButton.click(); + + await expect(groups.tabContent).toContainText( + 'No users associated with this group', + ); + await page.reload(); + await groups.waitForItemDetails(); + await expect(groups.tabContent).toContainText( + 'No users associated with this group', + ); + }); + }); + + test.describe('US-GRP-007: Manage Group Zones', () => { + test('AC-GRP-007-1: zones tab lists zones with their access', async () => { + await groups.gotoItem('group-av-technicians', 'zones'); + + const allowed = await groups.tableRow('Level 30'); + await expect(allowed.nth(1)).toHaveText(/^\s*Read\s+Operate\s*$/); + await expect(allowed.nth(2)).toHaveText('No'); + const denied = await groups.tableRow('Level 31'); + await expect(denied.nth(1)).toHaveText('Default'); + await expect(denied.nth(2)).toHaveText('Yes'); + }); + }); +}); diff --git a/e2e/src/mobile.mobile.spec.ts b/e2e/src/mobile.mobile.spec.ts index 3aff19184..08f724181 100644 --- a/e2e/src/mobile.mobile.spec.ts +++ b/e2e/src/mobile.mobile.spec.ts @@ -1,695 +1,146 @@ -import { expect, Page, test } from '@playwright/test'; -import { buildAppUrl } from './config/test-env'; -import { MobileSystemsPage } from './pages'; +import { expect, test } from './fixtures/test-fixtures'; +import { MobilePage } from './pages'; /** * Mobile E2E Tests * - * Tests mobile-specific user flows and interactions. - * These tests run on mobile viewports (Pixel 5, iPhone 12). - * - * Key mobile differences: - * - Navigation via hamburger menu - * - Item selection via modal overlay (item-selection component) - * - Responsive layout adjustments - */ - -/** - * Wait for the app to fully load (loader gone, router ready) - */ -async function waitForAppReady(page: Page): Promise { - // Wait for loader to disappear - await page - .waitForSelector('[loader]', { state: 'detached', timeout: 30000 }) - .catch(() => undefined); - - // Wait for router outlet - await page.waitForSelector('router-outlet', { - state: 'attached', - timeout: 30000, - }); - - // Wait for item-selection component (mobile) - await page - .waitForSelector('item-selection', { timeout: 30000 }) - .catch(() => undefined); - - // Small delay for animations - await page.waitForTimeout(500); -} - -/** - * Close item-selection if it's open (by selecting first item) - * This is needed because item-selection auto-opens when no item is selected + * Mobile flows on phone screens (Pixel 5, iPhone 12): + * - sections open from the hamburger menu + * - items are picked from the `item-selection` overlay */ -async function closeItemSelectionIfOpen(page: Page): Promise { - // Check if the input is visible (indicates overlay is open) - const input = page.locator('item-selection input'); - const is_open = await input.isVisible().catch(() => false); - if (is_open) { - // Wait for items to load - await page - .waitForSelector('item-selection virtual-scroll a', { - timeout: 10000, - }) - .catch(() => undefined); +const ACTIVITY_SPACE = '2.30.08 - Activity Space'; - // Select first item to close the overlay - const first_item = page - .locator('item-selection virtual-scroll a') - .first(); - if (await first_item.isVisible().catch(() => false)) { - await first_item.click(); - await page.waitForTimeout(500); - - // Verify overlay closed - await page - .waitForFunction( - () => { - const input = document.querySelector( - 'item-selection input', - ); - return !input || !input.checkVisibility(); - }, - { timeout: 5000 }, - ) - .catch(() => undefined); - } - } -} +/** Sections reached from the menu and their route */ +const MENU_SECTIONS = [ + { name: 'Zones', route: 'zones' }, + { name: 'Modules', route: 'modules' }, + { name: 'Drivers', route: 'drivers' }, + { name: 'Triggers', route: 'triggers' }, +]; test.describe('Mobile', () => { - test.describe('Navigation', () => { - test('should display hamburger menu button on mobile', async ({ - page, - }) => { - await page.addInitScript(() => { - localStorage.setItem('BACKOFFICE.mock', 'true'); - }); - - await page.goto(buildAppUrl('/#/systems')); - await waitForAppReady(page); - - // Hamburger menu should be visible on mobile - const hamburger_button = page.locator( - 'button:has(icon:has-text("menu"))', - ); - await expect(hamburger_button).toBeVisible({ timeout: 10000 }); - }); - - test('should open sidebar menu when hamburger button is clicked', async ({ - page, - }) => { - await page.addInitScript(() => { - localStorage.setItem('BACKOFFICE.mock', 'true'); - }); - - await page.goto(buildAppUrl('/#/systems')); - await waitForAppReady(page); - - // Close item-selection overlay first - await closeItemSelectionIfOpen(page); - - // Click hamburger menu - await page.locator('button:has(icon:has-text("menu"))').click(); - - // Sidebar should become visible (look for the flex class that shows it) - const sidebar = page.locator('sidebar-menu [sidebar-menu]'); - await expect(sidebar).toHaveClass(/!flex/, { timeout: 5000 }); - }); - - test('should close sidebar menu when close button is clicked', async ({ - page, - }) => { - await page.addInitScript(() => { - localStorage.setItem('BACKOFFICE.mock', 'true'); - }); - - await page.goto(buildAppUrl('/#/systems')); - await waitForAppReady(page); - - // Close item-selection overlay first - await closeItemSelectionIfOpen(page); - - // Open hamburger menu - await page.locator('button:has(icon:has-text("menu"))').click(); - await page.waitForSelector('sidebar-menu [sidebar-menu].\\!flex', { - timeout: 5000, - }); - - // Click close button - await page - .locator('sidebar-menu button:has(icon:has-text("close"))') - .click(); - await page.waitForTimeout(300); - - // Verify sidebar is closed (doesn't have !flex class) - const sidebar = page.locator('sidebar-menu [sidebar-menu]'); - await expect(sidebar).not.toHaveClass(/!flex/); - }); - - test('should navigate to different sections via hamburger menu', async ({ - page, - }) => { - await page.addInitScript(() => { - localStorage.setItem('BACKOFFICE.mock', 'true'); - }); - - await page.goto(buildAppUrl('/#/systems')); - await waitForAppReady(page); - - // Close item-selection overlay first - await closeItemSelectionIfOpen(page); - - // Open hamburger menu and navigate to Zones - await page.locator('button:has(icon:has-text("menu"))').click(); - await page.waitForSelector('sidebar-menu [sidebar-menu].\\!flex', { - timeout: 5000, - }); - - await page.locator('sidebar-menu a[btn]:has-text("Zones")').click(); - await page.waitForTimeout(500); + let systems: MobilePage; - // Verify URL changed to zones - await expect(page).toHaveURL(/zones/); - }); + test.beforeEach(async ({ page }) => { + systems = new MobilePage(page, 'systems'); }); - test.describe('Item Selection', () => { - test('should display item selection component on mobile', async ({ - page, - }) => { - await page.addInitScript(() => { - localStorage.setItem('BACKOFFICE.mock', 'true'); - }); - - await page.goto(buildAppUrl('/#/systems')); - await waitForAppReady(page); - - // Item selection should be visible on mobile - const item_selection = page.locator('item-selection'); - await expect(item_selection).toBeVisible({ timeout: 10000 }); - }); - - test('should open item selection overlay when search button is clicked', async ({ - page, - }) => { - await page.addInitScript(() => { - localStorage.setItem('BACKOFFICE.mock', 'true'); - }); - - await page.goto(buildAppUrl('/#/systems')); - await waitForAppReady(page); - - // First select an item to close the auto-opened overlay - await closeItemSelectionIfOpen(page); - - // Now click the search/selection trigger to re-open - // The trigger button has a specific class with rounded-lg - await page.locator('item-selection button.rounded-lg').click(); - - // Search input should be visible (give more time as there may be animation) - const search_input = page.locator('item-selection input'); - await expect(search_input).toBeVisible({ timeout: 10000 }); - }); - - test('should display items in selection list', async ({ page }) => { - await page.addInitScript(() => { - localStorage.setItem('BACKOFFICE.mock', 'true'); - }); - - await page.goto(buildAppUrl('/#/systems')); - await waitForAppReady(page); - - // The overlay should already be open, items should be listed - const items = page.locator('item-selection virtual-scroll a'); - await expect(items.first()).toBeVisible({ timeout: 10000 }); - }); - - test('should search and filter items', async ({ page }) => { - await page.addInitScript(() => { - localStorage.setItem('BACKOFFICE.mock', 'true'); - }); - - await page.goto(buildAppUrl('/#/systems')); - await waitForAppReady(page); - - // Type in search (overlay should already be open) - await page.locator('item-selection input').fill('Activity'); - await page.waitForTimeout(500); - - // Verify search input has the value - await expect(page.locator('item-selection input')).toHaveValue( - 'Activity', - ); - }); - - test('should select an item and show details', async ({ page }) => { - await page.addInitScript(() => { - localStorage.setItem('BACKOFFICE.mock', 'true'); - }); - - await page.goto(buildAppUrl('/#/systems')); - await waitForAppReady(page); - - // Wait for items to load (overlay auto-opens) - await page.waitForSelector('item-selection virtual-scroll a', { - timeout: 10000, - }); - - // Select first item - await page - .locator('item-selection virtual-scroll a') - .first() - .click(); - await page.waitForTimeout(500); - - // Item details should be visible - await expect(page.locator('item-details')).toBeVisible({ - timeout: 10000, - }); - }); - - // Skip: The item-selection component doesn't have Escape key handling implemented - test.skip('should close item selection with Escape key', async ({ - page, - }) => { - await page.addInitScript(() => { - localStorage.setItem('BACKOFFICE.mock', 'true'); - }); - - await page.goto(buildAppUrl('/#/systems')); - await waitForAppReady(page); - - // First select an item so the overlay can be closed - await page.waitForSelector('item-selection virtual-scroll a', { - timeout: 10000, - }); - await page - .locator('item-selection virtual-scroll a') - .first() - .click(); - await page.waitForTimeout(500); - - // Open selection again using the trigger button (has rounded-lg class) - await page.locator('item-selection button.rounded-lg').click(); - await page.waitForSelector('item-selection input', { - timeout: 10000, - }); - - // Press Escape - await page.keyboard.press('Escape'); - await page.waitForTimeout(500); - - // Overlay should be closed - check input is not visible - const input = page.locator('item-selection input'); - await expect(input).not.toBeVisible({ timeout: 5000 }); - }); - }); - - test.describe('Systems - Mobile Flow', () => { - let systems_page: MobileSystemsPage; - - test.beforeEach(async ({ page }) => { - await page.addInitScript(() => { - localStorage.setItem('BACKOFFICE.mock', 'true'); - }); - systems_page = new MobileSystemsPage(page); - }); - - test('should view systems list on mobile', async () => { - await systems_page.goto(); - - // Verify item selection is visible - await expect(systems_page.itemSelectionComponent).toBeVisible(); - - // Items should be visible (overlay auto-opens) - await systems_page.expectItemSelectionHasItems(); - }); - - test('should search systems on mobile', async ({ page }) => { - await systems_page.goto(); - - // Search for a system (overlay is auto-opened) - await page.locator('item-selection input').fill('Activity'); - await page.waitForTimeout(500); - - // Verify search input has the value - await expect(systems_page.itemSelectionSearchInput).toHaveValue( - 'Activity', - ); - }); - - test('should select a system and view details on mobile', async () => { - await systems_page.goto(); - - // Select first system - await systems_page.selectFirstItemMobile(); - - // Verify details are displayed - await expect(systems_page.mainContent).toBeVisible(); - await expect(systems_page.itemDetails).toBeVisible({ - timeout: 10000, - }); - }); - - test('should open add modal on mobile', async ({ page }) => { - await systems_page.goto(); - - // Close item-selection overlay first - await closeItemSelectionIfOpen(page); - - // Click add button - await systems_page.addButton.click(); - - // Verify modal appears - await expect(systems_page.dialog).toBeVisible(); - }); - - test('should navigate system tabs on mobile', async ({ page }) => { - await systems_page.goto(); - - // Select a system first - await systems_page.selectFirstItemMobile(); - await page.waitForTimeout(500); - - // Navigate to modules tab - await systems_page.viewModules(); - await expect(systems_page.mainContent).toBeVisible(); + test.describe('Navigation', () => { + test('hides the desktop lists behind the menu button', async () => { + await systems.goto(); - // Navigate to zones tab - await systems_page.viewZones(); - await expect(systems_page.mainContent).toBeVisible(); + await expect(systems.page.locator('item-sidebar')).toBeHidden(); + await expect(systems.menuButton).toBeVisible(); + await expect(systems.menu).not.toHaveClass(/!flex/); }); - test('should open edit modal on mobile', async ({ page }) => { - await systems_page.goto(); - - // Select a system - await systems_page.selectFirstItemMobile(); - await page.waitForTimeout(500); - - // Open edit modal - await systems_page.openEditModal(); + test('the menu opens and closes', async () => { + await systems.goto(); + await systems.pick(ACTIVITY_SPACE); - // Verify modal with pre-populated fields - await expect(systems_page.dialog).toBeVisible(); + await systems.openMenu(); + await expect( + systems.page.locator('sidebar-menu a').filter({ + hasText: 'PlaceOS', + }), + ).toBeVisible(); + await systems.closeMenu(); }); - test('should show delete confirmation on mobile', async ({ page }) => { - await systems_page.goto(); - - // Select a system - await systems_page.selectFirstItemMobile(); - await page.waitForTimeout(500); + test('menu links open each section', async ({ page }) => { + for (const section of MENU_SECTIONS) { + await test.step(section.name, async () => { + // The picker covers the menu button until an item is picked + await systems.gotoItem('sys-QjJvCH7_dA'); + await systems.openMenu(); + await systems.menuLink(section.name).click(); - // Open action menu and click delete - await systems_page.openActionMenu(); - await systems_page.deleteButton.click(); - - // Verify confirmation dialog appears - await expect(systems_page.dialog).toBeVisible(); + await expect(page).toHaveURL( + new RegExp(`#/${section.route}/`), + ); + await expect(systems.pickerInput).toBeVisible(); + }); + } }); }); - test.describe('Responsive Layout', () => { - test('should hide desktop sidebar on mobile', async ({ page }) => { - await page.addInitScript(() => { - localStorage.setItem('BACKOFFICE.mock', 'true'); - }); - - await page.goto(buildAppUrl('/#/systems')); - await waitForAppReady(page); + test.describe('Item Picker', () => { + test('opens with the item list when nothing is selected', async () => { + await systems.goto(); - // Desktop item-sidebar should be hidden on mobile - const desktop_sidebar = page.locator('item-sidebar'); - await expect(desktop_sidebar).toHaveClass(/hidden/); + await expect(systems.pickerInput).toBeVisible(); + await expect(systems.pickerItem(ACTIVITY_SPACE)).toBeVisible(); }); - test('should show add button on mobile', async ({ page }) => { - await page.addInitScript(() => { - localStorage.setItem('BACKOFFICE.mock', 'true'); - }); - - await page.goto(buildAppUrl('/#/systems')); - await waitForAppReady(page); - - // Add button should be visible - const add_button = page - .locator('button:has(icon:has-text("add"))') - .first(); - await expect(add_button).toBeVisible(); - }); + test('search filters the list', async () => { + await systems.goto(); + await systems.pickerInput.fill('2.30.09'); - test('should display PlaceOS logo in sidebar menu', async ({ - page, - }) => { - await page.addInitScript(() => { - localStorage.setItem('BACKOFFICE.mock', 'true'); - }); - - await page.goto(buildAppUrl('/#/systems')); - await waitForAppReady(page); - - // Close item-selection first - await closeItemSelectionIfOpen(page); - - // Open hamburger menu - await page.locator('button:has(icon:has-text("menu"))').click(); - await page.waitForSelector('sidebar-menu [sidebar-menu].\\!flex', { - timeout: 5000, - }); - - // Logo text should be visible await expect( - page.locator('sidebar-menu a:has-text("PlaceOS")'), + systems.pickerItem('2.30.09 - Activity Space'), ).toBeVisible(); + await expect(systems.pickerItem(ACTIVITY_SPACE)).toHaveCount(0); }); - }); - test.describe('Cross-Feature Navigation', () => { - test('should navigate from Systems to Zones on mobile', async ({ + test('tapping an item opens its details and closes the picker', async ({ page, }) => { - await page.addInitScript(() => { - localStorage.setItem('BACKOFFICE.mock', 'true'); - }); - - await page.goto(buildAppUrl('/#/systems')); - await waitForAppReady(page); - - // Close item-selection first - await closeItemSelectionIfOpen(page); - - // Navigate via hamburger menu - await page.locator('button:has(icon:has-text("menu"))').click(); - await page.waitForSelector('sidebar-menu [sidebar-menu].\\!flex', { - timeout: 5000, - }); - await page.locator('sidebar-menu a[btn]:has-text("Zones")').click(); + await systems.goto(); + await systems.pickerItem(ACTIVITY_SPACE).tap(); - // Verify zones page loaded - await expect(page).toHaveURL(/zones/); - await expect(page.locator('item-selection')).toBeVisible(); + await expect(page).toHaveURL(/#\/systems\/sys-QjJvCH7_dA\//); + await expect(systems.itemName).toHaveText(ACTIVITY_SPACE); + await expect(systems.pickerInput).toBeHidden(); }); - test('should navigate from Systems to Modules on mobile', async ({ - page, - }) => { - await page.addInitScript(() => { - localStorage.setItem('BACKOFFICE.mock', 'true'); - }); - - await page.goto(buildAppUrl('/#/systems')); - await waitForAppReady(page); - - // Close item-selection first - await closeItemSelectionIfOpen(page); - - // Navigate via hamburger menu - await page.locator('button:has(icon:has-text("menu"))').click(); - await page.waitForSelector('sidebar-menu [sidebar-menu].\\!flex', { - timeout: 5000, - }); - await page - .locator('sidebar-menu a[btn]:has-text("Modules")') - .click(); - - // Verify modules page loaded - await expect(page).toHaveURL(/modules/); - await expect(page.locator('item-selection')).toBeVisible(); - }); - - test('should navigate from Systems to Drivers on mobile', async ({ - page, - }) => { - await page.addInitScript(() => { - localStorage.setItem('BACKOFFICE.mock', 'true'); - }); - - await page.goto(buildAppUrl('/#/systems')); - await waitForAppReady(page); - - // Close item-selection first - await closeItemSelectionIfOpen(page); - - // Navigate via hamburger menu - await page.locator('button:has(icon:has-text("menu"))').click(); - await page.waitForSelector('sidebar-menu [sidebar-menu].\\!flex', { - timeout: 5000, - }); - await page - .locator('sidebar-menu a[btn]:has-text("Drivers")') - .click(); - - // Verify drivers page loaded - await expect(page).toHaveURL(/drivers/); - await expect(page.locator('item-selection')).toBeVisible(); - }); - - test('should navigate from Systems to Triggers on mobile', async ({ - page, - }) => { - await page.addInitScript(() => { - localStorage.setItem('BACKOFFICE.mock', 'true'); - }); - - await page.goto(buildAppUrl('/#/systems')); - await waitForAppReady(page); - - // Close item-selection first - await closeItemSelectionIfOpen(page); - - // Navigate via hamburger menu - await page.locator('button:has(icon:has-text("menu"))').click(); - await page.waitForSelector('sidebar-menu [sidebar-menu].\\!flex', { - timeout: 5000, - }); - await page - .locator('sidebar-menu a[btn]:has-text("Triggers")') - .click(); + test('the picker opens again after a pick', async () => { + await systems.goto(); + await systems.pick(ACTIVITY_SPACE); - // Verify triggers page loaded - await expect(page).toHaveURL(/triggers/); - await expect(page.locator('item-selection')).toBeVisible(); + await systems.openPicker(); + await expect(systems.pickerInput).toHaveValue(ACTIVITY_SPACE); + await systems.pick('2.30.09 - Activity Space'); }); }); - test.describe('Form Interactions on Mobile', () => { - test('should fill form fields in modal on mobile', async ({ page }) => { - await page.addInitScript(() => { - localStorage.setItem('BACKOFFICE.mock', 'true'); - }); + test.describe('Systems', () => { + test('tabs show their content', async () => { + await systems.goto(); + await systems.pick(ACTIVITY_SPACE); - await page.goto(buildAppUrl('/#/systems')); - await waitForAppReady(page); + await systems.openTab('zones'); - // Close item-selection first - await closeItemSelectionIfOpen(page); - - // Open add modal - await page - .locator('button:has(icon:has-text("add"))') - .first() - .click(); - await page.waitForSelector( - 'fullscreen-modal-shell, mat-dialog-container', - { timeout: 5000 }, - ); - - // Fill name field - const name_field = page - .locator( - 'mat-form-field:has(mat-label:text("Name")) input, input[placeholder*="Name" i]', - ) - .first(); - await name_field.fill('Test Mobile System'); - - // Verify value - await expect(name_field).toHaveValue('Test Mobile System'); - }); - - test('should close modal with cancel button on mobile', async ({ - page, - }) => { - await page.addInitScript(() => { - localStorage.setItem('BACKOFFICE.mock', 'true'); - }); - - await page.goto(buildAppUrl('/#/systems')); - await waitForAppReady(page); - - // Close item-selection first - await closeItemSelectionIfOpen(page); - - // Open add modal - await page - .locator('button:has(icon:has-text("add"))') - .first() - .click(); - await page.waitForSelector( - 'fullscreen-modal-shell, mat-dialog-container', - { timeout: 5000 }, - ); - - // Click cancel - await page - .locator( - 'fullscreen-modal-shell button[mat-dialog-close], mat-dialog-container button:has-text("Cancel")', - ) - .first() - .click(); - await page.waitForTimeout(500); - - // Modal should be closed await expect( - page.locator('fullscreen-modal-shell, mat-dialog-container'), - ).not.toBeVisible(); + systems.tabContent.getByRole('link', { + name: 'Tower 2', + exact: true, + }), + ).toBeVisible(); }); - }); - - test.describe('Touch Interactions', () => { - test('should support tap on items', async ({ page }) => { - await page.addInitScript(() => { - localStorage.setItem('BACKOFFICE.mock', 'true'); - }); - await page.goto(buildAppUrl('/#/systems')); - await waitForAppReady(page); + test('the edit form holds the current name', async () => { + await systems.goto(); + await systems.pick(ACTIVITY_SPACE); + await systems.openEditModal(); - // Wait for items in the auto-opened selection - await page.waitForSelector('item-selection virtual-scroll a', { - timeout: 10000, - }); - - // Tap on first item - await page.locator('item-selection virtual-scroll a').first().tap(); - await page.waitForTimeout(500); - - // Item details should be visible - await expect(page.locator('item-details')).toBeVisible({ - timeout: 10000, - }); + await expect( + systems.dialog.getByPlaceholder('System Name'), + ).toHaveValue(ACTIVITY_SPACE); }); - test('should support tap on hamburger menu', async ({ page }) => { - await page.addInitScript(() => { - localStorage.setItem('BACKOFFICE.mock', 'true'); - }); - - await page.goto(buildAppUrl('/#/systems')); - await waitForAppReady(page); - - // Close item-selection first - await closeItemSelectionIfOpen(page); + test('closing the edit form keeps the system unchanged', async () => { + await systems.goto(); + await systems.pick(ACTIVITY_SPACE); + await systems.openEditModal(); - // Tap hamburger menu - await page.locator('button:has(icon:has-text("menu"))').tap(); + await systems.dialog + .getByPlaceholder('System Name') + .fill('Not Saved'); + await systems.discardChanges(); - // Sidebar should be visible - const sidebar = page.locator('sidebar-menu [sidebar-menu]'); - await expect(sidebar).toHaveClass(/!flex/, { timeout: 5000 }); + await expect(systems.dialog).toBeHidden(); + await expect(systems.itemName).toHaveText(ACTIVITY_SPACE); }); }); }); diff --git a/e2e/src/modules.spec.ts b/e2e/src/modules.spec.ts new file mode 100644 index 000000000..92eb739fe --- /dev/null +++ b/e2e/src/modules.spec.ts @@ -0,0 +1,230 @@ +import { expect, test } from './fixtures/test-fixtures'; +import { FeaturePage } from './pages'; + +/** + * Modules E2E Tests + * + * Tests for user stories in `user-stories/modules.md` (US-MOD-*). + * Uses the default mock modules from `src/app/mocks/data/modules.ts`. + */ + +/** Running logic module in system '2.30.Central-Core' */ +const LOGIC_ID = 'mod-lAHXR38sRX'; +const LOGIC_SYSTEM_ID = 'sys-l7lvddob4I'; +/** Running device module with the custom name 'Door' */ +const DOOR_ID = 'mod-ke85vi5csE'; +/** Stopped device module */ +const DISPLAY_ID = 'mod-l7l-m0gK7G'; + +test.describe('Modules', () => { + let modules: FeaturePage; + + test.beforeEach(async ({ page }) => { + modules = new FeaturePage(page, 'modules'); + }); + + test.describe('US-MOD-001: View Modules List', () => { + test('AC-MOD-001-2: search filters the list', async () => { + await modules.goto(); + await expect( + modules.itemLink('Samsung MD & DM Series LCD'), + ).toBeVisible(); + + await modules.search('QSC'); + + await expect(modules.itemLink('QSC PTZ Camera Proxy')).toHaveCount( + 2, + ); + await expect( + modules.itemLink('Samsung MD & DM Series LCD'), + ).toHaveCount(0); + }); + + test('AC-MOD-001-3: selecting a module opens its details', async ({ + page, + }) => { + await modules.goto(); + await modules.itemLink('ACA Meeting Room Logic').click(); + + await expect(page).toHaveURL(new RegExp(`#/modules/${LOGIC_ID}/`)); + await expect(modules.itemName).toHaveText('ACA Meeting Room Logic'); + }); + }); + + test.describe('US-MOD-002: View Module Details', () => { + test('AC-MOD-002-2: shows the IP address and port', async () => { + await modules.gotoItem(DOOR_ID); + + await expect(modules.itemName).toHaveText('Door'); + await expect(modules.tabContent).toContainText('IP 10.1.1.83'); + await expect(modules.tabContent).toContainText('Port Number 4998'); + }); + + test('AC-MOD-002-3: the driver link opens the driver', async ({ + page, + }) => { + await modules.gotoItem(DOOR_ID); + await modules.tabContent + .getByRole('link', { name: 'GlobalCache IO Gateway' }) + .click(); + + await expect(page).toHaveURL(/#\/drivers\/dep-S9OfntF_TR/); + await expect(modules.itemName).toHaveText('GlobalCache IO Gateway'); + }); + }); + + test.describe('US-MOD-003: Create New Module', () => { + test('AC-MOD-003-4: a driver is required', async () => { + await modules.goto(); + await modules.openAddModal(); + + await modules.saveButton.click(); + + await expect( + modules.notification('Check these fields: System, Driver id'), + ).toBeVisible(); + await expect(modules.dialog).toBeVisible(); + }); + + test('AC-MOD-003-5: saves the module and opens it', async ({ + page, + }) => { + await modules.goto(); + + await modules.createWith(async () => { + const driver = modules.dialog.getByRole('combobox', { + name: 'Search...', + }); + await driver.fill('KNX'); + await page + .getByRole('option', { name: /KNX BAOS Lighting/ }) + .click(); + await modules.dialog + .getByRole('textbox', { name: /IP Address/ }) + .fill('10.0.0.5'); + await modules.dialog + .getByRole('spinbutton', { name: 'Port Number *' }) + .fill('12004'); + await modules.dialog + .getByRole('textbox', { name: 'Custom Name' }) + .fill('Lobby_Lights'); + }); + + await expect(modules.itemName).toHaveText('Lobby_Lights'); + await expect(modules.tabContent).toContainText('IP 10.0.0.5'); + await expect(modules.tabContent).toContainText('Port Number 12004'); + await expect( + modules.tabContent.getByRole('link', { + name: 'KNX BAOS Lighting', + }), + ).toHaveAttribute('href', '#/drivers/dep-WjCyo9xO8H'); + await expect(modules.itemLink('10.0.0.5')).toBeVisible(); + await page.reload(); + await modules.waitForItemDetails(); + await expect(modules.itemName).toHaveText('Lobby_Lights'); + }); + }); + + test.describe('US-MOD-004: Edit Module Configuration', () => { + test('AC-MOD-004-4: saved network settings and notes persist', async ({ + page, + }) => { + await modules.gotoItem(DOOR_ID); + await modules.openEditModal(); + + const ip = modules.dialog.getByRole('textbox', { + name: /IP Address/, + }); + await expect(ip).toHaveValue('10.1.1.83'); + await ip.fill('10.1.1.90'); + await modules.dialog + .getByRole('textbox', { name: 'Notes' }) + .fill('Front entrance'); + await modules.saveButton.click(); + await expect(modules.dialog).toBeHidden(); + + await expect(modules.tabContent).toContainText('IP 10.1.1.90'); + await page.reload(); + await modules.waitForItemDetails(); + await expect(modules.tabContent).toContainText('IP 10.1.1.90'); + await expect(modules.tabContent).toContainText( + 'Notes Front entrance', + ); + }); + }); + + test.describe('US-MOD-005: Delete Module', () => { + test('AC-MOD-005-2: removes the module from the list', async ({ + page, + }) => { + await modules.gotoItem(DISPLAY_ID); + await modules.deleteSelected(); + + await expect( + modules.itemLink('Samsung MD & DM Series LCD'), + ).toHaveCount(0); + await page.reload(); + await modules.waitForLoad(); + await expect( + modules.itemLink('ACA Meeting Room Logic'), + ).toBeVisible(); + await expect( + modules.itemLink('Samsung MD & DM Series LCD'), + ).toHaveCount(0); + }); + }); + + test.describe('US-MOD-006: Start and Stop Module', () => { + test('AC-MOD-006-2 and AC-MOD-006-1: stops and restarts a module', async () => { + await modules.gotoItem(LOGIC_ID); + const start = modules.tabContent.getByRole('button', { + name: 'START', + }); + const stop = modules.tabContent.getByRole('button', { + name: 'STOP', + }); + await expect(start).toBeDisabled(); + + await stop.click(); + await expect( + modules.notification('Module successfully stopped'), + ).toBeVisible(); + await expect(stop).toBeDisabled(); + await expect(start).toBeEnabled(); + + await start.click(); + await expect( + modules.notification('Module successfully started'), + ).toBeVisible(); + await expect(start).toBeDisabled(); + await expect(stop).toBeEnabled(); + }); + }); + + test.describe('US-MOD-008: View Systems Using Module', () => { + test('AC-MOD-008-2: a system link opens that system', async ({ + page, + }) => { + await modules.gotoItem(LOGIC_ID); + await modules.openTab('systems'); + await modules.tabContent + .getByRole('link', { name: '2.30.Central-Core' }) + .click(); + + await expect(page).toHaveURL( + new RegExp(`#/systems/${LOGIC_SYSTEM_ID}/`), + ); + await expect(modules.itemName).toHaveText('2.30.Central-Core'); + }); + }); + + test.describe('US-MOD-011: View Module Connection Status', () => { + test('AC-MOD-011-1 and AC-MOD-011-2: shows Online or Offline', async () => { + await modules.gotoItem(LOGIC_ID); + await expect(modules.itemDetails).toContainText('Online'); + + await modules.gotoItem(DISPLAY_ID); + await expect(modules.itemDetails).toContainText('Offline'); + }); + }); +}); diff --git a/e2e/src/navigation.spec.ts b/e2e/src/navigation.spec.ts new file mode 100644 index 000000000..c4e542a31 --- /dev/null +++ b/e2e/src/navigation.spec.ts @@ -0,0 +1,114 @@ +import { Page } from '@playwright/test'; +import { expect, seedMockData, test } from './fixtures/test-fixtures'; +import { FeaturePage } from './pages'; + +/** + * Navigation E2E Tests + * + * Tests for the main menu and for routing between sections on desktop. + * The mobile spec covers the menu on small screens. + */ + +/** Sections in the main menu that list items in the item sidebar */ +const LIST_SECTIONS = [ + { route: 'systems', title: 'Systems' }, + { route: 'modules', title: 'Modules' }, + { route: 'zones', title: 'Zones' }, + { route: 'drivers', title: 'Drivers' }, + { route: 'repositories', title: 'Repositories' }, + { route: 'triggers', title: 'Triggers' }, + { route: 'users', title: 'Users' }, + { route: 'groups', title: 'Groups' }, + { route: 'domains', title: 'Domains' }, +] as const; + +/** Link to a section in the main menu */ +function menuLink(page: Page, route: string) { + return page.locator(`sidebar-menu a[href="#/${route}"]`); +} + +/** Search field of the item sidebar */ +function sidebarSearch(page: Page) { + return new FeaturePage(page, 'systems').searchInput; +} + +/** URL of a section, with or without a selected item */ +function sectionUrl(route: string) { + return new RegExp(`#/${route}(/|$)`); +} + +test.describe('Navigation', () => { + test.describe('Main menu', () => { + test('each list link opens its section', async ({ page }) => { + // Start outside the list sections so every click changes section + await new FeaturePage(page, 'admin').goto(); + + for (const { route, title } of LIST_SECTIONS) { + await test.step(title, async () => { + await menuLink(page, route).click(); + + await expect(page).toHaveURL(sectionUrl(route)); + await expect(sidebarSearch(page)).toHaveAttribute( + 'placeholder', + `Search for ${title}...`, + ); + await expect(menuLink(page, route)).toHaveClass( + /text-secondary-content/, + ); + }); + } + }); + + test('opens Manage Instance', async ({ page }) => { + await new FeaturePage(page, 'systems').goto(); + await menuLink(page, 'admin').click(); + + await expect(page).toHaveURL(sectionUrl('admin')); + await expect( + page.locator('a[href="#/admin/database"]'), + ).toBeVisible(); + await expect(page.locator('item-sidebar')).toHaveCount(0); + }); + }); + + test('an unknown route redirects to systems', async ({ page }) => { + await page.goto('/#/not-a-real-page'); + + await expect(page).toHaveURL(sectionUrl('systems')); + await expect(sidebarSearch(page)).toHaveAttribute( + 'placeholder', + 'Search for Systems...', + ); + }); + + test('a deep link opens the tab of the item', async ({ page }) => { + await seedMockData(page, 'zones', [ + { id: 'zone-level-1', name: 'Level 1', tags: [] }, + ]); + const zones = new FeaturePage(page, 'zones'); + await zones.gotoItem('zone-level-1', 'systems'); + + await expect(page).toHaveURL(/#\/zones\/zone-level-1\/systems$/); + await expect(zones.itemName).toHaveText('Level 1'); + await expect( + page.locator('item-tablist a[href*="/systems"]'), + ).toHaveAttribute('aria-selected', 'true'); + }); + + test('browser back returns to the previous section', async ({ page }) => { + await new FeaturePage(page, 'systems').goto(); + await menuLink(page, 'zones').click(); + await expect(sidebarSearch(page)).toHaveAttribute( + 'placeholder', + 'Search for Zones...', + ); + + await page.goBack(); + + await expect(page).toHaveURL(sectionUrl('systems')); + await expect(sidebarSearch(page)).toHaveAttribute( + 'placeholder', + 'Search for Systems...', + ); + }); +}); diff --git a/e2e/src/pages/admin.page.ts b/e2e/src/pages/admin.page.ts index a16421d9e..ad88ae466 100644 --- a/e2e/src/pages/admin.page.ts +++ b/e2e/src/pages/admin.page.ts @@ -1,13 +1,11 @@ import { Locator, Page, expect } from '@playwright/test'; +import { buildAppUrl } from '../config/test-env'; import { BasePage } from './base.page'; /** * Page Object for the Admin section * - * The admin section uses routerLink navigation with tab IDs: - * about, database, clusters, edge, interfaces, brokers, staff-api, - * resource-imports, extensions, api-keys, schemas, upload-storage, - * upload-library, build-jobs + * Each admin section has its own route, e.g. `#/admin/api-keys` */ export class AdminPage extends BasePage { readonly path = '/#/admin'; @@ -16,246 +14,35 @@ export class AdminPage extends BasePage { super(page); } - /** - * Navigate to a specific admin section by route id - */ - async navigateToSection(routeId: string): Promise { - await this.page.locator(`a[href*="/admin/${routeId}"]`).first().click(); - await this.page.waitForTimeout(500); - } - - /** - * Get the clusters section link (route: /admin/clusters) - */ - get clustersLink(): Locator { - return this.page.locator('a[href*="/admin/clusters"]').first(); - } - - /** - * Get the about section link (route: /admin/about) - */ - get aboutLink(): Locator { - return this.page.locator('a[href*="/admin/about"]').first(); - } - - /** - * Get the database section link (route: /admin/database) - */ - get databaseLink(): Locator { - return this.page.locator('a[href*="/admin/database"]').first(); - } - - /** - * Get the interfaces section link (route: /admin/interfaces) - */ - get interfacesLink(): Locator { - return this.page.locator('a[href*="/admin/interfaces"]').first(); - } - - /** - * Get the brokers section link (route: /admin/brokers) - */ - get brokersLink(): Locator { - return this.page.locator('a[href*="/admin/brokers"]').first(); - } - - /** - * Get the edge section link (route: /admin/edge) - */ - get edgeLink(): Locator { - return this.page.locator('a[href*="/admin/edge"]').first(); - } - - /** - * Get the extensions section link (route: /admin/extensions) - */ - get extensionsLink(): Locator { - return this.page.locator('a[href*="/admin/extensions"]').first(); - } - - /** - * Get the staff API section link (route: /admin/staff-api) - */ - get staffApiLink(): Locator { - return this.page.locator('a[href*="/admin/staff-api"]').first(); - } - - /** - * Get the API keys section link (route: /admin/api-keys) - */ - get apiKeysLink(): Locator { - return this.page.locator('a[href*="/admin/api-keys"]').first(); - } - - /** - * Get the upload storage section link (route: /admin/upload-storage) - */ - get uploadStorageLink(): Locator { - return this.page.locator('a[href*="/admin/upload-storage"]').first(); - } - - /** - * Get the upload library section link (route: /admin/upload-library) - */ - get uploadLibraryLink(): Locator { - return this.page.locator('a[href*="/admin/upload-library"]').first(); - } - - /** - * Get the build jobs section link (route: /admin/build-jobs) - */ - get buildJobsLink(): Locator { - return this.page.locator('a[href*="/admin/build-jobs"]').first(); - } - - /** - * Get the schemas section link (route: /admin/schemas) - */ - get schemasLink(): Locator { - return this.page.locator('a[href*="/admin/schemas"]').first(); - } - - /** - * Override mainContent for admin section. - * Admin pages use router-outlet within a content div, not item-details. - * The content is inside app-engine > div > div > div (second child) - */ - get mainContent(): Locator { - // Look for admin-specific content components - return this.page - .locator( - 'app-engine router-outlet + *, app-engine .flex-1.overflow-auto > *', - ) - .first(); - } - - /** - * Wait for admin page content to load - */ - async waitForAdminContent(): Promise { - // Wait for the admin navigation to be visible (the sidebar with links) - await this.page.waitForSelector('a[href*="/admin/about"]', { - timeout: 10000, - }); - } - - // Cluster Management - - /** - * View cluster status - */ - async viewClusters(): Promise { - await this.clustersLink.click(); - await this.page.waitForTimeout(500); - } - - /** - * Get cluster nodes list - */ - get clusterNodes(): Locator { - return this.mainContent.locator( - '[class*="node"], [class*="cluster-item"]', - ); + /** Open an admin section directly by its route id */ + async gotoSection(id: string): Promise { + await this.page.goto(buildAppUrl(`/#/admin/${id}`)); + await this.waitForLoad(); } - // System Information - - /** - * View system information - */ - async viewAbout(): Promise { - await this.aboutLink.click(); - await this.page.waitForTimeout(500); + /** Link to an admin section in the admin menu */ + sectionLink(id: string): Locator { + return this.page.locator(`a[href="#/admin/${id}"]`); } - // Database Management - - /** - * View database details - */ - async viewDatabase(): Promise { - await this.databaseLink.click(); - await this.page.waitForTimeout(500); + /** Content of the open admin section */ + get section(): Locator { + return this.page.locator('app-engine router-outlet + *'); } - /** - * Get the zone tree export button - */ get exportZoneTreeButton(): Locator { - return this.page.locator('button:has-text("Export Zone Tree")').first(); + return this.page.getByRole('button', { name: 'Export Zone Tree' }); } - /** - * Get the zone tree import button - */ get importZoneTreeButton(): Locator { - return this.page.locator('button:has-text("Import Zone Tree")').first(); + return this.page.getByRole('button', { name: 'Import Zone Tree' }); } - /** - * Get the zone tree file input - */ get zoneTreeImportInput(): Locator { return this.page.locator('input[type="file"][accept*=".csv"]').first(); } - /** - * Open the zone tree export modal - */ - async openZoneTreeExportModal(): Promise { - await this.exportZoneTreeButton.click(); - await this.dialog.waitFor({ timeout: 5000 }); - } - - /** - * Get version info - */ - get versionInfo(): Locator { - return this.mainContent - .locator('[class*="version"], :text("Version") ~ *') - .first(); - } - - // API Keys Management - - /** - * View API keys - */ - async viewApiKeys(): Promise { - await this.apiKeysLink.click(); - await this.page.waitForTimeout(500); - } - - /** - * Generate new API key - */ - async generateApiKey(data: { - name: string; - domain?: string; - }): Promise { - await this.viewApiKeys(); - await this.addButton.click(); - await this.dialog.waitFor({ timeout: 5000 }); - - await this.fillField('Name', data.name); - - if (data.domain) { - await this.selectDropdown('Domain', data.domain); - } - - await this.save(); - } - - /** - * Get API keys list - */ - get apiKeysList(): Locator { - return this.mainContent.locator('[class*="api-key"], table tr'); - } - - /** - * Get the API key add button - */ + /** Add button of the API keys section */ get apiKeyAddButton(): Locator { return this.page .locator('admin-api-keys button:has(icon:has-text("add"))') @@ -270,224 +57,52 @@ export class AdminPage extends BasePage { .locator('admin-api-keys mat-select[name="type"]') .click(); await this.page - .locator(`mat-option:has-text("${domain}")`) + .locator('mat-option') + .filter({ hasText: domain }) .first() .click(); - await this.page.waitForTimeout(500); } - /** - * Get the API key user search input in the modal - */ + /** User search input of the API key modal */ get apiKeyUserSearchInput(): Locator { return this.dialog.locator('input#user').first(); } - /** - * Get the API key permissions dropdown in the modal - */ + /** Permissions select of the API key modal */ get apiKeyPermissionsSelect(): Locator { return this.dialog .locator('[data-testid="api-key-permissions"]') .first(); } - /** - * Get a user option from the API key user autocomplete panel - */ + /** User option in the API key user autocomplete panel */ apiKeyUserOption(name: string): Locator { - return this.page.locator(`mat-option:has-text("${name}")`).first(); + return this.page + .locator('mat-option') + .filter({ hasText: name }) + .first(); } /** * Search for a user while editing an API key */ async searchApiKeyUser(query: string): Promise { - await this.apiKeyUserSearchInput.click(); await this.apiKeyUserSearchInput.fill(query); - await this.dialog - .locator('.animate-spin') - .waitFor({ state: 'hidden', timeout: 10000 }) - .catch(() => undefined); + await expect(this.dialog.locator('.animate-spin')).toBeHidden(); await this.apiKeyUserSearchInput.press('ArrowDown'); - await this.apiKeyUserOption(query).waitFor({ - state: 'visible', - timeout: 10000, - }); + await expect(this.apiKeyUserOption(query)).toBeVisible(); } /** - * Select API key permissions + * Select API key permissions, e.g. `admin` */ async selectApiKeyPermissions(permission: string): Promise { - const permission_label = - permission.charAt(0).toUpperCase() + permission.slice(1); + const label = permission.charAt(0).toUpperCase() + permission.slice(1); await this.apiKeyPermissionsSelect.click(); await this.page - .locator(`mat-option:has-text("${permission_label}")`) + .locator('mat-option') + .filter({ hasText: label }) .first() .click(); - await this.page.waitForTimeout(300); - } - - // Extensions Management - - /** - * View extensions - */ - async viewExtensions(): Promise { - await this.extensionsLink.click(); - await this.page.waitForTimeout(500); - } - - /** - * Create extension - */ - async createExtension(data: { - name: string; - url: string; - section?: string; - }): Promise { - await this.viewExtensions(); - await this.addButton.click(); - await this.dialog.waitFor({ timeout: 5000 }); - - await this.fillField('Name', data.name); - await this.fillField('URL', data.url); - - if (data.section) { - await this.selectDropdown('Section', data.section); - } - - await this.save(); - } - - /** - * Get extensions list - */ - get extensionsList(): Locator { - return this.mainContent.locator('[class*="extension"], table tr'); - } - - // Tenants Management - - /** - * View tenants - */ - async viewTenants(): Promise { - await this.staffApiLink.click(); - await this.page.waitForTimeout(500); - } - - /** - * Create tenant - */ - async createTenant(data: { name: string; domain?: string }): Promise { - await this.viewTenants(); - await this.addButton.click(); - await this.dialog.waitFor({ timeout: 5000 }); - - await this.fillField('Name', data.name); - - if (data.domain) { - await this.selectDropdown('Domain', data.domain); - } - - await this.save(); - } - - /** - * Get tenants list - */ - get tenantsList(): Locator { - return this.mainContent.locator('[class*="tenant"], table tr'); - } - - // Upload Storage Management - - /** - * View upload storage providers - */ - async viewUploadStorage(): Promise { - await this.uploadStorageLink.click(); - await this.page.waitForTimeout(500); - } - - /** - * Add storage provider - */ - async addStorageProvider(data: { - name: string; - type: 'AWS S3' | 'Azure' | 'GCS'; - bucket?: string; - region?: string; - }): Promise { - await this.viewUploadStorage(); - await this.addButton.click(); - await this.dialog.waitFor({ timeout: 5000 }); - - await this.fillField('Name', data.name); - await this.selectDropdown('Type', data.type); - - if (data.bucket) { - await this.fillField('Bucket', data.bucket); - } - - if (data.region) { - await this.fillField('Region', data.region); - } - - await this.save(); - } - - // Build Jobs - - /** - * View build jobs - */ - async viewBuildJobs(): Promise { - await this.buildJobsLink.click(); - await this.page.waitForTimeout(500); - } - - /** - * Get build jobs list - */ - get buildJobsList(): Locator { - return this.mainContent.locator( - '[class*="job"], [class*="build"], table tr', - ); - } - - // Assertions - - /** - * Expect cluster nodes to be visible - */ - async expectClusterNodes(): Promise { - await this.viewClusters(); - await expect(this.clusterNodes.first()).toBeVisible({ timeout: 10000 }); - } - - /** - * Expect version info to be visible - */ - async expectVersionInfo(): Promise { - await this.viewAbout(); - await expect(this.versionInfo).toBeVisible({ timeout: 10000 }); - } - - /** - * Expect API key in list - */ - async expectApiKeyInList(name: string): Promise { - await expect(this.mainContent.locator(`text=${name}`)).toBeVisible(); - } - - /** - * Expect extension in list - */ - async expectExtensionInList(name: string): Promise { - await expect(this.mainContent.locator(`text=${name}`)).toBeVisible(); } } diff --git a/e2e/src/pages/base.page.ts b/e2e/src/pages/base.page.ts index 5fd963451..6e97e00d1 100644 --- a/e2e/src/pages/base.page.ts +++ b/e2e/src/pages/base.page.ts @@ -2,7 +2,9 @@ import { Locator, Page, expect } from '@playwright/test'; import { buildAppUrl } from '../config/test-env'; /** - * Base Page Object for common functionality across all pages + * Base Page Object for the list/detail layout every section shares: + * a searchable item list (`item-sidebar`), the selected item's header + * (`item-details`) with an action menu, and its tabs (`item-tablist`). */ export abstract class BasePage { readonly page: Page; @@ -12,6 +14,11 @@ export abstract class BasePage { this.page = page; } + /** Route segment of the feature, e.g. `zones` for `/#/zones` */ + get feature(): string { + return this.path.replace(/^\/#\//, '').split('/')[0]; + } + /** * Navigate to this page */ @@ -21,60 +28,57 @@ export abstract class BasePage { } /** - * Wait for the page to load + * Open an item directly on one of its tabs */ - async waitForLoad(): Promise { - // Wait for loader to disappear - await this.page - .waitForSelector('[loader]', { state: 'detached', timeout: 30000 }) - .catch(() => undefined); - - // Wait for router outlet to be attached (it's an empty element so won't be "visible") - await this.page.waitForSelector('router-outlet', { - state: 'attached', - timeout: 30000, - }); - - // Wait for main content to be visible (sidebar-menu or item-sidebar) - await this.page - .waitForSelector('sidebar-menu, item-sidebar', { timeout: 30000 }) - .catch(() => undefined); + async gotoItem(id: string, tab = 'about'): Promise { + await this.page.goto(buildAppUrl(`/#/${this.feature}/${id}/${tab}`)); + await this.waitForLoad(); + await this.waitForItemDetails(); } /** - * Get the sidebar search input + * Wait for the app shell to render */ - get searchInput(): Locator { - return this.page - .locator( - 'item-sidebar input[placeholder*="earch"], input[placeholder*="earch"]', - ) - .first(); + async waitForLoad(): Promise { + await this.page.locator('sidebar-menu').waitFor({ timeout: 30000 }); } /** - * Get the sidebar list container (visible on current viewport) + * Wait for the selected item's header to render */ - get sidebarList(): Locator { - // sidebar-menu is always visible, item-sidebar is desktop only - return this.page.locator('sidebar-menu, item-sidebar').first(); + async waitForItemDetails(): Promise { + await this.itemName.waitFor(); } - /** - * Get sidebar items (works for both desktop and mobile views) - */ + // Item list + + /** Search field of the item list */ + get searchInput(): Locator { + return this.page.locator('item-sidebar').getByPlaceholder(/^Search/); + } + + /** Rendered entries of the item list. It is virtual, so long lists are cut */ get sidebarItems(): Locator { - // Desktop: item-sidebar contains the list with virtual-scroll - return this.page.locator( - 'item-sidebar a, virtual-scroll a, scroll-item a', - ); + return this.page.locator('item-sidebar a'); } - /** - * Get the add/new button (floating button at bottom left) - */ + /** Entries of the item list whose text contains `name` */ + itemLink(name: string): Locator { + return this.sidebarItems.filter({ hasText: name }); + } + + async search(query: string): Promise { + // Entering a section clears the search, so wait for the first list + await this.sidebarItems.first().waitFor(); + await this.searchInput.fill(query); + } + + async clearSearch(): Promise { + await this.searchInput.clear(); + } + + /** Floating button that opens the create modal */ get addButton(): Locator { - // The add button is a floating button with a + icon (icon > div > i contains "add") return this.page .locator( 'button:has(icon i:text("add")), button:has(icon:has-text("add"))', @@ -82,192 +86,152 @@ export abstract class BasePage { .first(); } - /** - * Get the action menu button (more_vert icon in item-details) - */ - get actionMenuButton(): Locator { - return this.page - .locator('item-details button:has(icon:has-text("more_vert"))') - .first(); + // Selected item + + /** Header of the selected item */ + get itemDetails(): Locator { + return this.page.locator('item-details').first(); + } + + /** Name in the header of the selected item */ + get itemName(): Locator { + return this.page.locator('item-details [name]').first(); + } + + /** Content of the active item tab */ + get tabContent(): Locator { + return this.page.locator('item-tablist ~ div').first(); + } + + /** Spinner of the metadata tab */ + get metadataLoadingSpinner(): Locator { + return this.page.locator('metadata-display mat-spinner').first(); } /** - * Open the action menu + * Open a tab of the selected item by its route id and wait for the route */ + async openTab(id: string): Promise { + const tab = this.page.locator(`item-tablist a[href*="/${id}"]`).first(); + // A click on a tab that is partly out of view only scrolls the tab + // bar, so focus it first to bring it fully into view + await tab.focus(); + await expect(tab).toBeInViewport({ ratio: 0.9 }); + await tab.click(); + await expect(this.page).toHaveURL(new RegExp(`/${id}(\\?.*)?$`)); + } + + /** Open the action menu in the header of the selected item */ async openActionMenu(): Promise { - await this.actionMenuButton.click(); - // Angular Material MDC menus are in CDK overlay with various class names - await this.page.waitForSelector( - '.mat-mdc-menu-panel, .cdk-overlay-pane:has(button[mat-menu-item])', - { timeout: 5000 }, - ); + await this.page + .locator('item-details button:has(icon:has-text("more_vert"))') + .first() + .click(); + await expect(this.page.locator('.mat-mdc-menu-panel')).toBeVisible(); } - /** - * Get the edit button (inside action menu) - */ + /** Edit entry of the action menu */ get editButton(): Locator { return this.page .locator('button[mat-menu-item]:has(icon:has-text("edit"))') .first(); } - /** - * Get the delete button (inside action menu) - */ + /** Delete entry of the action menu */ get deleteButton(): Locator { return this.page .locator('button[mat-menu-item]:has(icon:has-text("delete"))') .first(); } - /** - * Get the bulk add button - */ - get bulkAddButton(): Locator { - return this.page - .locator('button:has(icon:has-text("playlist_add"))') - .first(); - } + // Create and edit modals - /** - * Get the dialog/modal container (fullscreen-modal-shell or mat-dialog) - */ + /** The open modal */ get dialog(): Locator { return this.page - .locator( - 'fullscreen-modal-shell, mat-dialog-container, [mat-dialog-container]', - ) + .locator('fullscreen-modal-shell, mat-dialog-container') .first(); } - /** - * Get the save button in dialog (footer button) - */ + /** Save button in the footer of the open modal */ get saveButton(): Locator { - return this.page - .locator( - 'fullscreen-modal-shell footer button[btn], mat-dialog-container button:has-text("Save"), mat-dialog-container button:has-text("Create")', - ) - .first(); + return this.dialog.getByRole('button', { name: /^Save/ }); } - /** - * Get the cancel/close button in dialog - */ - get cancelButton(): Locator { - return this.page - .locator( - 'fullscreen-modal-shell button[mat-dialog-close], mat-dialog-container button:has-text("Cancel")', - ) - .first(); - } - - /** - * Get the confirm button in dialog (for delete confirmation etc.) - */ - get confirmButton(): Locator { - return this.page - .locator( - 'mat-dialog-container button:has-text("Ok"), mat-dialog-container button:has-text("Confirm"), mat-dialog-container button:has-text("Yes"), confirm-modal button:has-text("Ok")', - ) - .first(); - } - - /** - * Search for an item in the sidebar - */ - async search(query: string): Promise { - await this.searchInput.fill(query); - await this.page.waitForTimeout(500); // Wait for debounce + async openAddModal(): Promise { + await this.addButton.click(); + await expect(this.dialog).toBeVisible(); } - /** - * Clear the search - */ - async clearSearch(): Promise { - await this.searchInput.clear(); - await this.page.waitForTimeout(500); + async openEditModal(): Promise { + await this.openActionMenu(); + await this.editButton.click(); + await expect(this.dialog).toBeVisible(); } - /** - * Select an item from the sidebar by clicking on it - */ - async selectItem(name: string): Promise { - await this.page - .locator( - `item-sidebar a:has-text("${name}"), virtual-scroll a:has-text("${name}")`, - ) - .first() - .click(); - await this.page.waitForTimeout(500); + /** Close the open modal after an edit and confirm the changes are lost */ + async discardChanges(): Promise { + await this.dialog.getByRole('button', { name: 'Close' }).click(); + await expect( + this.page.getByRole('heading', { name: 'Discard changes?' }), + ).toBeVisible(); + await this.page.getByRole('button', { name: 'Discard' }).click(); + await expect(this.page.locator('mat-dialog-container')).toHaveCount(0); } /** - * Select the first item from the sidebar + * Open the create modal, fill it with `fill` and save it. + * Resolves with the id of the new item from the URL the app opens. */ - async selectFirstItem(): Promise { - await this.sidebarItems.first().click(); - await this.page.waitForTimeout(500); + async createWith(fill: () => Promise): Promise { + await this.openAddModal(); + await fill(); + await this.saveButton.click(); + await expect(this.dialog).toBeHidden({ timeout: 10000 }); + const url = new RegExp(`#/${this.feature}/([^/]+)/about`); + await expect(this.page).not.toHaveURL( + new RegExp(`#/${this.feature}/-/`), + ); + await expect(this.page).toHaveURL(url); + return decodeURIComponent(this.page.url().match(url)?.[1] || ''); } - /** - * Open the add/create modal - */ - async openAddModal(): Promise { - await this.addButton.click(); - await this.dialog.waitFor({ timeout: 5000 }); - } + // Delete confirmation - /** - * Open the edit modal (requires opening action menu first) - */ - async openEditModal(): Promise { - // Wait for item-details to be visible (it's only rendered when an item is selected) - await this.page.waitForSelector('item-details', { timeout: 10000 }); - await this.openActionMenu(); - await this.editButton.click(); - await this.dialog.waitFor({ timeout: 5000 }); + /** Accept button of the confirmation modal */ + get acceptButton(): Locator { + return this.page.locator('confirm-modal button[name="accept"]'); } - /** - * Get the "also delete associated resources" checkbox on the delete - * confirmation - */ + /** "Also delete associated resources" checkbox */ get cascadeCheckbox(): Locator { return this.page.locator( 'confirm-modal [confirm-option] input[type="checkbox"]', ); } - /** Get the resolved list of what the cascade would remove */ + /** Resolved list of what the cascade would remove */ get cascadeSummary(): Locator { return this.page.locator('confirm-modal [details-summary]'); } - /** Get the "nothing else to remove" message */ + /** "Nothing else to remove" message */ get cascadeEmpty(): Locator { return this.page.locator('confirm-modal [details-empty]'); } - /** Get the lines describing what the cascade will leave alone */ + /** Lines describing what the cascade will leave alone */ get cascadeWarnings(): Locator { return this.page.locator('confirm-modal [details-warning]'); } - /** Get the confirmation dialog's accept button */ - get acceptButton(): Locator { - return this.page.locator('confirm-modal button[name="accept"]'); - } - /** * Open the delete confirmation without confirming it */ async openDeleteConfirmation(): Promise { - await this.page.waitForSelector('item-details', { timeout: 10000 }); await this.openActionMenu(); await this.deleteButton.click(); - await this.page.waitForSelector('confirm-modal', { timeout: 5000 }); + await expect(this.page.locator('confirm-modal')).toBeVisible(); } /** @@ -275,156 +239,45 @@ export abstract class BasePage { */ async enableCascade(): Promise { await this.cascadeCheckbox.click(); - await this.page.waitForSelector( - 'confirm-modal [details-summary], confirm-modal [details-empty]', - { timeout: 20000 }, - ); - } - - /** - * Click delete and confirm (requires opening action menu first) - */ - async deleteItem(): Promise { - // Wait for item-details to be visible (it's only rendered when an item is selected) - await this.page.waitForSelector('item-details', { timeout: 10000 }); - await this.openActionMenu(); - await this.deleteButton.click(); - await this.page.waitForTimeout(300); // Wait for confirmation dialog - await this.confirmButton.click(); - await this.page.waitForTimeout(500); - } - - /** - * Save the current dialog - */ - async save(): Promise { - await this.saveButton.click(); - await this.page.waitForTimeout(1000); // Wait for save to complete - } - - /** - * Cancel/close the current dialog - */ - async cancel(): Promise { - await this.cancelButton.click(); - await this.page.waitForTimeout(500); - } - - /** - * Navigate to a tab (uses item-tablist) - */ - async navigateToTab(tabName: string): Promise { await this.page .locator( - `item-tablist a:has-text("${tabName}"), [role="tab"]:has-text("${tabName}")`, + 'confirm-modal [details-summary], confirm-modal [details-empty]', ) .first() - .click(); - await this.page.waitForTimeout(500); + .waitFor({ timeout: 20000 }); } /** - * Fill a form field by label + * Delete the selected item through the action menu and wait for the app + * to return to the empty list view */ - async fillField(label: string, value: string): Promise { - // Try multiple selectors for form fields - const field = this.page - .locator( - [ - `mat-form-field:has(mat-label:text("${label}")) input`, - `mat-form-field:has(mat-label:text("${label}")) textarea`, - `label:has-text("${label}") + input`, - `label:has-text("${label}") ~ input`, - `input[placeholder*="${label}" i]`, - `textarea[placeholder*="${label}" i]`, - ].join(', '), - ) - .first(); - await field.fill(value, { timeout: 15000 }); - } - - /** - * Select from a dropdown by label - */ - async selectDropdown(label: string, option: string): Promise { - const select = this.page - .locator( - `mat-form-field:has(mat-label:text("${label}")) mat-select, mat-select[placeholder*="${label}" i]`, - ) - .first(); - await select.click(); - await this.page - .locator(`mat-option:has-text("${option}")`) - .first() - .click(); - } - - /** - * Toggle a checkbox by label - */ - async toggleCheckbox(label: string): Promise { - await this.page - .locator( - `mat-checkbox:has-text("${label}"), mat-slide-toggle:has-text("${label}")`, - ) - .first() - .click(); + async deleteSelected(): Promise { + await this.openDeleteConfirmation(); + await this.acceptButton.click(); + await expect(this.page.locator('confirm-modal')).toHaveCount(0, { + timeout: 15000, + }); + await expect(this.page).toHaveURL(new RegExp(`#/${this.feature}/-`)); } - /** - * Check if snackbar notification appears - */ - async expectNotification(text: string): Promise { - await expect( - this.page.locator(`mat-snack-bar-container:has-text("${text}")`), - ).toBeVisible({ timeout: 5000 }); - } + // Shared widgets - /** - * Get the main content area (item-details or the content section). - * Angular components don't have default display styles, so we look for - * the actual visible content inside them. - */ - get mainContent(): Locator { - // Look for item-details (shown when an item is selected) or the main container div + /** Snack bar notification with `text`. An old one can still be closing. */ + notification(text: string): Locator { return this.page - .locator( - 'item-details, new-systems-view > div, new-zones-view > div, new-users-view > div, new-domains-view > div', - ) - .first(); + .locator('mat-snack-bar-container') + .filter({ hasText: text }); } /** - * Get the item details component + * Cells of the table row that contains `text`, in the active tab unless + * `scope` is given. Table cells only carry their row index, so the row is + * found by the index of the matching cell. */ - get itemDetails(): Locator { - return this.page.locator('item-details').first(); - } - - /** - * Get tabs container - */ - get tabList(): Locator { - return this.page.locator('item-tablist').first(); - } - - /** - * Check the page loaded without errors - */ - async expectNoErrors(): Promise { - const error = await this.page - .locator('.error-state, [class*="error"]:has-text("Error")') - .isVisible() - .catch(() => false); - expect(error).toBeFalsy(); - } - - /** - * Wait for item details to be loaded - */ - async waitForItemDetails(): Promise { - await this.page - .locator('item-details [name]') - .waitFor({ timeout: 10000 }); + async tableRow(text: string, scope = this.tabContent): Promise { + const cell = scope.locator('[data-row]').filter({ hasText: text }); + await expect(cell).toHaveCount(1); + const index = await cell.getAttribute('data-row'); + return scope.locator(`[data-row="${index}"]`); } } diff --git a/e2e/src/pages/domains.page.ts b/e2e/src/pages/domains.page.ts deleted file mode 100644 index f9d4d1541..000000000 --- a/e2e/src/pages/domains.page.ts +++ /dev/null @@ -1,274 +0,0 @@ -import { Locator, Page, expect } from '@playwright/test'; -import { BasePage } from './base.page'; - -/** - * Page Object for the Domains section - */ -export class DomainsPage extends BasePage { - readonly path = '/#/domains'; - - constructor(page: Page) { - super(page); - } - - /** - * Get the domains list items - */ - get domainsList(): Locator { - return this.sidebarItems; - } - - /** - * Get the applications tab - */ - get applicationsTab(): Locator { - return this.page - .locator( - 'item-tablist a:has-text("Application"), [role="tab"]:has-text("Application")', - ) - .first(); - } - - /** - * Get the users tab - */ - get usersTab(): Locator { - return this.page - .locator( - 'item-tablist a:has-text("Users"), [role="tab"]:has-text("Users")', - ) - .first(); - } - - /** - * Get the auth sources tab - */ - get authSourcesTab(): Locator { - return this.page - .locator( - 'item-tablist a:has-text("Auth"), item-tablist a:has-text("Authentication"), [role="tab"]:has-text("Auth")', - ) - .first(); - } - - /** - * Get the extensions tab - */ - get extensionsTab(): Locator { - return this.page - .locator( - 'item-tablist a:has-text("Extension"), [role="tab"]:has-text("Extension")', - ) - .first(); - } - - /** - * Get domain name display - */ - get domainName(): Locator { - return this.page.locator('item-details [name]').first(); - } - - /** - * Get user count display - */ - get userCount(): Locator { - return this.page - .locator('[class*="count"], :text("Users") ~ *') - .first(); - } - - /** - * Get the add OAuth button - */ - get addOAuthButton(): Locator { - return this.page - .locator('button:has-text("OAuth"), button:has-text("Add OAuth")') - .first(); - } - - /** - * Get the add SAML button - */ - get addSAMLButton(): Locator { - return this.page - .locator('button:has-text("SAML"), button:has-text("Add SAML")') - .first(); - } - - /** - * Get the add LDAP button - */ - get addLDAPButton(): Locator { - return this.page - .locator('button:has-text("LDAP"), button:has-text("Add LDAP")') - .first(); - } - - /** - * Create a new domain - */ - async createDomain(data: { - name: string; - domain: string; - description?: string; - login_url?: string; - logout_url?: string; - }): Promise { - await this.openAddModal(); - - await this.fillField('Name', data.name); - - // Domain Name field has specific placeholder - use that to find it - const domainField = this.page - .locator( - 'input[placeholder*="Domain e.g."], input[placeholder*="www.google.com"]', - ) - .first(); - await domainField.fill(data.domain); - - if (data.description) { - await this.fillField('Description', data.description); - } - - if (data.login_url) { - await this.fillField('Login', data.login_url); - } - - if (data.logout_url) { - await this.fillField('Logout', data.logout_url); - } - - await this.save(); - } - - /** - * Edit the current domain - */ - async editDomain(data: { - name?: string; - domain?: string; - description?: string; - }): Promise { - await this.openEditModal(); - - if (data.name) { - await this.fillField('Name', data.name); - } - - if (data.domain) { - await this.fillField('Domain', data.domain); - } - - if (data.description) { - await this.fillField('Description', data.description); - } - - await this.save(); - } - - /** - * View OAuth applications - */ - async viewApplications(): Promise { - await this.applicationsTab.click(); - await this.page.waitForTimeout(500); - } - - /** - * View domain users - */ - async viewUsers(): Promise { - await this.usersTab.click(); - await this.page.waitForTimeout(500); - } - - /** - * View authentication sources - */ - async viewAuthSources(): Promise { - await this.authSourcesTab.click(); - await this.page.waitForTimeout(500); - } - - /** - * View extensions - */ - async viewExtensions(): Promise { - await this.extensionsTab.click(); - await this.page.waitForTimeout(500); - } - - /** - * Add an OAuth application - */ - async addOAuthApplication(data: { - name: string; - redirect_uri: string; - scopes?: string[]; - }): Promise { - await this.viewApplications(); - await this.page.locator('button:has-text("Add")').first().click(); - await this.dialog.waitFor({ timeout: 5000 }); - - await this.fillField('Name', data.name); - await this.fillField('Redirect', data.redirect_uri); - - await this.save(); - } - - /** - * Add an OAuth authentication source - */ - async addOAuthSource(data: { - name: string; - client_id: string; - client_secret: string; - site?: string; - }): Promise { - await this.viewAuthSources(); - await this.addOAuthButton.click(); - await this.dialog.waitFor({ timeout: 5000 }); - - await this.fillField('Name', data.name); - await this.fillField('Client ID', data.client_id); - await this.fillField('Secret', data.client_secret); - - if (data.site) { - await this.fillField('Site', data.site); - } - - await this.save(); - } - - /** - * Assert domain details are visible - */ - async expectDomainDetails(name: string): Promise { - await expect( - this.page - .locator( - `item-details [name]:has-text("${name}"), item-details:has-text("${name}")`, - ) - .first(), - ).toBeVisible({ timeout: 10000 }); - } - - /** - * Assert domain is in the list - */ - async expectDomainInList(name: string): Promise { - await expect( - this.domainsList.filter({ hasText: name }).first(), - ).toBeVisible({ timeout: 10000 }); - } - - /** - * Assert domain is not in the list - */ - async expectDomainNotInList(name: string): Promise { - await expect( - this.domainsList.filter({ hasText: name }).first(), - ).not.toBeVisible({ timeout: 5000 }); - } -} diff --git a/e2e/src/pages/feature.page.ts b/e2e/src/pages/feature.page.ts new file mode 100644 index 000000000..872df757c --- /dev/null +++ b/e2e/src/pages/feature.page.ts @@ -0,0 +1,15 @@ +import { Page } from '@playwright/test'; +import { BasePage } from './base.page'; + +/** + * Page Object for a list/detail section that needs no extra helpers, + * e.g. `new FeaturePage(page, 'triggers')` + */ +export class FeaturePage extends BasePage { + readonly path: string; + + constructor(page: Page, feature: string) { + super(page); + this.path = `/#/${feature}`; + } +} diff --git a/e2e/src/pages/index.ts b/e2e/src/pages/index.ts index bbd5d8b1c..aa99d9766 100644 --- a/e2e/src/pages/index.ts +++ b/e2e/src/pages/index.ts @@ -5,11 +5,7 @@ export { AdminPage } from './admin.page'; export { BasePage } from './base.page'; -export { DomainsPage } from './domains.page'; +export { FeaturePage } from './feature.page'; +export { MobilePage } from './mobile.page'; export { SystemsPage } from './systems.page'; export { UsersPage } from './users.page'; -export { ZonesPage } from './zones.page'; - -// Mobile page objects -export { MobileBasePage } from './mobile-base.page'; -export { MobileSystemsPage } from './mobile-systems.page'; diff --git a/e2e/src/pages/mobile-base.page.ts b/e2e/src/pages/mobile-base.page.ts deleted file mode 100644 index 21e051007..000000000 --- a/e2e/src/pages/mobile-base.page.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { Locator, Page, expect } from '@playwright/test'; -import { BasePage } from './base.page'; - -/** - * Mobile Base Page Object - * - * Extends BasePage with mobile-specific interactions. - * On mobile: - * - sidebar-menu is hidden and accessed via hamburger menu - * - item-sidebar is hidden, items are selected via item-selection overlay - * - item-selection component provides search/select functionality - */ -export abstract class MobileBasePage extends BasePage { - constructor(page: Page) { - super(page); - } - - /** - * Get the hamburger menu button (visible only on mobile) - */ - get hamburgerMenuButton(): Locator { - return this.page.locator('button:has(icon:has-text("menu"))').first(); - } - - /** - * Get the sidebar menu overlay - */ - get sidebarMenuOverlay(): Locator { - return this.page.locator('sidebar-menu [sidebar-menu]'); - } - - /** - * Get the close button in sidebar menu - */ - get sidebarCloseButton(): Locator { - return this.page.locator( - 'sidebar-menu button:has(icon:has-text("close"))', - ); - } - - /** - * Get the item selection component (mobile search/select overlay) - */ - get itemSelectionComponent(): Locator { - return this.page.locator('item-selection'); - } - - /** - * Get the item selection trigger button - */ - get itemSelectionTrigger(): Locator { - return this.page.locator('item-selection > div > button').first(); - } - - /** - * Get the item selection search input - */ - get itemSelectionSearchInput(): Locator { - return this.page.locator('item-selection input'); - } - - /** - * Get the item selection overlay/modal - */ - get itemSelectionOverlay(): Locator { - return this.page.locator('item-selection > div.absolute.left-1\\/2'); - } - - /** - * Get items in the item selection list - */ - get itemSelectionItems(): Locator { - return this.page.locator('item-selection virtual-scroll a'); - } - - /** - * Open the hamburger menu (sidebar) - */ - async openHamburgerMenu(): Promise { - await this.hamburgerMenuButton.click(); - // Wait for sidebar to be visible - await this.page.waitForSelector('sidebar-menu [sidebar-menu].\\!flex', { - timeout: 5000, - }); - } - - /** - * Close the hamburger menu - */ - async closeHamburgerMenu(): Promise { - await this.sidebarCloseButton.click(); - await this.page.waitForTimeout(300); - } - - /** - * Navigate to a feature via the hamburger menu - */ - async navigateViaMenu(linkText: string): Promise { - await this.openHamburgerMenu(); - await this.page - .locator(`sidebar-menu a[btn]:has-text("${linkText}")`) - .click(); - await this.waitForLoad(); - } - - /** - * Open the item selection overlay - */ - async openItemSelection(): Promise { - await this.itemSelectionTrigger.click(); - await this.page.waitForSelector('item-selection input', { - timeout: 5000, - }); - } - - /** - * Search for an item in the mobile item selection - */ - async searchMobile(query: string): Promise { - // The selection overlay might already be open, or we need to open it - const input = this.itemSelectionSearchInput; - if (!(await input.isVisible())) { - await this.openItemSelection(); - } - await input.fill(query); - await this.page.waitForTimeout(500); // Wait for debounce - } - - /** - * Select an item from the mobile item selection list - */ - async selectItemMobile(name: string): Promise { - // Ensure selection overlay is open - const input = this.itemSelectionSearchInput; - if (!(await input.isVisible())) { - await this.openItemSelection(); - } - - // Wait for items to load - await this.page.waitForSelector( - 'item-selection virtual-scroll a', - { timeout: 10000 }, - ); - - // Click on the item - await this.page - .locator(`item-selection virtual-scroll a:has-text("${name}")`) - .first() - .click(); - await this.page.waitForTimeout(500); - } - - /** - * Select the first item from the mobile item selection list - */ - async selectFirstItemMobile(): Promise { - // Ensure selection overlay is open - const input = this.itemSelectionSearchInput; - if (!(await input.isVisible())) { - await this.openItemSelection(); - } - - // Wait for items to load - await this.page.waitForSelector( - 'item-selection virtual-scroll a', - { timeout: 10000 }, - ); - - // Click on the first item - await this.itemSelectionItems.first().click(); - await this.page.waitForTimeout(500); - } - - /** - * Close the item selection overlay by pressing Escape - */ - async closeItemSelection(): Promise { - await this.page.keyboard.press('Escape'); - await this.page.waitForTimeout(300); - } - - /** - * Wait for the mobile page to load - */ - override async waitForLoad(): Promise { - // Wait for loader to disappear - await this.page - .waitForSelector('[loader]', { state: 'detached', timeout: 30000 }) - .catch(() => undefined); - - // Wait for router outlet - await this.page.waitForSelector('router-outlet', { - state: 'attached', - timeout: 30000, - }); - - // On mobile, wait for item-selection component - await this.page - .waitForSelector('item-selection', { timeout: 30000 }) - .catch(() => undefined); - - // Wait for any progress bars to complete - await this.page.waitForTimeout(500); - } - - /** - * Verify item selection list is visible - */ - async expectItemSelectionVisible(): Promise { - await expect(this.itemSelectionOverlay).toBeVisible(); - } - - /** - * Verify item selection has items - */ - async expectItemSelectionHasItems(): Promise { - await expect(this.itemSelectionItems.first()).toBeVisible({ - timeout: 10000, - }); - } - - /** - * Get count of items in item selection - */ - async getItemSelectionCount(): Promise { - return await this.itemSelectionItems.count(); - } -} diff --git a/e2e/src/pages/mobile-systems.page.ts b/e2e/src/pages/mobile-systems.page.ts deleted file mode 100644 index 3689eee0d..000000000 --- a/e2e/src/pages/mobile-systems.page.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { Locator, Page, expect } from '@playwright/test'; -import { MobileBasePage } from './mobile-base.page'; - -/** - * Mobile Systems Page Object - * - * Provides mobile-specific interactions for the Systems feature. - */ -export class MobileSystemsPage extends MobileBasePage { - readonly path = '/#/systems'; - - constructor(page: Page) { - super(page); - } - - /** - * Get the systems list in item selection - */ - get systemsList(): Locator { - return this.itemSelectionItems; - } - - /** - * Create a new system (opens modal) - */ - async createSystem(data: { - name: string; - description?: string; - }): Promise { - await this.openAddModal(); - await this.fillField('Name', data.name); - if (data.description) { - await this.fillField('Description', data.description); - } - await this.save(); - } - - /** - * Verify a system appears in the item selection list - */ - async expectSystemInList(name: string): Promise { - await this.openItemSelection(); - await expect( - this.page - .locator(`item-selection virtual-scroll a:has-text("${name}")`) - .first(), - ).toBeVisible({ timeout: 10000 }); - } - - /** - * Navigate to a specific tab in system details - */ - async viewModules(): Promise { - await this.navigateToTab('Modules'); - } - - async viewZones(): Promise { - await this.navigateToTab('Zones'); - } - - async viewTriggers(): Promise { - await this.navigateToTab('Triggers'); - } - - async viewMetadata(): Promise { - await this.navigateToTab('Metadata'); - } - - async viewHistory(): Promise { - await this.navigateToTab('History'); - } - - /** - * Get start button - */ - get startButton(): Locator { - return this.page - .locator( - 'button:has-text("Start"), button:has(icon:has-text("play_arrow"))', - ) - .first(); - } - - /** - * Get stop button - */ - get stopButton(): Locator { - return this.page - .locator( - 'button:has-text("Stop"), button:has(icon:has-text("stop"))', - ) - .first(); - } -} diff --git a/e2e/src/pages/mobile.page.ts b/e2e/src/pages/mobile.page.ts new file mode 100644 index 000000000..6ba1e7aef --- /dev/null +++ b/e2e/src/pages/mobile.page.ts @@ -0,0 +1,79 @@ +import { Locator, Page, expect } from '@playwright/test'; +import { BasePage } from './base.page'; + +/** + * Page Object for a list/detail section on a phone sized screen, + * e.g. `new MobilePage(page, 'systems')` + * + * On mobile: + * - the section menu (`sidebar-menu`) opens from a hamburger button + * - the item list (`item-sidebar`) is hidden. Items are picked from the + * `item-selection` overlay, which opens by itself when nothing is selected + */ +export class MobilePage extends BasePage { + readonly path: string; + + constructor(page: Page, feature: string) { + super(page); + this.path = `/#/${feature}`; + } + + /** Button that opens the section menu */ + get menuButton(): Locator { + return this.page.locator('button:has(icon:has-text("menu"))').first(); + } + + /** Section menu panel */ + get menu(): Locator { + return this.page.locator('sidebar-menu [sidebar-menu]'); + } + + /** Link in the section menu, e.g. `Zones` */ + menuLink(name: string): Locator { + return this.page + .locator('sidebar-menu a[btn]') + .filter({ hasText: name }); + } + + async openMenu(): Promise { + await this.menuButton.click(); + await expect(this.menu).toHaveClass(/!flex/); + } + + async closeMenu(): Promise { + await this.page + .locator('sidebar-menu button:has(icon:has-text("close"))') + .click(); + await expect(this.menu).not.toHaveClass(/!flex/); + } + + /** Search field of the item picker overlay */ + get pickerInput(): Locator { + return this.page.locator('item-selection input'); + } + + /** Items of the item picker overlay whose text contains `name` */ + pickerItem(name: string): Locator { + return this.page + .locator('item-selection virtual-scroll a') + .filter({ hasText: name }); + } + + /** Re-open the item picker after an item was picked */ + async openPicker(): Promise { + await this.page.locator('item-selection button.rounded-lg').click(); + await expect(this.pickerInput).toBeVisible(); + } + + /** Search for an item in the picker and pick it */ + async pick(name: string): Promise { + if (!(await this.pickerInput.isVisible())) await this.openPicker(); + await this.pickerInput.fill(name); + await this.pickerItem(name).first().click(); + await expect(this.itemName).toHaveText(name); + } + + override async waitForLoad(): Promise { + await this.page.waitForSelector('item-selection', { timeout: 30000 }); + } +} diff --git a/e2e/src/pages/systems.page.ts b/e2e/src/pages/systems.page.ts index 8c68b99a2..9b74ce21f 100644 --- a/e2e/src/pages/systems.page.ts +++ b/e2e/src/pages/systems.page.ts @@ -11,91 +11,19 @@ export class SystemsPage extends BasePage { super(page); } - /** - * Get the systems list items - */ - get systemsList(): Locator { - return this.sidebarItems; - } - - /** - * Get the modules tab - */ - get modulesTab(): Locator { - return this.page - .locator( - 'item-tablist a:has-text("Modules"), [role="tab"]:has-text("Modules")', - ) - .first(); - } - - /** - * Get the zones tab - */ - get zonesTab(): Locator { - return this.page - .locator( - 'item-tablist a:has-text("Zones"), [role="tab"]:has-text("Zones")', - ) - .first(); - } - - /** - * Get the triggers tab - */ - get triggersTab(): Locator { - return this.page - .locator( - 'item-tablist a:has-text("Triggers"), [role="tab"]:has-text("Triggers")', - ) - .first(); - } - - /** - * Get the metadata tab - */ - get metadataTab(): Locator { - return this.page - .locator( - 'item-tablist a[href*="/metadata"]:has-text("Metadata"), a[role="tab"][href*="/metadata"]:has-text("Metadata")', - ) - .first(); - } - - /** - * Get the history tab - */ - get historyTab(): Locator { - return this.page - .locator( - 'item-tablist a:has-text("History"), [role="tab"]:has-text("History")', - ) - .first(); - } - - /** - * Get the start button - */ + /** Start control on the about tab */ get startButton(): Locator { - return this.page.locator('button:has-text("Start")').first(); + return this.page.locator('button[start]').first(); } - /** - * Get the stop button - */ + /** Stop control on the about tab */ get stopButton(): Locator { - return this.page.locator('button:has-text("Stop")').first(); + return this.page.locator('button[stop]').first(); } - /** - * Get the add module button - */ - get addModuleButton(): Locator { - return this.page - .locator( - 'button:has-text("Add Module"), button[mattooltip*="module"]', - ) - .first(); + /** Name field of the open create/edit modal */ + get nameField(): Locator { + return this.dialog.getByPlaceholder('System Name'); } /** @@ -113,225 +41,26 @@ export class SystemsPage extends BasePage { } /** - * Get metadata list loading spinner + * Pick a zone in the zone field of the open create/edit modal */ - get metadataLoadingSpinner(): Locator { - return this.page.locator('metadata-display mat-spinner').first(); - } - - /** - * Create a new system - */ - async createSystem(data: { - name: string; - display_name?: string; - description?: string; - zone?: string; - bookable?: boolean; - email?: string; - }): Promise { - await this.openAddModal(); - - // Zone is required - select from autocomplete combobox - if (data.zone) { - await this.selectZone(data.zone); - } else { - // Select first available zone if not specified - await this.selectFirstZone(); - } - - // Fill required fields - await this.fillField('Name', data.name); - - if (data.display_name) { - await this.fillField('Display', data.display_name); - } - - if (data.description) { - await this.fillField('Description', data.description); - } - - if (data.email) { - await this.fillField('Email', data.email); - } - - if (data.bookable) { - await this.toggleCheckbox('Bookable'); - } - - await this.save(); - } - - /** - * Select a zone from the autocomplete combobox - */ - async selectZone(zoneName: string): Promise { - const zoneInput = this.dialog + async selectZone(zone_name: string): Promise { + const zone_input = this.dialog .locator('item-search-field input[name="item-search"]') .first(); - await zoneInput.click(); - await zoneInput.fill(zoneName); - await this.page.waitForTimeout(500); - // Click the matching option + await zone_input.fill(zone_name); await this.page - .locator(`mat-option:has-text("${zoneName}")`) + .locator('mat-option') + .filter({ hasText: zone_name }) .first() .click(); } - /** - * Select the first available zone from the dropdown - */ - async selectFirstZone(): Promise { - const zoneInput = this.dialog - .locator('item-search-field input[name="item-search"]') - .first(); - await zoneInput.click(); - await this.page.waitForTimeout(500); - // Click the first option - await this.page.locator('mat-option').first().click(); - } - - /** - * Edit the current system - */ - async editSystem(data: { - name?: string; - display_name?: string; - description?: string; - bookable?: boolean; - email?: string; - }): Promise { - await this.openEditModal(); - - if (data.name) { - await this.fillField('Name', data.name); - } - - if (data.display_name) { - await this.fillField('Display', data.display_name); - } - - if (data.description) { - await this.fillField('Description', data.description); - } - - if (data.email) { - await this.fillField('Email', data.email); - } - - if (data.bookable !== undefined) { - await this.toggleCheckbox('Bookable'); - } - - await this.save(); - } - - /** - * View system modules - */ - async viewModules(): Promise { - await this.modulesTab.click(); - await this.page.waitForTimeout(500); - } - - /** - * View system zones - */ - async viewZones(): Promise { - await this.zonesTab.click(); - await this.page.waitForTimeout(500); - } - - /** - * View system triggers - */ - async viewTriggers(): Promise { - await this.triggersTab.click(); - await this.page.waitForTimeout(500); - } - - /** - * View system metadata - */ - async viewMetadata(): Promise { - const url = new URL(this.page.url()); - const match = url.hash.match(/^#\/systems\/([^/]+)/); - if (match?.[1]) { - url.hash = `#/systems/${match[1]}/metadata`; - await this.page.goto(url.toString()); - await this.waitForLoad(); - } else { - await this.metadataTab.click(); - await this.page.waitForTimeout(500); - } - } - /** * Open the execute module selector and filter available modules */ async searchExecuteModules(query: string): Promise { - await expect(this.executeModuleSelect).toBeVisible({ timeout: 10000 }); await this.executeModuleSelect.click(); - await expect(this.executeModuleSearchInput).toBeVisible({ - timeout: 5000, - }); + await expect(this.executeModuleSearchInput).toBeVisible(); await this.executeModuleSearchInput.fill(query); - await this.page.waitForTimeout(300); - } - - /** - * View system history - */ - async viewHistory(): Promise { - await this.historyTab.click(); - await this.page.waitForTimeout(500); - } - - /** - * Start the system - */ - async startSystem(): Promise { - await this.startButton.click(); - await this.page.waitForTimeout(1000); - } - - /** - * Stop the system - */ - async stopSystem(): Promise { - await this.stopButton.click(); - await this.page.waitForTimeout(1000); - } - - /** - * Assert system details are visible (looks in item-details component) - */ - async expectSystemDetails(name: string): Promise { - await expect( - this.page - .locator( - `item-details [name]:has-text("${name}"), item-details:has-text("${name}")`, - ) - .first(), - ).toBeVisible({ timeout: 10000 }); - } - - /** - * Assert system is in the list - */ - async expectSystemInList(name: string): Promise { - await expect( - this.systemsList.filter({ hasText: name }).first(), - ).toBeVisible({ timeout: 10000 }); - } - - /** - * Assert system is not in the list - */ - async expectSystemNotInList(name: string): Promise { - await expect( - this.systemsList.filter({ hasText: name }).first(), - ).not.toBeVisible({ timeout: 5000 }); } } diff --git a/e2e/src/pages/users.page.ts b/e2e/src/pages/users.page.ts index eff93ad9b..9fb694f21 100644 --- a/e2e/src/pages/users.page.ts +++ b/e2e/src/pages/users.page.ts @@ -11,259 +11,36 @@ export class UsersPage extends BasePage { super(page); } - /** - * Get the users list items - */ - get usersList(): Locator { - return this.sidebarItems; - } - - /** - * Get the metadata tab - */ - get metadataTab(): Locator { - return this.page - .locator( - 'item-tablist a[href*="/metadata"]:has-text("Metadata"), a[role="tab"][href*="/metadata"]:has-text("Metadata")', - ) - .first(); - } - - /** - * Get the history tab - */ - get historyTab(): Locator { - return this.page - .locator( - 'item-tablist a:has-text("History"), [role="tab"]:has-text("History")', - ) - .first(); - } - - /** - * Get the groups tab - */ - get groupsTab(): Locator { - return this.page - .locator( - 'item-tablist a:has-text("Groups"), [role="tab"]:has-text("Groups")', - ) - .first(); - } - - /** - * Get user name display - */ - get userName(): Locator { - return this.page.locator('item-details [name]').first(); - } - - /** - * Get user email display - */ - get userEmail(): Locator { - return this.page.locator('[class*="email"], text=@').first(); - } - - /** - * Get user status indicator - */ - get userStatus(): Locator { - return this.page.locator('[class*="status"], mat-chip').first(); - } - - /** - * Get the disable/enable button - */ - get toggleStatusButton(): Locator { - return this.page - .locator('button:has-text("Disable"), button:has-text("Enable")') - .first(); - } - - /** - * Get metadata list loading spinner - */ - get metadataLoadingSpinner(): Locator { - return this.page.locator('metadata-display mat-spinner').first(); + /** Text field of the open create/edit modal by its label */ + field(label: string): Locator { + return this.dialog.getByRole('textbox', { name: label, exact: true }); } /** - * Create a new user + * Fill the required fields of the open create user modal. + * Waits for the form to pick the default domain first. */ - async createUser(data: { + async fillNewUser(data: { first_name: string; last_name: string; email: string; password: string; - domain?: string; - admin?: boolean; - support?: boolean; }): Promise { - await this.openAddModal(); - - // Fill required fields - await this.fillField('First Name', data.first_name); - await this.fillField('Last Name', data.last_name); - await this.fillField('Email', data.email); - - // Fill password fields - use accessible name within dialog to avoid hidden fake password field - const passwordField = this.dialog.getByRole('textbox', { - name: 'Password', - exact: true, - }); - await passwordField.fill(data.password); - const confirmPasswordField = this.dialog.getByRole('textbox', { - name: 'Confirm Password', - }); - await confirmPasswordField.fill(data.password); - - if (data.domain) { - await this.selectDropdown('Domain', data.domain); - } - - if (data.admin) { - await this.toggleCheckbox('System Admin'); - } - - if (data.support) { - await this.toggleCheckbox('PlaceOS Support'); - } - - await this.save(); - } - - /** - * Edit the current user - */ - async editUser(data: { - name?: string; - email?: string; - admin?: boolean; - support?: boolean; - }): Promise { - await this.openEditModal(); - - if (data.name) { - await this.fillField('Name', data.name); - } - - if (data.email) { - await this.fillField('Email', data.email); - } - - if (data.admin !== undefined) { - await this.toggleCheckbox('Admin'); - } - - if (data.support !== undefined) { - await this.toggleCheckbox('Support'); - } - - await this.save(); - } - - /** - * Toggle user enabled/disabled status - */ - async toggleUserStatus(): Promise { - await this.toggleStatusButton.click(); - await this.page.waitForTimeout(1000); - } - - /** - * View user metadata - */ - async viewMetadata(): Promise { - await this.metadataTab.click(); - await this.page.waitForTimeout(500); - } - - /** - * View user history - */ - async viewHistory(): Promise { - await this.historyTab.click(); - await this.page.waitForTimeout(500); - } - - /** - * View user groups - */ - async viewGroups(): Promise { - await this.groupsTab.click(); - await this.page.waitForTimeout(500); - } - - /** - * Filter users by domain - */ - async filterByDomain(domain: string): Promise { - const domainFilter = this.page - .locator( - 'mat-select[placeholder*="Domain"], mat-select:near(:text("Domain"))', - ) - .first(); - await domainFilter.click(); - await this.page - .locator(`mat-option:has-text("${domain}")`) - .first() - .click(); - await this.page.waitForTimeout(500); - } - - /** - * Assert user details are visible - */ - async expectUserDetails(name: string): Promise { await expect( - this.page - .locator( - `item-details [name]:has-text("${name}"), item-details:has-text("${name}")`, - ) - .first(), - ).toBeVisible({ timeout: 10000 }); - } - - /** - * Assert user email is displayed - */ - async expectUserEmail(email: string): Promise { - await expect(this.page.locator(`text=${email}`)).toBeVisible({ - timeout: 10000, - }); - } - - /** - * Assert user is in the list - */ - async expectUserInList(name: string): Promise { - await expect( - this.usersList.filter({ hasText: name }).first(), - ).toBeVisible({ timeout: 10000 }); - } - - /** - * Assert user is not in the list - */ - async expectUserNotInList(name: string): Promise { - await expect( - this.usersList.filter({ hasText: name }).first(), - ).not.toBeVisible({ timeout: 5000 }); - } - - /** - * Assert user status - */ - async expectUserStatus(status: 'active' | 'disabled'): Promise { - if (status === 'active') { - await expect( - this.toggleStatusButton.locator(':has-text("Disable")'), - ).toBeVisible(); - } else { - await expect( - this.toggleStatusButton.locator(':has-text("Enable")'), - ).toBeVisible(); - } + this.dialog.getByRole('combobox', { name: 'Domain' }), + ).toContainText('Place Technology'); + await this.field('First Name*').fill(data.first_name); + await this.field('Last Name*').fill(data.last_name); + await this.field('Email*').fill(data.email); + await this.field('Password').fill(data.password); + await this.field('Confirm Password').fill(data.password); + } + + /** Search for a user by name and select them */ + async openUser(name: string): Promise { + await this.goto(); + await this.search(name); + await this.itemLink(name).first().click(); + await this.waitForItemDetails(); } } diff --git a/e2e/src/pages/zones.page.ts b/e2e/src/pages/zones.page.ts deleted file mode 100644 index 3f487a127..000000000 --- a/e2e/src/pages/zones.page.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { Locator, Page, expect } from '@playwright/test'; -import { BasePage } from './base.page'; - -/** - * Page Object for the Zones section - */ -export class ZonesPage extends BasePage { - readonly path = '/#/zones'; - - constructor(page: Page) { - super(page); - } - - /** - * Get the zones list items - */ - get zonesList(): Locator { - return this.sidebarItems; - } - - /** - * Get the children tab - */ - get childrenTab(): Locator { - return this.page - .locator( - 'item-tablist a:has-text("Children"), [role="tab"]:has-text("Children")', - ) - .first(); - } - - /** - * Get the systems tab - */ - get systemsTab(): Locator { - return this.page - .locator( - 'item-tablist a:has-text("Systems"), [role="tab"]:has-text("Systems")', - ) - .first(); - } - - /** - * Get the triggers tab - */ - get triggersTab(): Locator { - return this.page - .locator( - 'item-tablist a:has-text("Triggers"), [role="tab"]:has-text("Triggers")', - ) - .first(); - } - - /** - * Get the metadata tab - */ - get metadataTab(): Locator { - return this.page - .locator( - 'item-tablist a[href*="/metadata"]:has-text("Metadata"), a[role="tab"][href*="/metadata"]:has-text("Metadata")', - ) - .first(); - } - - /** - * Get the history tab - */ - get historyTab(): Locator { - return this.page - .locator( - 'item-tablist a:has-text("History"), [role="tab"]:has-text("History")', - ) - .first(); - } - - /** - * Get zone name display - */ - get zoneName(): Locator { - return this.page.locator('item-details [name]').first(); - } - - /** - * Get parent zone display - */ - get parentZone(): Locator { - return this.page - .locator('[class*="parent"], :text("Parent") ~ *') - .first(); - } - - /** - * Get system count - */ - get systemCount(): Locator { - return this.page - .locator('[class*="count"], :text("Systems") ~ *') - .first(); - } - - /** - * Get metadata list loading spinner - */ - get metadataLoadingSpinner(): Locator { - return this.page.locator('metadata-display mat-spinner').first(); - } - - /** - * Create a new zone - */ - async createZone(data: { - name: string; - display_name?: string; - description?: string; - parent?: string; - tags?: string; - }): Promise { - await this.openAddModal(); - - await this.fillField('Name', data.name); - - if (data.display_name) { - await this.fillField('Display', data.display_name); - } - - if (data.description) { - await this.fillField('Description', data.description); - } - - if (data.parent) { - await this.selectDropdown('Parent', data.parent); - } - - if (data.tags) { - await this.fillField('Tags', data.tags); - } - - await this.save(); - } - - /** - * Edit the current zone - */ - async editZone(data: { - name?: string; - display_name?: string; - description?: string; - parent?: string; - tags?: string; - }): Promise { - await this.openEditModal(); - - if (data.name) { - await this.fillField('Name', data.name); - } - - if (data.display_name) { - await this.fillField('Display', data.display_name); - } - - if (data.description) { - await this.fillField('Description', data.description); - } - - if (data.parent) { - await this.selectDropdown('Parent', data.parent); - } - - if (data.tags) { - await this.fillField('Tags', data.tags); - } - - await this.save(); - } - - /** - * View child zones - */ - async viewChildren(): Promise { - await this.childrenTab.click(); - await this.page.waitForTimeout(500); - } - - /** - * View systems in zone - */ - async viewSystems(): Promise { - await this.systemsTab.click(); - await this.page.waitForTimeout(500); - } - - /** - * View zone triggers - */ - async viewTriggers(): Promise { - await this.triggersTab.click(); - await this.page.waitForTimeout(500); - } - - /** - * View zone metadata - */ - async viewMetadata(): Promise { - await this.metadataTab.click(); - await this.page.waitForTimeout(500); - } - - /** - * View zone history - */ - async viewHistory(): Promise { - await this.historyTab.click(); - await this.page.waitForTimeout(500); - } - - /** - * Assert zone details are visible - */ - async expectZoneDetails(name: string): Promise { - await expect( - this.page - .locator( - `item-details [name]:has-text("${name}"), item-details:has-text("${name}")`, - ) - .first(), - ).toBeVisible({ timeout: 10000 }); - } - - /** - * Assert zone is in the list - */ - async expectZoneInList(name: string): Promise { - await expect( - this.zonesList.filter({ hasText: name }).first(), - ).toBeVisible({ timeout: 10000 }); - } - - /** - * Assert zone is not in the list - */ - async expectZoneNotInList(name: string): Promise { - await expect( - this.zonesList.filter({ hasText: name }).first(), - ).not.toBeVisible({ timeout: 5000 }); - } - - /** - * Assert parent zone is displayed - */ - async expectParentZone(parentName: string): Promise { - await expect(this.page.locator(`text=${parentName}`)).toBeVisible({ - timeout: 10000, - }); - } - - /** - * Assert child zones are displayed - */ - async expectChildZones(count: number): Promise { - await this.viewChildren(); - const children = this.page.locator('.child-zone, [class*="child"]'); - await expect(children).toHaveCount(count); - } -} diff --git a/e2e/src/repositories.spec.ts b/e2e/src/repositories.spec.ts new file mode 100644 index 000000000..eed3c7cdc --- /dev/null +++ b/e2e/src/repositories.spec.ts @@ -0,0 +1,256 @@ +import { expect, seedMockData, test } from './fixtures/test-fixtures'; +import { FeaturePage } from './pages'; + +/** + * Repositories E2E Tests + * + * Tests for user stories in `user-stories/repositories.md` (US-REP-*) + * + * The mock backend has no branch, pull or remote lookup endpoints, so + * creating a repository, pulling and branch selection are not covered. + */ + +const REPOSITORIES = [ + { + id: 'repo-drivers', + name: 'PlaceOS Drivers', + repo_type: 'driver', + folder_name: 'drivers', + uri: 'https://github.com/PlaceOS/drivers', + branch: 'master', + commit_hash: 'HEAD', + }, + { + id: 'repo-kiosk', + name: 'Kiosk Interface', + repo_type: 'interface', + folder_name: 'kiosk', + uri: 'https://github.com/PlaceOS/kiosk', + branch: 'build/prod', + commit_hash: 'a1b2c3d4', + }, +]; + +/** Driver the mock lists for every driver repository */ +const DRIVER_FILE = 'disc-::Aca::DeviceProbe'; + +test.describe('Repositories', () => { + let repositories: FeaturePage; + + test.beforeEach(async ({ page }) => { + // Interface repositories fetch `//CHANGELOG.md`, which the + // dev proxy can send to a live server. Keep it local. + await page.route('**/CHANGELOG.md', (route) => + route.fulfill({ status: 404 }), + ); + await seedMockData(page, 'repositories', REPOSITORIES); + repositories = new FeaturePage(page, 'repositories'); + }); + + test.describe('US-REP-001: View Repositories List', () => { + test('AC-REP-001-1: lists every repository', async () => { + await repositories.goto(); + + await expect( + repositories.itemLink('PlaceOS Drivers'), + ).toBeVisible(); + await expect( + repositories.itemLink('Kiosk Interface'), + ).toBeVisible(); + }); + + test('AC-REP-001-2: search filters the list', async () => { + await repositories.goto(); + await repositories.search('Kiosk'); + + await expect( + repositories.itemLink('Kiosk Interface'), + ).toBeVisible(); + await expect(repositories.itemLink('PlaceOS Drivers')).toHaveCount( + 0, + ); + }); + + test('AC-REP-001-3: selecting a repository opens its details', async ({ + page, + }) => { + await repositories.goto(); + await repositories.itemLink('Kiosk Interface').click(); + + await expect(page).toHaveURL(/#\/repositories\/repo-kiosk\/about/); + await expect(repositories.itemName).toHaveText('Kiosk Interface'); + }); + }); + + test.describe('US-REP-002: View Repository Details', () => { + test('AC-REP-002-1: shows the repository URL', async () => { + await repositories.gotoItem('repo-drivers'); + + await expect( + repositories.tabContent.getByRole('link', { + name: 'https://github.com/PlaceOS/drivers', + }), + ).toHaveAttribute('href', 'https://github.com/PlaceOS/drivers'); + }); + + test('AC-REP-002-2: shows the repository type', async () => { + await repositories.gotoItem('repo-drivers'); + await expect(repositories.tabContent).toContainText( + 'Driver Repository', + ); + + await repositories.itemLink('Kiosk Interface').click(); + await expect(repositories.itemName).toHaveText('Kiosk Interface'); + await expect(repositories.tabContent).toContainText( + 'Interface Repository', + ); + }); + + test('AC-REP-002-4: shows the branch and the latest commit', async () => { + await repositories.gotoItem('repo-drivers'); + + await expect(repositories.tabContent).toContainText('master'); + // Pinned commit, then the latest commit the backend reports + await expect( + repositories.tabContent.locator('code').filter({ + hasText: 'HEAD', + }), + ).toHaveText(/^\s*HEAD\s+\([0-9a-f]{6}\)\s*$/); + }); + }); + + test.describe('US-REP-003: Create New Repository', () => { + test('AC-REP-003-1: the add button opens an empty form that requires name, folder and URI', async () => { + await repositories.goto(); + await repositories.openAddModal(); + + await expect( + repositories.dialog.getByRole('heading', { + name: 'New repository', + }), + ).toBeVisible(); + await repositories.saveButton.click(); + + await expect(repositories.dialog).toBeVisible(); + await expect(repositories.dialog.locator('mat-error')).toHaveText([ + 'A repository name is required', + 'A folder name is required', + 'A URI is required', + ]); + }); + }); + + test.describe('US-REP-004: Edit Repository Configuration', () => { + test('AC-REP-004-3: saved changes persist', async ({ page }) => { + await repositories.gotoItem('repo-drivers'); + await repositories.openEditModal(); + + const name = repositories.dialog.getByRole('textbox', { + name: 'Name*', + }); + await expect(name).toHaveValue('PlaceOS Drivers'); + await name.fill('Core Drivers'); + await repositories.dialog + .getByRole('textbox', { name: 'Description' }) + .fill('Drivers for the core platform'); + await repositories.saveButton.click(); + await expect(repositories.dialog).toBeHidden(); + + await page.reload(); + await repositories.waitForItemDetails(); + await expect(repositories.itemName).toHaveText('Core Drivers'); + await expect(repositories.tabContent).toContainText( + 'Drivers for the core platform', + ); + await expect(repositories.itemLink('Core Drivers')).toBeVisible(); + }); + }); + + test.describe('US-REP-005: Delete Repository', () => { + test('AC-REP-005-1: dismissing the confirmation keeps the repository', async ({ + page, + }) => { + await repositories.gotoItem('repo-drivers'); + await repositories.openDeleteConfirmation(); + await page.keyboard.press('Escape'); + + await expect(page.locator('confirm-modal')).toHaveCount(0); + await expect(repositories.itemName).toHaveText('PlaceOS Drivers'); + await expect( + repositories.itemLink('PlaceOS Drivers'), + ).toBeVisible(); + }); + + test('AC-REP-005-3: a deleted repository is removed', async ({ + page, + }) => { + await repositories.gotoItem('repo-drivers'); + await repositories.deleteSelected(); + + await page.reload(); + await expect( + repositories.itemLink('Kiosk Interface'), + ).toBeVisible(); + await expect(repositories.itemLink('PlaceOS Drivers')).toHaveCount( + 0, + ); + }); + }); + + test.describe('US-REP-008: View Drivers in Repository', () => { + test('AC-REP-008-1: the drivers tab lists every driver', async ({ + page, + }) => { + await repositories.gotoItem('repo-drivers', 'drivers'); + + const tab = page.locator('item-tablist a[href*="/drivers"]'); + await expect(tab).toContainText('20'); + await expect( + repositories.tabContent.locator( + 'button:has(icon:has-text("add"))', + ), + ).toHaveCount(20); + await expect(repositories.tabContent).toContainText(DRIVER_FILE); + }); + + test('AC-REP-008-3: a driver can be added from the repository', async ({ + page, + }) => { + await repositories.gotoItem('repo-drivers', 'drivers'); + const row = await repositories.tableRow(DRIVER_FILE); + await row.getByRole('button').click(); + + const dialog = repositories.dialog; + await expect( + dialog.getByRole('heading', { name: 'New driver' }), + ).toBeVisible(); + await expect( + dialog.getByRole('combobox', { + name: 'Search for repositories...', + }), + ).toHaveValue('PlaceOS Drivers'); + await expect( + dialog.getByRole('combobox', { name: 'Search for drivers...' }), + ).toHaveValue(DRIVER_FILE); + + await dialog + .getByRole('combobox', { name: 'Search for commit...' }) + .click(); + await dialog.getByRole('option').first().click(); + // The mock driver details have no `descriptive_name` or + // `generic_name`, so both fields start empty + await dialog + .getByRole('textbox', { name: 'Name *', exact: true }) + .fill('Lobby Device Probe'); + await dialog + .getByRole('textbox', { name: 'Module Name *' }) + .fill('Probe'); + await repositories.saveButton.click(); + await expect(dialog).toBeHidden(); + + const drivers = new FeaturePage(page, 'drivers'); + await drivers.goto(); + await expect(drivers.itemLink('Lobby Device Probe')).toBeVisible(); + }); + }); +}); diff --git a/e2e/src/systems.spec.ts b/e2e/src/systems.spec.ts index 63122391e..7a1869f55 100644 --- a/e2e/src/systems.spec.ts +++ b/e2e/src/systems.spec.ts @@ -1,5 +1,4 @@ -import { expect, test } from '@playwright/test'; -import { buildAppUrl } from './config/test-env'; +import { expect, test } from './fixtures/test-fixtures'; import { SystemsPage } from './pages'; /** @@ -8,417 +7,302 @@ import { SystemsPage } from './pages'; * Tests for user stories US-SYS-001 through US-SYS-012 */ +/** 2.30.08 - Activity Space: 10 modules, 4 zones, no triggers */ +const ACTIVITY_SPACE = 'sys-QjJvCH7_dA'; +const ACTIVITY_SPACE_NAME = '2.30.08 - Activity Space'; + test.describe('Systems', () => { - let systemsPage: SystemsPage; + let systems: SystemsPage; test.beforeEach(async ({ page }) => { - // Set mock mode in localStorage - await page.addInitScript(() => { - localStorage.setItem('BACKOFFICE.mock', 'true'); - }); - systemsPage = new SystemsPage(page); + systems = new SystemsPage(page); }); - /** - * US-SYS-001: View Systems List - */ test.describe('US-SYS-001: View Systems List', () => { - test('AC-SYS-001-1: Display Systems List - should show sidebar with all systems', async () => { - await systemsPage.goto(); + test('AC-SYS-001-1: lists the systems', async () => { + await systems.goto(); - // Verify the sidebar is visible - await expect(systemsPage.sidebarList).toBeVisible(); - - // Verify systems are listed - await expect(systemsPage.systemsList.first()).toBeVisible({ - timeout: 10000, - }); + await expect(systems.itemLink(ACTIVITY_SPACE_NAME)).toBeVisible(); + await expect( + systems.itemLink('2.30.09 - Activity Space'), + ).toBeVisible(); }); - test('AC-SYS-001-2: Search Systems - should filter systems by search term', async ({ - page, - }) => { - await systemsPage.goto(); - - // Wait for list to load - await page.waitForTimeout(1000); + test('AC-SYS-001-2: search filters the list', async () => { + await systems.goto(); + await systems.search('2.30.09'); - // Search for a specific system - await systemsPage.search('Activity'); - - // Wait for search results to filter - await page.waitForTimeout(500); - - // Verify filtered results - use paragraph elements inside the links which contain the system names - const items = page - .locator('item-sidebar a p, virtual-scroll a p') - .filter({ hasText: /Activity/i }); - const count = await items.count(); - expect(count).toBeGreaterThan(0); + await expect( + systems.itemLink('2.30.09 - Activity Space'), + ).toBeVisible(); + await expect(systems.itemLink(ACTIVITY_SPACE_NAME)).toHaveCount(0); }); - test('AC-SYS-001-3: System Selection - should display system details when selected', async ({ + test('AC-SYS-001-3: selecting a system opens its details', async ({ page, }) => { - await systemsPage.goto(); - - // Wait for list to load - await page.waitForTimeout(1000); + await systems.goto(); + await systems.itemLink(ACTIVITY_SPACE_NAME).click(); - // Select first system - const firstSystem = systemsPage.systemsList.first(); - const systemName = await firstSystem.textContent(); - await firstSystem.click(); - - // Verify details are displayed - await expect(systemsPage.mainContent).toBeVisible(); - if (systemName) { - await expect( - page - .locator(`text=${systemName.trim().split('\n')[0]}`) - .first(), - ).toBeVisible(); - } + await expect(page).toHaveURL( + new RegExp(`#/systems/${ACTIVITY_SPACE}/`), + ); + await expect(systems.itemName).toHaveText(ACTIVITY_SPACE_NAME); }); }); - /** - * US-SYS-002: Create New System - */ test.describe('US-SYS-002: Create New System', () => { - test('AC-SYS-002-1: Access Create Form - should open create modal when Add button clicked', async () => { - await systemsPage.goto(); - - // Click add button - await systemsPage.addButton.click(); - - // Verify modal appears - await expect(systemsPage.dialog).toBeVisible(); - }); - - test('AC-SYS-002-2: Required Fields Validation - should show validation errors for missing required fields', async ({ - page, - }) => { - await systemsPage.goto(); - await systemsPage.openAddModal(); + test('AC-SYS-002-2: required fields are checked', async () => { + await systems.goto(); + await systems.openAddModal(); - // Try to save without filling required fields - await systemsPage.saveButton.click(); + await systems.saveButton.click(); - // Dialog should still be visible (validation failed) - await expect(systemsPage.dialog).toBeVisible(); - - // Check for validation error indicators - const errorFields = page.locator( - 'mat-error, .mat-mdc-form-field-error, [class*="error"]', - ); - await expect(errorFields.first()) - .toBeVisible({ timeout: 5000 }) - .catch(() => { - // Validation might be shown differently - }); + await expect(systems.dialog).toBeVisible(); + await expect( + systems.dialog.locator('mat-error').first(), + ).toBeVisible(); }); - test('AC-SYS-002-3: Successful Creation - should create system and show in list', async ({ - page, - }) => { - await systemsPage.goto(); - - const uniqueName = `Test System ${Date.now()}`; + test('AC-SYS-002-3: saves the system and opens it', async () => { + await systems.goto(); - await systemsPage.createSystem({ - name: uniqueName, - description: 'E2E test system', + await systems.createWith(async () => { + await systems.selectZone('Tower 2'); + await systems.nameField.fill('Boardroom'); }); - // Wait for creation to complete - await page.waitForTimeout(1000); - - // In mock mode, verify the dialog closed (creation succeeded) - // Mock mode doesn't persist items, so we verify the form submission worked - await expect(systemsPage.dialog).not.toBeVisible({ timeout: 5000 }); + await expect(systems.itemName).toHaveText('Boardroom'); + await systems.search('Boardroom'); + await expect(systems.itemLink('Boardroom')).toBeVisible(); }); }); - /** - * US-SYS-003: Edit System Details - */ test.describe('US-SYS-003: Edit System Details', () => { - test('AC-SYS-003-1: Access Edit Mode - should open edit modal with current details', async ({ - page, - }) => { - await systemsPage.goto(); + test('AC-SYS-003-1: the edit form holds the current name', async () => { + await systems.gotoItem(ACTIVITY_SPACE); + await systems.openEditModal(); - // Select first system - await systemsPage.systemsList.first().click(); - await page.waitForTimeout(500); - - // Click edit - await systemsPage.openEditModal(); - - // Verify modal with pre-populated fields - await expect(systemsPage.dialog).toBeVisible(); - const nameField = systemsPage.dialog.locator('input').first(); - const value = await nameField.inputValue(); - expect(value.length).toBeGreaterThan(0); + await expect(systems.nameField).toHaveValue(ACTIVITY_SPACE_NAME); }); - test('AC-SYS-003-4: Cancel Edit - should discard changes when cancelled', async ({ + test('AC-SYS-003-3: a renamed system keeps its new name', async ({ page, }) => { - await systemsPage.goto(); - - // Select first system - await systemsPage.systemsList.first().click(); - await page.waitForTimeout(500); - - // Get original name - const originalName = await systemsPage.mainContent - .locator('h2, h3, [class*="title"]') - .first() - .textContent(); - - // Open edit and make changes - await systemsPage.openEditModal(); - await systemsPage.fillField('Name', 'Changed Name'); + await systems.gotoItem(ACTIVITY_SPACE); + await systems.openEditModal(); + await systems.nameField.fill('2.30.08 - Quiet Room'); + await systems.saveButton.click(); + await expect(systems.dialog).toBeHidden(); + + await expect(systems.itemName).toHaveText('2.30.08 - Quiet Room'); + await page.reload(); + await systems.waitForItemDetails(); + await expect(systems.itemName).toHaveText('2.30.08 - Quiet Room'); + }); - // Cancel - await systemsPage.cancel(); + test('AC-SYS-003-4: closing the modal discards changes', async () => { + await systems.gotoItem(ACTIVITY_SPACE); + await systems.openEditModal(); + await systems.nameField.fill('Not Saved'); + await systems.discardChanges(); - // Verify original name is still displayed - await page.waitForTimeout(500); - const currentName = await systemsPage.mainContent - .locator('h2, h3, [class*="title"]') - .first() - .textContent(); - expect(currentName).toBe(originalName); + await expect(systems.dialog).toBeHidden(); + await expect(systems.itemName).toHaveText(ACTIVITY_SPACE_NAME); }); }); - /** - * US-SYS-004: Delete System - */ test.describe('US-SYS-004: Delete System', () => { - test('AC-SYS-004-1: Delete Confirmation - should show confirmation dialog', async ({ - page, - }) => { - await systemsPage.goto(); + test('AC-SYS-004-1: asks for confirmation', async () => { + await systems.gotoItem(ACTIVITY_SPACE); + await systems.openDeleteConfirmation(); - // Select a system - await systemsPage.systemsList.first().click(); - await page.waitForTimeout(500); + await expect( + systems.page.locator('confirm-modal h2'), + ).toContainText('Delete'); + }); - // Open action menu and click delete - await systemsPage.openActionMenu(); - await systemsPage.deleteButton.click(); + test('AC-SYS-004-2: removes the system from the list', async () => { + await systems.gotoItem(ACTIVITY_SPACE); + await systems.deleteSelected(); - // Verify confirmation dialog appears with delete heading - await expect(systemsPage.dialog).toBeVisible(); + await expect(systems.itemLink(ACTIVITY_SPACE_NAME)).toHaveCount(0); await expect( - systemsPage.dialog.locator( - 'h2:has-text("Delete"), h2:has-text("delete")', - ), + systems.itemLink('2.30.09 - Activity Space'), ).toBeVisible(); }); - test('AC-SYS-004-3: Cancel Deletion - should keep system when cancelled', async ({ + test('AC-SYS-004-3: keeps the system when the confirmation is dismissed', async ({ page, }) => { - await systemsPage.goto(); - - // Select a system - const firstSystem = systemsPage.systemsList.first(); - const systemName = await firstSystem.textContent(); - await firstSystem.click(); - await page.waitForTimeout(500); - - // Open action menu and click delete - await systemsPage.openActionMenu(); - await systemsPage.deleteButton.click(); - await expect(systemsPage.dialog).toBeVisible(); - - // Cancel - await systemsPage.cancelButton.click(); - - // Verify system still exists - if (systemName) { - await systemsPage.expectSystemInList( - systemName.trim().split('\n')[0], - ); - } + await systems.gotoItem(ACTIVITY_SPACE); + await systems.openDeleteConfirmation(); + await page.keyboard.press('Escape'); + + await expect(page.locator('confirm-modal')).toHaveCount(0); + await expect(systems.itemName).toHaveText(ACTIVITY_SPACE_NAME); + await expect(systems.itemLink(ACTIVITY_SPACE_NAME)).toBeVisible(); }); }); - /** - * US-SYS-006: Manage System Modules - */ test.describe('US-SYS-006: Manage System Modules', () => { - test('AC-SYS-006-1: View Attached Modules - should display modules list', async ({ + test('AC-SYS-006-1: lists the modules and links to them', async ({ page, }) => { - await systemsPage.goto(); - - // Select a system - await systemsPage.systemsList.first().click(); - await page.waitForTimeout(500); + await systems.gotoItem(ACTIVITY_SPACE); + await systems.openTab('modules'); - // Navigate to modules tab - await systemsPage.viewModules(); + await systems.tabContent + .getByRole('link', { name: 'KNX BAOS Lighting' }) + .first() + .click(); - // Verify modules tab content is visible - await expect(systemsPage.mainContent).toBeVisible(); + await expect(page).toHaveURL(/#\/modules\/mod-/); + await expect(systems.itemName).toHaveText('KNX BAOS Lighting'); }); - test('AC-SYS-006-5: Search Modules for Execution - should filter the execute module selector', async ({ + test('AC-SYS-006-5: search filters the execute module selector', async ({ page, }) => { - await systemsPage.goto(); - - await systemsPage.search('2.30.08'); - await systemsPage.systemsList.first().click(); - await page.waitForTimeout(500); - - await systemsPage.viewModules(); - await systemsPage.searchExecuteModules('Lighting'); + await systems.gotoItem(ACTIVITY_SPACE, 'modules'); + // The selector re-renders and closes when the module list loads + await expect( + systems.tabContent + .getByRole('link', { name: 'KNX BAOS Lighting' }) + .first(), + ).toBeVisible(); + await systems.searchExecuteModules('Lighting'); - const visibleOptions = page + const options = page .locator('.cdk-overlay-pane mat-option') - .filter({ hasText: /Lighting/i }); - await expect(visibleOptions.first()).toBeVisible({ - timeout: 10000, - }); + .filter({ hasText: /\S/ }); await expect( - page.locator('input[name="module-search"]'), - ).toHaveValue('Lighting'); + options.filter({ hasText: /Lighting/i }).first(), + ).toBeVisible(); + await expect( + options.filter({ hasNotText: /Lighting/i }), + ).toHaveCount(0); }); }); - /** - * US-SYS-007: View System Zones - */ test.describe('US-SYS-007: View System Zones', () => { - test('AC-SYS-007-1: Display Zone Membership - should show zones list', async ({ - page, - }) => { - await systemsPage.goto(); + test('AC-SYS-007-1: lists every zone the system is in', async () => { + await systems.gotoItem(ACTIVITY_SPACE); + await systems.openTab('zones'); + + for (const name of [ + 'L30 Activity Spaces', + 'Level 30', + 'Tower 2', + 'Place Technology', + ]) { + await expect( + systems.tabContent.getByRole('link', { + name, + exact: true, + }), + ).toBeVisible(); + } + }); - // Select a system - await systemsPage.systemsList.first().click(); - await page.waitForTimeout(500); + test('AC-SYS-007-2: a zone link opens that zone', async ({ page }) => { + await systems.gotoItem(ACTIVITY_SPACE, 'zones'); - // Navigate to zones tab - await systemsPage.viewZones(); + await systems.tabContent + .getByRole('link', { name: 'Tower 2' }) + .click(); - // Verify zones tab content is visible - await expect(systemsPage.mainContent).toBeVisible(); + await expect(page).toHaveURL(/#\/zones\/zone-Kl0HN~nDwc/); + await expect(systems.itemName).toHaveText('Tower 2'); }); }); - /** - * US-SYS-008: Manage System Triggers - */ test.describe('US-SYS-008: Manage System Triggers', () => { - test('AC-SYS-008-1: View System Triggers - should display triggers list', async ({ - page, - }) => { - await systemsPage.goto(); - - // Select a system - await systemsPage.systemsList.first().click(); - await page.waitForTimeout(500); + test('AC-SYS-008-1: shows the empty triggers list', async () => { + await systems.gotoItem(ACTIVITY_SPACE); + await systems.openTab('triggers'); - // Navigate to triggers tab - await systemsPage.viewTriggers(); - - // Verify triggers tab content is visible - await expect(systemsPage.mainContent).toBeVisible(); + await expect(systems.tabContent).toContainText( + 'No triggers for selected system', + ); }); }); - /** - * US-SYS-009: Manage System Metadata - */ test.describe('US-SYS-009: Manage System Metadata', () => { - test('AC-SYS-009-1: View Metadata - should display metadata entries', async ({ - page, - }) => { - await systemsPage.goto(); - - // Select a system - await systemsPage.systemsList.first().click(); - await page.waitForTimeout(500); - - // Navigate to metadata tab - await systemsPage.viewMetadata(); + test('AC-SYS-009-1: shows the empty metadata list', async () => { + await systems.gotoItem(ACTIVITY_SPACE); + await systems.openTab('metadata'); - // Verify metadata tab content is visible - await expect(systemsPage.mainContent).toBeVisible(); + await expect(systems.tabContent).toContainText( + 'No Metadata blocks found', + ); }); - test('AC-SYS-009-2: Metadata Loading State - should render the shared metadata view after loading', async ({ + test('AC-SYS-009-2: renders the metadata view after a slow load', async ({ page, }) => { await page.addInitScript(() => { sessionStorage.setItem('PLACEOS.mocks.metadata_delay', '3000'); }); - await page.goto(buildAppUrl('/#/systems/sys-QjJvCH7_dA/metadata')); - await systemsPage.waitForLoad(); - await expect(page.locator('metadata-display')).toBeAttached({ - timeout: 5000, + await systems.gotoItem(ACTIVITY_SPACE, 'metadata'); + + await expect(systems.metadataLoadingSpinner).toBeVisible(); + await expect(systems.metadataLoadingSpinner).toBeHidden({ + timeout: 10000, }); + await expect(systems.tabContent).toContainText( + 'No Metadata blocks found', + ); + await expect(page.getByText(/Error loading metadata/i)).toHaveCount( + 0, + ); + }); + }); + + test.describe('US-SYS-010: View System History', () => { + test('AC-SYS-010-3: opens the settings history view', async () => { + await systems.gotoItem(ACTIVITY_SPACE); + await systems.openTab('history'); + await expect( - page.locator('metadata-display input[placeholder*="metadata"]'), + systems.tabContent.getByRole('combobox', { + name: 'Select current settings', + }), ).toBeVisible(); - await expect( - page.locator('text=/Error loading metadata/i'), - ).toHaveCount(0); }); }); - /** - * US-SYS-010: View System History - */ - test.describe('US-SYS-010: View System History', () => { - test('AC-SYS-010-1: Display Change History - should show history list', async ({ + test.describe('US-SYS-011: Start and Stop System', () => { + test('AC-SYS-011-2: stops the system after confirmation', async ({ page, }) => { - await systemsPage.goto(); - - // Select a system - await systemsPage.systemsList.first().click(); - await page.waitForTimeout(500); + await systems.gotoItem(ACTIVITY_SPACE); + await systems.stopButton.click(); - // Navigate to history tab - await systemsPage.viewHistory(); + await expect(page.locator('confirm-modal')).toContainText( + 'Stop system?', + ); + await systems.acceptButton.click(); - // Verify history tab content is visible - await expect(systemsPage.mainContent).toBeVisible(); + await expect( + systems.notification('Successfully stopped system'), + ).toBeVisible(); }); - }); - /** - * US-SYS-011: Start and Stop System - */ - test.describe('US-SYS-011: Start and Stop System', () => { - test('AC-SYS-011-1/2: Start/Stop buttons should be visible', async ({ + test('AC-SYS-011-1: starts the system after confirmation', async ({ page, }) => { - await systemsPage.goto(); - - // Select a system - await systemsPage.systemsList.first().click(); - await page.waitForTimeout(500); - - // Verify start or stop button is visible - const startVisible = await systemsPage.startButton - .isVisible() - .catch(() => false); - const stopVisible = await systemsPage.stopButton - .isVisible() - .catch(() => false); - - // At least one should be visible depending on system state - expect(startVisible || stopVisible).toBeTruthy(); + await systems.gotoItem(ACTIVITY_SPACE); + await systems.startButton.click(); + + await expect(page.locator('confirm-modal')).toContainText( + 'Start system?', + ); + await systems.acceptButton.click(); + + await expect( + systems.notification('Successfully started system'), + ).toBeVisible(); }); }); }); diff --git a/e2e/src/triggers.spec.ts b/e2e/src/triggers.spec.ts new file mode 100644 index 000000000..4d7dc15a6 --- /dev/null +++ b/e2e/src/triggers.spec.ts @@ -0,0 +1,176 @@ +import { expect, seedMockData, test } from './fixtures/test-fixtures'; +import { FeaturePage } from './pages'; + +/** + * Triggers E2E Tests + * + * Tests for user stories in `user-stories/triggers.md` (US-TRG-*) + */ + +const TRIGGERS = [ + { + id: 'trigger-occupancy', + name: 'Occupancy Alert', + description: 'Turns the lights on when people arrive', + conditions: { comparisons: [], time_dependents: [] }, + actions: { functions: [], mailers: [] }, + }, + { + id: 'trigger-after-hours', + name: 'After Hours Shutdown', + description: 'Turns displays off at night', + conditions: { comparisons: [], time_dependents: [] }, + actions: { functions: [], mailers: [] }, + }, +]; + +test.describe('Triggers', () => { + let triggers: FeaturePage; + + test.beforeEach(async ({ page }) => { + await seedMockData(page, 'triggers', TRIGGERS); + triggers = new FeaturePage(page, 'triggers'); + }); + + test.describe('US-TRG-001: View Triggers List', () => { + test('AC-TRG-001-1: lists every trigger', async () => { + await triggers.goto(); + + await expect(triggers.itemLink('Occupancy Alert')).toBeVisible(); + await expect( + triggers.itemLink('After Hours Shutdown'), + ).toBeVisible(); + }); + + test('AC-TRG-001-2: search filters the list', async () => { + await triggers.goto(); + await triggers.search('After'); + + await expect( + triggers.itemLink('After Hours Shutdown'), + ).toBeVisible(); + await expect(triggers.itemLink('Occupancy Alert')).toHaveCount(0); + }); + + test('AC-TRG-001-3: selecting a trigger opens its details', async ({ + page, + }) => { + await triggers.goto(); + await triggers.itemLink('Occupancy Alert').click(); + + await expect(page).toHaveURL(/#\/triggers\/trigger-occupancy\//); + await expect(triggers.itemName).toHaveText('Occupancy Alert'); + }); + }); + + test.describe('US-TRG-002: View Trigger Details', () => { + test('AC-TRG-002-1: shows the description on the about tab', async () => { + await triggers.gotoItem('trigger-occupancy'); + + await expect(triggers.tabContent).toContainText( + 'Turns the lights on when people arrive', + ); + }); + }); + + test.describe('US-TRG-003: Create New Trigger', () => { + test('AC-TRG-003-1: name is required', async () => { + await triggers.goto(); + await triggers.openAddModal(); + + await triggers.saveButton.click(); + + await expect(triggers.dialog).toBeVisible(); + await expect(triggers.dialog.locator('mat-error')).toBeVisible(); + }); + + test('AC-TRG-003-5: saves the trigger and opens it', async () => { + await triggers.goto(); + + await triggers.createWith(async () => { + await triggers.dialog + .getByRole('textbox', { name: 'Name*' }) + .fill('Fire Alarm'); + await triggers.dialog + .getByRole('textbox', { name: 'Description' }) + .fill('Unlocks every door'); + }); + + await expect(triggers.itemName).toHaveText('Fire Alarm'); + await expect(triggers.tabContent).toContainText( + 'Unlocks every door', + ); + await expect(triggers.itemLink('Fire Alarm')).toBeVisible(); + }); + }); + + test.describe('US-TRG-004: Edit Trigger Configuration', () => { + test('AC-TRG-004-2: a renamed trigger keeps its new name', async ({ + page, + }) => { + await triggers.gotoItem('trigger-occupancy'); + await triggers.openEditModal(); + + const name = triggers.dialog.getByRole('textbox', { + name: 'Name*', + }); + await expect(name).toHaveValue('Occupancy Alert'); + await name.fill('Occupancy Lighting'); + await triggers.saveButton.click(); + await expect(triggers.dialog).toBeHidden(); + + await expect(triggers.itemName).toHaveText('Occupancy Lighting'); + await page.reload(); + await triggers.waitForItemDetails(); + await expect(triggers.itemName).toHaveText('Occupancy Lighting'); + }); + + test('closing the modal discards changes', async () => { + await triggers.gotoItem('trigger-occupancy'); + await triggers.openEditModal(); + + await triggers.dialog + .getByRole('textbox', { name: 'Name*' }) + .fill('Not Saved'); + await triggers.discardChanges(); + + await expect(triggers.dialog).toBeHidden(); + await expect(triggers.itemName).toHaveText('Occupancy Alert'); + }); + }); + + test.describe('US-TRG-005: Delete Trigger', () => { + test('removes the trigger from the list', async () => { + await triggers.gotoItem('trigger-occupancy'); + await triggers.deleteSelected(); + + await expect(triggers.itemLink('Occupancy Alert')).toHaveCount(0); + await expect( + triggers.itemLink('After Hours Shutdown'), + ).toBeVisible(); + }); + + test('keeps the trigger when the confirmation is dismissed', async ({ + page, + }) => { + await triggers.gotoItem('trigger-occupancy'); + await triggers.openDeleteConfirmation(); + await page.keyboard.press('Escape'); + + await expect(page.locator('confirm-modal')).toHaveCount(0); + await expect(triggers.itemName).toHaveText('Occupancy Alert'); + await expect(triggers.itemLink('Occupancy Alert')).toBeVisible(); + }); + }); + + test.describe('US-TRG-008: View Trigger Instances', () => { + test('shows the empty state for an unused trigger', async () => { + await triggers.gotoItem('trigger-occupancy'); + await triggers.openTab('instances'); + + await expect(triggers.tabContent).toContainText( + 'No instance of trigger', + ); + }); + }); +}); diff --git a/e2e/src/users.spec.ts b/e2e/src/users.spec.ts index ddd45b365..85c426d5a 100644 --- a/e2e/src/users.spec.ts +++ b/e2e/src/users.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from './fixtures/test-fixtures'; import { UsersPage } from './pages'; /** @@ -8,345 +8,166 @@ import { UsersPage } from './pages'; */ test.describe('Users', () => { - let usersPage: UsersPage; + let users: UsersPage; test.beforeEach(async ({ page }) => { - // Set mock mode in localStorage - await page.addInitScript(() => { - localStorage.setItem('BACKOFFICE.mock', 'true'); - }); - usersPage = new UsersPage(page); + users = new UsersPage(page); }); - /** - * US-USR-001: View Users List - */ test.describe('US-USR-001: View Users List', () => { - test('AC-USR-001-1: Display Users List - should show sidebar with all users', async () => { - await usersPage.goto(); - - // Verify the sidebar is visible - await expect(usersPage.sidebarList).toBeVisible(); + test('AC-USR-001-1: lists the users', async () => { + await users.goto(); - // Verify users are listed - await expect(usersPage.usersList.first()).toBeVisible({ - timeout: 10000, - }); + await expect(users.itemLink('Alex Sorafumo')).toBeVisible(); + await expect(users.itemLink('Jeremy West')).toBeVisible(); }); - test('AC-USR-001-2: Search Users - should filter users by search term', async ({ - page, - }) => { - await usersPage.goto(); + test('AC-USR-001-2: search filters the list', async () => { + await users.goto(); + await users.search('Jeremy'); - // Wait for list to load - await page.waitForTimeout(1000); - - // Search for a specific user - await usersPage.search('Alex'); - - // Verify filtered results - const items = usersPage.usersList; - const count = await items.count(); - - // If mock data has Alex, should find at least one - if (count > 0) { - const text = await items.first().textContent(); - expect(text?.toLowerCase()).toContain('alex'); - } + await expect(users.itemLink('Jeremy West')).toBeVisible(); + await expect(users.itemLink('Alex Sorafumo')).toHaveCount(0); }); - test('AC-USR-001-3: User Selection - should display user details when selected', async ({ + test('AC-USR-001-3: selecting a user opens their details', async ({ page, }) => { - await usersPage.goto(); - - // Wait for list to load - await page.waitForTimeout(1000); + await users.goto(); + await users.itemLink('Jeremy West').click(); - // Select first user - const firstUser = usersPage.usersList.first(); - await firstUser.click(); - - // Verify details are displayed - await expect(usersPage.mainContent).toBeVisible(); + await expect(page).toHaveURL(/#\/users\/user-[^/]+\//); + await expect(users.itemName).toHaveText('Jeremy West'); }); }); - /** - * US-USR-002: View User Details - */ test.describe('US-USR-002: View User Details', () => { - test('AC-USR-002-1: Display Basic Information - should show name and email', async ({ - page, - }) => { - await usersPage.goto(); - await page.waitForTimeout(1000); - - // Select a user - await usersPage.usersList.first().click(); - await page.waitForTimeout(500); + test('AC-USR-002-1: shows the email address', async () => { + await users.openUser('Jeremy West'); - // Verify basic info is displayed - await expect(usersPage.mainContent).toBeVisible(); - - // Should show email (contains @) - await expect(usersPage.mainContent.locator('text=/@/').first()) - .toBeVisible({ timeout: 5000 }) - .catch(() => { - // Email might be displayed differently - }); + await expect( + users.tabContent.getByRole('link', { + name: 'Jeremy.West@place.tech', + }), + ).toHaveAttribute('href', 'mailto:Jeremy.West@place.tech'); }); }); - /** - * US-USR-003: Create New User - */ test.describe('US-USR-003: Create New User', () => { - test('AC-USR-003-1: Access Create Form - should open create modal', async () => { - await usersPage.goto(); + test('AC-USR-003-2: names, email and password are required', async () => { + await users.goto(); + await users.openAddModal(); - // Click add button - await usersPage.addButton.click(); + await users.saveButton.click(); - // Verify modal appears - await expect(usersPage.dialog).toBeVisible(); + await expect(users.dialog).toBeVisible(); + await expect(users.dialog.locator('mat-error')).toHaveCount(4); }); - test('AC-USR-003-6: Successful Creation - should create user and show in list', async ({ - page, - }) => { - await usersPage.goto(); + test('AC-USR-003-6: saves the user and opens them', async () => { + await users.goto(); - const timestamp = Date.now(); - const uniqueEmail = `test.user.${timestamp}@test.com`; + await users.createWith(() => + users.fillNewUser({ + first_name: 'Grace', + last_name: 'Hopper', + email: 'grace.hopper@place.tech', + password: 'Cobol-1959!', + }), + ); - await usersPage.createUser({ - first_name: 'Test', - last_name: `User${timestamp}`, - email: uniqueEmail, - password: 'TestPassword123!', - }); - - // In mock mode, verify the dialog closed (creation succeeded) or no validation errors remain - // Mock mode might not properly close the dialog due to API mocking limitations - try { - await expect(usersPage.dialog).not.toBeVisible({ - timeout: 10000, - }); - } catch { - // If dialog is still visible, verify at least no validation errors are shown - const validationErrors = page.locator( - '.mat-mdc-form-field-error, mat-error, [class*="error"]:has-text("required")', - ); - await expect(validationErrors).toHaveCount(0, { - timeout: 2000, - }); - } + await expect(users.itemName).toHaveText('Grace Hopper'); + await expect( + users.tabContent.getByRole('link', { + name: 'grace.hopper@place.tech', + }), + ).toBeVisible(); + await users.search('Grace'); + await expect(users.itemLink('Grace Hopper')).toBeVisible(); }); }); - /** - * US-USR-004: Edit User Details - */ test.describe('US-USR-004: Edit User Details', () => { - test('AC-USR-004-1: Access Edit Mode - should open edit modal with current details', async ({ - page, - }) => { - await usersPage.goto(); - - // Select first user - await usersPage.usersList.first().click(); - await page.waitForTimeout(500); + test('AC-USR-004-1: the edit form holds the current email', async () => { + await users.openUser('Jeremy West'); + await users.openEditModal(); - // Click edit - await usersPage.openEditModal(); - - // Verify modal with pre-populated fields - await expect(usersPage.dialog).toBeVisible(); + await expect(users.field('Email*')).toHaveValue( + 'Jeremy.West@place.tech', + ); }); - test('AC-USR-004-4: Save Changes - should persist changes', async ({ - page, - }) => { - await usersPage.goto(); - - // Select first user - await usersPage.usersList.first().click(); - await page.waitForTimeout(500); + test('AC-USR-004-4: a changed email is kept', async ({ page }) => { + await users.openUser('Jeremy West'); + await users.openEditModal(); + await users.field('Email*').fill('jeremy@place.tech'); + await users.saveButton.click(); + await expect(users.dialog).toBeHidden(); - // Open edit modal - await usersPage.openEditModal(); - - // Wait for the form field to be available - const nameField = usersPage.dialog - .locator('input[formcontrolname="name"], input') - .first(); - await nameField.inputValue(); - - // Save without changes (just verify save works) - await usersPage.save(); - - // Verify dialog closed - await expect(usersPage.dialog) - .not.toBeVisible({ timeout: 5000 }) - .catch(() => undefined); + const email = users.tabContent.getByRole('link', { + name: 'jeremy@place.tech', + }); + await expect(email).toBeVisible(); + await page.reload(); + await users.waitForItemDetails(); + await expect(email).toBeVisible(); }); }); - /** - * US-USR-005: Delete User - */ test.describe('US-USR-005: Delete User', () => { - test('AC-USR-005-1: Delete Confirmation - should show confirmation dialog', async ({ - page, - }) => { - await usersPage.goto(); - - // Select a user - await usersPage.usersList.first().click(); - await page.waitForTimeout(500); - - // Open action menu and click delete - await usersPage.openActionMenu(); - await usersPage.deleteButton.click(); + test('AC-USR-005-2: removes the user from the list', async () => { + await users.openUser('Jeremy West'); + await users.deleteSelected(); - // Verify confirmation dialog appears - await expect(usersPage.dialog).toBeVisible(); + await expect(users.itemLink('Jeremy West')).toHaveCount(0); + await users.clearSearch(); + await expect(users.itemLink('Alex Sorafumo')).toBeVisible(); }); - test('AC-USR-005-3: Cancel Deletion - should keep user when cancelled', async ({ + test('AC-USR-005-3: keeps the user when the confirmation is dismissed', async ({ page, }) => { - await usersPage.goto(); + await users.openUser('Jeremy West'); + await users.openDeleteConfirmation(); + await page.keyboard.press('Escape'); - // Select a user - const firstUser = usersPage.usersList.first(); - const userName = await firstUser.textContent(); - await firstUser.click(); - await page.waitForTimeout(500); - - // Open action menu and click delete - await usersPage.openActionMenu(); - await usersPage.deleteButton.click(); - await expect(usersPage.dialog).toBeVisible(); - - // Cancel - await usersPage.cancelButton.click(); - - // Verify user still exists - if (userName) { - await usersPage.expectUserInList( - userName.trim().split('\n')[0], - ); - } - }); - }); - - /** - * US-USR-007: Enable/Disable User - */ - test.describe('US-USR-007: Enable/Disable User', () => { - test('AC-USR-007-3: Status Indicator - should display user status', async ({ - page, - }) => { - await usersPage.goto(); - - // Select a user - await usersPage.usersList.first().click(); - await page.waitForTimeout(500); - - // Verify user details are shown - await expect(usersPage.mainContent).toBeVisible(); + await expect(page.locator('confirm-modal')).toHaveCount(0); + await expect(users.itemName).toHaveText('Jeremy West'); }); }); - /** - * US-USR-009: Manage User Metadata - */ test.describe('US-USR-009: Manage User Metadata', () => { - test('AC-USR-009-1: View Metadata - should display metadata entries', async ({ - page, - }) => { - await usersPage.goto(); + test('AC-USR-009-1: shows the empty metadata list', async () => { + await users.openUser('Jeremy West'); + await users.openTab('metadata'); - // Select a user - await usersPage.usersList.first().click(); - await page.waitForTimeout(500); - - // Navigate to metadata tab - await usersPage.viewMetadata(); - - // Verify metadata tab content is visible - await expect(usersPage.mainContent).toBeVisible(); + await expect(users.tabContent).toContainText( + 'No Metadata blocks found', + ); }); - test('AC-USR-009-2: Metadata Loading State - should show spinner while metadata is loading', async ({ + test('AC-USR-009-2: shows a spinner while metadata loads', async ({ page, }) => { await page.addInitScript(() => { sessionStorage.setItem('PLACEOS.mocks.metadata_delay', '1000'); }); - await usersPage.goto(); - - await usersPage.usersList.first().click(); - await page.waitForTimeout(500); - - await usersPage.metadataTab.click(); - await expect(page.locator('metadata-display')).toBeAttached({ - timeout: 5000, - }); + await users.openUser('Jeremy West'); + await users.openTab('metadata'); - await expect(usersPage.metadataLoadingSpinner).toBeVisible({ - timeout: 5000, - }); - await expect(usersPage.metadataLoadingSpinner).not.toBeVisible({ - timeout: 10000, - }); + await expect(users.metadataLoadingSpinner).toBeVisible(); + await expect(users.metadataLoadingSpinner).toBeHidden(); }); }); - /** - * US-USR-010: View User History - */ test.describe('US-USR-010: View User History', () => { - test('AC-USR-010-1: Display Change History - should show history list', async ({ - page, - }) => { - await usersPage.goto(); - - // Select a user - await usersPage.usersList.first().click(); - await page.waitForTimeout(500); - - // Navigate to history tab - await usersPage.viewHistory(); - - // Verify history tab content is visible - await expect(usersPage.mainContent).toBeVisible(); - }); - }); - - /** - * US-USR-012: View User Groups - */ - test.describe('US-USR-012: View User Groups', () => { - test('AC-USR-012-1: Display Groups - should show user groups', async ({ - page, - }) => { - await usersPage.goto(); - - // Select a user - await usersPage.usersList.first().click(); - await page.waitForTimeout(500); - - // Navigate to groups tab (if available) - await usersPage.viewGroups().catch(() => { - // Groups tab might not be available in mock mode - }); + test('AC-USR-010-1: shows the empty session log', async () => { + await users.openUser('Jeremy West'); + await users.openTab('history'); - // Verify content is visible - await expect(usersPage.mainContent).toBeVisible(); + await expect(users.tabContent).toContainText( + 'No logs found for this user', + ); }); }); }); diff --git a/e2e/src/zones.spec.ts b/e2e/src/zones.spec.ts index 58ab900cb..1c2b3878c 100644 --- a/e2e/src/zones.spec.ts +++ b/e2e/src/zones.spec.ts @@ -1,370 +1,255 @@ -import { expect, test } from '@playwright/test'; -import { ZonesPage } from './pages'; +import { expect, test } from './fixtures/test-fixtures'; +import { FeaturePage } from './pages'; /** * Zones E2E Tests * - * Tests for user stories US-ZON-001 through US-ZON-011 + * Tests for user stories US-ZON-001 through US-ZON-011. + * + * The mock zone tree these tests rely on: + * + * Place Technology (org) + * └── Tower 2 (building) + * ├── Level 30 zone-LEHeo501Er + * │ └── L30 Activity Spaces zone-iIdF20naW0 + * └── Level 31 zone-QjLXbYUxuC + * ├── L31 Activity Spaces + * ├── L31 Multifunction: 31.22 + * └── L31 R7 Activity Space + * New Zone (root, no children, no systems) zone-lmhh_hVfz0 */ +const L30_SPACES = 'zone-iIdF20naW0'; +const LEVEL_31 = 'zone-QjLXbYUxuC'; +const NEW_ZONE = 'zone-lmhh_hVfz0'; + test.describe('Zones', () => { - let zonesPage: ZonesPage; + let zones: FeaturePage; test.beforeEach(async ({ page }) => { - // Set mock mode in localStorage - await page.addInitScript(() => { - localStorage.setItem('BACKOFFICE.mock', 'true'); - }); - zonesPage = new ZonesPage(page); + zones = new FeaturePage(page, 'zones'); }); - /** - * US-ZON-001: View Zones List - */ test.describe('US-ZON-001: View Zones List', () => { - test('AC-ZON-001-1: Display Zones List - should show sidebar with all zones', async () => { - await zonesPage.goto(); - - // Verify the sidebar is visible - await expect(zonesPage.sidebarList).toBeVisible(); + test('AC-ZON-001-1: lists the zones', async () => { + await zones.goto(); - // Verify zones are listed - await expect(zonesPage.zonesList.first()).toBeVisible({ - timeout: 10000, - }); + await expect(zones.itemLink('Place Technology')).toBeVisible(); + await expect(zones.itemLink('Tower 2')).toBeVisible(); }); - test('AC-ZON-001-3: Search Zones - should filter zones by search term', async ({ - page, - }) => { - await zonesPage.goto(); - - // Wait for list to load - await page.waitForTimeout(1000); + test('AC-ZON-001-3: search filters the list', async () => { + await zones.goto(); + await zones.search('Level 3'); - // Search for a specific zone - await zonesPage.search('Level'); - - // Wait for search results to filter - await page.waitForTimeout(500); - - // Verify filtered results - use paragraph elements inside the links which contain the zone names - const items = page - .locator('item-sidebar a p, virtual-scroll a p') - .filter({ hasText: /Level/i }); - const count = await items.count(); - expect(count).toBeGreaterThan(0); + await expect(zones.itemLink('Level 30')).toBeVisible(); + await expect(zones.itemLink('Level 31')).toBeVisible(); + await expect(zones.itemLink('Tower 2')).toHaveCount(0); }); - test('AC-ZON-001-4: Zone Selection - should display zone details when selected', async ({ + test('AC-ZON-001-4: selecting a zone opens its details', async ({ page, }) => { - await zonesPage.goto(); - - // Wait for list to load - await page.waitForTimeout(1000); + await zones.goto(); + await zones.itemLink('New Zone').click(); - // Select first zone - const firstZone = zonesPage.zonesList.first(); - await firstZone.click(); - - // Verify details are displayed - await expect(zonesPage.mainContent).toBeVisible(); + await expect(page).toHaveURL(new RegExp(`#/zones/${NEW_ZONE}/`)); + await expect(zones.itemName).toHaveText('New Zone'); }); }); - /** - * US-ZON-002: View Zone Details - */ test.describe('US-ZON-002: View Zone Details', () => { - test('AC-ZON-002-1: Display Basic Information - should show zone name', async ({ - page, - }) => { - await zonesPage.goto(); - await page.waitForTimeout(1000); - - // Select a zone - const firstZone = zonesPage.zonesList.first(); - const zoneName = await firstZone.textContent(); - await firstZone.click(); - await page.waitForTimeout(500); - - // Verify zone details - await expect(zonesPage.mainContent).toBeVisible(); - if (zoneName) { - await expect( - zonesPage.mainContent - .locator(`text=${zoneName.trim().split('\n')[0]}`) - .first(), - ) - .toBeVisible({ timeout: 5000 }) - .catch(() => undefined); - } + test('AC-ZON-002-1: shows each tag once', async () => { + await zones.gotoItem(LEVEL_31); + + await expect( + zones.itemDetails.getByText('level', { exact: true }), + ).toHaveCount(1); }); - }); - /** - * US-ZON-003: Create New Zone - */ - test.describe('US-ZON-003: Create New Zone', () => { - test('AC-ZON-003-1: Access Create Form - should open create modal', async () => { - await zonesPage.goto(); + test('AC-ZON-002-2: links to the parent zone', async ({ page }) => { + await zones.gotoItem(L30_SPACES); - // Click add button - await zonesPage.addButton.click(); + const parent = zones.tabContent.getByRole('link', { + name: 'Level 30 (zone-LEHeo501Er)', + }); + await parent.click(); - // Verify modal appears - await expect(zonesPage.dialog).toBeVisible(); + await expect(page).toHaveURL(/#\/zones\/zone-LEHeo501Er\/about/); + await expect(zones.itemName).toHaveText('Level 30'); }); + }); - test('AC-ZON-003-5: Successful Creation - should create zone and show in list', async ({ - page, - }) => { - await zonesPage.goto(); + test.describe('US-ZON-003: Create New Zone', () => { + test('AC-ZON-003-2: name is required', async () => { + await zones.goto(); + await zones.openAddModal(); - const uniqueName = `Test Zone ${Date.now()}`; + await zones.saveButton.click(); - await zonesPage.createZone({ - name: uniqueName, - description: 'E2E test zone', - }); + await expect(zones.dialog).toBeVisible(); + await expect(zones.dialog.locator('mat-error')).toBeVisible(); + }); - // Wait for creation - await page.waitForTimeout(1000); + test('AC-ZON-003-5: saves the zone and opens it', async () => { + await zones.goto(); - // Search for the new zone - await zonesPage.search(uniqueName); + await zones.createWith(async () => { + await zones.dialog + .getByRole('textbox', { name: 'Name*', exact: true }) + .fill('Level 32'); + }); - // Verify it appears in the list - await zonesPage.expectZoneInList(uniqueName); + await expect(zones.itemName).toHaveText('Level 32'); + await expect(zones.itemLink('Level 32')).toBeVisible(); }); }); - /** - * US-ZON-004: Edit Zone Details - */ test.describe('US-ZON-004: Edit Zone Details', () => { - test('AC-ZON-004-1: Access Edit Mode - should open edit modal with current details', async ({ - page, - }) => { - await zonesPage.goto(); - - // Select first zone - await zonesPage.zonesList.first().click(); - await page.waitForTimeout(500); - - // Click edit - await zonesPage.openEditModal(); - - // Verify modal with pre-populated fields - await expect(zonesPage.dialog).toBeVisible(); - // Find the Name field specifically using accessible name with exact match. - // The label includes the required marker, so the name is "Name*". - const nameField = zonesPage.dialog.getByRole('textbox', { - name: 'Name*', - exact: true, - }); - const value = await nameField.inputValue(); - expect(value.length).toBeGreaterThan(0); + test('AC-ZON-004-1: the edit form holds the current name', async () => { + await zones.gotoItem(NEW_ZONE); + await zones.openEditModal(); + + await expect( + zones.dialog.getByRole('textbox', { + name: 'Name*', + exact: true, + }), + ).toHaveValue('New Zone'); }); - test('AC-ZON-004-5: Save Changes - should persist changes', async ({ + test('AC-ZON-004-5: a renamed zone keeps its new name', async ({ page, }) => { - await zonesPage.goto(); - - // Select first zone - await zonesPage.zonesList.first().click(); - await page.waitForTimeout(500); - - // Open edit modal - await zonesPage.openEditModal(); - - // Save without changes - await zonesPage.save(); - - // Verify dialog closed - await expect(zonesPage.dialog) - .not.toBeVisible({ timeout: 5000 }) - .catch(() => undefined); + await zones.gotoItem(NEW_ZONE); + await zones.openEditModal(); + await zones.dialog + .getByRole('textbox', { name: 'Name*', exact: true }) + .fill('Basement'); + await zones.saveButton.click(); + await expect(zones.dialog).toBeHidden(); + + await expect(zones.itemName).toHaveText('Basement'); + await page.reload(); + await zones.waitForItemDetails(); + await expect(zones.itemName).toHaveText('Basement'); + await expect(zones.itemLink('New Zone')).toHaveCount(0); + }); + + test('AC-ZON-004-6: closing the modal discards changes', async () => { + await zones.gotoItem(NEW_ZONE); + await zones.openEditModal(); + await zones.dialog + .getByRole('textbox', { name: 'Name*', exact: true }) + .fill('Not Saved'); + await zones.discardChanges(); + + await expect(zones.dialog).toBeHidden(); + await expect(zones.itemName).toHaveText('New Zone'); }); }); - /** - * US-ZON-005: Delete Zone - */ test.describe('US-ZON-005: Delete Zone', () => { - test('AC-ZON-005-1: Delete Confirmation - should show confirmation dialog', async ({ - page, - }) => { - await zonesPage.goto(); - - // Select a zone - await zonesPage.zonesList.first().click(); - await page.waitForTimeout(500); - - // Open action menu and click delete - await zonesPage.openActionMenu(); - await zonesPage.deleteButton.click(); + test('AC-ZON-005-3: removes the zone from the list', async () => { + await zones.gotoItem(NEW_ZONE); + await zones.deleteSelected(); - // Verify confirmation dialog appears - await expect(zonesPage.dialog).toBeVisible(); + await expect(zones.itemLink('New Zone')).toHaveCount(0); + await expect(zones.itemLink('Tower 2')).toBeVisible(); }); - test('AC-ZON-005-4: Cancel Deletion - should keep zone when cancelled', async ({ + test('AC-ZON-005-4: keeps the zone when the confirmation is dismissed', async ({ page, }) => { - await zonesPage.goto(); - - // Select a zone - const firstZone = zonesPage.zonesList.first(); - const zoneName = await firstZone.textContent(); - await firstZone.click(); - await page.waitForTimeout(500); - - // Open action menu and click delete - await zonesPage.openActionMenu(); - await zonesPage.deleteButton.click(); - await expect(zonesPage.dialog).toBeVisible(); - - // Cancel - await zonesPage.cancelButton.click(); - - // Verify zone still exists - if (zoneName) { - await zonesPage.expectZoneInList( - zoneName.trim().split('\n')[0], - ); - } + await zones.gotoItem(NEW_ZONE); + await zones.openDeleteConfirmation(); + await page.keyboard.press('Escape'); + + await expect(page.locator('confirm-modal')).toHaveCount(0); + await expect(zones.itemName).toHaveText('New Zone'); + await expect(zones.itemLink('New Zone')).toBeVisible(); }); }); - /** - * US-ZON-006: View Child Zones - */ test.describe('US-ZON-006: View Child Zones', () => { - test('AC-ZON-006-1: Display Child Zones - should show children tab', async ({ - page, - }) => { - await zonesPage.goto(); - - // Select a zone - await zonesPage.zonesList.first().click(); - await page.waitForTimeout(500); - - // Navigate to children tab - await zonesPage.viewChildren(); - - // Verify children tab content is visible - await expect(zonesPage.mainContent).toBeVisible(); + test('AC-ZON-006-1: lists the child zones', async () => { + await zones.gotoItem(LEVEL_31); + await zones.openTab('children'); + + for (const name of [ + 'L31 Activity Spaces', + 'L31 Multifunction: 31.22', + 'L31 R7 Activity Space', + ]) { + await expect( + zones.tabContent.getByRole('link', { name }), + ).toBeVisible(); + } }); }); - /** - * US-ZON-007: View Systems in Zone - */ test.describe('US-ZON-007: View Systems in Zone', () => { - test('AC-ZON-007-1: Display Systems List - should show systems in zone', async ({ + test('AC-ZON-007-1: lists the systems and links to them', async ({ page, }) => { - await zonesPage.goto(); - - // Select a zone - await zonesPage.zonesList.first().click(); - await page.waitForTimeout(500); + await zones.gotoItem(L30_SPACES); + await zones.openTab('systems'); - // Navigate to systems tab - await zonesPage.viewSystems(); + const system = zones.tabContent.getByRole('link', { + name: '2.30.08 - Activity Space', + }); + await system.click(); - // Verify systems tab content is visible - await expect(zonesPage.mainContent).toBeVisible(); + await expect(page).toHaveURL(/#\/systems\/sys-QjJvCH7_dA/); + await expect(zones.itemName).toHaveText('2.30.08 - Activity Space'); }); }); - /** - * US-ZON-008: Manage Zone Triggers - */ test.describe('US-ZON-008: Manage Zone Triggers', () => { - test('AC-ZON-008-1: View Zone Triggers - should display triggers list', async ({ - page, - }) => { - await zonesPage.goto(); - - // Select a zone - await zonesPage.zonesList.first().click(); - await page.waitForTimeout(500); - - // Navigate to triggers tab - await zonesPage.viewTriggers(); + test('AC-ZON-008-1: shows the empty triggers list', async () => { + await zones.gotoItem(NEW_ZONE); + await zones.openTab('triggers'); - // Verify triggers tab content is visible - await expect(zonesPage.mainContent).toBeVisible(); + await expect(zones.tabContent).toContainText( + 'No triggers for selected zone', + ); }); }); - /** - * US-ZON-009: Manage Zone Metadata - */ test.describe('US-ZON-009: Manage Zone Metadata', () => { - test('AC-ZON-009-1: View Metadata - should display metadata entries', async ({ - page, - }) => { - await zonesPage.goto(); + test('AC-ZON-009-1: shows the empty metadata list', async () => { + await zones.gotoItem(NEW_ZONE); + await zones.openTab('metadata'); - // Select a zone - await zonesPage.zonesList.first().click(); - await page.waitForTimeout(500); - - // Navigate to metadata tab - await zonesPage.viewMetadata(); - - // Verify metadata tab content is visible - await expect(zonesPage.mainContent).toBeVisible(); + await expect(zones.tabContent).toContainText( + 'No Metadata blocks found', + ); }); - test('AC-ZON-009-2: Metadata Loading State - should show spinner while metadata is loading', async ({ + test('AC-ZON-009-2: shows a spinner while metadata loads', async ({ page, }) => { await page.addInitScript(() => { sessionStorage.setItem('PLACEOS.mocks.metadata_delay', '1000'); }); - await zonesPage.goto(); + await zones.gotoItem(NEW_ZONE); + await zones.openTab('metadata'); - await zonesPage.zonesList.first().click(); - await page.waitForTimeout(500); - - await zonesPage.metadataTab.click(); - await expect(page.locator('metadata-display')).toBeAttached({ - timeout: 5000, - }); - - await expect(zonesPage.metadataLoadingSpinner).toBeVisible({ - timeout: 5000, - }); - await expect(zonesPage.metadataLoadingSpinner).not.toBeVisible({ - timeout: 10000, - }); + await expect(zones.metadataLoadingSpinner).toBeVisible(); + await expect(zones.metadataLoadingSpinner).toBeHidden(); }); }); - /** - * US-ZON-010: View Zone History - */ test.describe('US-ZON-010: View Zone History', () => { - test('AC-ZON-010-1: Display Change History - should show history list', async ({ - page, - }) => { - await zonesPage.goto(); - - // Select a zone - await zonesPage.zonesList.first().click(); - await page.waitForTimeout(500); - - // Navigate to history tab - await zonesPage.viewHistory(); - - // Verify history tab content is visible - await expect(zonesPage.mainContent).toBeVisible(); + test('AC-ZON-010-1: opens the settings history view', async () => { + await zones.gotoItem(NEW_ZONE); + await zones.openTab('history'); + + await expect( + zones.tabContent.getByRole('combobox', { + name: 'Select current settings', + }), + ).toBeVisible(); }); }); }); diff --git a/src/app/mocks/backend/domains.mock.ts b/src/app/mocks/backend/domains.mock.ts index d4b5479a1..6eb7efec4 100644 --- a/src/app/mocks/backend/domains.mock.ts +++ b/src/app/mocks/backend/domains.mock.ts @@ -1,5 +1,5 @@ import { HashMap } from '../../common/types'; -import { API, generateBasicHandlers } from '../common.mock'; +import { API, DOMAIN, generateBasicHandlers } from '../common.mock'; const FILTER_FN = (item: Record, q: HashMap) => { if (!q || Object.keys(q).length <= 0) { @@ -16,7 +16,18 @@ const FILTER_FN = (item: Record, q: HashMap) => { return match; }; -const DOMAIN_DATA = []; +/** Id of the authority the ts-client mock signs in to. Every mock user and + * group belongs to it */ +export const AUTHORITY_ID = 'mock-authority'; -/** Add basic API handlers for systems */ +const DOMAIN_DATA = [ + { + id: AUTHORITY_ID, + name: 'Place Technology', + domain: DOMAIN, + email_domains: [DOMAIN], + }, +]; + +/** Add basic API handlers for domains */ generateBasicHandlers(`${API}/domains`, DOMAIN_DATA, FILTER_FN); diff --git a/src/app/mocks/backend/general.mock.ts b/src/app/mocks/backend/general.mock.ts index a59718be8..69c84b61e 100644 --- a/src/app/mocks/backend/general.mock.ts +++ b/src/app/mocks/backend/general.mock.ts @@ -1,5 +1,5 @@ import { registerMockEndpoint } from '@placeos/ts-client'; -import { API } from '../common.mock'; +import { API, generateBasicHandlers } from '../common.mock'; registerMockEndpoint({ path: `${API}/version`, @@ -17,3 +17,19 @@ registerMockEndpoint({ method: 'GET', callback: () => [], }); + +/** Backend versions for the admin about section */ +registerMockEndpoint({ + path: `${API}/cluster/versions`, + method: 'GET', + callback: () => [], +}); + +registerMockEndpoint({ + path: `${API}/platform`, + method: 'GET', + callback: () => ({ version: 'mock', changelog: '' }), +}); + +/** Custom schemas in the admin section */ +generateBasicHandlers(`${API}/schema`, []); diff --git a/src/app/mocks/backend/groups.mock.ts b/src/app/mocks/backend/groups.mock.ts new file mode 100644 index 000000000..faff0b17a --- /dev/null +++ b/src/app/mocks/backend/groups.mock.ts @@ -0,0 +1,296 @@ +import { MockHttpRequest, registerMockEndpoint } from '@placeos/ts-client'; +import { HashMap } from '../../common/types'; +import { + API, + endpointData, + generateBasicHandlers, + loadFromSession, + saveToSession, +} from '../common.mock'; +import { AUTHORITY_ID } from './domains.mock'; + +const SEED_TIME = '2024-03-01T09:00:00.000Z'; + +interface MockGroup { + id: string; + name: string; + description: string; + authority_id: string; + parent_id?: string; + subsystems: string[]; + default_permissions: number; + ad_group_mappings: Record; + created_at: string; + updated_at: string; +} + +interface MockGroupUser { + user_id: string; + group_id: string; + permissions: number; + auto_assigned: string; + created_at: string; + updated_at: string; +} + +interface MockGroupZone { + group_id: string; + zone_id: string; + permissions: number; + deny: boolean; + created_at: string; + updated_at: string; +} + +const GROUP_DATA: MockGroup[] = [ + { + id: 'group-facilities', + name: 'Facilities', + description: 'Building and facilities staff', + authority_id: AUTHORITY_ID, + subsystems: ['bookings'], + default_permissions: 1, + ad_group_mappings: {}, + created_at: SEED_TIME, + updated_at: SEED_TIME, + }, + { + id: 'group-av-technicians', + name: 'AV Technicians', + description: 'Maintains the meeting room AV equipment', + authority_id: AUTHORITY_ID, + parent_id: 'group-facilities', + subsystems: ['bookings', 'control'], + default_permissions: 1 | 16, + ad_group_mappings: {}, + created_at: SEED_TIME, + updated_at: SEED_TIME, + }, + { + id: 'group-reception', + name: 'Reception', + description: 'Front desk and visitor management', + authority_id: AUTHORITY_ID, + subsystems: ['visitors'], + default_permissions: 1, + ad_group_mappings: {}, + created_at: SEED_TIME, + updated_at: SEED_TIME, + }, +]; + +/** User IDs are from `./users.mock` */ +const GROUP_USER_SEED: [string, string, number][] = [ + ['user-alex-sorafumo', 'group-facilities', 1 | 4], + ['user-jeremy-west', 'group-av-technicians', 1 | 16], + ['current', 'group-reception', 255], +]; + +/** Zone IDs are from `../data/zones` */ +const GROUP_ZONE_SEED: [string, string, number, boolean][] = [ + ['group-facilities', 'zone-Kl0HN~nDwc', 1, false], // Tower 2 + ['group-av-technicians', 'zone-LEHeo501Er', 1 | 16, false], // Level 30 + ['group-av-technicians', 'zone-QjLXbYUxuC', 0, true], // Level 31 +]; + +const GROUP_FILTER_FN = (item: MockGroup, q: HashMap) => { + if (q.q) { + const search = `${q.q}`.toLowerCase(); + if (!item.name.toLowerCase().includes(search)) return false; + } + if (q.parent_id) { + // `root` matches groups without a parent, like the API + const parent_id = item.parent_id || ''; + if (q.parent_id === 'root' ? !!parent_id : q.parent_id !== parent_id) + return false; + } + if (q.authority_id && item.authority_id !== q.authority_id) return false; + return true; +}; + +const groups = generateBasicHandlers( + `${API}/groups`, + GROUP_DATA, + GROUP_FILTER_FN, +); + +/** Count of direct children, which the API adds to group details */ +function withChildCount(group: MockGroup) { + const children_count = groups().filter( + ({ parent_id }) => parent_id === group.id, + ).length; + return { ...group, children_count }; +} + +registerMockEndpoint({ + path: `${API}/groups/:id`, + metadata: GROUP_DATA, + method: 'GET', + callback: (event) => { + const group = groups().find(({ id }) => id === event.route_params.id); + if (!group) throw { status: 404, message: 'Not found' }; + return withChildCount(group); + }, +}); + +/** + * Registers index, add, show, update and remove handlers for a join table. + * Rows are keyed by two IDs in the order of the item path, + * e.g. `group_users/:user_id/:group_id`. + * Rows persist to the same session key as `generateBasicHandlers`, so e2e + * tests can seed them with `seedMockData`. + * @param endpoint API endpoint path + * @param keys Names of the two ID fields, in path order + * @param seed Rows used when session storage has none. Called on first use + * @param create Makes a full row from the POST body + * @param expand Adds related details to rows in responses + */ +function generateJoinHandlers( + endpoint: string, + keys: [keyof T & string, keyof T & string], + seed: () => T[], + create: (body: Partial) => T, + expand: (row: T) => T & Record, +) { + const storage_key = endpoint.replace(/\//g, '_').replace(/^_/, ''); + let rows: T[] | null = null; + const list = () => (rows ??= loadFromSession(storage_key, seed())); + const save = (next: T[]) => { + rows = next; + saveToSession(storage_key, next); + }; + const matches = (row: T, ids: HashMap) => + keys.every((key) => !ids[key] || `${row[key]}` === ids[key]); + const indexOf = (event: MockHttpRequest) => { + const index = list().findIndex((row) => + keys.every((key) => `${row[key]}` === event.route_params[key]), + ); + if (index < 0) throw { status: 404, message: 'Not found' }; + return index; + }; + const item_path = `${endpoint}/:${keys[0]}/:${keys[1]}`; + + registerMockEndpoint({ + path: endpoint, + method: 'GET', + callback: (event) => + list() + .filter((row) => matches(row, event.query_params)) + .map(expand), + }); + registerMockEndpoint({ + path: endpoint, + method: 'POST', + callback: (event) => { + const row = create(event.body); + const ids = { + [keys[0]]: `${row[keys[0]]}`, + [keys[1]]: `${row[keys[1]]}`, + }; + if (list().some((existing) => matches(existing, ids))) + throw { status: 409, message: 'Already exists' }; + save([...list(), row]); + return expand(row); + }, + }); + registerMockEndpoint({ + path: item_path, + method: 'GET', + callback: (event) => expand(list()[indexOf(event)]), + }); + for (const method of ['PATCH', 'PUT'] as const) { + registerMockEndpoint({ + path: item_path, + method, + callback: (event) => { + const index = indexOf(event); + const next = [...list()]; + next[index] = { + ...next[index], + ...event.body, + updated_at: new Date().toISOString(), + }; + save(next); + return expand(next[index]); + }, + }); + } + registerMockEndpoint({ + path: item_path, + method: 'DELETE', + callback: (event) => { + const index = indexOf(event); + save(list().filter((_, idx) => idx !== index)); + return null; + }, + }); +} + +/** Group details for the user and zone pages */ +function findGroup(group_id: string) { + return groups().find(({ id }) => id === group_id); +} + +generateJoinHandlers( + `${API}/group_users`, + ['user_id', 'group_id'], + () => + GROUP_USER_SEED.map(([user_id, group_id, permissions]) => ({ + user_id, + group_id, + permissions, + auto_assigned: '', + created_at: SEED_TIME, + updated_at: SEED_TIME, + })), + (body) => { + const now = new Date().toISOString(); + return { + user_id: body.user_id || '', + group_id: body.group_id || '', + // Users added without permissions get the group defaults + permissions: + body.permissions ?? + findGroup(body.group_id || '')?.default_permissions ?? + 0, + auto_assigned: '', + created_at: now, + updated_at: now, + }; + }, + (row) => ({ + ...row, + user: endpointData(`${API}/users`).find(({ id }) => id === row.user_id), + group: findGroup(row.group_id), + }), +); + +generateJoinHandlers( + `${API}/group_zones`, + ['group_id', 'zone_id'], + () => + GROUP_ZONE_SEED.map(([group_id, zone_id, permissions, deny]) => ({ + group_id, + zone_id, + permissions, + deny, + created_at: SEED_TIME, + updated_at: SEED_TIME, + })), + (body) => { + const now = new Date().toISOString(); + return { + group_id: body.group_id || '', + zone_id: body.zone_id || '', + permissions: body.permissions || 0, + deny: !!body.deny, + created_at: now, + updated_at: now, + }; + }, + (row) => ({ + ...row, + zone: endpointData(`${API}/zones`).find(({ id }) => id === row.zone_id), + group: findGroup(row.group_id), + }), +); diff --git a/src/app/mocks/backend/index.ts b/src/app/mocks/backend/index.ts index fefede566..8e7569caf 100644 --- a/src/app/mocks/backend/index.ts +++ b/src/app/mocks/backend/index.ts @@ -1,5 +1,6 @@ import './domains.mock'; import './drivers.mock'; +import './groups.mock'; import './modules.mock'; import './repositories.mock'; import './systems.mock'; diff --git a/src/app/mocks/backend/users.mock.ts b/src/app/mocks/backend/users.mock.ts index 3af3e6168..050611bbf 100644 --- a/src/app/mocks/backend/users.mock.ts +++ b/src/app/mocks/backend/users.mock.ts @@ -1,5 +1,6 @@ import { HashMap } from '../../common/types'; import { API, generateBasicHandlers, generateID } from '../common.mock'; +import { AUTHORITY_ID } from './domains.mock'; const FILTER_FN = (item: Record, q: HashMap) => { if (!q || Object.keys(q).length <= 0) { @@ -13,9 +14,24 @@ const FILTER_FN = (item: Record, q: HashMap) => { .toLowerCase() .indexOf(((q.q as string) || '').toLowerCase()) >= 0; } + if (q.authority_id) { + match = match && item.authority_id === q.authority_id; + } return match; }; +type MockUser = Record; + +/** Keeps `name` and `first_name`/`last_name` in step, like the API does */ +function withNames(user: MockUser): MockUser { + if (user.first_name || user.last_name) { + const name = `${user.first_name || ''} ${user.last_name || ''}`; + return { ...user, name: name.trim() }; + } + const [first_name, ...rest] = `${user.name || ''}`.split(' '); + return { ...user, first_name, last_name: rest.join(' ') }; +} + const PREDFINED_USERS = [ 'Alex Sorafumo', 'Jonathan McFarlane', @@ -25,9 +41,11 @@ const PREDFINED_USERS = [ 'Phil Kheav', 'Sravani Kotha', ].map((name) => ({ - id: `user-${generateID()}`, + // Stable ids, so saved data that points to a user survives a reload + id: `user-${name.toLowerCase().replace(/\W+/g, '-')}`, name, email: `${name.split(' ').join('.')}@place.tech`, + authority_id: AUTHORITY_ID, support: Math.floor(Math.random() * 999_999) % 10 === 0, sys_admin: Math.floor(Math.random() * 999_999) % 10 === 0, })); @@ -40,6 +58,7 @@ const USER_DATA = Array(0) id: `user-${generateID()}`, name, email: `${name.split(' ').join('.')}@place.tech`, + authority_id: AUTHORITY_ID, support: Math.floor(Math.random() * 999_999) % 10 === 0, sys_admin: Math.floor(Math.random() * 999_999) % 10 === 0, }; @@ -50,9 +69,15 @@ const CURRENT_USER = { id: `current`, name: 'Place Admin', email: `admin@place.tech`, + authority_id: AUTHORITY_ID, support: Math.floor(Math.random() * 999_999) % 10 === 0, sys_admin: true, }; -/** Add basic API handlers for systems */ -generateBasicHandlers(`${API}/users`, [...USER_DATA, CURRENT_USER], FILTER_FN); +/** Add basic API handlers for users */ +generateBasicHandlers( + `${API}/users`, + [...USER_DATA, CURRENT_USER].map(withNames), + FILTER_FN, + withNames, +); diff --git a/src/app/mocks/common.mock.ts b/src/app/mocks/common.mock.ts index ecec5171f..14a80a886 100644 --- a/src/app/mocks/common.mock.ts +++ b/src/app/mocks/common.mock.ts @@ -132,11 +132,13 @@ function endpointToStorageKey(endpoint: string): string { * @param endpoint API endpoint path * @param data Initial data (used as fallback if no session data) * @param filter Filter function for queries + * @param prepare Fills in server-side fields of created or updated items */ export function generateBasicHandlers( endpoint: string, data: T[], filter: FilterFn = (_: T, _q: HashMap) => true, + prepare: (item: T) => T = (item) => item, ): MockChangeSignal { const storage_key = endpointToStorageKey(endpoint); @@ -182,10 +184,12 @@ export function generateBasicHandlers( metadata: data, method: 'POST', callback: (event) => { - const item = { ...event.body }; - item.id = `item-${generateID()}`; - item.created_at = Date.now() / 1000; - item.updated_at = Date.now() / 1000; + const item = prepare({ + ...event.body, + id: `item-${generateID()}`, + created_at: Date.now() / 1000, + updated_at: Date.now() / 1000, + }); const list = [...(ENDPOINT_SIGNALS[endpoint]() || [])]; list.push(item); ENDPOINT_SIGNALS[endpoint].set(list); @@ -207,11 +211,11 @@ export function generateBasicHandlers( ); if (index >= 0) { const old_item = list[index]; - const updated_item = { + const updated_item = prepare({ ...old_item, ...item, updated_at: Date.now() / 1000, - }; + }); list.splice(index, 1, updated_item); ENDPOINT_SIGNALS[endpoint].set(list); saveToSession(storage_key, list); diff --git a/src/app/mocks/data/zones.ts b/src/app/mocks/data/zones.ts index b9bf5d45a..e0eae2096 100644 --- a/src/app/mocks/data/zones.ts +++ b/src/app/mocks/data/zones.ts @@ -2,7 +2,7 @@ export const ZONES = [ { name: 'Place Technology', description: 'org', - tags: 'org', + tags: ['org'], settings: { touch_enabled: false, discovery_info: { @@ -224,7 +224,7 @@ export const ZONES = [ { name: 'L30 Activity Spaces', description: null, - tags: null, + tags: [], settings: { mics: [{ name: 'Soundbar Mic', id: 'mic', basic_mixer: true }], inputs: ['Wireless', 'Laptop', 'Web Conference', 'Tele Conference'], @@ -310,7 +310,7 @@ export const ZONES = [ { name: 'L31 Activity Spaces', description: '', - tags: '', + tags: [], settings: { mics: [{ name: 'Soundbar Mic', id: 'mic', basic_mixer: true }], inputs: ['Wireless', 'Laptop', 'Web Conference', 'Tele Conference'], @@ -395,7 +395,7 @@ export const ZONES = [ { name: 'L31 Multifunction: 31.22', description: null, - tags: null, + tags: [], settings: { rooms: ['sys-beGO1NjTjy', 'sys-beJ1G79tKo'], ignore_modes: true, @@ -453,7 +453,7 @@ export const ZONES = [ { name: 'L31 R7 Activity Space', description: null, - tags: null, + tags: [], settings: { mics: [{ name: 'Soundbar Mic', id: 'mic', basic_mixer: true }], inputs: ['Wireless', 'Laptop', 'Web Conference', 'Tele Conference'], @@ -538,7 +538,7 @@ export const ZONES = [ { name: 'Level 30', description: 'level', - tags: 'level', + tags: ['level'], settings: { discovery_info: {}, number: 30 }, triggers: [], created_at: 1495599360, @@ -548,7 +548,7 @@ export const ZONES = [ { name: 'Level 31', description: 'level', - tags: 'level', + tags: ['level'], settings: { number: 31, discovery_info: {} }, triggers: [], created_at: 1506945022, @@ -558,7 +558,7 @@ export const ZONES = [ { name: 'New Zone', description: null, - tags: null, + tags: [], settings: { booking_max_duration: 900 }, triggers: [], created_at: 1550546496, @@ -567,7 +567,7 @@ export const ZONES = [ { name: 'Tower 2', description: 'building', - tags: 'building', + tags: ['building'], settings: { discovery_info: { locker_locations: { diff --git a/user-stories/groups.md b/user-stories/groups.md new file mode 100644 index 000000000..125d65dd4 --- /dev/null +++ b/user-stories/groups.md @@ -0,0 +1,136 @@ +# Groups User Stories + +## Overview +The Groups section lets administrators put users into groups. A group gives its members permissions on zones. Groups can have a parent group. + +--- + +## US-GRP-001: View Groups List + +**As a** building administrator, +**I want** to see all groups, +**So that** I can find and manage a group. + +### Acceptance Criteria + +**AC-GRP-001-1: Group Tree** +- Given I am an authorized user +- When I open the Groups section +- Then the sidebar shows the root groups +- And a child group shows below its parent when I expand the parent + +**AC-GRP-001-2: Search Groups** +- Given I am on the groups list +- When I type a search term +- Then the list shows only the groups that match the term + +**AC-GRP-001-3: Group Selection** +- Given I am on the groups list +- When I click a group +- Then the details of the group show in the main area + +--- + +## US-GRP-002: View Group Details + +**As a** building administrator, +**I want** to see the settings of a group, +**So that** I know what access the group gives. + +### Acceptance Criteria + +**AC-GRP-002-1: About Tab** +- Given I have selected a group +- When the about tab shows +- Then I see the parent group, the subsystems, the default permissions and the description + +--- + +## US-GRP-003: Create Group + +**As a** building administrator, +**I want** to make a new group, +**So that** I can give access to a new set of users. + +### Acceptance Criteria + +**AC-GRP-003-1: Save New Group** +- Given I am in the new group form +- When I type a name and click Save +- Then the app opens the new group on its about tab + +**AC-GRP-003-2: Parent Group** +- Given I am in the new group form +- When I select a parent group and click Save +- Then the new group shows below the parent in the tree +- And the new group gets the subsystems of the parent + +--- + +## US-GRP-004: Edit Group + +**As a** building administrator, +**I want** to change a group, +**So that** its settings stay correct. + +### Acceptance Criteria + +**AC-GRP-004-1: Save Changes** +- Given I am in the edit form of a group +- When I change the name and click Save +- Then the group keeps the new name after a page reload + +--- + +## US-GRP-005: Delete Group + +**As a** building administrator, +**I want** to delete a group, +**So that** unused groups do not give access. + +### Acceptance Criteria + +**AC-GRP-005-1: Remove Group** +- Given I have selected a group +- When I delete the group and confirm +- Then the group does not show in the list + +--- + +## US-GRP-006: Manage Group Users + +**As a** building administrator, +**I want** to add users to a group and remove users from it, +**So that** the correct users get the access of the group. + +### Acceptance Criteria + +**AC-GRP-006-1: List Users** +- Given I have selected a group +- When I open the users tab +- Then I see each member with their email and permissions + +**AC-GRP-006-2: Add User** +- Given I am on the users tab +- When I search for a user and select the user +- Then the user shows in the list with the default permissions of the group + +**AC-GRP-006-3: Remove User** +- Given I am on the users tab +- When I remove a user and confirm +- Then the user does not show in the list + +--- + +## US-GRP-007: Manage Group Zones + +**As a** building administrator, +**I want** to see the zones of a group, +**So that** I know where the group gives or denies access. + +### Acceptance Criteria + +**AC-GRP-007-1: List Zones** +- Given I have selected a group +- When I open the zones tab +- Then I see each zone with its permissions and its deny flag