diff --git a/.claude/agents/playwright-test-generator.md b/.claude/agents/playwright-test-generator.md new file mode 100644 index 0000000..0504c92 --- /dev/null +++ b/.claude/agents/playwright-test-generator.md @@ -0,0 +1,59 @@ +--- +name: playwright-test-generator +description: 'Use this agent when you need to create automated browser tests using Playwright Examples: Context: User wants to generate a test for the test plan item. ' +tools: Glob, Grep, Read, LS, mcp__playwright-test__browser_click, mcp__playwright-test__browser_drag, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_file_upload, mcp__playwright-test__browser_handle_dialog, mcp__playwright-test__browser_hover, mcp__playwright-test__browser_navigate, mcp__playwright-test__browser_press_key, mcp__playwright-test__browser_select_option, mcp__playwright-test__browser_snapshot, mcp__playwright-test__browser_type, mcp__playwright-test__browser_verify_element_visible, mcp__playwright-test__browser_verify_list_visible, mcp__playwright-test__browser_verify_text_visible, mcp__playwright-test__browser_verify_value, mcp__playwright-test__browser_wait_for, mcp__playwright-test__generator_read_log, mcp__playwright-test__generator_setup_page, mcp__playwright-test__generator_write_test +model: sonnet +color: blue +--- + +You are a Playwright Test Generator, an expert in browser automation and end-to-end testing. +Your specialty is creating robust, reliable Playwright tests that accurately simulate user interactions and validate +application behavior. + +# For each test you generate +- Obtain the test plan with all the steps and verification specification +- Run the `generator_setup_page` tool to set up page for the scenario +- For each step and verification in the scenario, do the following: + - Use Playwright tool to manually execute it in real-time. + - Use the step description as the intent for each Playwright tool call. +- Retrieve generator log via `generator_read_log` +- Immediately after reading the test log, invoke `generator_write_test` with the generated source code + - File should contain single test + - File name must be fs-friendly scenario name + - Test must be placed in a describe matching the top-level test plan item + - Test title must match the scenario name + - Includes a comment with the step text before each step execution. Do not duplicate comments if step requires + multiple actions. + - Always use best practices from the log when generating tests. + + + For following plan: + + ```markdown file=specs/plan.md + ### 1. Adding New Todos + **Seed:** `tests/seed.spec.ts` + + #### 1.1 Add Valid Todo + **Steps:** + 1. Click in the "What needs to be done?" input field + + #### 1.2 Add Multiple Todos + ... + ``` + + Following file is generated: + + ```ts file=add-valid-todo.spec.ts + // spec: specs/plan.md + // seed: tests/seed.spec.ts + + test.describe('Adding New Todos', () => { + test('Add Valid Todo', async { page } => { + // 1. Click in the "What needs to be done?" input field + await page.click(...); + + ... + }); + }); + ``` + \ No newline at end of file diff --git a/.claude/agents/playwright-test-healer.md b/.claude/agents/playwright-test-healer.md new file mode 100644 index 0000000..b66280f --- /dev/null +++ b/.claude/agents/playwright-test-healer.md @@ -0,0 +1,45 @@ +--- +name: playwright-test-healer +description: Use this agent when you need to debug and fix failing Playwright tests +tools: Glob, Grep, Read, LS, Edit, MultiEdit, Write, mcp__playwright-test__browser_console_messages, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_generate_locator, mcp__playwright-test__browser_network_requests, mcp__playwright-test__browser_snapshot, mcp__playwright-test__test_debug, mcp__playwright-test__test_list, mcp__playwright-test__test_run +model: sonnet +color: red +--- + +You are the Playwright Test Healer, an expert test automation engineer specializing in debugging and +resolving Playwright test failures. Your mission is to systematically identify, diagnose, and fix +broken Playwright tests using a methodical approach. + +Your workflow: +1. **Initial Execution**: Run all tests using `test_run` tool to identify failing tests +2. **Debug failed tests**: For each failing test run `test_debug`. +3. **Error Investigation**: When the test pauses on errors, use available Playwright MCP tools to: + - Examine the error details + - Capture page snapshot to understand the context + - Analyze selectors, timing issues, or assertion failures +4. **Root Cause Analysis**: Determine the underlying cause of the failure by examining: + - Element selectors that may have changed + - Timing and synchronization issues + - Data dependencies or test environment problems + - Application changes that broke test assumptions +5. **Code Remediation**: Edit the test code to address identified issues, focusing on: + - Updating selectors to match current application state + - Fixing assertions and expected values + - Improving test reliability and maintainability + - For inherently dynamic data, utilize regular expressions to produce resilient locators +6. **Verification**: Restart the test after each fix to validate the changes +7. **Iteration**: Repeat the investigation and fixing process until the test passes cleanly + +Key principles: +- Be systematic and thorough in your debugging approach +- Document your findings and reasoning for each fix +- Prefer robust, maintainable solutions over quick hacks +- Use Playwright best practices for reliable test automation +- If multiple errors exist, fix them one at a time and retest +- Provide clear explanations of what was broken and how you fixed it +- You will continue this process until the test runs successfully without any failures or errors. +- If the error persists and you have high level of confidence that the test is correct, mark this test as test.fixme() + so that it is skipped during the execution. Add a comment before the failing step explaining what is happening instead + of the expected behavior. +- Do not ask user questions, you are not interactive tool, do the most reasonable thing possible to pass the test. +- Never wait for networkidle or use other discouraged or deprecated apis \ No newline at end of file diff --git a/.claude/agents/playwright-test-planner.md b/.claude/agents/playwright-test-planner.md new file mode 100644 index 0000000..b33d6ba --- /dev/null +++ b/.claude/agents/playwright-test-planner.md @@ -0,0 +1,52 @@ +--- +name: playwright-test-planner +description: Use this agent when you need to create comprehensive test plan for a web application or website +tools: Glob, Grep, Read, LS, mcp__playwright-test__browser_click, mcp__playwright-test__browser_close, mcp__playwright-test__browser_console_messages, mcp__playwright-test__browser_drag, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_file_upload, mcp__playwright-test__browser_handle_dialog, mcp__playwright-test__browser_hover, mcp__playwright-test__browser_navigate, mcp__playwright-test__browser_navigate_back, mcp__playwright-test__browser_network_requests, mcp__playwright-test__browser_press_key, mcp__playwright-test__browser_run_code, mcp__playwright-test__browser_select_option, mcp__playwright-test__browser_snapshot, mcp__playwright-test__browser_take_screenshot, mcp__playwright-test__browser_type, mcp__playwright-test__browser_wait_for, mcp__playwright-test__planner_setup_page, mcp__playwright-test__planner_save_plan +model: sonnet +color: green +--- + +You are an expert web test planner with extensive experience in quality assurance, user experience testing, and test +scenario design. Your expertise includes functional testing, edge case identification, and comprehensive test coverage +planning. + +You will: + +1. **Navigate and Explore** + - Invoke the `planner_setup_page` tool once to set up page before using any other tools + - Explore the browser snapshot + - Do not take screenshots unless absolutely necessary + - Use `browser_*` tools to navigate and discover interface + - Thoroughly explore the interface, identifying all interactive elements, forms, navigation paths, and functionality + +2. **Analyze User Flows** + - Map out the primary user journeys and identify critical paths through the application + - Consider different user types and their typical behaviors + +3. **Design Comprehensive Scenarios** + + Create detailed test scenarios that cover: + - Happy path scenarios (normal user behavior) + - Edge cases and boundary conditions + - Error handling and validation + +4. **Structure Test Plans** + + Each scenario must include: + - Clear, descriptive title + - Detailed step-by-step instructions + - Expected outcomes where appropriate + - Assumptions about starting state (always assume blank/fresh state) + - Success criteria and failure conditions + +5. **Create Documentation** + + Submit your test plan using `planner_save_plan` tool. + +**Quality Standards**: +- Write steps that are specific enough for any tester to follow +- Include negative testing scenarios +- Ensure scenarios are independent and can be run in any order + +**Output Format**: Always save the complete test plan as a markdown file with clear headings, numbered steps, and +professional formatting suitable for sharing with development and QA teams. \ No newline at end of file diff --git a/.env.dev.example b/.env.dev.example new file mode 100644 index 0000000..5ca1dd6 --- /dev/null +++ b/.env.dev.example @@ -0,0 +1,13 @@ +# Development environment template — copy to .env.dev and fill in values +ANGULAR_APP_URL=http://localhost:4200 +API_APP_URL=https://localhost:44378/api/v1 +IDENTITY_SERVER_URL=https://localhost:44310 + +TEST_USER_EMPLOYEE_USERNAME= +TEST_USER_EMPLOYEE_PASSWORD= + +TEST_USER_MANAGER_USERNAME= +TEST_USER_MANAGER_PASSWORD= + +TEST_USER_HRADMIN_USERNAME= +TEST_USER_HRADMIN_PASSWORD= diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8766783 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Test User Credentials +# Copy this file to .env and fill in real values. +# Get values from Azure Key Vault: +# az keyvault secret show --vault-name --name playwright-employee-username --query value -o tsv + +TEST_USER_EMPLOYEE_USERNAME= +TEST_USER_EMPLOYEE_PASSWORD= + +TEST_USER_MANAGER_USERNAME= +TEST_USER_MANAGER_PASSWORD= + +TEST_USER_HRADMIN_USERNAME= +TEST_USER_HRADMIN_PASSWORD= diff --git a/.env.prod.example b/.env.prod.example new file mode 100644 index 0000000..d8242da --- /dev/null +++ b/.env.prod.example @@ -0,0 +1,13 @@ +# Production environment template — copy to .env.prod and fill in values +ANGULAR_APP_URL=https://mango-flower-0ced4011e.4.azurestaticapps.net +API_APP_URL=https://app-talent-api-dev.azurewebsites.net/api/v1 +IDENTITY_SERVER_URL=https://app-talent-ids-dev.azurewebsites.net + +TEST_USER_EMPLOYEE_USERNAME= +TEST_USER_EMPLOYEE_PASSWORD= + +TEST_USER_MANAGER_USERNAME= +TEST_USER_MANAGER_PASSWORD= + +TEST_USER_HRADMIN_USERNAME= +TEST_USER_HRADMIN_PASSWORD= diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index b7891c2..7633bab 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -34,10 +34,27 @@ jobs: - name: Install Playwright Browsers run: npx playwright install --with-deps ${{ matrix.project }} + - name: Run auth setup (pre-authenticate all roles via PKCE) + run: npx playwright test --project=setup + env: + CI: true + TEST_USER_EMPLOYEE_USERNAME: ${{ secrets.TEST_USER_EMPLOYEE_USERNAME }} + TEST_USER_EMPLOYEE_PASSWORD: ${{ secrets.TEST_USER_EMPLOYEE_PASSWORD }} + TEST_USER_MANAGER_USERNAME: ${{ secrets.TEST_USER_MANAGER_USERNAME }} + TEST_USER_MANAGER_PASSWORD: ${{ secrets.TEST_USER_MANAGER_PASSWORD }} + TEST_USER_HRADMIN_USERNAME: ${{ secrets.TEST_USER_HRADMIN_USERNAME }} + TEST_USER_HRADMIN_PASSWORD: ${{ secrets.TEST_USER_HRADMIN_PASSWORD }} + - name: Run Playwright tests run: npx playwright test --project=${{ matrix.project }} env: CI: true + TEST_USER_EMPLOYEE_USERNAME: ${{ secrets.TEST_USER_EMPLOYEE_USERNAME }} + TEST_USER_EMPLOYEE_PASSWORD: ${{ secrets.TEST_USER_EMPLOYEE_PASSWORD }} + TEST_USER_MANAGER_USERNAME: ${{ secrets.TEST_USER_MANAGER_USERNAME }} + TEST_USER_MANAGER_PASSWORD: ${{ secrets.TEST_USER_MANAGER_PASSWORD }} + TEST_USER_HRADMIN_USERNAME: ${{ secrets.TEST_USER_HRADMIN_USERNAME }} + TEST_USER_HRADMIN_PASSWORD: ${{ secrets.TEST_USER_HRADMIN_PASSWORD }} - name: Upload HTML Report uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index 998af49..ba2b227 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,12 @@ +# Credentials — never commit real values (commit only *.example files) +.env +.env.dev +.env.prod + +# Playwright auth state — contains JWT tokens, regenerated by auth.setup.ts +/.auth/ + # Playwright node_modules/ /test-results/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..6da4077 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,13 @@ +{ + "mcpServers": { + "playwright-test": { + "command": "cmd", + "args": [ + "/c", + "npx", + "playwright", + "run-test-mcp-server" + ] + } + } +} \ No newline at end of file diff --git a/config/environments.json b/config/environments.json index 917f270..d75714a 100644 --- a/config/environments.json +++ b/config/environments.json @@ -16,11 +16,12 @@ "description": "Staging environment for testing" }, "production": { - "angularUrl": "https://app.example.com", - "apiUrl": "https://api.example.com", - "apiBaseUrl": "https://api.example.com/api/v1", - "identityServerUrl": "https://sts.example.com", + "angularUrl": "https://mango-flower-0ced4011e.4.azurestaticapps.net", + "apiUrl": "https://app-talent-api-dev.azurewebsites.net", + "apiBaseUrl": "https://app-talent-api-dev.azurewebsites.net/api/v1", + "identityServerUrl": "https://app-talent-ids-dev.azurewebsites.net", + "identityServerAdminUrl": "https://app-talent-admin-dev.azurewebsites.net", "timeout": 45000, - "description": "Production environment (use with caution)" + "description": "Production environment — Azure hosted" } } diff --git a/config/test-config.ts b/config/test-config.ts index 8bc6c4f..7f62cae 100644 --- a/config/test-config.ts +++ b/config/test-config.ts @@ -5,6 +5,66 @@ * Modify these values in one place to affect all tests. */ +/** + * Load environment-specific credentials before any constant is evaluated. + * + * Placing dotenv loading here (rather than only in playwright.config.ts) ensures + * that worker processes which import this module directly also pick up the .env + * file, because module imports in playwright.config.ts are evaluated before the + * dotenv.config() call in that file executes. + * + * Select environment with APP_ENV: + * APP_ENV=dev → .env.dev (local development) + * APP_ENV=prod → .env.prod (production) + * (none) → .env (default) + */ +import dotenv from 'dotenv'; +import path from 'path'; + +const _env = process.env.APP_ENV; +const _envFile = _env ? `.env.${_env}` : '.env'; +dotenv.config({ path: path.resolve(__dirname, '..', _envFile) }); + +/** + * Test User Credentials + * + * Loaded from environment variables — never hardcoded. + * Local dev: copy .env.example to .env and fill in values. + * CI: injected automatically via GitHub Secrets. + */ +export const TEST_USERS = { + employee: { + username: process.env.TEST_USER_EMPLOYEE_USERNAME || '', + password: process.env.TEST_USER_EMPLOYEE_PASSWORD || '', + role: 'employee' as const, + }, + manager: { + username: process.env.TEST_USER_MANAGER_USERNAME || '', + password: process.env.TEST_USER_MANAGER_PASSWORD || '', + role: 'manager' as const, + }, + hradmin: { + username: process.env.TEST_USER_HRADMIN_USERNAME || '', + password: process.env.TEST_USER_HRADMIN_PASSWORD || '', + role: 'hradmin' as const, + }, +} as const; + +/** + * Fail fast if credentials are missing — prevents cryptic login failures. + */ +export function assertCredentialsLoaded(): void { + const missing = (Object.entries(TEST_USERS) as [string, { username: string; password: string }][]) + .filter(([, u]) => !u.username || !u.password) + .map(([role]) => role); + if (missing.length > 0) { + throw new Error( + `Missing credentials for roles: ${missing.join(', ')}. ` + + `Copy .env.example to .env and fill in values, or set GitHub Secrets for CI.` + ); + } +} + /** * Application URLs */ @@ -192,3 +252,16 @@ export function getUrl(path: string): string { export function getApiUrl(endpoint: string): string { return `${APP_URLS.api}${endpoint}`; } + +/** + * Returns a RegExp that matches any URL on the Angular app host. + * Use instead of hardcoded /localhost:4200/ so tests work across environments. + * + * @example + * await expect(page).toHaveURL(getAngularUrlPattern()); + * await page.waitForURL(getAngularUrlPattern()); + */ +export function getAngularUrlPattern(): RegExp { + const host = new URL(APP_URLS.angular).host.replace(/\./g, '\\.'); + return new RegExp(host); +} diff --git a/config/test-users.json b/config/test-users.json index fb42b9c..8526a31 100644 --- a/config/test-users.json +++ b/config/test-users.json @@ -1,8 +1,6 @@ { "employee": { "username": "antoinette16", - "password": "Pa$$word123", - "email": "antoinette16@ethereal.email", "role": "Employee", "roles": ["Employee"], "permissions": ["read"], @@ -13,8 +11,6 @@ }, "manager": { "username": "rosamond33", - "password": "Pa$$word123", - "email": "rosamond33@ethereal.email", "role": "Manager", "roles": ["Employee", "Manager"], "permissions": ["read", "write"], @@ -26,8 +22,6 @@ }, "hradmin": { "username": "ashtyn1", - "password": "Pa$$word123", - "email": "ashtyn1@ethereal.email", "role": "HRAdmin", "roles": ["Employee", "Manager", "HRAdmin"], "permissions": ["read", "write", "delete", "admin"], diff --git a/fixtures/api.fixtures.ts b/fixtures/api.fixtures.ts index e03311d..a59208d 100644 --- a/fixtures/api.fixtures.ts +++ b/fixtures/api.fixtures.ts @@ -2,6 +2,7 @@ import { APIRequestContext, Page, Browser, chromium } from '@playwright/test'; import type { EmployeeData, DepartmentData } from './data.fixtures'; import { loginAsRole, getTokenFromProfile } from './auth.fixtures'; import testUsers from '../config/test-users.json'; +import { APP_URLS } from '../config/test-config'; /** * API Fixtures @@ -13,7 +14,7 @@ import testUsers from '../config/test-users.json'; * - Token acquisition for roles */ -const API_BASE_URL = 'https://localhost:44378/api/v1'; +const API_BASE_URL = APP_URLS.api; /** * Gets an access token for a specific role using browser-based authentication diff --git a/fixtures/auth.fixtures.ts b/fixtures/auth.fixtures.ts index dd3c124..99261ee 100644 --- a/fixtures/auth.fixtures.ts +++ b/fixtures/auth.fixtures.ts @@ -1,6 +1,5 @@ -import { Page, APIRequestContext } from '@playwright/test'; -import testUsers from '../config/test-users.json'; -import { APP_URLS } from '../config/test-config'; +import { Page } from '@playwright/test'; +import { APP_URLS, TEST_USERS } from '../config/test-config'; /** * Authentication Fixtures @@ -30,22 +29,30 @@ export async function loginAs( ): Promise { // Navigate to Angular app (loads as Guest/Anonymous) await page.goto('/'); - await page.waitForLoadState('networkidle'); + + // Wait for Angular to fully render its shell before checking auth state. + // The sidebar user panel (h4 heading) is rendered by Angular change detection + // which runs after domcontentloaded — we must wait for it explicitly. + await page.waitForSelector('h4, mat-sidenav-container, .matero-sidenav', { timeout: 15000 }); // If we're already authenticated for some reason, log out first if (await isAuthenticated(page)) { // the logout helper already waits for navigation etc. await logout(page); await page.goto('/'); - await page.waitForLoadState('networkidle'); + await page.waitForSelector('h4, mat-sidenav-container, .matero-sidenav', { timeout: 15000 }); } - // Clear any existing auth tokens to ensure clean state + // Clear any existing auth tokens AND all cookies (including IdentityServer session + // cookies) to ensure a clean login state. Without clearing IS session cookies, + // the next authorization request will auto-login as the previously authenticated + // user instead of showing the login form. await clearAuthTokens(page); + await page.context().clearCookies(); // Reload page after clearing tokens to ensure Guest state await page.reload(); - await page.waitForLoadState('networkidle'); + await page.waitForSelector('h4, mat-sidenav-container, .matero-sidenav', { timeout: 15000 }); // Pause briefly to allow Angular to render guest UI await page.waitForTimeout(1000); @@ -110,7 +117,8 @@ export async function loginAs( await page.click('button:has-text("Login")'); // Wait for OAuth callback redirect back to Angular app - await page.waitForURL(/localhost:4200.*/, { timeout: 30000 }); + const angularHost = new URL(APP_URLS.angular).host.replace(/\./g, '\\.'); + await page.waitForURL(new RegExp(`${angularHost}.*`), { timeout: 30000 }); // Wait for dashboard to load (indicating successful authentication) await page.waitForSelector( @@ -133,75 +141,13 @@ export async function loginAsRole( page: Page, role: 'employee' | 'manager' | 'hradmin' ): Promise { - const user = testUsers[role]; + const user = TEST_USERS[role]; if (!user) { throw new Error(`Unknown role: ${role}`); } await loginAs(page, user.username, user.password); } -/** - * Acquires an API access token from IdentityServer - * - * @param request - Playwright APIRequestContext - * @param username - Username for token request - * @param password - Password for token request - * @returns Promise resolving to access token string - * - * @example - * const token = await getApiToken(request, 'ashtyn1', 'Pa$$word123'); - * const response = await request.get('/api/v1/employees', { - * headers: { Authorization: `Bearer ${token}` } - * }); - */ -export async function getApiToken( - request: APIRequestContext, - username: string, - password: string -): Promise { - const tokenEndpoint = `${APP_URLS.identityServer}/connect/token`; - - const response = await request.post(tokenEndpoint, { - form: { - grant_type: 'password', - client_id: 'TalentManagement', - client_secret: 'secret', // Note: Update with actual client secret - scope: 'openid profile email roles app.api.talentmanagement.read app.api.talentmanagement.write', - username: username, - password: password, - }, - ignoreHTTPSErrors: true, - }); - - if (!response.ok()) { - throw new Error(`Failed to get token: ${response.status()} ${response.statusText()}`); - } - - const data = await response.json(); - return data.access_token; -} - -/** - * Acquires an API token using a predefined test user role - * - * @param request - Playwright APIRequestContext - * @param role - User role: 'employee' | 'manager' | 'hradmin' - * @returns Promise resolving to access token string - * - * @example - * const token = await getTokenForRole(request, 'manager'); - */ -export async function getTokenForRole( - request: APIRequestContext, - role: 'employee' | 'manager' | 'hradmin' -): Promise { - const user = testUsers[role]; - if (!user) { - throw new Error(`Unknown role: ${role}`); - } - return await getApiToken(request, user.username, user.password); -} - /** * Performs logout from the application * @@ -212,6 +158,14 @@ export async function getTokenForRole( * await logout(page); */ export async function logout(page: Page): Promise { + // Wait for Angular to render before checking auth state + await page.waitForSelector('h4, mat-sidenav-container, .matero-sidenav', { timeout: 10000 }).catch(() => {}); + + // If already in Guest state, nothing to logout from — return early + if (!(await isAuthenticated(page))) { + return; + } + // Click user icon in upper right corner (same as login flow) const userIcon = page.locator('button[aria-label="User menu"], button mat-icon:has-text("account_circle"), header button:has(mat-icon)').last(); await userIcon.click(); @@ -224,7 +178,8 @@ export async function logout(page: Page): Promise { // Wait for redirect to logout page. Depending on environment this may go to // the configured identity server host or local /Account/Logout. const idHost = new URL(APP_URLS.identityServer).host.replace(/\./g, '\\.'); - const logoutRegex = new RegExp(`(${idHost}.*|localhost\/Account\/Logout.*)`); + const angularHostLogoutPage = new URL(APP_URLS.angular).host.replace(/\./g, '\\.'); + const logoutRegex = new RegExp(`(${idHost}.*|${angularHostLogoutPage}\/Account\/Logout.*)`); try { await page.waitForURL(logoutRegex, { timeout: 15000 }); } catch (err) { @@ -237,7 +192,8 @@ export async function logout(page: Page): Promise { // Look for the "click here" link to return to Angular // Try multiple possible selectors for the return link - const returnLink = page.locator('a:has-text("click here"), a:has-text("return"), a:has-text("back to"), a[href*="localhost:4200"]').first(); + const angularUrl = APP_URLS.angular; + const returnLink = page.locator(`a:has-text("click here"), a:has-text("return"), a:has-text("back to"), a[href*="${angularUrl}"]`).first(); // Check if return link exists and click it const linkExists = await returnLink.isVisible({ timeout: 5000 }).catch(() => false); @@ -245,14 +201,15 @@ export async function logout(page: Page): Promise { await returnLink.click(); // Wait for redirect back to Angular - await page.waitForURL(/localhost:4200.*/, { timeout: 10000 }); + const angularHostLogout = new URL(APP_URLS.angular).host.replace(/\./g, '\\.'); + await page.waitForURL(new RegExp(`${angularHostLogout}.*`), { timeout: 10000 }); } else { // If no return link found, just navigate back to Angular await page.goto('/'); } // Wait for page to settle - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); await page.waitForTimeout(1000); // Clear all auth tokens to ensure complete logout @@ -322,7 +279,7 @@ export async function getTokenFromProfile(page: Page): Promise { await profileOption.click(); // Wait for profile page to load - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); await page.waitForTimeout(1000); // Click on "Access Token" tab (ID Token tab is selected by default) diff --git a/package-lock.json b/package-lock.json index 531aeb5..97b66a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,60 @@ { - "name": "playwright", + "name": "angularnettutorial-playwright", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "playwright", + "name": "angularnettutorial-playwright", "version": "1.0.0", "license": "ISC", "devDependencies": { "@playwright/test": "1.59.1", - "@types/node": "^25.2.2" + "@types/node": "^25.2.2", + "cross-env": "^7.0.3", + "dotenv": "^16.4.5", + "ts-node": "^10.9.2" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" } }, "node_modules/@playwright/test": { @@ -29,6 +73,34 @@ "node": ">=18" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.2.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.2.tgz", @@ -39,6 +111,103 @@ "undici-types": "~7.16.0" } }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -54,6 +223,30 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/playwright": { "version": "1.59.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", @@ -86,12 +279,127 @@ "node": ">=18" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "dev": true, "license": "MIT" + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } } } } diff --git a/package.json b/package.json index b553b87..e157d71 100644 --- a/package.json +++ b/package.json @@ -2,13 +2,25 @@ "name": "angularnettutorial-playwright", "version": "1.0.0", "main": "index.js", - "scripts": {}, + "scripts": { + "test": "cross-env APP_ENV=dev playwright test", + "test:prod": "cross-env APP_ENV=prod playwright test", + "test:ui": "cross-env APP_ENV=dev playwright test --ui", + "test:headed": "cross-env APP_ENV=dev playwright test --headed", + "test:smoke": "cross-env APP_ENV=dev playwright test --project=smoke", + "test:smoke:prod": "cross-env APP_ENV=prod playwright test --project=smoke", + "setup:env": "npx ts-node scripts/fetch-secrets.ts", + "report": "playwright show-report" + }, "keywords": [], "author": "", "license": "ISC", "description": "", "devDependencies": { "@playwright/test": "1.59.1", - "@types/node": "^25.2.2" + "@types/node": "^25.2.2", + "cross-env": "^7.0.3", + "dotenv": "^16.4.5", + "ts-node": "^10.9.2" } } diff --git a/playwright.config.ts b/playwright.config.ts index 2a76355..0e81cff 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,12 +2,19 @@ import { defineConfig, devices } from '@playwright/test'; import { APP_URLS, TIMEOUTS, VIEWPORTS } from './config/test-config'; /** - * Read environment variables from file. - * https://github.com/motdotla/dotenv + * Load environment-specific credentials and URLs. + * + * Select environment with APP_ENV: + * APP_ENV=dev → .env.dev (local development, default) + * APP_ENV=prod → .env.prod (production) + * (none) → .env (CI injects via GitHub Secrets / no file needed) */ -// import dotenv from 'dotenv'; -// import path from 'path'; -// dotenv.config({ path: path.resolve(__dirname, '.env') }); +import dotenv from 'dotenv'; +import path from 'path'; + +const env = process.env.APP_ENV; +const envFile = env ? `.env.${env}` : '.env'; +dotenv.config({ path: path.resolve(__dirname, envFile) }); /** * Playwright Configuration for AngularNetTutorial Testing diff --git a/scripts/fetch-secrets.ts b/scripts/fetch-secrets.ts new file mode 100644 index 0000000..f41f51b --- /dev/null +++ b/scripts/fetch-secrets.ts @@ -0,0 +1,81 @@ +/** + * Azure Key Vault → .env generator + * + * Pulls Playwright test credentials from Azure Key Vault and writes them + * to a local .env file (gitignored). + * + * Prerequisites: + * az login + * export AZURE_KEYVAULT_NAME= + * + * Usage: + * npm run setup:env + * + * Azure Key Vault secrets expected (create once with az keyvault secret set): + * playwright-employee-username + * playwright-employee-password + * playwright-manager-username + * playwright-manager-password + * playwright-hradmin-username + * playwright-hradmin-password + * + * To create secrets in Key Vault (run once): + * VAULT= + * az keyvault secret set --vault-name $VAULT --name "playwright-employee-username" --value "antoinette16" + * az keyvault secret set --vault-name $VAULT --name "playwright-employee-password" --value "Pa\$\$word123" + * az keyvault secret set --vault-name $VAULT --name "playwright-manager-username" --value "rosamond33" + * az keyvault secret set --vault-name $VAULT --name "playwright-manager-password" --value "Pa\$\$word123" + * az keyvault secret set --vault-name $VAULT --name "playwright-hradmin-username" --value "ashtyn1" + * az keyvault secret set --vault-name $VAULT --name "playwright-hradmin-password" --value "Pa\$\$word123" + * + * RBAC: grant yourself 'Key Vault Secrets User' role on the vault: + * az role assignment create \ + * --role "Key Vault Secrets User" \ + * --assignee \ + * --scope /subscriptions//resourceGroups//providers/Microsoft.KeyVault/vaults/ + */ + +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; + +const VAULT = process.env.AZURE_KEYVAULT_NAME; +if (!VAULT) { + console.error('Error: AZURE_KEYVAULT_NAME environment variable is not set.'); + console.error('Run: export AZURE_KEYVAULT_NAME='); + process.exit(1); +} + +function getSecret(secretName: string): string { + try { + return execSync( + `az keyvault secret show --vault-name ${VAULT} --name ${secretName} --query value -o tsv`, + { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] } + ).trim(); + } catch (err) { + console.error(`Failed to fetch secret "${secretName}" from vault "${VAULT}".`); + console.error('Make sure you are logged in (az login) and have Key Vault Secrets User role.'); + throw err; + } +} + +console.log(`Fetching secrets from Azure Key Vault: ${VAULT}`); + +const secrets: Record = { + TEST_USER_EMPLOYEE_USERNAME: getSecret('playwright-employee-username'), + TEST_USER_EMPLOYEE_PASSWORD: getSecret('playwright-employee-password'), + TEST_USER_MANAGER_USERNAME: getSecret('playwright-manager-username'), + TEST_USER_MANAGER_PASSWORD: getSecret('playwright-manager-password'), + TEST_USER_HRADMIN_USERNAME: getSecret('playwright-hradmin-username'), + TEST_USER_HRADMIN_PASSWORD: getSecret('playwright-hradmin-password'), +}; + +const envContent = Object.entries(secrets) + .map(([k, v]) => `${k}=${v}`) + .join('\n') + '\n'; + +const envPath = path.resolve(__dirname, '../.env'); +fs.writeFileSync(envPath, envContent, { encoding: 'utf8' }); + +console.log(`\n.env written to: ${envPath}`); +console.log('Run: npx playwright test --project=setup'); diff --git a/tests/accessibility/aria-labels.spec.ts b/tests/accessibility/aria-labels.spec.ts index 40cb1c9..39c0ba7 100644 --- a/tests/accessibility/aria-labels.spec.ts +++ b/tests/accessibility/aria-labels.spec.ts @@ -1,5 +1,4 @@ import { test, expect } from '@playwright/test'; -import { loginAsRole } from '../../fixtures/auth.fixtures'; /** * ARIA Labels Tests @@ -12,17 +11,9 @@ import { loginAsRole } from '../../fixtures/auth.fixtures'; */ test.describe('ARIA Labels', () => { - let authFailed = false; + test.use({ storageState: '.auth/manager.json' }); - test.beforeEach(async ({ page }) => { - try { - await loginAsRole(page, 'manager'); - authFailed = false; - } catch (error) { - authFailed = true; - console.log('Authentication failed - IdentityServer may not be running. Tests will be skipped.'); - } - }); + let authFailed = false; test('should have ARIA labels on form fields', async ({ page }) => { if (authFailed) test.skip(); diff --git a/tests/accessibility/keyboard-navigation.spec.ts b/tests/accessibility/keyboard-navigation.spec.ts index 4a33b3a..c381899 100644 --- a/tests/accessibility/keyboard-navigation.spec.ts +++ b/tests/accessibility/keyboard-navigation.spec.ts @@ -1,5 +1,4 @@ import { test, expect } from '@playwright/test'; -import { loginAsRole } from '../../fixtures/auth.fixtures'; import { EmployeeFormPage } from '../../page-objects/employee-form.page'; /** @@ -13,17 +12,9 @@ import { EmployeeFormPage } from '../../page-objects/employee-form.page'; */ test.describe('Keyboard Navigation', () => { - let authFailed = false; + test.use({ storageState: '.auth/manager.json' }); - test.beforeEach(async ({ page }) => { - try { - await loginAsRole(page, 'manager'); - authFailed = false; - } catch (error) { - authFailed = true; - console.log('Authentication failed - IdentityServer may not be running. Tests will be skipped.'); - } - }); + let authFailed = false; test('should navigate through form using Tab key', async ({ page }) => { if (authFailed) test.skip(); diff --git a/tests/ai/ai-assistant.spec.ts b/tests/ai/ai-assistant.spec.ts index 4b0d8c7..2d251c2 100644 --- a/tests/ai/ai-assistant.spec.ts +++ b/tests/ai/ai-assistant.spec.ts @@ -1,5 +1,4 @@ import { test, expect } from '@playwright/test'; -import { loginAsRole } from '../../fixtures/auth.fixtures'; import { AiAssistantPage } from '../../page-objects/ai-assistant.page'; /** @@ -14,9 +13,7 @@ import { AiAssistantPage } from '../../page-objects/ai-assistant.page'; */ test.describe('AI Assistant Page', () => { - test.beforeEach(async ({ page }) => { - await loginAsRole(page, 'manager'); - }); + test.use({ storageState: '.auth/manager.json' }); test('should load the AI Assistant page without errors', async ({ page }) => { const aiPage = new AiAssistantPage(page); diff --git a/tests/ai/ai-hr-insight.spec.ts b/tests/ai/ai-hr-insight.spec.ts index 535b8b7..d10093e 100644 --- a/tests/ai/ai-hr-insight.spec.ts +++ b/tests/ai/ai-hr-insight.spec.ts @@ -1,5 +1,4 @@ import { test, expect } from '@playwright/test'; -import { loginAsRole } from '../../fixtures/auth.fixtures'; import { AiHrInsightPage } from '../../page-objects/ai-hr-insight.page'; /** @@ -13,9 +12,7 @@ import { AiHrInsightPage } from '../../page-objects/ai-hr-insight.page'; */ test.describe('AI HR Insight Page', () => { - test.beforeEach(async ({ page }) => { - await loginAsRole(page, 'manager'); - }); + test.use({ storageState: '.auth/manager.json' }); test('should load the HR Insight page without errors', async ({ page }) => { const hrPage = new AiHrInsightPage(page); diff --git a/tests/ai/ai-navigation.spec.ts b/tests/ai/ai-navigation.spec.ts index 1aae6df..14f1e28 100644 --- a/tests/ai/ai-navigation.spec.ts +++ b/tests/ai/ai-navigation.spec.ts @@ -1,5 +1,4 @@ import { test, expect } from '@playwright/test'; -import { loginAsRole } from '../../fixtures/auth.fixtures'; /** * AI Submenu Navigation Tests @@ -12,11 +11,7 @@ import { loginAsRole } from '../../fixtures/auth.fixtures'; */ test.describe('AI Submenu Navigation', () => { - test.beforeEach(async ({ page }) => { - await loginAsRole(page, 'manager'); - await page.goto('/dashboard'); - await page.waitForLoadState('networkidle'); - }); + test.use({ storageState: '.auth/manager.json' }); test('should navigate directly to /ai/assistant', async ({ page }) => { await page.goto('/ai/assistant'); diff --git a/tests/ai/ai-nl-search.spec.ts b/tests/ai/ai-nl-search.spec.ts index 838c434..ef392b8 100644 --- a/tests/ai/ai-nl-search.spec.ts +++ b/tests/ai/ai-nl-search.spec.ts @@ -1,5 +1,4 @@ import { test, expect } from '@playwright/test'; -import { loginAsRole } from '../../fixtures/auth.fixtures'; import { AiNlSearchPage } from '../../page-objects/ai-nl-search.page'; /** @@ -14,9 +13,7 @@ import { AiNlSearchPage } from '../../page-objects/ai-nl-search.page'; */ test.describe('AI NL Search Page', () => { - test.beforeEach(async ({ page }) => { - await loginAsRole(page, 'manager'); - }); + test.use({ storageState: '.auth/manager.json' }); test('should load the NL Search page without errors', async ({ page }) => { const nlPage = new AiNlSearchPage(page); diff --git a/tests/ai/ai-vector-search.spec.ts b/tests/ai/ai-vector-search.spec.ts index 0280184..9560d18 100644 --- a/tests/ai/ai-vector-search.spec.ts +++ b/tests/ai/ai-vector-search.spec.ts @@ -1,5 +1,4 @@ import { test, expect } from '@playwright/test'; -import { loginAsRole } from '../../fixtures/auth.fixtures'; import { AiVectorSearchPage } from '../../page-objects/ai-vector-search.page'; /** @@ -14,9 +13,7 @@ import { AiVectorSearchPage } from '../../page-objects/ai-vector-search.page'; */ test.describe('AI Vector Search Page', () => { - test.beforeEach(async ({ page }) => { - await loginAsRole(page, 'manager'); - }); + test.use({ storageState: '.auth/manager.json' }); test('should load the Vector Search page without errors', async ({ page }) => { const vsPage = new AiVectorSearchPage(page); diff --git a/tests/api/auth-api.spec.ts b/tests/api/auth-api.spec.ts index 790bc7f..4315072 100644 --- a/tests/api/auth-api.spec.ts +++ b/tests/api/auth-api.spec.ts @@ -391,7 +391,7 @@ test.describe('Authentication API', () => { * This works when IdentityServer password grant is not configured for programmatic access */ test.describe('API Authentication via Profile Page', () => { - const baseURL = 'https://localhost:44378/api/v1'; + const baseURL = APP_URLS.api; test.beforeEach(async ({ page }) => { // Try to detect if IdentityServer is available diff --git a/tests/api/cache-api.spec.ts b/tests/api/cache-api.spec.ts index 1d9335b..06bce48 100644 --- a/tests/api/cache-api.spec.ts +++ b/tests/api/cache-api.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from '@playwright/test'; import { getTokenForRole } from '../../fixtures/api.fixtures'; +import { APP_URLS } from '../../config/test-config'; /** * Cache API Tests @@ -18,7 +19,7 @@ let authToken: string | null = null; let authFailed = false; test.describe('Cache API', () => { - const baseURL = 'https://localhost:44378/api/v1'; + const baseURL = APP_URLS.api; test.beforeAll(async ({ request }) => { // Try to get authentication token with timeout diff --git a/tests/api/departments-api.spec.ts b/tests/api/departments-api.spec.ts index ad045f7..96105ba 100644 --- a/tests/api/departments-api.spec.ts +++ b/tests/api/departments-api.spec.ts @@ -1,6 +1,7 @@ import { test, expect } from '@playwright/test'; import { getTokenForRole } from '../../fixtures/api.fixtures'; import { createDepartmentData } from '../../fixtures/data.fixtures'; +import { APP_URLS } from '../../config/test-config'; /** * Department API Tests @@ -17,7 +18,7 @@ let authToken: string | null = null; let authFailed = false; test.describe('Department API', () => { - const baseURL = 'https://localhost:44378/api/v1'; + const baseURL = APP_URLS.api; let testDepartmentId: number; test.beforeAll(async ({ request }) => { diff --git a/tests/api/employees-api.spec.ts b/tests/api/employees-api.spec.ts index 37a162d..db4faa3 100644 --- a/tests/api/employees-api.spec.ts +++ b/tests/api/employees-api.spec.ts @@ -1,6 +1,7 @@ import { test, expect } from '@playwright/test'; import { getTokenForRole } from '../../fixtures/api.fixtures'; import { createEmployeeData } from '../../fixtures/data.fixtures'; +import { APP_URLS } from '../../config/test-config'; /** * Employee API Tests @@ -18,7 +19,7 @@ let authToken: string | null = null; let authFailed = false; test.describe('Employee API', () => { - const baseURL = 'https://localhost:44378/api/v1'; + const baseURL = APP_URLS.api; let testEmployeeId: number; test.beforeAll(async ({ request }) => { diff --git a/tests/auth.setup.ts b/tests/auth.setup.ts new file mode 100644 index 0000000..21f05bd --- /dev/null +++ b/tests/auth.setup.ts @@ -0,0 +1,52 @@ +/** + * Global Authentication Setup + * + * Runs ONCE before all tests via the 'setup' project in playwright.config.ts. + * + * Flow per role: + * 1. Navigate to Angular app (loads as Guest) + * 2. Click Login → redirected to IdentityServer (PKCE/Authorization Code flow) + * 3. Fill username + password from environment variables + * 4. Submit → PKCE exchange → redirected back to Angular with JWT in sessionStorage + * 5. Save browser storageState (cookies + sessionStorage containing JWT) to .auth/.json + * + * Tests then use: + * test.use({ storageState: '.auth/manager.json' }); + * ...instead of calling loginAsRole() in beforeEach — no repeated logins. + * + * Credentials come from: + * - Local dev: .env file (gitignored) + * - CI: GitHub Secrets injected as environment variables + */ + +import { test as setup } from '@playwright/test'; +import { loginAs } from '../fixtures/auth.fixtures'; +import { TEST_USERS, assertCredentialsLoaded } from '../config/test-config'; +import * as fs from 'fs'; + +const AUTH_DIR = '.auth'; + +setup.beforeAll(() => { + assertCredentialsLoaded(); + if (!fs.existsSync(AUTH_DIR)) { + fs.mkdirSync(AUTH_DIR, { recursive: true }); + } +}); + +setup('authenticate as employee', async ({ page }) => { + const { username, password } = TEST_USERS.employee; + await loginAs(page, username, password); + await page.context().storageState({ path: `${AUTH_DIR}/employee.json` }); +}); + +setup('authenticate as manager', async ({ page }) => { + const { username, password } = TEST_USERS.manager; + await loginAs(page, username, password); + await page.context().storageState({ path: `${AUTH_DIR}/manager.json` }); +}); + +setup('authenticate as hradmin', async ({ page }) => { + const { username, password } = TEST_USERS.hradmin; + await loginAs(page, username, password); + await page.context().storageState({ path: `${AUTH_DIR}/hradmin.json` }); +}); diff --git a/tests/auth/login.spec.ts b/tests/auth/login.spec.ts index f05dbeb..7ec7397 100644 --- a/tests/auth/login.spec.ts +++ b/tests/auth/login.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from '@playwright/test'; import { loginAs, loginAsRole, isAuthenticated, getStoredToken, clearAuthTokens, logout } from '../../fixtures/auth.fixtures'; +import { getAngularUrlPattern } from '../../config/test-config'; /** * Authentication Tests - Login Flow @@ -61,7 +62,7 @@ test.describe('Login Flow', () => { await loginAs(page, 'ashtyn1', 'Pa$$word123'); // Verify successful redirect back to Angular - await expect(page).toHaveURL(/localhost:4200/); + await expect(page).toHaveURL(getAngularUrlPattern()); // Verify dashboard is visible (indicating authenticated state) await expect(page.locator('h1:has-text("Dashboard")')).toBeVisible(); @@ -81,7 +82,7 @@ test.describe('Login Flow', () => { await loginAs(page, 'ashtyn1', 'Pa$$word123'); // Wait for authentication to complete - await page.waitForURL(/localhost:4200/); + await page.waitForURL(getAngularUrlPattern()); await page.waitForTimeout(2000); // Allow time for token storage // Check if token is stored (may be in localStorage or sessionStorage) diff --git a/tests/dashboard/dashboard-metrics.spec.ts b/tests/dashboard/dashboard-metrics.spec.ts index bd8053a..59a1fa0 100644 --- a/tests/dashboard/dashboard-metrics.spec.ts +++ b/tests/dashboard/dashboard-metrics.spec.ts @@ -14,12 +14,7 @@ import { loginAsRole, logout } from '../../fixtures/auth.fixtures'; */ test.describe('Dashboard Metrics', () => { - test.beforeEach(async ({ page }) => { - // Login as Manager (has dashboard access) - await loginAsRole(page, 'manager'); - await page.goto('/dashboard'); - await page.waitForLoadState('networkidle'); - }); + test.use({ storageState: '.auth/manager.json' }); test('should load dashboard successfully', async ({ page }) => { // Verify dashboard page loads diff --git a/tests/dashboard/dashboard-navigation.spec.ts b/tests/dashboard/dashboard-navigation.spec.ts index 907bcd7..18fe118 100644 --- a/tests/dashboard/dashboard-navigation.spec.ts +++ b/tests/dashboard/dashboard-navigation.spec.ts @@ -12,12 +12,7 @@ import { loginAsRole, logout } from '../../fixtures/auth.fixtures'; */ test.describe('Dashboard Navigation', () => { - test.beforeEach(async ({ page }) => { - // Login as Manager (has create permissions) - await loginAsRole(page, 'manager'); - await page.goto('/dashboard'); - await page.waitForLoadState('networkidle'); - }); + test.use({ storageState: '.auth/manager.json' }); test('should navigate to employee list from dashboard', async ({ page }) => { // Use sidebar navigation to employees diff --git a/tests/department-management/department-crud.spec.ts b/tests/department-management/department-crud.spec.ts index 7453eca..5228a50 100644 --- a/tests/department-management/department-crud.spec.ts +++ b/tests/department-management/department-crud.spec.ts @@ -16,9 +16,9 @@ import { DepartmentFormPage } from '../../page-objects/department-form.page'; */ test.describe('Department CRUD', () => { + test.use({ storageState: '.auth/manager.json' }); + test.beforeEach(async ({ page }) => { - // Login as Manager (has create/edit permission) - await loginAsRole(page, 'manager'); const list = new DepartmentListPage(page); await list.goto(); }); diff --git a/tests/department-management/department-validation.spec.ts b/tests/department-management/department-validation.spec.ts index 1d64d09..e2cf4c0 100644 --- a/tests/department-management/department-validation.spec.ts +++ b/tests/department-management/department-validation.spec.ts @@ -23,8 +23,9 @@ import { DepartmentFormPage } from '../../page-objects/department-form.page'; const MAT_ERROR = 'mat-error, .mat-mdc-form-field-error, .mat-error'; test.describe('Department Validation', () => { + test.use({ storageState: '.auth/manager.json' }); + test.beforeEach(async ({ page }) => { - await loginAsRole(page, 'manager'); const list = new DepartmentListPage(page); await list.goto(); }); diff --git a/tests/diagnostic.spec.ts b/tests/diagnostic.spec.ts index 377e366..ea285ae 100644 --- a/tests/diagnostic.spec.ts +++ b/tests/diagnostic.spec.ts @@ -1,11 +1,11 @@ import { test, expect } from '@playwright/test'; -import { APP_URLS } from '../config/test-config'; +import { APP_URLS, getAngularUrlPattern } from '../config/test-config'; test('Diagnostic: Check Angular app behavior', async ({ page }) => { console.log('\n=== DIAGNOSTIC TEST ==='); // Navigate to Angular - await page.goto('http://localhost:4200'); + await page.goto(APP_URLS.angular); await page.waitForTimeout(3000); const currentUrl = page.url(); @@ -15,7 +15,7 @@ test('Diagnostic: Check Angular app behavior', async ({ page }) => { const idHost = new URL(APP_URLS.identityServer).host; if (currentUrl.includes(idHost)) { console.log('✅ Redirected to IdentityServer (AUTH ENABLED)'); - } else if (currentUrl.includes('localhost:4200')) { + } else if (currentUrl.includes(new URL(APP_URLS.angular).host)) { console.log('❌ Stayed on Angular app (AUTH DISABLED)'); } diff --git a/tests/employee-management/employee-create.spec.ts b/tests/employee-management/employee-create.spec.ts index e216f85..f3a081c 100644 --- a/tests/employee-management/employee-create.spec.ts +++ b/tests/employee-management/employee-create.spec.ts @@ -14,9 +14,9 @@ import { EmployeeFormPage } from '../../page-objects/employee-form.page'; */ test.describe('Employee Create', () => { + test.use({ storageState: '.auth/hradmin.json' }); + test.beforeEach(async ({ page }) => { - // Login as HRAdmin (only HRAdmin can create) - await loginAsRole(page, 'hradmin'); await page.goto('/employees'); await page.waitForLoadState('networkidle'); diff --git a/tests/employee-management/employee-delete.spec.ts b/tests/employee-management/employee-delete.spec.ts index 518070a..d6a7bb6 100644 --- a/tests/employee-management/employee-delete.spec.ts +++ b/tests/employee-management/employee-delete.spec.ts @@ -15,9 +15,9 @@ import { createEmployee, getTokenForRole, deleteEmployee } from '../../fixtures/ */ test.describe('Employee Delete', () => { + test.use({ storageState: '.auth/hradmin.json' }); + test.beforeEach(async ({ page }) => { - // Login as HRAdmin (has delete permission) - await loginAsRole(page, 'hradmin'); await page.goto('/employees'); await page.waitForLoadState('networkidle'); }); diff --git a/tests/employee-management/employee-edit.spec.ts b/tests/employee-management/employee-edit.spec.ts index 0cfd086..7524077 100644 --- a/tests/employee-management/employee-edit.spec.ts +++ b/tests/employee-management/employee-edit.spec.ts @@ -14,9 +14,9 @@ import { EmployeeFormPage } from '../../page-objects/employee-form.page'; */ test.describe('Employee Edit', () => { + test.use({ storageState: '.auth/hradmin.json' }); + test.beforeEach(async ({ page }) => { - // Login as HRAdmin (has full edit permission) - await loginAsRole(page, 'hradmin'); await page.goto('/employees'); await page.waitForLoadState('networkidle'); }); diff --git a/tests/employee-management/employee-list.spec.ts b/tests/employee-management/employee-list.spec.ts index 41bd896..cd72e03 100644 --- a/tests/employee-management/employee-list.spec.ts +++ b/tests/employee-management/employee-list.spec.ts @@ -1,5 +1,4 @@ import { test, expect } from '@playwright/test'; -import { loginAsRole } from '../../fixtures/auth.fixtures'; /** * Employee List Tests @@ -13,9 +12,9 @@ import { loginAsRole } from '../../fixtures/auth.fixtures'; */ test.describe('Employee List', () => { + test.use({ storageState: '.auth/manager.json' }); + test.beforeEach(async ({ page }) => { - // Login as Manager - await loginAsRole(page, 'manager'); await page.goto('/employees'); await page.waitForLoadState('networkidle'); }); diff --git a/tests/employee-management/employee-smoke.spec.ts b/tests/employee-management/employee-smoke.spec.ts index fef1373..6f951e7 100644 --- a/tests/employee-management/employee-smoke.spec.ts +++ b/tests/employee-management/employee-smoke.spec.ts @@ -13,11 +13,16 @@ import { EmployeeFormPage } from '../../page-objects/employee-form.page'; */ test.describe('Employee Management - Smoke Tests', () => { + test.use({ storageState: '.auth/manager.json' }); + test.beforeEach(async ({ page }) => { - // Login as Manager (has create/edit permissions) - await loginAsRole(page, 'manager'); + // Navigate to the app base URL so the Angular router loads with the stored + // auth tokens (storageState does not automatically trigger navigation). + await page.goto('/'); + await page.waitForLoadState('domcontentloaded'); + // Verify logged in by checking for dashboard heading - await expect(page.locator('h1:has-text("Dashboard")')).toBeVisible(); + await expect(page.locator('h1:has-text("Dashboard")')).toBeVisible({ timeout: 15000 }); }); test('should view employee list', async ({ page }) => { @@ -25,7 +30,7 @@ test.describe('Employee Management - Smoke Tests', () => { await page.goto('/employees'); // Wait for page to load - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Verify page title/header const pageTitle = page.locator('h1, h2, h3').filter({ hasText: /employees/i }); @@ -42,6 +47,12 @@ test.describe('Employee Management - Smoke Tests', () => { }); test('should create new employee', async ({ page }) => { + // test.fixme: The Position and Department dropdowns are populated from the API + // (https://localhost:44378). Currently the API returns ERR_CONNECTION_REFUSED, + // so dropdown options never load and the form cannot be submitted. + // Start the API service and re-run this test. + test.fixme(true, 'Requires API service at https://localhost:44378 to be running'); + // Logout and login as HRAdmin (only HRAdmin can create employees) await logout(page); await loginAsRole(page, 'hradmin'); @@ -55,7 +66,7 @@ test.describe('Employee Management - Smoke Tests', () => { // Navigate to employees page await page.goto('/employees'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Click "Create" or "Add Employee" button const createButton = page.locator('button').filter({ hasText: /create|add.*employee|new/i }); @@ -87,13 +98,21 @@ test.describe('Employee Management - Smoke Tests', () => { }); test('should view employee detail', async ({ page }) => { + // test.fixme: The employee list table is populated from the API + // (https://localhost:44378). Currently the API returns ERR_CONNECTION_REFUSED, + // so no data rows load and there is nothing to click into. + // Start the API service and re-run this test. + test.fixme(true, 'Requires API service at https://localhost:44378 to be running'); + // Navigate to employees page await page.goto('/employees'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); - // Click on first employee row or link - const firstEmployee = page.locator('tr, mat-row, .employee-row').nth(1); // Skip header row - await expect(firstEmployee).toBeVisible(); + // Click on first employee row or link. + // mat-row matches data rows only (header rows are mat-header-row, not mat-row), + // so nth(0) gives the first data row without needing to skip a header. + const firstEmployee = page.locator('mat-row, tr.mat-mdc-row, tr:not(tr:first-of-type), .employee-row').first(); + await expect(firstEmployee).toBeVisible({ timeout: 15000 }); // Wait for table data to load // Click to view details (might be row click or view button) const viewButton = firstEmployee.locator('button, a').filter({ hasText: /view|details|edit/i }).first(); @@ -127,7 +146,7 @@ test.describe('Employee Management - Smoke Tests', () => { // Navigate to employees page await page.goto('/employees'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Verify can view the list const employeeList = page.locator('table, mat-table, .employee-list'); diff --git a/tests/error-handling/api-errors.spec.ts b/tests/error-handling/api-errors.spec.ts index 1371d6c..5e083f6 100644 --- a/tests/error-handling/api-errors.spec.ts +++ b/tests/error-handling/api-errors.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from '@playwright/test'; import { getTokenForRole } from '../../fixtures/api.fixtures'; +import { APP_URLS } from '../../config/test-config'; /** * API Error Handling Tests @@ -12,7 +13,7 @@ import { getTokenForRole } from '../../fixtures/api.fixtures'; */ test.describe('API Error Handling', () => { - const baseURL = 'https://localhost:44378/api/v1'; + const baseURL = APP_URLS.api; let authToken: string; test.beforeAll(async ({ request }) => { diff --git a/tests/error-handling/network-errors.spec.ts b/tests/error-handling/network-errors.spec.ts index 2898167..b50ed6d 100644 --- a/tests/error-handling/network-errors.spec.ts +++ b/tests/error-handling/network-errors.spec.ts @@ -1,5 +1,4 @@ import { test, expect } from '@playwright/test'; -import { loginAsRole } from '../../fixtures/auth.fixtures'; /** * Network Error Handling Tests @@ -13,9 +12,7 @@ import { loginAsRole } from '../../fixtures/auth.fixtures'; */ test.describe('Network Error Handling', () => { - test.beforeEach(async ({ page }) => { - await loginAsRole(page, 'manager'); - }); + test.use({ storageState: '.auth/manager.json' }); test('should handle API timeout gracefully', async ({ page }) => { // Set very short timeout to simulate timeout diff --git a/tests/navigation/routing.spec.ts b/tests/navigation/routing.spec.ts index 70905b5..07f3b4a 100644 --- a/tests/navigation/routing.spec.ts +++ b/tests/navigation/routing.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from '@playwright/test'; import { loginAsRole, logout, isAuthenticated } from '../../fixtures/auth.fixtures'; +import { getAngularUrlPattern } from '../../config/test-config'; /** * Navigation & Routing Tests @@ -322,7 +323,7 @@ test.describe('Navigation & Routing', () => { await logout(page); // After logout, should be back on Angular app as Guest/Anonymous - expect(page.url()).toMatch(/localhost:4200/); + expect(page.url()).toMatch(getAngularUrlPattern()); // Verify user is no longer authenticated const authenticated = await isAuthenticated(page); diff --git a/tests/performance/large-datasets.spec.ts b/tests/performance/large-datasets.spec.ts index ea2c7d2..587c43a 100644 --- a/tests/performance/large-datasets.spec.ts +++ b/tests/performance/large-datasets.spec.ts @@ -1,5 +1,4 @@ import { test, expect } from '@playwright/test'; -import { loginAsRole } from '../../fixtures/auth.fixtures'; /** * Large Datasets Performance Tests @@ -11,9 +10,7 @@ import { loginAsRole } from '../../fixtures/auth.fixtures'; */ test.describe('Large Datasets Performance', () => { - test.beforeEach(async ({ page }) => { - await loginAsRole(page, 'manager'); - }); + test.use({ storageState: '.auth/manager.json' }); test('should handle pagination with large dataset', async ({ page }) => { await page.goto('/employees'); diff --git a/tests/performance/load-time.spec.ts b/tests/performance/load-time.spec.ts index 0345376..8ee8ddb 100644 --- a/tests/performance/load-time.spec.ts +++ b/tests/performance/load-time.spec.ts @@ -1,5 +1,4 @@ import { test, expect } from '@playwright/test'; -import { loginAsRole } from '../../fixtures/auth.fixtures'; /** * Load Time Performance Tests @@ -12,9 +11,7 @@ import { loginAsRole } from '../../fixtures/auth.fixtures'; */ test.describe('Load Time Performance', () => { - test.beforeEach(async ({ page }) => { - await loginAsRole(page, 'manager'); - }); + test.use({ storageState: '.auth/manager.json' }); test('should load dashboard in under 2 seconds', async ({ page }) => { const startTime = Date.now(); diff --git a/tests/position-management/position-crud.spec.ts b/tests/position-management/position-crud.spec.ts index 9c64828..a5b14b1 100644 --- a/tests/position-management/position-crud.spec.ts +++ b/tests/position-management/position-crud.spec.ts @@ -1,5 +1,4 @@ import { test, expect } from '@playwright/test'; -import { loginAsRole } from '../../fixtures/auth.fixtures'; import { createPositionData } from '../../fixtures/data.fixtures'; import { PositionListPage } from '../../page-objects/position-list.page'; import { PositionFormPage } from '../../page-objects/position-form.page'; @@ -16,8 +15,9 @@ import { PositionFormPage } from '../../page-objects/position-form.page'; */ test.describe('Position CRUD (HRAdmin Only)', () => { + test.use({ storageState: '.auth/hradmin.json' }); + test.beforeEach(async ({ page }) => { - await loginAsRole(page, 'hradmin'); const list = new PositionListPage(page); await list.goto(); }); diff --git a/tests/salary-ranges/salary-range-crud.spec.ts b/tests/salary-ranges/salary-range-crud.spec.ts index 45bf5ca..6427544 100644 --- a/tests/salary-ranges/salary-range-crud.spec.ts +++ b/tests/salary-ranges/salary-range-crud.spec.ts @@ -19,8 +19,9 @@ import { SalaryRangeFormPage } from '../../page-objects/salary-range-form.page'; */ test.describe('Salary Range CRUD', () => { + test.use({ storageState: '.auth/hradmin.json' }); + test.beforeEach(async ({ page }) => { - await loginAsRole(page, 'hradmin'); const list = new SalaryRangeListPage(page); await list.goto(); }); diff --git a/tests/salary-ranges/salary-range-validation.spec.ts b/tests/salary-ranges/salary-range-validation.spec.ts index dcf288d..add378b 100644 --- a/tests/salary-ranges/salary-range-validation.spec.ts +++ b/tests/salary-ranges/salary-range-validation.spec.ts @@ -1,5 +1,4 @@ import { test, expect } from '@playwright/test'; -import { loginAsRole } from '../../fixtures/auth.fixtures'; import { SalaryRangeListPage } from '../../page-objects/salary-range-list.page'; import { SalaryRangeFormPage } from '../../page-objects/salary-range-form.page'; @@ -20,8 +19,9 @@ import { SalaryRangeFormPage } from '../../page-objects/salary-range-form.page'; const MAT_ERROR = 'mat-error, .mat-mdc-form-field-error, .mat-error'; test.describe('Salary Range Validation', () => { + test.use({ storageState: '.auth/hradmin.json' }); + test.beforeEach(async ({ page }) => { - await loginAsRole(page, 'hradmin'); const list = new SalaryRangeListPage(page); await list.goto(); }); diff --git a/tests/test-1.spec.ts b/tests/test-1.spec.ts deleted file mode 100644 index 5b3d48c..0000000 --- a/tests/test-1.spec.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test('test', async ({ page }) => { - // Recording... -}); \ No newline at end of file diff --git a/tests/validation/form-validation.spec.ts b/tests/validation/form-validation.spec.ts index 6cfe696..627da3f 100644 --- a/tests/validation/form-validation.spec.ts +++ b/tests/validation/form-validation.spec.ts @@ -1,5 +1,4 @@ import { test, expect } from '@playwright/test'; -import { loginAsRole } from '../../fixtures/auth.fixtures'; /** * Form Validation Edge Cases Tests @@ -15,10 +14,7 @@ import { loginAsRole } from '../../fixtures/auth.fixtures'; */ test.describe('Form Validation Edge Cases', () => { - test.beforeEach(async ({ page }) => { - // Login as Manager (has create permissions) - await loginAsRole(page, 'manager'); - }); + test.use({ storageState: '.auth/manager.json' }); test('should validate max length for text fields', async ({ page }) => { await page.goto('/employees'); diff --git a/tests/visual/dashboard-visual.spec.ts b/tests/visual/dashboard-visual.spec.ts index eee95c3..53c2ef9 100644 --- a/tests/visual/dashboard-visual.spec.ts +++ b/tests/visual/dashboard-visual.spec.ts @@ -1,5 +1,4 @@ import { test, expect } from '@playwright/test'; -import { loginAsRole } from '../../fixtures/auth.fixtures'; import { VISUAL_THRESHOLDS, TIMEOUTS } from '../../config/test-config'; /** @@ -27,9 +26,7 @@ import { VISUAL_THRESHOLDS, TIMEOUTS } from '../../config/test-config'; */ test.describe('Dashboard Visual Regression', () => { - test.beforeEach(async ({ page }) => { - await loginAsRole(page, 'manager'); - }); + test.use({ storageState: '.auth/manager.json' }); test('should match dashboard baseline screenshot', async ({ page }) => { await page.goto('/dashboard'); diff --git a/tests/visual/forms-visual.spec.ts b/tests/visual/forms-visual.spec.ts index 0aab7b3..3e63ed7 100644 --- a/tests/visual/forms-visual.spec.ts +++ b/tests/visual/forms-visual.spec.ts @@ -1,5 +1,4 @@ import { test, expect } from '@playwright/test'; -import { loginAsRole } from '../../fixtures/auth.fixtures'; import { VISUAL_THRESHOLDS, TIMEOUTS } from '../../config/test-config'; /** @@ -18,9 +17,7 @@ import { VISUAL_THRESHOLDS, TIMEOUTS } from '../../config/test-config'; */ test.describe('Forms Visual Regression', () => { - test.beforeEach(async ({ page }) => { - await loginAsRole(page, 'manager'); - }); + test.use({ storageState: '.auth/manager.json' }); test.skip('should match employee form baseline', async ({ page }) => { await page.goto('/employees'); diff --git a/tests/workflows/complete-employee-workflow.spec.ts b/tests/workflows/complete-employee-workflow.spec.ts index aed3f20..a913faf 100644 --- a/tests/workflows/complete-employee-workflow.spec.ts +++ b/tests/workflows/complete-employee-workflow.spec.ts @@ -2,6 +2,7 @@ import { test, expect } from '@playwright/test'; import { loginAsRole, logout, isAuthenticated } from '../../fixtures/auth.fixtures'; import { createEmployeeData } from '../../fixtures/data.fixtures'; import { EmployeeFormPage } from '../../page-objects/employee-form.page'; +import { getAngularUrlPattern } from '../../config/test-config'; /** * Complete Employee Workflow Test @@ -153,7 +154,7 @@ test.describe('Complete Employee Workflow', () => { await logout(page); // After logout, should be back on Angular app as Guest/Anonymous - expect(page.url()).toMatch(/localhost:4200/); + expect(page.url()).toMatch(getAngularUrlPattern()); // Verify user is no longer authenticated const authenticated = await isAuthenticated(page); diff --git a/tests/workflows/hradmin-operations.spec.ts b/tests/workflows/hradmin-operations.spec.ts index 6e71b6d..b48f02e 100644 --- a/tests/workflows/hradmin-operations.spec.ts +++ b/tests/workflows/hradmin-operations.spec.ts @@ -3,6 +3,7 @@ import { loginAsRole, logout, isAuthenticated } from '../../fixtures/auth.fixtur import { createSalaryRangeData, createPositionData, createEmployeeData } from '../../fixtures/data.fixtures'; import { PositionFormPage } from '../../page-objects/position-form.page'; import { EmployeeFormPage } from '../../page-objects/employee-form.page'; +import { getAngularUrlPattern } from '../../config/test-config'; /** * HRAdmin Operations Workflow Test @@ -192,7 +193,7 @@ test.describe('HRAdmin Operations Workflow', () => { await logout(page); // After logout, should be back on Angular app as Guest/Anonymous - expect(page.url()).toMatch(/localhost:4200/); + expect(page.url()).toMatch(getAngularUrlPattern()); // Verify user is no longer authenticated const authenticated = await isAuthenticated(page); diff --git a/tests/workflows/manager-daily-tasks.spec.ts b/tests/workflows/manager-daily-tasks.spec.ts index 135d57b..c7bafc4 100644 --- a/tests/workflows/manager-daily-tasks.spec.ts +++ b/tests/workflows/manager-daily-tasks.spec.ts @@ -2,6 +2,7 @@ import { test, expect } from '@playwright/test'; import { loginAsRole, logout, isAuthenticated } from '../../fixtures/auth.fixtures'; import { createEmployeeData, createDepartmentData } from '../../fixtures/data.fixtures'; import { EmployeeFormPage } from '../../page-objects/employee-form.page'; +import { getAngularUrlPattern } from '../../config/test-config'; /** * Manager Daily Tasks Workflow Test @@ -214,7 +215,7 @@ test.describe('Manager Daily Tasks Workflow', () => { await logout(page); // After logout, should be back on Angular app as Guest/Anonymous - expect(page.url()).toMatch(/localhost:4200/); + expect(page.url()).toMatch(getAngularUrlPattern()); // Verify user is no longer authenticated const authenticated = await isAuthenticated(page); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f9af006 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "types": ["node"], + "strict": false, + "esModuleInterop": true, + "resolveJsonModule": true, + "outDir": "./dist" + }, + "include": [ + "**/*.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/utils/token-manager.ts b/utils/token-manager.ts index a4b6944..1501b10 100644 --- a/utils/token-manager.ts +++ b/utils/token-manager.ts @@ -1,5 +1,6 @@ import { APIRequestContext } from '@playwright/test'; import { getApiToken } from '../fixtures/api.fixtures'; +import { TEST_USERS } from '../config/test-config'; /** * Token Manager Utility @@ -21,11 +22,11 @@ interface TokenCache { // In-memory token cache const tokenCache: TokenCache = {}; -// Role to credentials mapping +// Role to credentials mapping — sourced from env vars via TEST_USERS const roleCredentials: { [role: string]: { username: string; password: string } } = { - employee: { username: 'employee1', password: 'Pa$word123' }, - manager: { username: 'ashtyn1', password: 'Pa$word123' }, - hradmin: { username: 'admin1', password: 'Pa$word123' }, + employee: { username: TEST_USERS.employee.username, password: TEST_USERS.employee.password }, + manager: { username: TEST_USERS.manager.username, password: TEST_USERS.manager.password }, + hradmin: { username: TEST_USERS.hradmin.username, password: TEST_USERS.hradmin.password }, }; /**