diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 00000000..72508610 --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -0,0 +1,348 @@ +# Copyright The Linux Foundation and each contributor to LFX. +# SPDX-License-Identifier: MIT + +name: E2E Tests + +permissions: + id-token: write + contents: read + issues: write + pull-requests: write + +on: + workflow_call: + inputs: + node-version: + description: 'Node.js version to use' + required: false + default: '22' + type: string + test-command: + description: 'Test command to run' + required: false + default: 'e2e' + type: string + browser: + description: 'Browser to test (chromium, firefox, mobile-chrome, or all)' + required: false + default: 'all' + type: string + base-url: + description: 'Base URL for testing' + required: false + default: 'http://localhost:4200' + type: string + skip-build: + description: 'Skip building the application (use existing build)' + required: false + default: false + type: boolean + secrets: + TEST_USERNAME: + description: 'Username for test authentication' + required: false + TEST_PASSWORD: + description: 'Password for test authentication' + required: false + outputs: + test-results: + description: 'Test results summary' + value: ${{ jobs.e2e-tests.outputs.test-results }} + report-url: + description: 'URL to the test report artifact' + value: ${{ jobs.e2e-tests.outputs.report-url }} + +jobs: + e2e-tests: + name: Playwright E2E Tests + runs-on: ubuntu-latest + timeout-minutes: 30 + + outputs: + test-results: ${{ steps.test-results.outputs.results }} + report-url: ${{ steps.upload-report.outputs.artifact-url }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Enable Corepack + run: corepack enable + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node-version }} + cache: 'yarn' + + - name: Install dependencies + run: yarn install --immutable + + - name: OIDC Auth + uses: aws-actions/configure-aws-credentials@v4 + id: oidc-auth + with: + audience: sts.amazonaws.com + role-to-assume: arn:aws:iam::788942260905:role/github-actions-deploy + aws-region: us-west-2 + + - name: Read secrets from AWS Secrets Manager into environment variables + id: get_secrets + uses: aws-actions/aws-secretsmanager-get-secrets@v2 + with: + secret-ids: | + SUPABASE, /cloudops/managed-secrets/cloud/supabase/api_key + AUTH0, /cloudops/managed-secrets/auth0/LFX_V2_PCC + + - name: Setup Turborepo cache + uses: actions/cache@v4 + with: + path: | + .turbo + node_modules/.cache/turbo + key: ${{ runner.os }}-turbo-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('**/*', '!**/node_modules/**', '!**/.turbo/**') }} + restore-keys: | + ${{ runner.os }}-turbo-${{ hashFiles('**/yarn.lock') }}- + ${{ runner.os }}-turbo- + + - name: Validate required secrets for E2E testing + id: validate-secrets + run: | + missing_secrets="" + + # Check AWS Secrets Manager secrets (masked environment variables) + if [ -z "$AUTH0" ]; then + missing_secrets="$missing_secrets AUTH0 (from AWS Secrets Manager)" + fi + if [ -z "$SUPABASE" ]; then + missing_secrets="$missing_secrets SUPABASE (from AWS Secrets Manager)" + fi + + # Check GitHub secrets (fallback) + if [ -z "${{ secrets.TEST_USERNAME }}" ]; then + missing_secrets="$missing_secrets TEST_USERNAME" + fi + if [ -z "${{ secrets.TEST_PASSWORD }}" ]; then + missing_secrets="$missing_secrets TEST_PASSWORD" + fi + + if [ -n "$missing_secrets" ]; then + echo "โŒ Missing required secrets for E2E testing:$missing_secrets" + echo "Please configure these secrets to enable E2E tests." + echo "can_run_tests=false" >> $GITHUB_OUTPUT + else + echo "โœ… All required secrets are configured" + echo "can_run_tests=true" >> $GITHUB_OUTPUT + fi + + - name: Set up non-sensitive environment variables + if: steps.validate-secrets.outputs.can_run_tests == 'true' + run: | + echo "ENV=development" >> $GITHUB_ENV + echo "PCC_BASE_URL=http://localhost:4200" >> $GITHUB_ENV + echo "PCC_AUTH0_ISSUER_BASE_URL=https://linuxfoundation-dev.auth0.com/" >> $GITHUB_ENV + echo "PCC_AUTH0_AUDIENCE=https://api-gw.dev.platform.linuxfoundation.org/" >> $GITHUB_ENV + echo "CI=true" >> $GITHUB_ENV + + - name: Set up sensitive environment variables + if: steps.validate-secrets.outputs.can_run_tests == 'true' + run: | + # Parse and set AUTH0 secrets with explicit masking + if [ -n "$AUTH0" ]; then + AUTH0_CLIENT_ID=$(echo "$AUTH0" | jq -r '.client_id // empty') + AUTH0_CLIENT_SECRET=$(echo "$AUTH0" | jq -r '.client_secret // empty') + + # Explicitly mask the values + echo "::add-mask::$AUTH0_CLIENT_ID" + echo "::add-mask::$AUTH0_CLIENT_SECRET" + + # Set as environment variables + echo "PCC_AUTH0_CLIENT_ID=$AUTH0_CLIENT_ID" >> $GITHUB_ENV + echo "PCC_AUTH0_CLIENT_SECRET=$AUTH0_CLIENT_SECRET" >> $GITHUB_ENV + echo "โœ… AUTH0 secrets set as masked environment variables" + fi + + # Parse and set SUPABASE secrets + if [ -n "$SUPABASE" ]; then + SUPABASE_URL=$(echo "$SUPABASE" | jq -r '.url // empty') + SUPABASE_API_KEY=$(echo "$SUPABASE" | jq -r '.api_key // empty') + + # Explicitly mask the values + echo "::add-mask::$SUPABASE_URL" + echo "::add-mask::$SUPABASE_API_KEY" + + # Set as environment variables + echo "SUPABASE_URL=$SUPABASE_URL" >> $GITHUB_ENV + echo "POSTGRES_API_KEY=$SUPABASE_API_KEY" >> $GITHUB_ENV + echo "โœ… SUPABASE secrets set as masked environment variables" + fi + + # Set test credentials + echo "::add-mask::${{ secrets.TEST_USERNAME }}" + echo "::add-mask::${{ secrets.TEST_PASSWORD }}" + echo "TEST_USERNAME=${{ secrets.TEST_USERNAME }}" >> $GITHUB_ENV + echo "TEST_PASSWORD=${{ secrets.TEST_PASSWORD }}" >> $GITHUB_ENV + + - name: Install Playwright browsers + if: steps.validate-secrets.outputs.can_run_tests == 'true' + working-directory: apps/lfx-pcc + run: npx playwright install --with-deps + + - name: Create Playwright auth directory + if: steps.validate-secrets.outputs.can_run_tests == 'true' + working-directory: apps/lfx-pcc + run: mkdir -p playwright/.auth + + - name: Build the application + if: steps.validate-secrets.outputs.can_run_tests == 'true' + run: yarn build + + - name: Run E2E tests (All browsers) + if: ${{ inputs.browser == 'all' && steps.validate-secrets.outputs.can_run_tests == 'true' }} + working-directory: apps/lfx-pcc + run: | + if [ -n "$TEST_USERNAME" ] && [ -n "$TEST_PASSWORD" ]; then + echo "๐Ÿ” Running authenticated E2E tests on all browsers" + echo "๐Ÿš€ Playwright will automatically start the dev server on localhost:4200" + echo "๐Ÿ“‹ Using secrets from AWS Secrets Manager" + yarn ${{ inputs.test-command }} --reporter=list + else + echo "โš ๏ธ No test credentials provided. Skipping E2E tests." + echo "Set TEST_USERNAME and TEST_PASSWORD secrets to enable E2E tests." + exit 0 + fi + + - name: Run E2E tests (Specific browser) + if: ${{ inputs.browser != 'all' && steps.validate-secrets.outputs.can_run_tests == 'true' }} + working-directory: apps/lfx-pcc + run: | + if [ -n "$TEST_USERNAME" ] && [ -n "$TEST_PASSWORD" ]; then + echo "๐Ÿ” Running authenticated E2E tests on ${{ inputs.browser }}" + echo "๐Ÿš€ Playwright will automatically start the dev server on localhost:4200" + echo "๐Ÿ“‹ Using secrets from AWS Secrets Manager" + yarn ${{ inputs.test-command }} --project=${{ inputs.browser }} --reporter=list + else + echo "โš ๏ธ No test credentials provided. Skipping E2E tests." + echo "Set TEST_USERNAME and TEST_PASSWORD secrets to enable E2E tests." + exit 0 + fi + + - name: E2E tests skipped + if: ${{ steps.validate-secrets.outputs.can_run_tests == 'false' }} + run: | + echo "โญ๏ธ E2E tests skipped due to missing required secrets" + echo "Configure the following secrets to enable E2E testing:" + echo "" + echo "AWS Secrets Manager (required):" + echo " - /cloudops/managed-secrets/auth0/LFX_V2_PCC (AUTH0 configuration)" + echo " - /cloudops/managed-secrets/cloud/supabase/api_key (SUPABASE configuration)" + echo "" + echo "GitHub Secrets (required for authenticated tests):" + echo " - TEST_USERNAME" + echo " - TEST_PASSWORD" + + - name: Generate test results summary + id: test-results + if: always() + working-directory: apps/lfx-pcc + run: | + if [ "${{ steps.validate-secrets.outputs.can_run_tests }}" == "false" ]; then + echo "โญ๏ธ E2E tests skipped (missing required secrets)" + echo "results=skipped" >> $GITHUB_OUTPUT + elif [ -z "$TEST_USERNAME" ] || [ -z "$TEST_PASSWORD" ]; then + echo "โญ๏ธ E2E tests skipped (no test credentials)" + echo "results=skipped" >> $GITHUB_OUTPUT + elif [ -f "test-results/.last-run.json" ]; then + echo "โœ… E2E tests completed successfully" + echo "results=success" >> $GITHUB_OUTPUT + else + echo "โŒ E2E tests failed" + echo "results=failure" >> $GITHUB_OUTPUT + fi + + - name: Upload Playwright report + id: upload-report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report-${{ inputs.browser }}-${{ github.run_id }} + path: | + apps/lfx-pcc/playwright-report/ + retention-days: 7 + + - name: Comment test results on PR + if: github.event_name == 'pull_request' && always() + uses: actions/github-script@v7 + with: + script: | + const results = '${{ steps.test-results.outputs.results }}'; + const browser = '${{ inputs.browser }}'; + const runId = '${{ github.run_id }}'; + + let emoji, status, details; + + if (results === 'success') { + emoji = 'โœ…'; + status = 'passed'; + details = 'All E2E tests passed successfully.'; + } else if (results === 'failure') { + emoji = 'โŒ'; + status = 'failed'; + details = 'Some E2E tests failed. Check the [test report](https://github.com/${{ github.repository }}/actions/runs/' + runId + ') for details.'; + } else { + emoji = 'โญ๏ธ'; + status = 'skipped'; + details = 'E2E tests were skipped (no test credentials provided).'; + } + + const comment = `## ${emoji} E2E Tests ${status.charAt(0).toUpperCase() + status.slice(1)} + + **Browser:** ${browser} + **Status:** ${status} + + ${details} + +
+ Test Configuration + + - **Node.js:** ${{ inputs.node-version }} + - **Command:** \`${{ inputs.test-command }}\` + - **Browser:** ${browser} + - **Base URL:** ${{ inputs.base-url }} + +
`; + + // Look for existing E2E test comment by this bot + const existingComments = await github.rest.issues.listComments({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo + }); + + const botComment = existingComments.data.find(comment => + comment.user.login === 'github-actions[bot]' && + comment.body.includes('## ') && + comment.body.includes('E2E Tests') + ); + + if (botComment) { + // Update existing comment + await github.rest.issues.updateComment({ + comment_id: botComment.id, + owner: context.repo.owner, + repo: context.repo.repo, + body: comment + }); + console.log('Updated existing E2E test comment'); + } else { + // Create new comment + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: comment + }); + console.log('Created new E2E test comment'); + } \ No newline at end of file diff --git a/.github/workflows/quality-check.yml b/.github/workflows/quality-check.yml index 2f7fdeb9..b686179f 100644 --- a/.github/workflows/quality-check.yml +++ b/.github/workflows/quality-check.yml @@ -70,4 +70,17 @@ jobs: with: name: turbo-cache-summary path: .turbo/ - retention-days: 1 \ No newline at end of file + retention-days: 1 + + e2e-tests: + name: E2E Tests + needs: quality-checks + uses: ./.github/workflows/e2e-tests.yml + with: + node-version: '22' + test-command: 'e2e' + browser: 'chromium' # Use only Chromium for PR testing for faster feedback + skip-build: false + secrets: + TEST_USERNAME: ${{ secrets.TEST_USERNAME }} + TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }} diff --git a/.github/workflows/weekly-e2e-tests.yml b/.github/workflows/weekly-e2e-tests.yml new file mode 100644 index 00000000..684b2b0e --- /dev/null +++ b/.github/workflows/weekly-e2e-tests.yml @@ -0,0 +1,146 @@ +# Copyright The Linux Foundation and each contributor to LFX. +# SPDX-License-Identifier: MIT + +name: Weekly E2E Tests + +permissions: + id-token: write + contents: read + issues: write + pull-requests: write + +on: + schedule: + # Run every Sunday at 2:00 AM UTC + - cron: '0 2 * * 0' + workflow_dispatch: + inputs: + browsers: + description: 'Browsers to test' + required: false + default: 'all' + type: choice + options: + - 'all' + - 'chromium' + - 'firefox' + - 'mobile-chrome' + branch: + description: 'Branch to test' + required: false + default: 'main' + type: string + +jobs: + determine-browsers: + name: Determine Browser Matrix + runs-on: ubuntu-latest + outputs: + browsers: ${{ steps.set-matrix.outputs.browsers }} + steps: + - name: Set browser matrix + id: set-matrix + run: | + if [ "${{ github.event.inputs.browsers }}" = "all" ] || [ "${{ github.event.inputs.browsers }}" = "" ]; then + echo "browsers=[\"chromium\",\"firefox\",\"mobile-chrome\"]" >> $GITHUB_OUTPUT + else + echo "browsers=[\"${{ github.event.inputs.browsers }}\"]" >> $GITHUB_OUTPUT + fi + + weekly-e2e-tests: + name: E2E Tests (${{ matrix.browser }}) + needs: determine-browsers + strategy: + matrix: + browser: ${{ fromJson(needs.determine-browsers.outputs.browsers) }} + fail-fast: false + max-parallel: 3 + uses: ./.github/workflows/e2e-tests.yml + with: + node-version: '22' + test-command: 'e2e' + browser: ${{ matrix.browser }} + skip-build: false + secrets: + TEST_USERNAME: ${{ secrets.TEST_USERNAME }} + TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }} + + report-results: + name: Report Weekly Test Results + needs: [determine-browsers, weekly-e2e-tests] + runs-on: ubuntu-latest + if: always() + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.branch || 'main' }} + + - name: Create summary report and issue on failure + uses: actions/github-script@v7 + with: + script: | + const browsers = ${{ needs.determine-browsers.outputs.browsers }}; + const results = ${{ toJson(needs.weekly-e2e-tests.result) }}; + const branch = '${{ github.event.inputs.branch || 'main' }}'; + const runId = '${{ github.run_id }}'; + + let summary = `# ๐Ÿ“Š Weekly E2E Test Results\n\n`; + summary += `**Branch:** ${branch}\n`; + summary += `**Run ID:** ${runId}\n`; + summary += `**Timestamp:** ${new Date().toISOString()}\n\n`; + + summary += `## Browser Results\n\n`; + + let overallStatus = results === 'success' ? 'success' : 'failure'; + let failedBrowsers = []; + + browsers.forEach(browser => { + const browserStatus = results === 'success' ? 'โœ… PASSED' : + results === 'failure' ? 'โŒ FAILED' : + results === 'cancelled' ? 'โน๏ธ CANCELLED' : 'โญ๏ธ SKIPPED'; + summary += `- **${browser}**: ${browserStatus}\n`; + + if (results === 'failure') { + failedBrowsers.push(browser); + } + }); + + summary += `\n## Overall Status: ${overallStatus === 'success' ? 'โœ… PASSED' : 'โŒ FAILED'}\n\n`; + + if (failedBrowsers.length > 0) { + summary += `### Failed Browsers\n`; + failedBrowsers.forEach(browser => { + summary += `- ${browser}: [View Report](https://github.com/${{ github.repository }}/actions/runs/${runId})\n`; + }); + summary += `\n`; + } + + summary += `### Test Reports\n`; + browsers.forEach(browser => { + summary += `- [${browser} Test Report](https://github.com/${{ github.repository }}/actions/runs/${runId})\n`; + }); + + console.log(summary); + + // Create GitHub issue if tests failed + if (overallStatus === 'failure') { + const title = `Weekly E2E Tests Failed - ${new Date().toISOString().split('T')[0]}`; + const body = summary + `\n\n**Auto-generated by:** Weekly E2E Tests workflow\n**Run:** https://github.com/${{ github.repository }}/actions/runs/${runId}`; + + try { + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + labels: ['bug', 'e2e-tests', 'automated'] + }); + + console.log('โœ… Created GitHub issue for test failures'); + } catch (error) { + console.log('โŒ Failed to create GitHub issue:', error.message); + } + } else { + console.log('โœ… All tests passed - no issue created'); + } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 0ba3b6b9..c0ea5226 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,19 +2,26 @@ "cSpell.words": [ "animateonscroll", "autorestart", + "cloudops", "confirmdialog", + "contentful", + "Contentful", "daygrid", "dismissable", + "domcontentloaded", "dynamicdialog", "fullcalendar", "iconfield", "inputicon", + "networkidle", + "nonexistentproject", "PostgreSQL", "PostgREST", "primeng", "supabase", "timegrid", - "Turborepo" + "Turborepo", + "viewports" ], "scss.lint.unknownAtRules": "ignore", "editor.formatOnSave": true, diff --git a/CLAUDE.md b/CLAUDE.md index aa60c483..134a73c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - [Authentication](docs/architecture/backend/authentication.md) - Auth0 setup - [SSR Server](docs/architecture/backend/ssr-server.md) - Server-side rendering - [Logging & Monitoring](docs/architecture/backend/logging-monitoring.md) - Structured logging +- [E2E Testing](docs/architecture/testing/e2e-testing.md) - Comprehensive end-to-end testing with dual architecture +- [Testing Best Practices](docs/architecture/testing/testing-best-practices.md) - Testing patterns and implementation guide ### ๐Ÿ’ก Quick Reference @@ -83,84 +85,6 @@ lfx-pcc-v3/ [... rest of the existing content remains unchanged ...] -## Component Organization Pattern - -All Angular components should follow this standardized organization pattern for consistency and maintainability: - -### Structure Overview - -```typescript -export class ComponentName { - // 1. Injected services (readonly) - private readonly serviceOne = inject(ServiceOne); - private readonly serviceTwo = inject(ServiceTwo); - - // 2. Class variables with explicit types - private dialogRef: DialogRef | undefined; - public someSignal: WritableSignal; - public someComputed: Signal; - public someForm: FormGroup; - public someArray: Type[]; - - constructor() { - // 3. Initialize all class variables by calling private methods - this.someSignal = signal(initialValue); - this.someComputed = this.initializeSomeComputed(); - this.someForm = this.initializeSomeForm(); - this.someArray = this.initializeSomeArray(); - } - - // 4. Public methods (lifecycle, event handlers, etc.) - public onSomeEvent(): void {} - public somePublicMethod(): void {} - - // 5. Private methods (business logic) - private somePrivateMethod(): void {} - private handleSomeAction(): void {} - - // 6. Private initialization methods (at the end of class) - private initializeSomeComputed(): Signal { - return computed(() => { - /* logic */ - }); - } - - private initializeSomeForm(): FormGroup { - return new FormGroup({ - /* controls */ - }); - } - - private initializeSomeArray(): Type[] { - return [ - /* initial values */ - ]; - } -} -``` - -### Key Benefits - -- **Clear variable declarations** with types at the top -- **Organized constructor** that only handles initialization calls -- **Separation of concerns** between declaration and initialization -- **Improved readability** and maintainability -- **Better testability** with isolated initialization methods -- **Consistent code structure** across the entire application - -### Implementation Rules - -1. **Always declare variables with explicit types** before constructor -2. **Use constructor only for initialization** - no complex logic -3. **Create private initialization methods** for complex setup -4. **Group related variables together** (signals, forms, arrays, etc.) -5. **Place initialization methods at the end** of the class -6. **Use descriptive method names** like `initializeSearchForm()` - -### Example: Committee Dashboard - -See `apps/lfx-pcc/src/app/modules/project/committees/committee-dashboard/committee-dashboard.component.ts` for a complete implementation following this pattern. - ## Development Memories - Always reference PrimeNG's component interface when trying to define types @@ -182,3 +106,9 @@ See `apps/lfx-pcc/src/app/modules/project/committees/committee-dashboard/committ - Do not nest ternary expressions - Always run yarn lint before yarn build to validate your linting - The JIRA project key for this is LFXV2. All tickets associated to this repo should generally be in there. +- **E2E tests use dual architecture** - both content-based (_.spec.ts) and structural (_-robust.spec.ts) tests +- **Always add data-testid attributes** when creating new components for reliable test targeting +- **Run yarn e2e before major changes** to ensure all 85+ tests pass consistently +- **Use data-testid naming convention** - `[section]-[component]-[element]` for hierarchical structure +- **Test responsive behavior** - validate mobile, tablet, and desktop viewports appropriately +- When running tests to validate UI tests, use reporter=list diff --git a/apps/lfx-pcc/.env.example b/apps/lfx-pcc/.env.example index 6d81424a..68aa5f96 100644 --- a/apps/lfx-pcc/.env.example +++ b/apps/lfx-pcc/.env.example @@ -17,4 +17,9 @@ QUERY_SERVICE_TOKEN=your-jwt-token-here # Supabase Database Configuration # Get these from your Supabase project settings SUPABASE_URL=https://your-project.supabase.co -POSTGRES_API_KEY=your-supabase-anon-key \ No newline at end of file +POSTGRES_API_KEY=your-supabase-anon-key + +# E2E Test Configuration (Optional) +# Test user credentials for automated testing +TEST_USERNAME=your-test-username +TEST_PASSWORD=your-test-password \ No newline at end of file diff --git a/apps/lfx-pcc/.gitignore b/apps/lfx-pcc/.gitignore index 36d8e5e0..48644cdc 100644 --- a/apps/lfx-pcc/.gitignore +++ b/apps/lfx-pcc/.gitignore @@ -43,3 +43,8 @@ testem.log # System files .DS_Store Thumbs.db + +# Playwright +/playwright/.auth/ +/playwright-report/ +/test-results/ diff --git a/apps/lfx-pcc/e2e/helpers/auth.helper.ts b/apps/lfx-pcc/e2e/helpers/auth.helper.ts new file mode 100644 index 00000000..696a14bf --- /dev/null +++ b/apps/lfx-pcc/e2e/helpers/auth.helper.ts @@ -0,0 +1,90 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { Page, BrowserContext } from '@playwright/test'; + +export interface AuthCredentials { + username: string; + password: string; +} + +export class AuthHelper { + /** + * Authenticate using Auth0 login form + * @param page - Playwright page object + * @param credentials - Username and password for authentication + */ + static async loginWithAuth0(page: Page, credentials: AuthCredentials): Promise { + // Wait for Auth0 login page (assuming we're already navigated to the app) + await page.waitForURL(/auth0\.com/, { timeout: 10000 }); + + // Fill in credentials using role-based selectors + await page.getByRole('textbox', { name: 'Username or Email' }).fill(credentials.username); + await page.getByRole('textbox', { name: 'Password' }).fill(credentials.password); + + // Wait for button to be enabled and click sign in button + await page.waitForSelector('button:has-text("SIGN IN"):not([disabled])', { timeout: 5000 }); + await page.getByRole('button', { name: 'SIGN IN' }).click(); + + // Wait for redirect back to the app + await page.waitForURL(/^(?!.*auth0\.com).*$/, { timeout: 15000 }); + } + + /** + * Set authentication cookies/tokens directly (if available) + * @param context - Browser context + * @param authToken - Authentication token + */ + static async setAuthCookies(context: BrowserContext, authToken: string): Promise { + // This is a placeholder - adjust based on your actual auth implementation + await context.addCookies([ + { + name: 'appSession', // Replace with actual cookie name + value: authToken, + domain: 'localhost', + path: '/', + httpOnly: true, + secure: false, // Set to true for HTTPS + sameSite: 'Lax', + }, + ]); + } + + /** + * Check if user is authenticated + * @param page - Playwright page object + */ + static async isAuthenticated(page: Page): Promise { + // Check if we're not on the auth page + return !page.url().includes('auth0.com'); + } + + /** + * Clear all authentication data (cookies, local storage, session storage) + * @param page - Playwright page object + */ + static async clearAuthData(page: Page): Promise { + await page.context().clearCookies(); + await page.evaluate(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + } + + /** + * Logout from the application + * @param page - Playwright page object + */ + static async logout(page: Page): Promise { + await page.goto('/logout'); + await page.waitForURL(/auth0\.com/, { timeout: 10000 }); + } +} + +// Test credentials - these should be stored in environment variables +export const TEST_CREDENTIALS: AuthCredentials = { + username: process.env.TEST_USERNAME || '', + password: process.env.TEST_PASSWORD || '', +}; + +// Generated with [Claude Code](https://claude.ai/code) diff --git a/apps/lfx-pcc/e2e/helpers/global-setup.ts b/apps/lfx-pcc/e2e/helpers/global-setup.ts new file mode 100644 index 00000000..84a0f3b1 --- /dev/null +++ b/apps/lfx-pcc/e2e/helpers/global-setup.ts @@ -0,0 +1,47 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { chromium, FullConfig } from '@playwright/test'; +import { AuthHelper, TEST_CREDENTIALS } from './auth.helper'; + +async function globalSetup(config: FullConfig) { + // Skip authentication if no credentials are provided + if (!TEST_CREDENTIALS.username || !TEST_CREDENTIALS.password) { + console.log('โš ๏ธ No test credentials provided. Tests requiring authentication will be skipped.'); + console.log(' Set TEST_USERNAME and TEST_PASSWORD environment variables to enable authenticated tests.'); + return; + } + + const { baseURL } = config.projects[0].use; + const browser = await chromium.launch(); + const context = await browser.newContext(); + const page = await context.newPage(); + + try { + // Use baseURL from config or default to localhost + const url = baseURL || 'http://localhost:4200'; + console.log(`๐Ÿ” Attempting to authenticate at ${url}`); + + // Clear all cookies to ensure clean state + await context.clearCookies(); + + // Navigate to logout to trigger authentication flow + await page.goto(`${url}/logout`); + + // Perform authentication + await AuthHelper.loginWithAuth0(page, TEST_CREDENTIALS); + + // Save authentication state + await context.storageState({ path: 'playwright/.auth/user.json' }); + console.log('โœ… Authentication successful. State saved.'); + } catch (error) { + console.error('โŒ Authentication failed:', error); + console.log(' Tests requiring authentication will be skipped.'); + } finally { + await browser.close(); + } +} + +export default globalSetup; + +// Generated with [Claude Code](https://claude.ai/code) diff --git a/apps/lfx-pcc/e2e/homepage-robust.spec.ts b/apps/lfx-pcc/e2e/homepage-robust.spec.ts new file mode 100644 index 00000000..8a9d874d --- /dev/null +++ b/apps/lfx-pcc/e2e/homepage-robust.spec.ts @@ -0,0 +1,415 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { test, expect } from '@playwright/test'; + +test.describe('Homepage - Robust Tests', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + + // Verify we're authenticated and on the homepage + await expect(page).not.toHaveURL(/auth0\.com/); + }); + + test.describe('Page Structure and Components', () => { + test('should have correct page structure with main sections', async ({ page }) => { + await expect(page).toHaveTitle('LFX Projects Self-Service'); + await expect(page).toHaveURL('/'); + + // Check main homepage component is present + await expect(page.locator('lfx-home')).toBeVisible(); + + // Check main content sections are present + await expect(page.locator('[data-testid="hero-section"]')).toBeVisible(); + await expect(page.locator('[data-testid="projects-section"]')).toBeVisible(); + }); + + test('should display home component selector', async ({ page }) => { + await expect(page.locator('lfx-home')).toBeVisible(); + }); + }); + + test.describe('Hero Section', () => { + test('should display hero section with proper structure', async ({ page }) => { + const heroSection = page.locator('[data-testid="hero-section"]'); + await expect(heroSection).toBeVisible(); + + // Check hero title and subtitle + await expect(page.locator('[data-testid="hero-title"]')).toBeVisible(); + await expect(page.locator('[data-testid="hero-subtitle"]')).toBeVisible(); + + // Check search container + await expect(page.locator('[data-testid="hero-search-container"]')).toBeVisible(); + }); + + test('should display hero content with correct text', async ({ page }) => { + // Test hero title content + const heroTitle = page.locator('[data-testid="hero-title"]'); + await expect(heroTitle).toContainText('Your personalized control panel for managing projects, committees, and meetings.'); + + // Test hero subtitle content + const heroSubtitle = page.locator('[data-testid="hero-subtitle"]'); + await expect(heroSubtitle).toContainText('Get a comprehensive overview of all your active initiatives and upcoming events in one centralized dashboard.'); + }); + + test('should display search input with proper structure', async ({ page }) => { + const searchContainer = page.locator('[data-testid="hero-search-container"]'); + await expect(searchContainer).toBeVisible(); + + // Check search input component + const searchInput = page.locator('[data-testid="hero-search-input"]'); + await expect(searchInput).toBeVisible(); + + // Verify it's an lfx-input-text component + const tagName = await searchInput.evaluate((el) => el.tagName.toLowerCase()); + expect(tagName).toBe('lfx-input-text'); + }); + + test('should have functional search input', async ({ page }) => { + // Find the actual input field within the lfx-input-text component + const searchInput = page.getByRole('textbox', { name: 'Search projects, committees,' }); + await expect(searchInput).toBeVisible(); + await expect(searchInput).toBeEditable(); + + // Test search functionality + await searchInput.fill('test search'); + await expect(searchInput).toHaveValue('test search'); + }); + }); + + test.describe('Loading State', () => { + test('should display loading state when projects are loading', async ({ page }) => { + // Navigate to a fresh page to potentially catch loading state + await page.goto('/', { waitUntil: 'domcontentloaded' }); + + // Check if loading state appears (might be brief) + const isLoadingVisible = await page + .locator('[data-testid="loading-state"]') + .isVisible() + .catch(() => false); + + if (isLoadingVisible) { + await expect(page.locator('[data-testid="loading-state"]')).toBeVisible(); + await expect(page.locator('[data-testid="loading-spinner"]')).toBeVisible(); + } + + // Eventually projects should load + await expect(page.locator('[data-testid="projects-section"]')).toBeVisible(); + }); + }); + + test.describe('Projects Section', () => { + test('should display projects section with proper structure', async ({ page }) => { + const projectsSection = page.locator('[data-testid="projects-section"]'); + await expect(projectsSection).toBeVisible(); + + // Wait for projects to load + await page.waitForLoadState('networkidle'); + + // Should have either projects grid or skeleton + const hasProjects = await page + .locator('[data-testid="projects-grid"]') + .isVisible() + .catch(() => false); + const hasSkeleton = await page + .locator('[data-testid="projects-skeleton-container"]') + .isVisible() + .catch(() => false); + + expect(hasProjects || hasSkeleton).toBe(true); + }); + + test('should display project cards when projects load', async ({ page }) => { + // Wait for projects to load + await page.waitForLoadState('networkidle'); + + // Check for projects grid + const projectsGrid = page.locator('[data-testid="projects-grid"]'); + const isGridVisible = await projectsGrid.isVisible().catch(() => false); + + if (isGridVisible) { + await expect(projectsGrid).toBeVisible(); + + // Should have at least one project card + const projectCards = page.locator('[data-testid="project-card"]'); + const cardCount = await projectCards.count(); + expect(cardCount).toBeGreaterThan(0); + } + }); + + test('should display project cards with proper structure', async ({ page }) => { + // Wait for projects to load + await page.waitForLoadState('networkidle'); + + const projectCards = page.locator('[data-testid="project-card"]'); + const cardCount = await projectCards.count(); + + if (cardCount > 0) { + const firstCard = projectCards.first(); + await expect(firstCard).toBeVisible(); + + // Check that it's an lfx-project-card component + const tagName = await firstCard.evaluate((el) => el.tagName.toLowerCase()); + expect(tagName).toBe('lfx-project-card'); + + // Check project card internal structure + await expect(firstCard.locator('[data-testid="project-card-container"]')).toBeVisible(); + await expect(firstCard.locator('[data-testid="project-header"]')).toBeVisible(); + await expect(firstCard.locator('[data-testid="project-info"]')).toBeVisible(); + } + }); + + test('should display project card content elements', async ({ page }) => { + // Wait for projects to load + await page.waitForLoadState('networkidle'); + + const projectCards = page.locator('[data-testid="project-card"]'); + const cardCount = await projectCards.count(); + + if (cardCount > 0) { + const firstCard = projectCards.first(); + + // Check project logo + await expect(firstCard.locator('[data-testid="project-logo"]')).toBeVisible(); + + // Check project title and description + await expect(firstCard.locator('[data-testid="project-title"]')).toBeVisible(); + await expect(firstCard.locator('[data-testid="project-description"]')).toBeVisible(); + + // Check metrics section + const hasMetrics = await firstCard + .locator('[data-testid="project-metrics"]') + .isVisible() + .catch(() => false); + if (hasMetrics) { + await expect(firstCard.locator('[data-testid="project-metrics"]')).toBeVisible(); + + // Should have at least one metric + const metrics = firstCard.locator('[data-testid="project-metric"]'); + const metricCount = await metrics.count(); + expect(metricCount).toBeGreaterThan(0); + } + } + }); + + test('should display project metrics with proper structure', async ({ page }) => { + // Wait for projects to load + await page.waitForLoadState('networkidle'); + + const projectCards = page.locator('[data-testid="project-card"]'); + const cardCount = await projectCards.count(); + + if (cardCount > 0) { + const firstCard = projectCards.first(); + const metrics = firstCard.locator('[data-testid="project-metric"]'); + const metricCount = await metrics.count(); + + if (metricCount > 0) { + const firstMetric = metrics.first(); + + // Check metric structure + await expect(firstMetric.locator('[data-testid="metric-label-container"]')).toBeVisible(); + await expect(firstMetric.locator('[data-testid="metric-value-container"]')).toBeVisible(); + + // Check metric content + await expect(firstMetric.locator('[data-testid="metric-icon"]')).toBeVisible(); + await expect(firstMetric.locator('[data-testid="metric-label"]')).toBeVisible(); + + // Should have either a badge or a value + const hasBadge = await firstMetric + .locator('[data-testid="metric-badge"]') + .isVisible() + .catch(() => false); + const hasValue = await firstMetric + .locator('[data-testid="metric-value"]') + .isVisible() + .catch(() => false); + + expect(hasBadge || hasValue).toBe(true); + } + } + }); + }); + + test.describe('Search Functionality', () => { + test('should filter projects when searching', async ({ page }) => { + // Wait for initial load + await page.waitForLoadState('networkidle'); + + // Get initial project count + const projectCards = page.locator('[data-testid="project-card"]'); + const initialCount = await projectCards.count(); + + if (initialCount > 0) { + // Perform search + const searchInput = page.getByRole('textbox', { name: 'Search projects, committees,' }); + await searchInput.fill('test'); + + // Wait for search to complete + await page.waitForTimeout(500); + await page.waitForLoadState('networkidle'); + + // Projects should be filtered (count may change) + await expect(page.locator('[data-testid="projects-section"]')).toBeVisible(); + } + }); + + test('should clear search and show all projects', async ({ page }) => { + // Wait for initial load + await page.waitForLoadState('networkidle'); + + const searchInput = page.getByRole('textbox', { name: 'Search projects, committees,' }); + + // Search for something + await searchInput.fill('nonexistent'); + await page.waitForTimeout(500); + + // Clear search + await searchInput.clear(); + await page.waitForTimeout(500); + await page.waitForLoadState('networkidle'); + + // Projects section should still be visible + await expect(page.locator('[data-testid="projects-section"]')).toBeVisible(); + }); + }); + + test.describe('Navigation and Interaction', () => { + test('should navigate to project detail when clicking a project card', async ({ page }) => { + // Wait for project cards to load + await page.waitForLoadState('networkidle'); + + const projectCards = page.locator('[data-testid="project-card"]'); + const cardCount = await projectCards.count(); + + if (cardCount > 0) { + const firstCard = projectCards.first(); + await expect(firstCard).toBeVisible(); + + // Get the project slug for navigation verification + const projectSlug = await firstCard.getAttribute('data-project-slug'); + + // Click the card + await firstCard.click(); + + // Should navigate to project detail + if (projectSlug) { + await expect(page).toHaveURL(new RegExp(`/project/${projectSlug}`)); + } else { + await expect(page).toHaveURL(/\/project\/\w+/); + } + } + }); + }); + + test.describe('Responsive Design', () => { + test('should display correctly on mobile viewport', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + + // Main sections should be visible + await expect(page.locator('[data-testid="hero-section"]')).toBeVisible(); + await expect(page.locator('[data-testid="projects-section"]')).toBeVisible(); + + // Search should be visible and functional + await expect(page.locator('[data-testid="hero-search-container"]')).toBeVisible(); + + // Header elements should adapt to mobile + const viewport = page.viewportSize(); + const isMobile = viewport && viewport.width < 768; + + if (isMobile) { + // Search in header should be hidden on mobile + await expect(page.getByPlaceholder('Search projects...')).toBeHidden(); + await expect(page.getByText('Projects Self-Service')).toBeHidden(); + } + + // Logo should still be visible + await expect(page.getByAltText('LFX Logo')).toBeVisible(); + }); + + test('should display correctly on tablet viewport', async ({ page }) => { + await page.setViewportSize({ width: 768, height: 1024 }); + + // All sections should be visible + await expect(page.locator('[data-testid="hero-section"]')).toBeVisible(); + await expect(page.locator('[data-testid="projects-section"]')).toBeVisible(); + + // Header elements should be visible on tablet (medium and up) + await expect(page.getByPlaceholder('Search projects...')).toBeVisible(); + await expect(page.getByText('Projects Self-Service')).toBeVisible(); + await expect(page.getByAltText('LFX Logo')).toBeVisible(); + }); + + test('should display correctly on desktop viewport', async ({ page }) => { + await page.setViewportSize({ width: 1024, height: 768 }); + + // All sections should be visible + await expect(page.locator('[data-testid="hero-section"]')).toBeVisible(); + await expect(page.locator('[data-testid="projects-section"]')).toBeVisible(); + + // Header elements should be visible on desktop + await expect(page.getByPlaceholder('Search projects...')).toBeVisible(); + await expect(page.getByText('Projects Self-Service')).toBeVisible(); + await expect(page.getByAltText('LFX Logo')).toBeVisible(); + }); + }); + + test.describe('Component Integration', () => { + test('should properly integrate Angular signals and computed values', async ({ page }) => { + // Wait for Angular to initialize and signals to resolve + await page.waitForLoadState('networkidle'); + + // The presence of project cards indicates successful signal integration + const projectsSection = page.locator('[data-testid="projects-section"]'); + await expect(projectsSection).toBeVisible(); + + // Either projects or skeleton should be present (indicates reactive state) + const hasProjects = await page + .locator('[data-testid="projects-grid"]') + .isVisible() + .catch(() => false); + const hasSkeleton = await page + .locator('[data-testid="projects-skeleton-container"]') + .isVisible() + .catch(() => false); + + expect(hasProjects || hasSkeleton).toBe(true); + }); + + test('should use lfx-input-text component for search', async ({ page }) => { + const searchInput = page.locator('[data-testid="hero-search-input"]'); + await expect(searchInput).toBeVisible(); + + // Check that it's an lfx-input-text element + const tagName = await searchInput.evaluate((el) => el.tagName.toLowerCase()); + expect(tagName).toBe('lfx-input-text'); + }); + + test('should use lfx-project-card components for project display', async ({ page }) => { + // Wait for projects to load + await page.waitForLoadState('networkidle'); + + const projectCards = page.locator('[data-testid="project-card"]'); + const cardCount = await projectCards.count(); + + if (cardCount > 0) { + // Check that each card is an lfx-project-card component + for (let i = 0; i < Math.min(cardCount, 3); i++) { + const card = projectCards.nth(i); + await expect(card).toBeVisible(); + const tagName = await card.evaluate((el) => el.tagName.toLowerCase()); + expect(tagName).toBe('lfx-project-card'); + } + } + }); + + test('should use lfx-home component as main container', async ({ page }) => { + const homeComponent = page.locator('lfx-home'); + await expect(homeComponent).toBeVisible(); + + // Check that it's an lfx-home element + const tagName = await homeComponent.evaluate((el) => el.tagName.toLowerCase()); + expect(tagName).toBe('lfx-home'); + }); + }); +}); diff --git a/apps/lfx-pcc/e2e/homepage.spec.ts b/apps/lfx-pcc/e2e/homepage.spec.ts new file mode 100644 index 00000000..5dd39977 --- /dev/null +++ b/apps/lfx-pcc/e2e/homepage.spec.ts @@ -0,0 +1,240 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { expect, test } from '@playwright/test'; + +test.describe('Homepage', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + + // Verify we're authenticated and on the homepage + await expect(page).not.toHaveURL(/auth0\.com/); + }); + + test('should display the homepage title and subtitle', async ({ page }) => { + // Check for the main heading + await expect(page.getByRole('heading', { level: 1 })).toContainText('Your personalized control panel for managing projects, committees, and meetings.'); + + // Check for the subtitle + await expect(page.locator('p').first()).toContainText( + 'Get a comprehensive overview of all your active initiatives and upcoming events in one centralized dashboard.' + ); + }); + + test('should display header elements on desktop', async ({ page }) => { + // Ensure we're in desktop viewport + await page.setViewportSize({ width: 1024, height: 768 }); + + // Check for logo and brand + await expect(page.getByRole('button', { name: 'Go to home page' })).toBeVisible(); + await expect(page.getByAltText('LFX Logo')).toBeVisible(); + await expect(page.getByText('Projects Self-Service')).toBeVisible(); + + // Header search should be visible on desktop (md and larger) + await expect(page.getByPlaceholder('Search projects...')).toBeVisible(); + + // Check for tools menu button + await expect(page.getByRole('button', { name: 'Open tools menu' })).toBeVisible(); + }); + + test('should display header elements on mobile', async ({ page }) => { + // Set mobile viewport + await page.setViewportSize({ width: 375, height: 667 }); + + // Check for logo and home button (should be visible) + await expect(page.getByRole('button', { name: 'Go to home page' })).toBeVisible(); + await expect(page.getByAltText('LFX Logo')).toBeVisible(); + + // Header search and brand text should be hidden on mobile + await expect(page.getByPlaceholder('Search projects...')).toBeHidden(); + await expect(page.getByText('Projects Self-Service')).toBeHidden(); + + // Mobile search toggle button should be visible + await expect(page.getByTestId('mobile-search-toggle')).toBeVisible(); + + // Check for tools menu button (should still be visible) + await expect(page.getByRole('button', { name: 'Open tools menu' })).toBeVisible(); + }); + + test('should open mobile search overlay when clicking search button', async ({ page }) => { + // Set mobile viewport + await page.setViewportSize({ width: 375, height: 667 }); + + // Click mobile search toggle button + await page.getByTestId('mobile-search-toggle').click(); + + // Mobile search overlay should be visible + await expect(page.getByTestId('mobile-search-overlay')).toBeVisible(); + + // Mobile search input should be visible + const mobileSearchInput = page.getByTestId('mobile-search-input'); + await expect(mobileSearchInput).toBeVisible(); + + // Close button should be visible + await expect(page.getByTestId('mobile-search-close')).toBeVisible(); + + // Type in the mobile search + await mobileSearchInput.fill('test search'); + await expect(mobileSearchInput).toHaveValue('test search'); + + // Close the search overlay + await page.getByTestId('mobile-search-close').click(); + await expect(page.getByTestId('mobile-search-overlay')).toBeHidden(); + }); + + test('should have main search input field', async ({ page }) => { + // Check for main search input in hero section + const searchInput = page.getByRole('textbox', { name: 'Search projects, committees,' }); + await expect(searchInput).toBeVisible(); + await expect(searchInput).toHaveAttribute('placeholder', 'Search projects, committees, meetings, or mailing lists...'); + }); + + test('should display project cards when projects load', async ({ page }) => { + // Wait for project data to load + await page.waitForLoadState('networkidle'); + + // Check if project cards are displayed + const projectCards = page.locator('lfx-project-card'); + + // Should have multiple project cards + const cardCount = await projectCards.count(); + expect(cardCount).toBeGreaterThan(0); + + // Check first project card has expected elements + const firstCard = projectCards.first(); + await expect(firstCard.getByRole('heading', { level: 3 })).toBeVisible(); + await expect(firstCard.locator('img')).toBeVisible(); // Project logo + await expect(firstCard.locator('p')).toBeVisible(); // Description + + // Check for metrics in project cards + await expect(firstCard.getByText('Meetings')).toBeVisible(); + await expect(firstCard.getByText('Committees')).toBeVisible(); + await expect(firstCard.getByText('Mailing Lists')).toBeVisible(); + }); + + test('should filter projects when searching', async ({ page }) => { + // Wait for initial load + await page.waitForLoadState('networkidle'); + + // Get initial project count + const initialCards = page.locator('lfx-project-card'); + const initialCount = await initialCards.count(); + expect(initialCount).toBeGreaterThan(1); + + // Search for specific project + const searchInput = page.getByRole('textbox', { name: 'Search projects, committees,' }); + await searchInput.fill('CNCF'); + + // Wait for search to complete + await page.waitForTimeout(500); + await page.waitForLoadState('networkidle'); + + // Verify search results are displayed + + // Should show CNCF project in results + await expect(page.getByRole('heading', { name: 'Cloud Native Computing Foundation' })).toBeVisible(); + + // Verify search input has the search term + await expect(searchInput).toHaveValue('CNCF'); + }); + + test('should clear search and show all projects', async ({ page }) => { + // Wait for initial load + await page.waitForLoadState('networkidle'); + + // Search for specific project + const searchInput = page.getByRole('textbox', { name: 'Search projects, committees,' }); + await searchInput.fill('CNCF'); + await page.waitForTimeout(500); + + // Clear search + await searchInput.clear(); + await page.waitForTimeout(500); + await page.waitForLoadState('networkidle'); + + // Should show multiple projects again + const allCards = page.locator('lfx-project-card'); + const finalCount = await allCards.count(); + expect(finalCount).toBeGreaterThanOrEqual(1); + }); + + test('should navigate to project detail when clicking a project card', async ({ page }) => { + // Wait for project cards to load + await page.waitForLoadState('networkidle'); + + // Click on first project card + const firstCard = page.locator('lfx-project-card').first(); + await expect(firstCard).toBeVisible(); + + // Get the project name to verify navigation + const projectName = await firstCard.getByRole('heading', { level: 3 }).innerText(); + + // Click the project card + await firstCard.click(); + + // Wait for navigation + await page.waitForLoadState('networkidle'); + + // Verify navigation to project page + expect(page.url()).toMatch(/\/project\/[\w-]+$/); + + // Verify project detail page elements - use heading that contains the project name + await expect(page.getByRole('heading', { level: 1 }).filter({ hasText: projectName })).toBeVisible(); + await expect(page.getByRole('link', { name: 'All Projects' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Dashboard' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Meetings' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Committees', exact: true })).toBeVisible(); + }); + + test('should have proper responsive layout', async ({ page }) => { + // Wait for content to load + await page.waitForLoadState('networkidle'); + + // Test desktop view + await page.setViewportSize({ width: 1920, height: 1080 }); + const projectCards = page.locator('lfx-project-card'); + await expect(projectCards.first()).toBeVisible(); + + // Test tablet view + await page.setViewportSize({ width: 768, height: 1024 }); + await expect(projectCards.first()).toBeVisible(); + + // Test mobile view + await page.setViewportSize({ width: 375, height: 667 }); + await expect(projectCards.first()).toBeVisible(); + + // On mobile, header search and brand text should be hidden + await expect(page.getByPlaceholder('Search projects...')).toBeHidden(); + await expect(page.getByText('Projects Self-Service')).toBeHidden(); + + // Logo should still be visible + await expect(page.getByAltText('LFX Logo')).toBeVisible(); + }); + + test('should display footer elements', async ({ page }) => { + // Check for footer content + await expect(page.getByText('Copyright ยฉ 2025 The Linux Foundationยฎ')).toBeVisible(); + await expect(page.getByRole('link', { name: 'Platform Usage' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Privacy Policy' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Policies' })).toBeVisible(); + }); + + test('should handle search with no results', async ({ page }) => { + // Wait for initial load + await page.waitForLoadState('networkidle'); + + // Search for something that should return no results + const searchInput = page.getByRole('textbox', { name: 'Search projects, committees,' }); + await searchInput.fill('nonexistentproject12345'); + + // Wait for search to complete + await page.waitForTimeout(500); + await page.waitForLoadState('networkidle'); + + // Should have no project cards visible + const projectCards = page.locator('lfx-project-card'); + await expect(projectCards).toHaveCount(0); + }); +}); + +// Generated with [Claude Code](https://claude.ai/code) diff --git a/apps/lfx-pcc/e2e/project-dashboard-robust.spec.ts b/apps/lfx-pcc/e2e/project-dashboard-robust.spec.ts new file mode 100644 index 00000000..d740327e --- /dev/null +++ b/apps/lfx-pcc/e2e/project-dashboard-robust.spec.ts @@ -0,0 +1,318 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { test, expect } from '@playwright/test'; + +test.describe('Project Dashboard - Robust Tests', () => { + test.beforeEach(async ({ page }) => { + // Navigate to homepage and search for a project + await page.goto('/'); + await page.getByRole('textbox', { name: 'Search projects, committees, meetings, or mailing lists...' }).fill('test'); + await page.keyboard.press('Enter'); + + // Click on the Academy Software Foundation project + await page.locator('lfx-project-card').first().click(); + + // Ensure we're on the dashboard tab + await page.getByRole('link', { name: 'Dashboard' }).click(); + }); + + test.describe('Page Structure and Components', () => { + test('should have correct page structure with main content', async ({ page }) => { + await expect(page).toHaveTitle('LFX Projects Self-Service'); + await expect(page).toHaveURL(/\/project\/\w+$/); + + // Check main project component is present + await expect(page.locator('lfx-project')).toBeVisible(); + + // Check that main content sections are present + await expect(page.locator('[data-testid="metrics-cards-container"]')).toBeVisible(); + }); + + test('should display project component selector', async ({ page }) => { + await expect(page.locator('lfx-project')).toBeVisible(); + }); + }); + + test.describe('Metrics Cards', () => { + test('should display all four metrics cards with proper structure', async ({ page }) => { + const metricsContainer = page.locator('[data-testid="metrics-cards-container"]'); + await expect(metricsContainer).toBeVisible(); + + // Verify individual cards by data-testid + await expect(page.locator('[data-testid="total-members-card"]')).toBeVisible(); + await expect(page.locator('[data-testid="total-committees-card"]')).toBeVisible(); + await expect(page.locator('[data-testid="total-meetings-card"]')).toBeVisible(); + await expect(page.locator('[data-testid="upcoming-meetings-card"]')).toBeVisible(); + }); + + test('should display metrics with proper labels and values', async ({ page }) => { + // Test Total Members card + const totalMembersCard = page.locator('[data-testid="total-members-card"]'); + await expect(totalMembersCard.getByText('Total Members')).toBeVisible(); + await expect(totalMembersCard.getByText(/^\d+$/)).toBeVisible(); + + // Test Total Committees card + const totalCommitteesCard = page.locator('[data-testid="total-committees-card"]'); + await expect(totalCommitteesCard.getByText('Committees')).toBeVisible(); + await expect(totalCommitteesCard.getByText(/^\d+$/)).toBeVisible(); + + // Test Total Meetings card + const totalMeetingsCard = page.locator('[data-testid="total-meetings-card"]'); + await expect(totalMeetingsCard.getByText('Total Meetings')).toBeVisible(); + await expect(totalMeetingsCard.getByText(/^\d+$/)).toBeVisible(); + + // Test Upcoming Meetings card + const upcomingCard = page.locator('[data-testid="upcoming-meetings-card"]'); + await expect(upcomingCard.getByText('Upcoming')).toBeVisible(); + await expect(upcomingCard.getByText(/^\d+$/)).toBeVisible(); + }); + + test('should display metrics with FontAwesome icons', async ({ page }) => { + // Check for specific FontAwesome icons in each card + await expect(page.locator('[data-testid="total-members-card"] i.fa-users')).toBeVisible(); + await expect(page.locator('[data-testid="total-committees-card"] i.fa-people-group')).toBeVisible(); + await expect(page.locator('[data-testid="total-meetings-card"] i.fa-calendar')).toBeVisible(); + await expect(page.locator('[data-testid="upcoming-meetings-card"] i.fa-calendar-clock')).toBeVisible(); + }); + + test('should have proper card layout and components', async ({ page }) => { + const metricsContainer = page.locator('[data-testid="metrics-cards-container"]'); + await expect(metricsContainer).toBeVisible(); + + // Check that each card uses lfx-card component by checking tag name + const cards = [ + page.locator('[data-testid="total-members-card"]'), + page.locator('[data-testid="total-committees-card"]'), + page.locator('[data-testid="total-meetings-card"]'), + page.locator('[data-testid="upcoming-meetings-card"]'), + ]; + + for (const card of cards) { + await expect(card).toBeVisible(); + // Check that it's an lfx-card element + const tagName = await card.evaluate((el) => el.tagName.toLowerCase()); + expect(tagName).toBe('lfx-card'); + } + + // Verify all cards are present in the container + await expect(metricsContainer.locator('lfx-card')).toHaveCount(4); + }); + }); + + test.describe('Project Health Indicators', () => { + test('should display Project Health card with proper structure', async ({ page }) => { + const healthCard = page.locator('[data-testid="project-health-card"]'); + await expect(healthCard).toBeVisible(); + + // Check card has health indicators container + await expect(healthCard.locator('[data-testid="activity-score-indicator"]')).toBeVisible(); + await expect(healthCard.locator('[data-testid="meeting-completion-indicator"]')).toBeVisible(); + await expect(healthCard.locator('[data-testid="meeting-trend-indicator"]')).toBeVisible(); + await expect(healthCard.locator('[data-testid="active-committees-indicator"]')).toBeVisible(); + }); + + test('should display all four health indicators', async ({ page }) => { + await expect(page.locator('[data-testid="activity-score-indicator"]')).toBeVisible(); + await expect(page.locator('[data-testid="meeting-completion-indicator"]')).toBeVisible(); + await expect(page.locator('[data-testid="meeting-trend-indicator"]')).toBeVisible(); + await expect(page.locator('[data-testid="active-committees-indicator"]')).toBeVisible(); + }); + + test('should display charts in health indicators', async ({ page }) => { + // Activity Score should have a doughnut chart + await expect(page.locator('[data-testid="activity-score-indicator"] lfx-chart')).toBeVisible(); + + // Meeting Completion should have a doughnut chart + await expect(page.locator('[data-testid="meeting-completion-indicator"] lfx-chart')).toBeVisible(); + + // Meeting Trend should have a line chart + await expect(page.locator('[data-testid="meeting-trend-indicator"] lfx-chart')).toBeVisible(); + + // Active Committees should have a doughnut chart + await expect(page.locator('[data-testid="active-committees-indicator"] lfx-chart')).toBeVisible(); + }); + + test('should display percentage overlays on doughnut charts', async ({ page }) => { + // Activity Score percentage overlay + await expect(page.locator('[data-testid="activity-score-indicator"] span').filter({ hasText: /%$/ })).toBeVisible(); + + // Meeting Completion percentage overlay + await expect(page.locator('[data-testid="meeting-completion-indicator"] span').filter({ hasText: /%$/ })).toBeVisible(); + + // Active Committees percentage overlay + await expect(page.locator('[data-testid="active-committees-indicator"] span').filter({ hasText: /%$/ })).toBeVisible(); + }); + + test('should display proper labels and tooltips', async ({ page }) => { + // Activity Score label and tooltip + const activityIndicator = page.locator('[data-testid="activity-score-indicator"]'); + await expect(activityIndicator.getByText('Activity Score')).toBeVisible(); + await expect(activityIndicator.locator('i[pTooltip]')).toBeVisible(); + + // Meeting Completion label and tooltip + const meetingIndicator = page.locator('[data-testid="meeting-completion-indicator"]'); + await expect(meetingIndicator.getByText('Meeting Completion')).toBeVisible(); + await expect(meetingIndicator.locator('i[pTooltip]')).toBeVisible(); + + // Meeting Trend label and tooltip + const trendIndicator = page.locator('[data-testid="meeting-trend-indicator"]'); + await expect(trendIndicator.getByText('Meeting Trend (30 days)')).toBeVisible(); + await expect(trendIndicator.locator('i[pTooltip]')).toBeVisible(); + + // Active Committees label and tooltip + const committeesIndicator = page.locator('[data-testid="active-committees-indicator"]'); + await expect(committeesIndicator.getByText('Active Committees')).toBeVisible(); + await expect(committeesIndicator.locator('i[pTooltip]')).toBeVisible(); + }); + }); + + test.describe('Quick Actions Menu', () => { + test('should display Quick Actions card with menu component', async ({ page }) => { + const quickActionsCard = page.locator('[data-testid="quick-actions-card"]'); + await expect(quickActionsCard).toBeVisible(); + + // Check card contains quick actions menu + await expect(quickActionsCard.getByText('Quick Actions')).toBeVisible(); + + // Check that it contains lfx-menu component + await expect(quickActionsCard.locator('lfx-menu')).toBeVisible(); + }); + + test('should display all menu items with proper structure', async ({ page }) => { + const menuContainer = page.locator('[data-testid="quick-actions-card"] lfx-menu'); + + // Check for menu items - these should be rendered as menu items by PrimeNG + await expect(menuContainer.locator('[role="menuitem"]')).toHaveCount(4); + }); + + test('should have proper menu styling', async ({ page }) => { + const menu = page.locator('[data-testid="quick-actions-card"] lfx-menu'); + + // Check menu styling attributes (these are passed as Angular attributes) + await expect(menu).toHaveAttribute('styleclass', 'w-full border-0 p-0'); + }); + }); + + test.describe('Recent Activity Section', () => { + test('should display Recent Activity card', async ({ page }) => { + const activityCard = page.locator('[data-testid="recent-activity-card"]'); + await expect(activityCard).toBeVisible(); + + // Check card is properly structured - it should be an lfx-card + const tagName = await activityCard.evaluate((el) => el.tagName.toLowerCase()); + expect(tagName).toBe('lfx-card'); + }); + + test('should show empty state when no activity', async ({ page }) => { + const activityCard = page.locator('[data-testid="recent-activity-card"]'); + + // Check for empty state content + await expect(activityCard.getByText('No recent activity')).toBeVisible(); + await expect(activityCard.locator('i.fa-clock-rotate-left')).toBeVisible(); + }); + + test('should have proper activity container structure', async ({ page }) => { + const activityCard = page.locator('[data-testid="recent-activity-card"]'); + // Activity container should be present + await expect(activityCard).toBeVisible(); + }); + }); + + test.describe('Layout and Responsive Design', () => { + test('should have proper main layout structure', async ({ page }) => { + // Check main project component + await expect(page.locator('lfx-project')).toBeVisible(); + + // Check main content sections are present + await expect(page.locator('[data-testid="metrics-cards-container"]')).toBeVisible(); + await expect(page.locator('[data-testid="project-health-card"]')).toBeVisible(); + + // Check sidebar content + await expect(page.locator('[data-testid="quick-actions-card"]')).toBeVisible(); + await expect(page.locator('[data-testid="recent-activity-card"]')).toBeVisible(); + }); + + test('should display correctly on mobile viewport', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + + // Main content should be visible + await expect(page.locator('lfx-project')).toBeVisible(); + + // All cards should still be visible and functional + await expect(page.locator('[data-testid="total-members-card"]')).toBeVisible(); + await expect(page.locator('[data-testid="project-health-card"]')).toBeVisible(); + await expect(page.locator('[data-testid="quick-actions-card"]')).toBeVisible(); + await expect(page.locator('[data-testid="recent-activity-card"]')).toBeVisible(); + + // Header elements should adapt to mobile + // Search bar should be hidden on mobile (responsive design) + await expect(page.getByPlaceholder('Search projects...')).toBeHidden(); + + // Projects Self-Service text should be hidden on mobile + await expect(page.getByText('Projects Self-Service')).toBeHidden(); + + // Logo should still be visible + await expect(page.getByAltText('LFX Logo')).toBeVisible(); + }); + + test('should display correctly on tablet viewport', async ({ page }) => { + await page.setViewportSize({ width: 768, height: 1024 }); + + // All components should be visible and functional + await expect(page.locator('[data-testid="total-members-card"]')).toBeVisible(); + await expect(page.locator('[data-testid="project-health-card"]')).toBeVisible(); + await expect(page.locator('[data-testid="quick-actions-card"]')).toBeVisible(); + await expect(page.locator('[data-testid="recent-activity-card"]')).toBeVisible(); + + // Header elements should be visible on tablet (medium and up) + await expect(page.getByPlaceholder('Search projects...')).toBeVisible(); + await expect(page.getByText('Projects Self-Service')).toBeVisible(); + await expect(page.getByAltText('LFX Logo')).toBeVisible(); + }); + }); + + test.describe('Component Integration', () => { + test('should properly integrate Angular signals and computed values', async ({ page }) => { + // Check that percentage values are rendered (they would be 0% initially but should be present) + const activityScore = page.locator('[data-testid="activity-score-indicator"] span').filter({ hasText: /%$/ }); + await expect(activityScore).toBeVisible(); + + const meetingCompletion = page.locator('[data-testid="meeting-completion-indicator"] span').filter({ hasText: /%$/ }); + await expect(meetingCompletion).toBeVisible(); + + const activeCommittees = page.locator('[data-testid="active-committees-indicator"] span').filter({ hasText: /%$/ }); + await expect(activeCommittees).toBeVisible(); + }); + + test('should use lfx-card components consistently', async ({ page }) => { + // Check that our test-targeted cards are present and are lfx-card components + const testCards = [ + page.locator('[data-testid="total-members-card"]'), + page.locator('[data-testid="total-committees-card"]'), + page.locator('[data-testid="total-meetings-card"]'), + page.locator('[data-testid="upcoming-meetings-card"]'), + page.locator('[data-testid="project-health-card"]'), + page.locator('[data-testid="quick-actions-card"]'), + page.locator('[data-testid="recent-activity-card"]'), + ]; + + for (const card of testCards) { + await expect(card).toBeVisible(); + const tagName = await card.evaluate((el) => el.tagName.toLowerCase()); + expect(tagName).toBe('lfx-card'); + } + }); + + test('should use lfx-chart components for visualizations', async ({ page }) => { + // Check for specific chart components in the health indicators + const healthCard = page.locator('[data-testid="project-health-card"]'); + + // Each health indicator should have a chart + await expect(healthCard.locator('[data-testid="activity-score-indicator"] lfx-chart')).toBeVisible(); + await expect(healthCard.locator('[data-testid="meeting-completion-indicator"] lfx-chart')).toBeVisible(); + await expect(healthCard.locator('[data-testid="meeting-trend-indicator"] lfx-chart')).toBeVisible(); + await expect(healthCard.locator('[data-testid="active-committees-indicator"] lfx-chart')).toBeVisible(); + }); + }); +}); diff --git a/apps/lfx-pcc/e2e/project-dashboard.spec.ts b/apps/lfx-pcc/e2e/project-dashboard.spec.ts new file mode 100644 index 00000000..ff033b74 --- /dev/null +++ b/apps/lfx-pcc/e2e/project-dashboard.spec.ts @@ -0,0 +1,299 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { test, expect } from '@playwright/test'; + +test.describe('Project Dashboard', () => { + test.beforeEach(async ({ page }) => { + // Navigate to homepage and search for a project + await page.goto('/'); + await page.getByRole('textbox', { name: 'Search projects, committees, meetings, or mailing lists...' }).fill('test'); + await page.keyboard.press('Enter'); + + // Click on the Academy Software Foundation project + await page.locator('lfx-project-card').first().click(); + + // Ensure we're on the dashboard tab + await page.getByRole('link', { name: 'Dashboard' }).click(); + }); + + test.describe('Navigation and Layout', () => { + test('should display correct page title and URL', async ({ page }) => { + await expect(page).toHaveTitle('LFX Projects Self-Service'); + await expect(page).toHaveURL(/\/project\/\w+$/); + }); + + test('should display header elements correctly for current viewport', async ({ page }) => { + await expect(page.getByRole('button', { name: 'Go to home page' })).toBeVisible(); + await expect(page.getByAltText('LFX Logo')).toBeVisible(); + + // Check viewport width to determine expected behavior + const viewport = page.viewportSize(); + const isMobile = viewport && viewport.width < 768; + + if (isMobile) { + // On mobile: search and brand text should be hidden + await expect(page.getByPlaceholder('Search projects...')).toBeHidden(); + await expect(page.getByText('Projects Self-Service')).toBeHidden(); + // Mobile search toggle should be visible + await expect(page.getByTestId('mobile-search-toggle')).toBeVisible(); + } else { + // On desktop: search and brand text should be visible + await expect(page.getByPlaceholder('Search projects...')).toBeVisible(); + await expect(page.getByText('Projects Self-Service')).toBeVisible(); + // Mobile search toggle should be hidden + await expect(page.getByTestId('mobile-search-toggle')).toBeHidden(); + } + }); + + test('should display breadcrumb navigation', async ({ page }) => { + await expect(page.getByRole('link', { name: 'All Projects' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'All Projects' })).toHaveAttribute('href', '/'); + }); + + test('should highlight active Dashboard tab', async ({ page }) => { + const dashboardTab = page.getByRole('link', { name: 'Dashboard' }); + await expect(dashboardTab).toHaveClass(/active|bg-blue-50/); + }); + + test('should display all navigation tabs', async ({ page }) => { + await expect(page.getByRole('link', { name: 'Dashboard' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Meetings' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Committees' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Mailing Lists' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Settings' })).toBeVisible(); + }); + }); + + test.describe('Project Header', () => { + test('should display project name and description', async ({ page }) => { + await expect(page.getByRole('heading', { name: 'Academy Software Foundation' })).toBeVisible(); + await expect(page.getByText(/The mission of the Academy Software Foundation/)).toBeVisible(); + }); + + test('should display project logo', async ({ page }) => { + // Project logo should be present (assuming it's an img element) + const projectImage = page.locator('img').first(); + await expect(projectImage).toBeVisible(); + }); + + test('should display project summary counts', async ({ page }) => { + // Look for summary cards in the upper section of the page + await expect(page.locator('span').filter({ hasText: 'Meetings' }).first()).toBeVisible(); + await expect(page.locator('span').filter({ hasText: 'Committees' }).first()).toBeVisible(); + await expect(page.locator('span').filter({ hasText: 'Mailing Lists' }).first()).toBeVisible(); + + // Check for count values (they should be visible as numbers) + await expect(page.getByText(/^\d+$/).first()).toBeVisible(); + }); + }); + + test.describe('Metrics Cards', () => { + test('should display all four metrics cards', async ({ page }) => { + // Use data-testid attributes for reliable targeting + await expect(page.locator('[data-testid="total-members-card"]')).toBeVisible(); + await expect(page.locator('[data-testid="total-committees-card"]')).toBeVisible(); + await expect(page.locator('[data-testid="total-meetings-card"]')).toBeVisible(); + await expect(page.locator('[data-testid="upcoming-meetings-card"]')).toBeVisible(); + + // Also verify the specific labels within each card + await expect(page.locator('[data-testid="total-members-card"] p.text-sm.text-gray-500')).toContainText('Total Members'); + await expect(page.locator('[data-testid="total-committees-card"] p.text-sm.text-gray-500')).toContainText('Committees'); + await expect(page.locator('[data-testid="total-meetings-card"] p.text-sm.text-gray-500')).toContainText('Total Meetings'); + await expect(page.locator('[data-testid="upcoming-meetings-card"] p.text-sm.text-gray-500')).toContainText('Upcoming'); + }); + + test('should display metric values', async ({ page }) => { + // Each metric card should have a numerical value + const totalMembersValue = page.getByText('Total Members').locator('..').getByText(/^\d+$/); + const committeesValue = page.getByText('Committees').locator('..').getByText(/^\d+$/); + const totalMeetingsValue = page.getByText('Total Meetings').locator('..').getByText(/^\d+$/); + const upcomingValue = page.getByText('Upcoming').locator('..').getByText(/^\d+$/); + + await expect(totalMembersValue).toBeVisible(); + await expect(committeesValue).toBeVisible(); + await expect(totalMeetingsValue).toBeVisible(); + await expect(upcomingValue).toBeVisible(); + }); + + test('should display metrics cards with proper structure', async ({ page }) => { + // Use data-testid to avoid strict mode violations with text content + await expect(page.locator('[data-testid="total-members-card"]')).toBeVisible(); + await expect(page.locator('[data-testid="total-committees-card"]')).toBeVisible(); + await expect(page.locator('[data-testid="total-meetings-card"]')).toBeVisible(); + await expect(page.locator('[data-testid="upcoming-meetings-card"]')).toBeVisible(); + + // Verify labels are present within specific cards + await expect(page.locator('[data-testid="total-members-card"] p.text-sm.text-gray-500')).toContainText('Total Members'); + await expect(page.locator('[data-testid="total-committees-card"] p.text-sm.text-gray-500')).toContainText('Committees'); + await expect(page.locator('[data-testid="total-meetings-card"] p.text-sm.text-gray-500')).toContainText('Total Meetings'); + await expect(page.locator('[data-testid="upcoming-meetings-card"] p.text-sm.text-gray-500')).toContainText('Upcoming'); + }); + }); + + test.describe('Project Health Indicators', () => { + test('should display Project Health section', async ({ page }) => { + await expect(page.getByText('Project Health')).toBeVisible(); + }); + + test('should display Activity Score indicator', async ({ page }) => { + await expect(page.getByText('Activity Score')).toBeVisible(); + await expect(page.getByText('0%').first()).toBeVisible(); + }); + + test('should display Meeting Completion indicator', async ({ page }) => { + await expect(page.getByText('Meeting Completion')).toBeVisible(); + // Should have a percentage value - look for any percentage in the health section + const healthSection = page.getByText('Project Health').locator('..'); + await expect(healthSection.getByText(/\d+%/).first()).toBeVisible(); + }); + + test('should display Meeting Trend indicator', async ({ page }) => { + await expect(page.getByText('Meeting Trend (30 days)')).toBeVisible(); + }); + + test('should display Active Committees indicator', async ({ page }) => { + await expect(page.getByText('Active Committees')).toBeVisible(); + // Should have a percentage value - look for any percentage in the health section + const healthSection = page.getByText('Project Health').locator('..'); + await expect(healthSection.getByText(/\d+%/).last()).toBeVisible(); + }); + + test('should display health indicator charts/icons', async ({ page }) => { + // Each health indicator should have a visual representation + const healthSection = page.getByText('Project Health').locator('..'); + const charts = healthSection.locator('img, svg, canvas'); + + await expect(charts).toHaveCount(4); + }); + }); + + test.describe('Quick Actions Menu', () => { + test('should display Quick Actions section', async ({ page }) => { + await expect(page.getByText('Quick Actions')).toBeVisible(); + }); + + test('should display all quick action items', async ({ page }) => { + await expect(page.getByRole('menuitem', { name: 'Schedule Meeting' })).toBeVisible(); + await expect(page.getByRole('menuitem', { name: 'Create Committee' })).toBeVisible(); + await expect(page.getByRole('menuitem', { name: 'View All Committees' })).toBeVisible(); + await expect(page.getByRole('menuitem', { name: 'View Calendar' })).toBeVisible(); + }); + + test('should have working links in quick actions', async ({ page }) => { + // Schedule Meeting should link to meetings page + const scheduleMeetingLink = page.getByRole('link', { name: 'Schedule Meeting' }); + await expect(scheduleMeetingLink).toHaveAttribute('href', /\/meetings$/); + + // View All Committees should link to committees page + const viewCommitteesLink = page.getByRole('link', { name: 'View All Committees' }); + await expect(viewCommitteesLink).toHaveAttribute('href', /\/committees$/); + + // View Calendar should link to meetings page + const viewCalendarLink = page.getByRole('link', { name: 'View Calendar' }); + await expect(viewCalendarLink).toHaveAttribute('href', /\/meetings$/); + }); + + test('should display quick action menu items properly', async ({ page }) => { + // Verify quick actions are clickable and properly structured + const quickActionsSection = page.getByText('Quick Actions').locator('..'); + + // Check that menu items are present and interactive + await expect(quickActionsSection.getByRole('menuitem', { name: 'Schedule Meeting' })).toBeVisible(); + await expect(quickActionsSection.getByRole('menuitem', { name: 'Create Committee' })).toBeVisible(); + await expect(quickActionsSection.getByRole('menuitem', { name: 'View All Committees' })).toBeVisible(); + await expect(quickActionsSection.getByRole('menuitem', { name: 'View Calendar' })).toBeVisible(); + }); + }); + + test.describe('Recent Activity Section', () => { + test('should display Recent Activity section', async ({ page }) => { + await expect(page.getByText('Recent Activity').first()).toBeVisible(); + }); + + test('should show empty state when no activity', async ({ page }) => { + await expect(page.getByText('No recent activity')).toBeVisible(); + }); + + test('should display activity icon in empty state', async ({ page }) => { + // Just verify the empty state message is there - icons may not be present + await expect(page.getByText('No recent activity')).toBeVisible(); + }); + }); + + test.describe('Footer', () => { + test('should display copyright and legal links', async ({ page }) => { + await expect(page.getByText(/Copyright ยฉ 2025 The Linux Foundation/)).toBeVisible(); + await expect(page.getByRole('link', { name: 'Platform Usage' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Privacy Policy' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Trademark Usage' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Policies' })).toBeVisible(); + }); + + test('should have correct external links', async ({ page }) => { + await expect(page.getByRole('link', { name: 'Platform Usage' })).toHaveAttribute('href', 'https://www.linuxfoundation.org/legal/platform-use-agreement/'); + await expect(page.getByRole('link', { name: 'Privacy Policy' })).toHaveAttribute( + 'href', + 'https://www.linuxfoundation.org/legal/privacy-policy?hsLang=en' + ); + await expect(page.getByRole('link', { name: 'Trademark Usage' })).toHaveAttribute( + 'href', + 'https://www.linuxfoundation.org/legal/trademark-usage?hsLang=en' + ); + await expect(page.getByRole('link', { name: 'Policies' })).toHaveAttribute('href', 'https://www.linuxfoundation.org/legal/policies'); + }); + }); + + test.describe('Search Functionality', () => { + test('should have working global search when visible', async ({ page }) => { + const searchBox = page.getByPlaceholder('Search projects...'); + const viewport = page.viewportSize(); + const isMobile = viewport && viewport.width < 768; + + if (isMobile) { + // On mobile: search should be hidden, so skip interaction test + await expect(searchBox).toBeHidden(); + } else { + // On desktop: search should be visible and functional + await expect(searchBox).toBeVisible(); + await expect(searchBox).toBeEditable(); + + // Test that we can type in the search box + await searchBox.fill('test search'); + await expect(searchBox).toHaveValue('test search'); + } + }); + }); + + test.describe('Responsive Design', () => { + test('should display correctly on mobile viewport', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + + // Key elements should still be visible on mobile + await expect(page.getByRole('heading', { name: 'Academy Software Foundation' })).toBeVisible(); + await expect(page.getByText('Total Members')).toBeVisible(); + await expect(page.getByText('Project Health')).toBeVisible(); + await expect(page.getByText('Quick Actions')).toBeVisible(); + + // Search bar should be hidden on mobile (responsive design) + await expect(page.getByPlaceholder('Search projects...')).toBeHidden(); + + // Projects Self-Service text should also be hidden on mobile + await expect(page.getByText('Projects Self-Service')).toBeHidden(); + + // Mobile search toggle should be visible + await expect(page.getByTestId('mobile-search-toggle')).toBeVisible(); + }); + + test('should display correctly on tablet viewport', async ({ page }) => { + await page.setViewportSize({ width: 768, height: 1024 }); + + // All dashboard elements should be visible on tablet + await expect(page.getByRole('heading', { name: 'Academy Software Foundation' })).toBeVisible(); + await expect(page.getByText('Total Members')).toBeVisible(); + await expect(page.getByText('Project Health')).toBeVisible(); + await expect(page.getByText('Quick Actions')).toBeVisible(); + await expect(page.getByText('Recent Activity').first()).toBeVisible(); + }); + }); +}); diff --git a/apps/lfx-pcc/eslint.config.mjs b/apps/lfx-pcc/eslint.config.mjs index d331c759..164b09da 100644 --- a/apps/lfx-pcc/eslint.config.mjs +++ b/apps/lfx-pcc/eslint.config.mjs @@ -32,6 +32,8 @@ export default [ '**/coverage/**', '**/*.min.js', '**/deps_ssr/**', + 'playwright.config.ts', + 'e2e/**/*', ], }, ...fixupConfigRules( diff --git a/apps/lfx-pcc/package.json b/apps/lfx-pcc/package.json index b14940d6..fc0de091 100644 --- a/apps/lfx-pcc/package.json +++ b/apps/lfx-pcc/package.json @@ -11,7 +11,10 @@ "lint": "eslint \"src/**/*.{ts,html}\" --fix", "lint:check": "eslint \"src/**/*.{ts,html}\"", "format": "prettier --write \"src/**/*.{ts,html,scss,css,json}\"", - "format:check": "prettier --check \"src/**/*.{ts,html,scss,css,json}\"" + "format:check": "prettier --check \"src/**/*.{ts,html,scss,css,json}\"", + "e2e": "playwright test", + "e2e:ui": "playwright test --ui", + "e2e:headed": "playwright test --headed" }, "private": true, "dependencies": { @@ -52,6 +55,7 @@ "@eslint/compat": "^1.3.1", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.31.0", + "@playwright/test": "^1.54.1", "@types/express": "^4.17.17", "@types/node": "^18.18.0", "@typescript-eslint/eslint-plugin": "^8.37.0", @@ -62,6 +66,7 @@ "autoprefixer": "^10.4.21", "eslint": "^9.30.1", "eslint-plugin-import": "^2.32.0", + "playwright": "^1.54.1", "postcss": "^8.5.6", "prettier": "^3.6.2", "prettier-plugin-organize-imports": "^4.1.0", diff --git a/apps/lfx-pcc/playwright.config.ts b/apps/lfx-pcc/playwright.config.ts new file mode 100644 index 00000000..797b35e6 --- /dev/null +++ b/apps/lfx-pcc/playwright.config.ts @@ -0,0 +1,76 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { defineConfig, devices } from '@playwright/test'; +import dotenv from 'dotenv'; + +// Load environment variables from .env file +dotenv.config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './e2e', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Global setup */ + globalSetup: require.resolve('./e2e/helpers/global-setup.ts'), + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: 'http://localhost:4200', + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + /* Take screenshot on failure */ + screenshot: 'only-on-failure', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + // Use saved auth state for all tests + storageState: 'playwright/.auth/user.json', + }, + }, + + { + name: 'firefox', + use: { + ...devices['Desktop Firefox'], + // Use saved auth state for all tests + storageState: 'playwright/.auth/user.json', + }, + }, + /* Test against mobile viewports. */ + { + name: 'Mobile Chrome', + use: { + ...devices['Pixel 5'], + // Use saved auth state for all tests + storageState: 'playwright/.auth/user.json', + }, + // Use single worker for mobile to prevent resource contention + workers: 1, + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'yarn start', + url: 'http://localhost:4200', + reuseExistingServer: !process.env.CI, + timeout: 120000, + }, +}); diff --git a/apps/lfx-pcc/src/app/layouts/project-layout/project-layout.component.html b/apps/lfx-pcc/src/app/layouts/project-layout/project-layout.component.html index 73db6167..ad74186f 100644 --- a/apps/lfx-pcc/src/app/layouts/project-layout/project-layout.component.html +++ b/apps/lfx-pcc/src/app/layouts/project-layout/project-layout.component.html @@ -17,7 +17,7 @@ -
+
@if (categoryLabel(); as category) { @@ -40,7 +40,7 @@

@if (metrics().length > 0) { -
+
@for (metric of metrics(); track metric.label) {
@@ -55,8 +55,8 @@

-
-
+
+
@for (menu of menuItems(); track menu.label) { {{ menu.label }} } +
-
+