p19-bug-fixes
This commit is contained in:
8
e2e/.gitignore
vendored
Normal file
8
e2e/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
test-results/
|
||||
playwright-report/
|
||||
playwright/.cache/
|
||||
.DS_Store
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
62
e2e/README.md
Normal file
62
e2e/README.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# ClawFort UI/UX Regression Test Suite
|
||||
|
||||
Playwright-based end-to-end testing for ClawFort AI News application.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install dependencies and browsers
|
||||
cd e2e
|
||||
npm install
|
||||
npm run install:browsers
|
||||
|
||||
# Run smoke tests (fast feedback)
|
||||
npm run test:smoke
|
||||
|
||||
# Run full regression suite
|
||||
npm run test:full
|
||||
|
||||
# Run with UI mode for debugging
|
||||
npm run test:ui
|
||||
|
||||
# Run in headed mode (see browser)
|
||||
npm run test:headed
|
||||
```
|
||||
|
||||
## Test Organization
|
||||
|
||||
```
|
||||
tests/
|
||||
├── fixtures/ # Shared test fixtures and utilities
|
||||
├── capabilities/ # Tests organized by capability
|
||||
│ ├── core-journeys/ # Hero/feed browsing, modal flows
|
||||
│ ├── accessibility/ # WCAG 2.2 AA compliance
|
||||
│ ├── responsive/ # Mobile/tablet/desktop
|
||||
│ ├── modal-experience/ # Summary modal interactions
|
||||
│ └── microinteractions/ # Share, contact, tooltips
|
||||
├── smoke/ # Fast smoke tests for PR gates
|
||||
└── e2e.spec.ts # Main test entry point
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `BASE_URL`: Target application URL (default: http://localhost:8000)
|
||||
- `CI`: Set to true for CI-specific behavior
|
||||
|
||||
## Test Profiles
|
||||
|
||||
### Smoke Profile
|
||||
- Runs on every PR
|
||||
- Covers critical paths: hero loading, modal open/close, basic accessibility
|
||||
- ~2-3 minutes execution time
|
||||
|
||||
### Full Profile
|
||||
- Runs on main/nightly builds
|
||||
- Complete capability coverage across all themes and breakpoints
|
||||
- ~15-20 minutes execution time
|
||||
|
||||
## CI Integration
|
||||
|
||||
Tests are integrated into GitHub Actions workflow:
|
||||
- PR Quality Gate: Smoke profile must pass
|
||||
- Main/Nightly: Full profile with artifact retention
|
||||
185
e2e/docs/test-strategy.md
Normal file
185
e2e/docs/test-strategy.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# ClawFort UI/UX Test Strategy
|
||||
|
||||
## Capability-to-Test Mapping
|
||||
|
||||
This document maps OpenSpec capabilities to Playwright test coverage.
|
||||
|
||||
### New Capabilities
|
||||
|
||||
#### `playwright-ui-ux-regression-suite`
|
||||
| Requirement | Test File | Scenarios |
|
||||
|-------------|-----------|-----------|
|
||||
| Capability-mapped suite execution | All files | Tests organized by capability in directory structure |
|
||||
| Theme and breakpoint matrix coverage | `responsive/*.spec.ts`, `accessibility/*.spec.ts` | Cross-theme and cross-viewport test execution |
|
||||
| Failure artifact collection | `playwright.config.ts` | Trace, screenshot, video on failure enabled |
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
#### `end-to-end-system-testing`
|
||||
| Requirement | Test File | Scenarios |
|
||||
|-------------|-----------|-----------|
|
||||
| Core user flow E2E | `core-journeys/hero-feed.spec.ts` | Hero loading, feed browsing, modal flows |
|
||||
| Browser-native interaction E2E | `modal-experience/*.spec.ts` | Modal open/close, source links, share actions |
|
||||
| Edge-case workflows | `core-journeys/edge-cases.spec.ts` | Empty data, invalid permalinks, error states |
|
||||
| UI failure-path resilience | `accessibility/modal.spec.ts` | Fallback messages, navigable error states |
|
||||
|
||||
#### `platform-quality-gates`
|
||||
| Requirement | Test File | Scenarios |
|
||||
|-------------|-----------|-----------|
|
||||
| Release quality gates | CI workflow | Smoke profile gates PR, full profile gates main |
|
||||
| Playwright gate failure blocks release | CI workflow | Fail-on-regression policy enforced |
|
||||
| Gate manifest | `playwright.config.ts` | Explicit browser/tool versions |
|
||||
| Gate profiles documented | This file | Smoke vs full profile criteria defined |
|
||||
|
||||
#### `wcag-2-2-aa-accessibility`
|
||||
| Requirement | Test File | Scenarios |
|
||||
|-------------|-----------|-----------|
|
||||
| Keyboard-only interaction flow | `accessibility/keyboard.spec.ts` | Modal navigation, icon-only controls, focus visibility |
|
||||
| Contrast and non-text alternatives | `accessibility/contrast.spec.ts` | Color contrast assertions across themes |
|
||||
| Accessibility CI gate | CI workflow | Automated accessibility checks in pipeline |
|
||||
| Interactive accessibility states | `accessibility/states.spec.ts` | Focus-visible, keyboard traversal, accessible names |
|
||||
|
||||
#### `responsive-device-agnostic-layout`
|
||||
| Requirement | Test File | Scenarios |
|
||||
|-------------|-----------|-----------|
|
||||
| Mobile layout behavior | `responsive/breakpoints.spec.ts` | No overflow, reachable controls |
|
||||
| Desktop and tablet adaptation | `responsive/breakpoints.spec.ts` | Layout reflow, no clipping |
|
||||
| Sticky shrinking glass header | `responsive/sticky.spec.ts` | Header behavior across scroll |
|
||||
| Sticky footer overlap | `responsive/sticky.spec.ts` | Content readability, control accessibility |
|
||||
| Breakpoint regression matrix | `responsive/*.spec.ts` | Overflow/clipping detection across breakpoints |
|
||||
|
||||
#### `summary-modal-experience`
|
||||
| Requirement | Test File | Scenarios |
|
||||
|-------------|-----------|-----------|
|
||||
| Open summary modal | `modal-experience/summary.spec.ts` | Modal opens with correct content order |
|
||||
| Close summary modal | `modal-experience/summary.spec.ts` | Modal dismisses, returns to feed |
|
||||
| Permalink-driven modal open | `modal-experience/deep-link.spec.ts` | Direct article URL opens modal |
|
||||
| Keyboard dismissal and focus continuity | `modal-experience/summary.spec.ts` | Escape closes, focus returns |
|
||||
| Source link-out from modal | `modal-experience/summary.spec.ts` | Source opens in new tab |
|
||||
| Modal exposes share entry points | `modal-experience/summary.spec.ts` | Share controls available |
|
||||
| Modal interaction regression coverage | `modal-experience/*.spec.ts` | All entry paths tested |
|
||||
|
||||
#### `share-and-contact-microinteractions`
|
||||
| Requirement | Test File | Scenarios |
|
||||
|-------------|-----------|-----------|
|
||||
| Supported share providers | `microinteractions/share.spec.ts` | X, WhatsApp, LinkedIn icons present |
|
||||
| Light-theme icon visibility | `microinteractions/share.spec.ts` | Contrast, keyboard focusability |
|
||||
| Copy-link share action | `microinteractions/share.spec.ts` | Clipboard write, no navigation |
|
||||
| Share controls state accessibility | `microinteractions/share.spec.ts` | States perceivable across themes |
|
||||
| Config present/absent footer links | `microinteractions/footer.spec.ts` | GitHub/contact conditional rendering |
|
||||
| Contact link visible when configured | `microinteractions/footer.spec.ts` | CONTACT_EMAIL shows affordance |
|
||||
| Randomized helper tooltip | `microinteractions/tooltip.spec.ts` | Hover shows safe message |
|
||||
| Keyboard-triggered helper tooltip | `microinteractions/tooltip.spec.ts` | Focus shows tooltip, blur dismisses |
|
||||
|
||||
## Scenario Taxonomy
|
||||
|
||||
### Journey Scenarios
|
||||
- Hero article loading and display
|
||||
- Feed browsing and pagination
|
||||
- Modal open/close from hero
|
||||
- Modal open/close from feed
|
||||
- Source link navigation
|
||||
- Deep-link direct modal open
|
||||
|
||||
### Accessibility-State Scenarios
|
||||
- Keyboard navigation through all interactive elements
|
||||
- Focus-visible indicators on all controls
|
||||
- Color contrast for text and UI components
|
||||
- Accessible names for icon-only controls
|
||||
- Screen reader compatibility for dynamic content
|
||||
|
||||
### Responsive Scenarios
|
||||
- Mobile viewport (375x667): No horizontal overflow, touch targets sized
|
||||
- Tablet viewport (768x1024): Layout reflow, readable content
|
||||
- Desktop viewport (1280x720): Full layout, all features accessible
|
||||
- Widescreen viewport (1920x1080): Max-width constraints
|
||||
|
||||
### Modal Scenarios
|
||||
- Summary modal: Open from hero, open from feed, open from permalink
|
||||
- Policy modals: Terms, Attribution, open/close, escape key
|
||||
- Focus containment within modals
|
||||
- Focus return on modal close
|
||||
|
||||
### Microinteraction Scenarios
|
||||
- Share button hover/focus states
|
||||
- Copy link success feedback
|
||||
- Contact tooltip hover/move/leave
|
||||
- Contact tooltip keyboard focus/blur
|
||||
- Back-to-top visibility on scroll
|
||||
- Theme switch animation
|
||||
|
||||
### Deep-Link Scenarios
|
||||
- Valid article permalink opens modal
|
||||
- Invalid article permalink shows error state
|
||||
- Policy permalink opens policy modal
|
||||
- Hash-based navigation
|
||||
|
||||
## Execution Profiles
|
||||
|
||||
### Smoke Profile
|
||||
**Trigger:** Pull request validation
|
||||
**Duration:** ~2-3 minutes
|
||||
**Browsers:** Chromium only
|
||||
**Coverage:**
|
||||
- Hero loads with content
|
||||
- Feed displays articles
|
||||
- Modal opens and closes
|
||||
- Basic keyboard navigation
|
||||
- One theme (dark)
|
||||
- One viewport (desktop)
|
||||
|
||||
### Full Profile
|
||||
**Trigger:** Main branch merge, nightly builds
|
||||
**Duration:** ~15-20 minutes
|
||||
**Browsers:** Chrome, Firefox, Safari
|
||||
**Coverage:**
|
||||
- All journey scenarios
|
||||
- All accessibility scenarios across themes
|
||||
- All responsive breakpoints
|
||||
- All modal interaction paths
|
||||
- All microinteraction states
|
||||
- Cross-browser compatibility
|
||||
|
||||
## CI Integration
|
||||
|
||||
### Pull Request Quality Gate
|
||||
```yaml
|
||||
- name: Playwright Smoke Tests
|
||||
run: npm run test:smoke
|
||||
env:
|
||||
BASE_URL: http://localhost:8000
|
||||
continue-on-error: false
|
||||
```
|
||||
|
||||
### Main/Nightly Pipeline
|
||||
```yaml
|
||||
- name: Playwright Full Regression
|
||||
run: npm run test:full
|
||||
env:
|
||||
BASE_URL: http://localhost:8000
|
||||
continue-on-error: false
|
||||
|
||||
- name: Upload Test Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: e2e/playwright-report/
|
||||
retention-days: 30
|
||||
```
|
||||
|
||||
## Test Data Assumptions
|
||||
|
||||
Tests assume:
|
||||
1. Application serves at least one article in hero position
|
||||
2. Feed contains multiple articles for pagination testing
|
||||
3. Backend API responds within 5 seconds
|
||||
4. Images load within 10 seconds (or placeholder shown)
|
||||
5. No authentication required for public content
|
||||
|
||||
## Failure Triage
|
||||
|
||||
1. **Smoke test failures:** Block PR merge, immediate investigation required
|
||||
2. **Full regression failures:** Create issue, assign to UI owner, fix before release
|
||||
3. **Flaky tests:** Quarantine after 3 consecutive failures, investigate separately
|
||||
4. **Browser-specific failures:** Check for polyfill or feature support issues
|
||||
156
e2e/docs/triage-workflow.md
Normal file
156
e2e/docs/triage-workflow.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# UI/UX Regression Test Triage Workflow
|
||||
|
||||
## Overview
|
||||
|
||||
This document defines the triage workflow and ownership for UI/UX regression test failures in the ClawFort Playwright test suite.
|
||||
|
||||
## Test Failure Severity
|
||||
|
||||
### Critical (Block Release)
|
||||
- Smoke test failures in PR gate
|
||||
- Core journey failures (hero/feed loading, modal open/close)
|
||||
- Accessibility violations (keyboard navigation, focus management)
|
||||
- Cross-browser compatibility issues
|
||||
|
||||
### High (Fix Before Release)
|
||||
- Full regression failures in main/nightly
|
||||
- Responsive layout issues on supported breakpoints
|
||||
- Theme-specific rendering problems
|
||||
- Deep-link functionality failures
|
||||
|
||||
### Medium (Fix in Next Sprint)
|
||||
- Minor visual inconsistencies
|
||||
- Non-critical microinteraction issues
|
||||
- Performance degradation in test execution
|
||||
|
||||
### Low (Backlog)
|
||||
- Cosmetic issues not affecting functionality
|
||||
- Test flakiness requiring investigation
|
||||
- Documentation gaps
|
||||
|
||||
## Ownership
|
||||
|
||||
### Primary Owner
|
||||
- **UI/UX Team Lead**: Overall test suite health and strategy
|
||||
|
||||
### Component Owners
|
||||
- **Core Journeys**: Frontend team
|
||||
- **Accessibility**: Accessibility specialist + Frontend team
|
||||
- **Responsive Design**: Frontend team + UX designer
|
||||
- **Modal Experience**: Frontend team
|
||||
- **Microinteractions**: Frontend team
|
||||
|
||||
### CI/CD Integration
|
||||
- **DevOps Team**: CI pipeline configuration and artifact management
|
||||
|
||||
## Triage Process
|
||||
|
||||
### Step 1: Detection (Automated)
|
||||
1. CI pipeline runs tests on PR or main branch
|
||||
2. Failures are reported in GitHub Actions
|
||||
3. Artifacts (screenshots, videos, traces) are uploaded
|
||||
4. Notifications sent to #ui-regression-alerts channel
|
||||
|
||||
### Step 2: Initial Assessment (Within 2 hours)
|
||||
1. Check if failure is reproducible locally
|
||||
2. Review failure artifacts in Playwright report
|
||||
3. Determine severity based on impact
|
||||
4. Assign to appropriate component owner
|
||||
|
||||
### Step 3: Investigation
|
||||
1. Review test logs and artifacts
|
||||
2. Check recent commits for related changes
|
||||
3. Attempt local reproduction
|
||||
4. Document findings in issue
|
||||
|
||||
### Step 4: Resolution
|
||||
1. **Critical**: Fix immediately, re-run tests
|
||||
2. **High**: Fix within 24 hours
|
||||
3. **Medium**: Schedule for next sprint
|
||||
4. **Low**: Add to backlog with priority
|
||||
|
||||
### Step 5: Verification
|
||||
1. Re-run failing tests
|
||||
2. Verify fix doesn't introduce new issues
|
||||
3. Update test if it was a false positive
|
||||
4. Document resolution
|
||||
|
||||
## Artifact Access
|
||||
|
||||
### Playwright Report
|
||||
- **Location**: GitHub Actions artifacts
|
||||
- **Retention**: 14 days (smoke), 30 days (full)
|
||||
- **Access**: Download from workflow run page
|
||||
|
||||
### Viewing Locally
|
||||
```bash
|
||||
# Download and extract artifact
|
||||
cd e2e
|
||||
npx playwright show-report playwright-report/
|
||||
```
|
||||
|
||||
### Key Artifacts
|
||||
- **trace.zip**: Full trace with DOM snapshots, network, console
|
||||
- **test-failed-*.png**: Screenshot at failure point
|
||||
- **video.webm**: Video recording of test execution
|
||||
|
||||
## Common Failure Patterns
|
||||
|
||||
### Flaky Tests
|
||||
- **Symptom**: Passes on retry
|
||||
- **Action**: Increase timeouts, add waits, stabilize selectors
|
||||
- **Owner**: Test maintainer
|
||||
|
||||
### Environment Issues
|
||||
- **Symptom**: Tests pass locally but fail in CI
|
||||
- **Action**: Check CI environment, browser versions, dependencies
|
||||
- **Owner**: DevOps + Test maintainer
|
||||
|
||||
### Application Regressions
|
||||
- **Symptom**: Consistent failures across runs
|
||||
- **Action**: Identify breaking change, fix application code
|
||||
- **Owner**: Component owner
|
||||
|
||||
### Test Data Issues
|
||||
- **Symptom**: Tests fail due to missing/changed data
|
||||
- **Action**: Update test fixtures, ensure deterministic data
|
||||
- **Owner**: Test maintainer
|
||||
|
||||
## Communication
|
||||
|
||||
### Slack Channels
|
||||
- `#ui-regression-alerts`: Automated failure notifications
|
||||
- `#frontend-team`: Discussion of UI issues
|
||||
- `#qa-team`: Test-related discussions
|
||||
|
||||
### Issue Labels
|
||||
- `ui-regression`: UI/UX test failures
|
||||
- `accessibility`: WCAG-related issues
|
||||
- `responsive`: Layout/breakpoint issues
|
||||
- `flaky-test`: Intermittent failures
|
||||
- `ci-blocker`: Blocking CI/CD pipeline
|
||||
|
||||
## Escalation Path
|
||||
|
||||
1. **Component Owner** investigates and attempts fix
|
||||
2. **UI/UX Team Lead** reviews if not resolved in 4 hours
|
||||
3. **Engineering Manager** escalates if blocking release
|
||||
4. **CTO** involved for critical production issues
|
||||
|
||||
## Prevention
|
||||
|
||||
### Pre-merge Checks
|
||||
- Run smoke tests locally before pushing
|
||||
- Review visual changes with design team
|
||||
- Test across themes and breakpoints for UI changes
|
||||
|
||||
### Monitoring
|
||||
- Weekly review of test flakiness metrics
|
||||
- Monthly review of test coverage
|
||||
- Quarterly review of test strategy
|
||||
|
||||
## Contact
|
||||
|
||||
- **UI/UX Test Suite**: ui-team@clawfort.ai
|
||||
- **CI/CD Issues**: devops@clawfort.ai
|
||||
- **Emergency Escalation**: engineering-manager@clawfort.ai
|
||||
99
e2e/package-lock.json
generated
Normal file
99
e2e/package-lock.json
generated
Normal file
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"name": "clawfort-e2e-tests",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "clawfort-e2e-tests",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.40.0",
|
||||
"@types/node": "^20.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.33",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
|
||||
"integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
24
e2e/package.json
Normal file
24
e2e/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "clawfort-e2e-tests",
|
||||
"version": "1.0.0",
|
||||
"description": "Playwright-based UI/UX regression test suite for ClawFort",
|
||||
"scripts": {
|
||||
"test": "playwright test",
|
||||
"test:smoke": "playwright test --grep @smoke",
|
||||
"test:full": "playwright test",
|
||||
"test:ui": "playwright test --ui",
|
||||
"test:debug": "playwright test --debug",
|
||||
"test:headed": "playwright test --headed",
|
||||
"install:browsers": "playwright install",
|
||||
"install:deps": "playwright install-deps",
|
||||
"report": "playwright show-report",
|
||||
"codegen": "playwright codegen"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.40.0",
|
||||
"@types/node": "^20.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
126
e2e/playwright.config.ts
Normal file
126
e2e/playwright.config.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Playwright configuration for ClawFort UI/UX regression testing
|
||||
* @see https://playwright.dev/docs/test-configuration
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
|
||||
/* 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 for stability */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: [
|
||||
["html", { open: "never" }],
|
||||
["list"],
|
||||
["junit", { outputFile: "test-results/junit.xml" }],
|
||||
],
|
||||
|
||||
/* 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: process.env.BASE_URL || "http://localhost:8000",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
|
||||
/* Capture screenshot on failure */
|
||||
screenshot: "only-on-failure",
|
||||
|
||||
/* Record video on failure */
|
||||
video: "on-first-retry",
|
||||
|
||||
/* Viewport defaults */
|
||||
viewport: { width: 1280, height: 720 },
|
||||
|
||||
/* Action timeout */
|
||||
actionTimeout: 15000,
|
||||
|
||||
/* Navigation timeout */
|
||||
navigationTimeout: 30000,
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers and viewports */
|
||||
projects: [
|
||||
// Smoke tests - fast feedback
|
||||
{
|
||||
name: "smoke-chromium",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
viewport: { width: 1280, height: 720 },
|
||||
},
|
||||
grep: /@smoke/,
|
||||
},
|
||||
|
||||
// Full regression - Desktop
|
||||
{
|
||||
name: "desktop-chrome",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
viewport: { width: 1280, height: 720 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "desktop-firefox",
|
||||
use: {
|
||||
...devices["Desktop Firefox"],
|
||||
viewport: { width: 1280, height: 720 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "desktop-webkit",
|
||||
use: {
|
||||
...devices["Desktop Safari"],
|
||||
viewport: { width: 1280, height: 720 },
|
||||
},
|
||||
},
|
||||
|
||||
// Tablet
|
||||
{
|
||||
name: "tablet-chrome",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
viewport: { width: 768, height: 1024 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tablet-webkit",
|
||||
use: {
|
||||
...devices["Desktop Safari"],
|
||||
viewport: { width: 768, height: 1024 },
|
||||
},
|
||||
},
|
||||
|
||||
// Mobile
|
||||
{
|
||||
name: "mobile-chrome",
|
||||
use: {
|
||||
...devices["Pixel 5"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mobile-safari",
|
||||
use: {
|
||||
...devices["iPhone 12"],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
/* Run local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: "cd .. && python -m backend.main",
|
||||
url: "http://localhost:8000",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120000,
|
||||
},
|
||||
});
|
||||
231
e2e/tests/capabilities/accessibility/contrast.spec.ts
Normal file
231
e2e/tests/capabilities/accessibility/contrast.spec.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import {
|
||||
assertContrast,
|
||||
checkContrast,
|
||||
getComputedColors,
|
||||
WCAG_CONTRAST,
|
||||
} from "../../fixtures/accessibility";
|
||||
import { SELECTORS } from "../../fixtures/selectors";
|
||||
import { expect, test } from "../../fixtures/test";
|
||||
import { THEMES, Theme } from "../../fixtures/themes";
|
||||
|
||||
test.describe("Color Contrast Across Themes", () => {
|
||||
test.beforeEach(async ({ gotoApp, waitForAppReady }) => {
|
||||
await gotoApp();
|
||||
await waitForAppReady();
|
||||
});
|
||||
|
||||
for (const theme of THEMES) {
|
||||
test(`hero text has sufficient contrast in ${theme} theme @smoke`, async ({
|
||||
page,
|
||||
setTheme,
|
||||
waitForHero,
|
||||
}) => {
|
||||
// Set theme
|
||||
await setTheme(theme);
|
||||
|
||||
const hero = await waitForHero();
|
||||
|
||||
// Check headline contrast
|
||||
const headline = hero.locator(SELECTORS.hero.headline);
|
||||
await assertContrast(page, headline, WCAG_CONTRAST.largeText);
|
||||
|
||||
// Check summary contrast
|
||||
const summary = hero.locator(SELECTORS.hero.summary);
|
||||
await assertContrast(page, summary, WCAG_CONTRAST.normalText);
|
||||
});
|
||||
|
||||
test(`feed card text has sufficient contrast in ${theme} theme`, async ({
|
||||
page,
|
||||
setTheme,
|
||||
waitForFeed,
|
||||
}) => {
|
||||
// Set theme
|
||||
await setTheme(theme);
|
||||
|
||||
const feed = await waitForFeed();
|
||||
const firstArticle = feed.locator(SELECTORS.feed.articles).first();
|
||||
|
||||
// Check headline contrast
|
||||
const headline = firstArticle.locator("h3");
|
||||
await assertContrast(page, headline, WCAG_CONTRAST.largeText);
|
||||
|
||||
// Check summary contrast
|
||||
const summary = firstArticle.locator(SELECTORS.feed.articleSummary);
|
||||
await assertContrast(page, summary, WCAG_CONTRAST.normalText);
|
||||
});
|
||||
|
||||
test(`modal text has sufficient contrast in ${theme} theme`, async ({
|
||||
page,
|
||||
setTheme,
|
||||
waitForHero,
|
||||
}) => {
|
||||
// Set theme
|
||||
await setTheme(theme);
|
||||
|
||||
// Open modal
|
||||
const hero = await waitForHero();
|
||||
await hero.locator(SELECTORS.hero.readButton).click();
|
||||
|
||||
const modal = page.locator(SELECTORS.summaryModal.root);
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// Check headline contrast
|
||||
const headline = modal.locator(SELECTORS.summaryModal.headline);
|
||||
await assertContrast(page, headline, WCAG_CONTRAST.largeText);
|
||||
|
||||
// Check body text contrast
|
||||
const bodyText = modal.locator(SELECTORS.summaryModal.summaryBody);
|
||||
await assertContrast(page, bodyText, WCAG_CONTRAST.normalText);
|
||||
|
||||
// Check TL;DR list contrast
|
||||
const tldrList = modal.locator(SELECTORS.summaryModal.tldrList);
|
||||
await assertContrast(page, tldrList, WCAG_CONTRAST.normalText);
|
||||
});
|
||||
|
||||
test(`link colors have sufficient contrast in ${theme} theme`, async ({
|
||||
page,
|
||||
setTheme,
|
||||
waitForFeed,
|
||||
}) => {
|
||||
// Set theme
|
||||
await setTheme(theme);
|
||||
|
||||
const feed = await waitForFeed();
|
||||
const firstArticle = feed.locator(SELECTORS.feed.articles).first();
|
||||
|
||||
// Check source link contrast
|
||||
const sourceLink = firstArticle.locator(SELECTORS.feed.articleSource);
|
||||
const hasSourceLink = (await sourceLink.count()) > 0;
|
||||
|
||||
if (hasSourceLink) {
|
||||
await assertContrast(page, sourceLink, WCAG_CONTRAST.normalText);
|
||||
}
|
||||
});
|
||||
|
||||
test(`button text has sufficient contrast in ${theme} theme`, async ({
|
||||
page,
|
||||
setTheme,
|
||||
waitForHero,
|
||||
}) => {
|
||||
// Set theme
|
||||
await setTheme(theme);
|
||||
|
||||
const hero = await waitForHero();
|
||||
|
||||
// Check read button contrast
|
||||
const readButton = hero.locator(SELECTORS.hero.readButton);
|
||||
await assertContrast(page, readButton, WCAG_CONTRAST.normalText);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("Interactive State Contrast", () => {
|
||||
test.beforeEach(async ({ gotoApp, waitForAppReady }) => {
|
||||
await gotoApp();
|
||||
await waitForAppReady();
|
||||
});
|
||||
|
||||
test("hover state maintains sufficient contrast", async ({
|
||||
page,
|
||||
waitForFeed,
|
||||
}) => {
|
||||
const feed = await waitForFeed();
|
||||
const firstArticle = feed.locator(SELECTORS.feed.articles).first();
|
||||
const readButton = firstArticle.locator(SELECTORS.feed.articleReadButton);
|
||||
|
||||
// Get normal state colors
|
||||
const normalColors = await getComputedColors(page, readButton);
|
||||
|
||||
// Hover over button
|
||||
await readButton.hover();
|
||||
await page.waitForTimeout(300); // Wait for transition
|
||||
|
||||
// Get hover state colors
|
||||
const hoverColors = await getComputedColors(page, readButton);
|
||||
|
||||
// Both states should have sufficient contrast
|
||||
const normalRatio = checkContrast(
|
||||
normalColors.color,
|
||||
normalColors.backgroundColor,
|
||||
);
|
||||
const hoverRatio = checkContrast(
|
||||
hoverColors.color,
|
||||
hoverColors.backgroundColor,
|
||||
);
|
||||
|
||||
expect(normalRatio).toBeGreaterThanOrEqual(WCAG_CONTRAST.normalText);
|
||||
expect(hoverRatio).toBeGreaterThanOrEqual(WCAG_CONTRAST.normalText);
|
||||
});
|
||||
|
||||
test("focus state maintains sufficient contrast @smoke", async ({
|
||||
page,
|
||||
waitForFeed,
|
||||
}) => {
|
||||
const feed = await waitForFeed();
|
||||
const firstArticle = feed.locator(SELECTORS.feed.articles).first();
|
||||
const readButton = firstArticle.locator(SELECTORS.feed.articleReadButton);
|
||||
|
||||
// Get normal state colors
|
||||
const normalColors = await getComputedColors(page, readButton);
|
||||
|
||||
// Focus the button
|
||||
await readButton.focus();
|
||||
|
||||
// Get focus state colors
|
||||
const focusColors = await getComputedColors(page, readButton);
|
||||
|
||||
// Both states should have sufficient contrast
|
||||
const normalRatio = checkContrast(
|
||||
normalColors.color,
|
||||
normalColors.backgroundColor,
|
||||
);
|
||||
const focusRatio = checkContrast(
|
||||
focusColors.color,
|
||||
focusColors.backgroundColor,
|
||||
);
|
||||
|
||||
expect(normalRatio).toBeGreaterThanOrEqual(WCAG_CONTRAST.normalText);
|
||||
expect(focusRatio).toBeGreaterThanOrEqual(WCAG_CONTRAST.normalText);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("High Contrast Theme", () => {
|
||||
test.beforeEach(async ({ gotoApp, waitForAppReady }) => {
|
||||
await gotoApp();
|
||||
await waitForAppReady();
|
||||
});
|
||||
|
||||
test("contrast theme provides enhanced visibility @smoke", async ({
|
||||
page,
|
||||
setTheme,
|
||||
waitForHero,
|
||||
waitForFeed,
|
||||
}) => {
|
||||
// Set high contrast theme
|
||||
await setTheme("contrast");
|
||||
|
||||
// Check hero
|
||||
const hero = await waitForHero();
|
||||
const headline = hero.locator(SELECTORS.hero.headline);
|
||||
const headlineColors = await getComputedColors(page, headline);
|
||||
const headlineRatio = checkContrast(
|
||||
headlineColors.color,
|
||||
headlineColors.backgroundColor,
|
||||
);
|
||||
|
||||
// High contrast should provide very strong contrast (7:1 or better)
|
||||
expect(headlineRatio).toBeGreaterThanOrEqual(7);
|
||||
|
||||
// Check feed
|
||||
const feed = await waitForFeed();
|
||||
const firstArticle = feed.locator(SELECTORS.feed.articles).first();
|
||||
const articleHeadline = firstArticle.locator("h3");
|
||||
const articleColors = await getComputedColors(page, articleHeadline);
|
||||
const articleRatio = checkContrast(
|
||||
articleColors.color,
|
||||
articleColors.backgroundColor,
|
||||
);
|
||||
|
||||
expect(articleRatio).toBeGreaterThanOrEqual(7);
|
||||
});
|
||||
});
|
||||
130
e2e/tests/capabilities/accessibility/icon-labels.spec.ts
Normal file
130
e2e/tests/capabilities/accessibility/icon-labels.spec.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
getAccessibleName,
|
||||
hasAccessibleName,
|
||||
} from "../../fixtures/accessibility";
|
||||
import { SELECTORS } from "../../fixtures/selectors";
|
||||
import { expect, test } from "../../fixtures/test";
|
||||
|
||||
test.describe("Icon-Only Control Accessible Names @smoke", () => {
|
||||
test.beforeEach(async ({ gotoApp, waitForAppReady }) => {
|
||||
await gotoApp();
|
||||
await waitForAppReady();
|
||||
});
|
||||
|
||||
test("share buttons have accessible names", async ({ page, waitForHero }) => {
|
||||
// Open modal to access share buttons
|
||||
const hero = await waitForHero();
|
||||
await hero.locator(SELECTORS.hero.readButton).click();
|
||||
|
||||
const modal = page.locator(SELECTORS.summaryModal.root);
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// Check X share button
|
||||
const shareX = modal.locator(SELECTORS.summaryModal.shareX);
|
||||
await expect(shareX).toHaveAttribute("aria-label", "Share on X");
|
||||
expect(await hasAccessibleName(shareX)).toBe(true);
|
||||
|
||||
// Check WhatsApp share button
|
||||
const shareWhatsApp = modal.locator(SELECTORS.summaryModal.shareWhatsApp);
|
||||
await expect(shareWhatsApp).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Share on WhatsApp",
|
||||
);
|
||||
expect(await hasAccessibleName(shareWhatsApp)).toBe(true);
|
||||
|
||||
// Check LinkedIn share button
|
||||
const shareLinkedIn = modal.locator(SELECTORS.summaryModal.shareLinkedIn);
|
||||
await expect(shareLinkedIn).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Share on LinkedIn",
|
||||
);
|
||||
expect(await hasAccessibleName(shareLinkedIn)).toBe(true);
|
||||
|
||||
// Check copy link button
|
||||
const shareCopy = modal.locator(SELECTORS.summaryModal.shareCopy);
|
||||
await expect(shareCopy).toHaveAttribute("aria-label", "Copy article link");
|
||||
expect(await hasAccessibleName(shareCopy)).toBe(true);
|
||||
});
|
||||
|
||||
test("theme menu button has accessible name", async ({ page }) => {
|
||||
const themeButton = page.locator(SELECTORS.header.themeMenuButton);
|
||||
|
||||
await expect(themeButton).toHaveAttribute("aria-label", "Open theme menu");
|
||||
expect(await hasAccessibleName(themeButton)).toBe(true);
|
||||
});
|
||||
|
||||
test("back to top button has accessible name", async ({ page }) => {
|
||||
// Scroll down to make back-to-top visible
|
||||
await page.evaluate(() => window.scrollTo(0, 500));
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const backToTop = page.locator(SELECTORS.backToTop.root);
|
||||
|
||||
// Button may not be visible yet, but should have accessible name
|
||||
const hasName = await hasAccessibleName(backToTop);
|
||||
expect(hasName).toBe(true);
|
||||
|
||||
const name = await getAccessibleName(page, backToTop);
|
||||
expect(name).toContain("top");
|
||||
});
|
||||
|
||||
test("modal close button has accessible name", async ({
|
||||
page,
|
||||
waitForHero,
|
||||
}) => {
|
||||
// Open modal
|
||||
const hero = await waitForHero();
|
||||
await hero.locator(SELECTORS.hero.readButton).click();
|
||||
|
||||
const modal = page.locator(SELECTORS.summaryModal.root);
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// Check close button
|
||||
const closeButton = modal.locator(SELECTORS.summaryModal.closeButton);
|
||||
expect(await hasAccessibleName(closeButton)).toBe(true);
|
||||
|
||||
const name = await getAccessibleName(page, closeButton);
|
||||
expect(name?.toLowerCase()).toContain("close");
|
||||
});
|
||||
|
||||
test("policy modal close button has accessible name", async ({
|
||||
page,
|
||||
gotoApp,
|
||||
}) => {
|
||||
// Open policy modal
|
||||
await gotoApp({ policy: "terms" });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const modal = page.locator(SELECTORS.policyModal.root);
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// Check close button
|
||||
const closeButton = modal.locator(SELECTORS.policyModal.closeButton);
|
||||
expect(await hasAccessibleName(closeButton)).toBe(true);
|
||||
|
||||
const name = await getAccessibleName(page, closeButton);
|
||||
expect(name?.toLowerCase()).toContain("close");
|
||||
});
|
||||
|
||||
test("all interactive icons have aria-hidden on SVG", async ({
|
||||
page,
|
||||
waitForHero,
|
||||
}) => {
|
||||
// Open modal to access share buttons
|
||||
const hero = await waitForHero();
|
||||
await hero.locator(SELECTORS.hero.readButton).click();
|
||||
|
||||
const modal = page.locator(SELECTORS.summaryModal.root);
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// Check all SVGs in share buttons are aria-hidden
|
||||
const svgs = modal.locator(".share-icon-btn svg");
|
||||
const count = await svgs.count();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const svg = svgs.nth(i);
|
||||
const ariaHidden = await svg.getAttribute("aria-hidden");
|
||||
expect(ariaHidden).toBe("true");
|
||||
}
|
||||
});
|
||||
});
|
||||
264
e2e/tests/capabilities/accessibility/keyboard.spec.ts
Normal file
264
e2e/tests/capabilities/accessibility/keyboard.spec.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { hasFocusVisible } from "../../fixtures/accessibility";
|
||||
import { SELECTORS } from "../../fixtures/selectors";
|
||||
import { expect, test } from "../../fixtures/test";
|
||||
|
||||
test.describe("Keyboard Navigation @smoke", () => {
|
||||
test.beforeEach(async ({ gotoApp, waitForAppReady }) => {
|
||||
await gotoApp();
|
||||
await waitForAppReady();
|
||||
});
|
||||
|
||||
test("skip link is first focusable element", async ({ page }) => {
|
||||
// Press Tab to focus skip link
|
||||
await page.keyboard.press("Tab");
|
||||
|
||||
// Skip link should be focused
|
||||
const skipLink = page.locator(SELECTORS.skipLink);
|
||||
await expect(skipLink).toBeFocused();
|
||||
|
||||
// Skip link should be visible when focused
|
||||
const isVisible = await skipLink.isVisible();
|
||||
expect(isVisible).toBe(true);
|
||||
});
|
||||
|
||||
test("skip link navigates to main content", async ({ page }) => {
|
||||
// Focus and activate skip link
|
||||
await page.keyboard.press("Tab");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
// Main content should be focused
|
||||
const mainContent = page.locator("#main-content");
|
||||
await expect(mainContent).toBeFocused();
|
||||
});
|
||||
|
||||
test("header controls are keyboard accessible @smoke", async ({ page }) => {
|
||||
// Tab through header controls
|
||||
await page.keyboard.press("Tab"); // Skip link
|
||||
await page.keyboard.press("Tab"); // Logo
|
||||
await page.keyboard.press("Tab"); // Language select
|
||||
|
||||
const languageSelect = page.locator(SELECTORS.header.languageSelect);
|
||||
await expect(languageSelect).toBeFocused();
|
||||
|
||||
await page.keyboard.press("Tab"); // Theme menu button
|
||||
|
||||
const themeButton = page.locator(SELECTORS.header.themeMenuButton);
|
||||
await expect(themeButton).toBeFocused();
|
||||
});
|
||||
|
||||
test("theme menu is keyboard operable", async ({ page }) => {
|
||||
// Navigate to theme button
|
||||
await page.keyboard.press("Tab"); // Skip link
|
||||
await page.keyboard.press("Tab"); // Logo
|
||||
await page.keyboard.press("Tab"); // Language select
|
||||
await page.keyboard.press("Tab"); // Theme button
|
||||
|
||||
// Open theme menu with Enter
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
// Menu should be visible
|
||||
const menu = page.locator(SELECTORS.themeMenu.root);
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
// Menu items should be focusable
|
||||
const menuItems = menu.locator('[role="menuitem"]');
|
||||
const count = await menuItems.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
|
||||
// Close menu with Escape
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(menu).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("hero read button is keyboard accessible @smoke", async ({
|
||||
page,
|
||||
waitForHero,
|
||||
}) => {
|
||||
const hero = await waitForHero();
|
||||
|
||||
// Navigate to hero read button
|
||||
// Skip header controls first
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await page.keyboard.press("Tab");
|
||||
}
|
||||
|
||||
// Hero read button should be focusable
|
||||
const readButton = hero.locator(SELECTORS.hero.readButton);
|
||||
|
||||
// Check if button is in tab order by trying to focus it
|
||||
let found = false;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const activeElement = await page.evaluate(
|
||||
() =>
|
||||
document.activeElement?.textContent?.trim() ||
|
||||
document.activeElement?.getAttribute("aria-label"),
|
||||
);
|
||||
|
||||
if (activeElement?.includes("Read TL;DR")) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
|
||||
await page.keyboard.press("Tab");
|
||||
}
|
||||
|
||||
expect(found).toBe(true);
|
||||
});
|
||||
|
||||
test("feed articles are keyboard navigable", async ({
|
||||
page,
|
||||
waitForFeed,
|
||||
}) => {
|
||||
const feed = await waitForFeed();
|
||||
|
||||
// Get first article
|
||||
const firstArticle = feed.locator(SELECTORS.feed.articles).first();
|
||||
|
||||
// Source link should be keyboard accessible
|
||||
const sourceLink = firstArticle.locator(SELECTORS.feed.articleSource);
|
||||
const hasSourceLink = (await sourceLink.count()) > 0;
|
||||
|
||||
if (hasSourceLink) {
|
||||
// Tab to source link
|
||||
let attempts = 0;
|
||||
let sourceLinkFocused = false;
|
||||
|
||||
while (attempts < 20 && !sourceLinkFocused) {
|
||||
await page.keyboard.press("Tab");
|
||||
const href = await page.evaluate(() =>
|
||||
document.activeElement?.getAttribute("href"),
|
||||
);
|
||||
|
||||
if (href && href.startsWith("http")) {
|
||||
sourceLinkFocused = true;
|
||||
}
|
||||
|
||||
attempts++;
|
||||
}
|
||||
|
||||
expect(sourceLinkFocused).toBe(true);
|
||||
}
|
||||
|
||||
// Read button should be keyboard accessible
|
||||
const readButton = firstArticle.locator(SELECTORS.feed.articleReadButton);
|
||||
|
||||
let attempts = 0;
|
||||
let readButtonFocused = false;
|
||||
|
||||
while (attempts < 30 && !readButtonFocused) {
|
||||
await page.keyboard.press("Tab");
|
||||
const text = await page.evaluate(() =>
|
||||
document.activeElement?.textContent?.trim(),
|
||||
);
|
||||
|
||||
if (text === "Read TL;DR") {
|
||||
readButtonFocused = true;
|
||||
}
|
||||
|
||||
attempts++;
|
||||
}
|
||||
|
||||
expect(readButtonFocused).toBe(true);
|
||||
});
|
||||
|
||||
test("focus-visible is shown on interactive elements", async ({
|
||||
page,
|
||||
waitForHero,
|
||||
}) => {
|
||||
const hero = await waitForHero();
|
||||
|
||||
// Navigate to hero read button
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await page.keyboard.press("Tab");
|
||||
}
|
||||
|
||||
// Find focused element
|
||||
const focusedElement = page.locator(":focus");
|
||||
|
||||
// Check that focused element has visible focus indicator
|
||||
const hasVisibleFocus = await hasFocusVisible(page, focusedElement);
|
||||
expect(hasVisibleFocus).toBe(true);
|
||||
});
|
||||
|
||||
test("footer links are keyboard accessible @smoke", async ({ page }) => {
|
||||
// Navigate to footer
|
||||
const footer = page.locator(SELECTORS.footer.root);
|
||||
await footer.scrollIntoViewIfNeeded();
|
||||
|
||||
// Tab through footer links
|
||||
let foundFooterLink = false;
|
||||
let attempts = 0;
|
||||
|
||||
while (attempts < 50 && !foundFooterLink) {
|
||||
await page.keyboard.press("Tab");
|
||||
|
||||
const activeElement = await page.evaluate(() => document.activeElement);
|
||||
|
||||
// Check if we're in footer
|
||||
const isInFooter = await page.evaluate(() => {
|
||||
const active = document.activeElement;
|
||||
const footer = document.querySelector("footer");
|
||||
return footer?.contains(active);
|
||||
});
|
||||
|
||||
if (isInFooter) {
|
||||
foundFooterLink = true;
|
||||
}
|
||||
|
||||
attempts++;
|
||||
}
|
||||
|
||||
expect(foundFooterLink).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Focus Management", () => {
|
||||
test.beforeEach(async ({ gotoApp, waitForAppReady }) => {
|
||||
await gotoApp();
|
||||
await waitForAppReady();
|
||||
});
|
||||
|
||||
test("focus moves to modal when opened @smoke", async ({
|
||||
page,
|
||||
waitForHero,
|
||||
}) => {
|
||||
const hero = await waitForHero();
|
||||
|
||||
// Click read button
|
||||
await hero.locator(SELECTORS.hero.readButton).click();
|
||||
|
||||
// Modal should be visible
|
||||
const modal = page.locator(SELECTORS.summaryModal.root);
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// Focus should be inside modal
|
||||
const isFocusInModal = await page.evaluate(() => {
|
||||
const modal = document.querySelector('[role="dialog"]');
|
||||
const active = document.activeElement;
|
||||
return modal?.contains(active);
|
||||
});
|
||||
|
||||
expect(isFocusInModal).toBe(true);
|
||||
});
|
||||
|
||||
test("focus is trapped within modal", async ({ page, waitForHero }) => {
|
||||
const hero = await waitForHero();
|
||||
|
||||
// Open modal
|
||||
await hero.locator(SELECTORS.hero.readButton).click();
|
||||
|
||||
// Tab multiple times
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await page.keyboard.press("Tab");
|
||||
|
||||
// Check focus is still in modal
|
||||
const isInModal = await page.evaluate(() => {
|
||||
const modal = document.querySelector('[role="dialog"]');
|
||||
const active = document.activeElement;
|
||||
return modal?.contains(active);
|
||||
});
|
||||
|
||||
expect(isInModal).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
173
e2e/tests/capabilities/accessibility/modal.spec.ts
Normal file
173
e2e/tests/capabilities/accessibility/modal.spec.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { SELECTORS } from "../../fixtures/selectors";
|
||||
import { expect, test } from "../../fixtures/test";
|
||||
|
||||
test.describe("Policy Modal Accessibility @smoke", () => {
|
||||
test.beforeEach(async ({ gotoApp, waitForAppReady }) => {
|
||||
await gotoApp();
|
||||
await waitForAppReady();
|
||||
});
|
||||
|
||||
test("terms modal opens and has correct ARIA attributes", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Click terms link
|
||||
const termsLink = page.locator(SELECTORS.footer.termsLink);
|
||||
await termsLink.click();
|
||||
|
||||
// Modal should be visible
|
||||
const modal = page.locator(SELECTORS.policyModal.root);
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// Check ARIA attributes
|
||||
await expect(modal).toHaveAttribute("role", "dialog");
|
||||
await expect(modal).toHaveAttribute("aria-modal", "true");
|
||||
|
||||
// Check aria-label
|
||||
const ariaLabel = await modal.getAttribute("aria-label");
|
||||
expect(ariaLabel).toMatch(/Terms|Attribution/);
|
||||
});
|
||||
|
||||
test("attribution modal opens and has correct ARIA attributes", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Click attribution link
|
||||
const attributionLink = page.locator(SELECTORS.footer.attributionLink);
|
||||
await attributionLink.click();
|
||||
|
||||
// Modal should be visible
|
||||
const modal = page.locator(SELECTORS.policyModal.root);
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// Check ARIA attributes
|
||||
await expect(modal).toHaveAttribute("role", "dialog");
|
||||
await expect(modal).toHaveAttribute("aria-modal", "true");
|
||||
});
|
||||
|
||||
test("policy modal closes with escape key @smoke", async ({ page }) => {
|
||||
// Open terms modal
|
||||
const termsLink = page.locator(SELECTORS.footer.termsLink);
|
||||
await termsLink.click();
|
||||
|
||||
const modal = page.locator(SELECTORS.policyModal.root);
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// Press escape
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Modal should be closed
|
||||
await expect(modal).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("policy modal closes with close button", async ({ page }) => {
|
||||
// Open terms modal
|
||||
const termsLink = page.locator(SELECTORS.footer.termsLink);
|
||||
await termsLink.click();
|
||||
|
||||
const modal = page.locator(SELECTORS.policyModal.root);
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// Click close button
|
||||
const closeButton = modal.locator(SELECTORS.policyModal.closeButton);
|
||||
await closeButton.click();
|
||||
|
||||
// Modal should be closed
|
||||
await expect(modal).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("policy modal closes with backdrop click", async ({ page }) => {
|
||||
// Open terms modal
|
||||
const termsLink = page.locator(SELECTORS.footer.termsLink);
|
||||
await termsLink.click();
|
||||
|
||||
const modal = page.locator(SELECTORS.policyModal.root);
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// Click backdrop
|
||||
const backdrop = page.locator(".fixed.inset-0.bg-black\\/70").first();
|
||||
await backdrop.click();
|
||||
|
||||
// Modal should be closed
|
||||
await page.waitForTimeout(500);
|
||||
await expect(modal).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("focus returns to trigger after closing policy modal @smoke", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Open terms modal
|
||||
const termsLink = page.locator(SELECTORS.footer.termsLink);
|
||||
await termsLink.click();
|
||||
|
||||
const modal = page.locator(SELECTORS.policyModal.root);
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// Close modal
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Focus should return to terms link
|
||||
await expect(termsLink).toBeFocused();
|
||||
});
|
||||
|
||||
test("focus is contained within policy modal", async ({ page }) => {
|
||||
// Open terms modal
|
||||
const termsLink = page.locator(SELECTORS.footer.termsLink);
|
||||
await termsLink.click();
|
||||
|
||||
const modal = page.locator(SELECTORS.policyModal.root);
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// Tab multiple times
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await page.keyboard.press("Tab");
|
||||
|
||||
// Check focus is still in modal
|
||||
const isInModal = await page.evaluate(() => {
|
||||
const modal = document.querySelector('[role="dialog"]');
|
||||
const active = document.activeElement;
|
||||
return modal?.contains(active);
|
||||
});
|
||||
|
||||
expect(isInModal).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("policy modal content is readable", async ({ page }) => {
|
||||
// Open terms modal
|
||||
const termsLink = page.locator(SELECTORS.footer.termsLink);
|
||||
await termsLink.click();
|
||||
|
||||
const modal = page.locator(SELECTORS.policyModal.root);
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// Check title is present
|
||||
const title = modal.locator(SELECTORS.policyModal.termsTitle);
|
||||
await expect(title).toBeVisible();
|
||||
await expect(title).toContainText("Terms of Use");
|
||||
|
||||
// Check content is present
|
||||
const content = modal.locator(".modal-body-text");
|
||||
const paragraphs = await content.locator("p").count();
|
||||
expect(paragraphs).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("policy modal has correct heading structure", async ({ page }) => {
|
||||
// Open terms modal
|
||||
const termsLink = page.locator(SELECTORS.footer.termsLink);
|
||||
await termsLink.click();
|
||||
|
||||
const modal = page.locator(SELECTORS.policyModal.root);
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// Should have h2 heading
|
||||
const heading = modal.locator("h2");
|
||||
await expect(heading).toBeVisible();
|
||||
|
||||
// Check heading level
|
||||
const headingLevel = await heading.evaluate((el) =>
|
||||
el.tagName.toLowerCase(),
|
||||
);
|
||||
expect(headingLevel).toBe("h2");
|
||||
});
|
||||
});
|
||||
368
e2e/tests/capabilities/core-journeys/hero-feed.spec.ts
Normal file
368
e2e/tests/capabilities/core-journeys/hero-feed.spec.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
import { SELECTORS } from "../../fixtures/selectors";
|
||||
import { expect, test } from "../../fixtures/test";
|
||||
|
||||
test.describe("Hero and Feed Browsing @smoke", () => {
|
||||
test.beforeEach(async ({ gotoApp, waitForAppReady }) => {
|
||||
await gotoApp();
|
||||
await waitForAppReady();
|
||||
});
|
||||
|
||||
test("hero section loads with article content", async ({
|
||||
page,
|
||||
waitForHero,
|
||||
}) => {
|
||||
const hero = await waitForHero();
|
||||
|
||||
// Hero should be visible
|
||||
await expect(hero).toBeVisible();
|
||||
|
||||
// Hero should have headline
|
||||
const headline = hero.locator(SELECTORS.hero.headline);
|
||||
await expect(headline).toBeVisible();
|
||||
await expect(headline).not.toBeEmpty();
|
||||
|
||||
// Hero should have summary
|
||||
const summary = hero.locator(SELECTORS.hero.summary);
|
||||
await expect(summary).toBeVisible();
|
||||
await expect(summary).not.toBeEmpty();
|
||||
|
||||
// Hero should have "Read TL;DR" button
|
||||
const readButton = hero.locator(SELECTORS.hero.readButton);
|
||||
await expect(readButton).toBeVisible();
|
||||
await expect(readButton).toBeEnabled();
|
||||
|
||||
// Hero should have image
|
||||
const image = hero.locator(SELECTORS.hero.image);
|
||||
await expect(image).toBeVisible();
|
||||
});
|
||||
|
||||
test("news feed loads with multiple articles", async ({
|
||||
page,
|
||||
waitForFeed,
|
||||
}) => {
|
||||
const feed = await waitForFeed();
|
||||
|
||||
// Feed section should be visible
|
||||
await expect(feed).toBeVisible();
|
||||
|
||||
// Should have "Recent News" heading
|
||||
const heading = feed.locator("h2");
|
||||
await expect(heading).toContainText("Recent News");
|
||||
|
||||
// Should have multiple article cards (at least 1)
|
||||
const articles = feed.locator(SELECTORS.feed.articles);
|
||||
const count = await articles.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Each article should have required elements
|
||||
const firstArticle = articles.first();
|
||||
await expect(firstArticle.locator("h3")).toBeVisible();
|
||||
await expect(
|
||||
firstArticle.locator(SELECTORS.feed.articleSummary),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
firstArticle.locator(SELECTORS.feed.articleReadButton),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("feed article cards have correct structure", async ({
|
||||
page,
|
||||
waitForFeed,
|
||||
}) => {
|
||||
const feed = await waitForFeed();
|
||||
const articles = feed.locator(SELECTORS.feed.articles);
|
||||
|
||||
// Check structure of first article
|
||||
const firstArticle = articles.first();
|
||||
|
||||
// Should have image container
|
||||
const imageContainer = firstArticle.locator(".relative.h-48");
|
||||
await expect(imageContainer).toBeVisible();
|
||||
|
||||
// Should have content area
|
||||
const contentArea = firstArticle.locator(".p-5");
|
||||
await expect(contentArea).toBeVisible();
|
||||
|
||||
// Should have headline
|
||||
const headline = firstArticle.locator("h3");
|
||||
await expect(headline).toBeVisible();
|
||||
await expect(headline).toHaveClass(/news-card-title/);
|
||||
|
||||
// Should have summary
|
||||
const summary = firstArticle.locator(SELECTORS.feed.articleSummary);
|
||||
await expect(summary).toBeVisible();
|
||||
await expect(summary).toHaveClass(/news-card-summary/);
|
||||
});
|
||||
|
||||
test("hero article displays correct metadata", async ({
|
||||
page,
|
||||
waitForHero,
|
||||
}) => {
|
||||
const hero = await waitForHero();
|
||||
|
||||
// Should have "LATEST" pill
|
||||
const latestPill = hero.locator(SELECTORS.hero.latestPill);
|
||||
await expect(latestPill).toBeVisible();
|
||||
await expect(latestPill).toContainText("LATEST");
|
||||
|
||||
// Should have time ago
|
||||
const timePill = hero.locator(SELECTORS.hero.timePill);
|
||||
await expect(timePill).toBeVisible();
|
||||
|
||||
// Time should contain "ago" or "just now"
|
||||
const timeText = await timePill.textContent();
|
||||
expect(timeText).toMatch(/ago|just now/);
|
||||
});
|
||||
|
||||
test("source link is present and clickable in hero", async ({
|
||||
page,
|
||||
waitForHero,
|
||||
}) => {
|
||||
const hero = await waitForHero();
|
||||
|
||||
const sourceLink = hero.locator(SELECTORS.hero.sourceLink);
|
||||
|
||||
// Source link may not be present if no source URL
|
||||
const count = await sourceLink.count();
|
||||
if (count > 0) {
|
||||
await expect(sourceLink).toBeVisible();
|
||||
await expect(sourceLink).toHaveAttribute("href");
|
||||
await expect(sourceLink).toHaveAttribute("target", "_blank");
|
||||
await expect(sourceLink).toHaveAttribute("rel", "noopener");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Summary Modal Flows", () => {
|
||||
test.beforeEach(async ({ gotoApp, waitForAppReady }) => {
|
||||
await gotoApp();
|
||||
await waitForAppReady();
|
||||
});
|
||||
|
||||
test("opens summary modal from hero @smoke", async ({
|
||||
page,
|
||||
waitForHero,
|
||||
isSummaryModalOpen,
|
||||
}) => {
|
||||
const hero = await waitForHero();
|
||||
|
||||
// Click "Read TL;DR" button in hero
|
||||
const readButton = hero.locator(SELECTORS.hero.readButton);
|
||||
await readButton.click();
|
||||
|
||||
// Modal should open
|
||||
const isOpen = await isSummaryModalOpen();
|
||||
expect(isOpen).toBe(true);
|
||||
|
||||
// Modal should have correct structure
|
||||
const modal = page.locator(SELECTORS.summaryModal.root);
|
||||
await expect(modal.locator(SELECTORS.summaryModal.headline)).toBeVisible();
|
||||
await expect(
|
||||
modal.locator(SELECTORS.summaryModal.tldrSection),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
modal.locator(SELECTORS.summaryModal.summarySection),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
modal.locator(SELECTORS.summaryModal.sourceSection),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
modal.locator(SELECTORS.summaryModal.shareSection),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("opens summary modal from feed article @smoke", async ({
|
||||
page,
|
||||
waitForFeed,
|
||||
isSummaryModalOpen,
|
||||
}) => {
|
||||
const feed = await waitForFeed();
|
||||
|
||||
// Get first feed article
|
||||
const articles = feed.locator(SELECTORS.feed.articles);
|
||||
const firstArticle = articles.first();
|
||||
|
||||
// Click "Read TL;DR" button
|
||||
const readButton = firstArticle.locator(SELECTORS.feed.articleReadButton);
|
||||
await readButton.click();
|
||||
|
||||
// Modal should open
|
||||
const isOpen = await isSummaryModalOpen();
|
||||
expect(isOpen).toBe(true);
|
||||
|
||||
// Modal headline should match article headline
|
||||
const modal = page.locator(SELECTORS.summaryModal.root);
|
||||
const articleHeadline = await firstArticle.locator("h3").textContent();
|
||||
const modalHeadline = await modal
|
||||
.locator(SELECTORS.summaryModal.headline)
|
||||
.textContent();
|
||||
expect(modalHeadline).toBe(articleHeadline);
|
||||
});
|
||||
|
||||
test("closes summary modal via close button @smoke", async ({
|
||||
page,
|
||||
waitForHero,
|
||||
isSummaryModalOpen,
|
||||
closeSummaryModal,
|
||||
}) => {
|
||||
// Open modal from hero
|
||||
const hero = await waitForHero();
|
||||
await hero.locator(SELECTORS.hero.readButton).click();
|
||||
|
||||
// Verify modal is open
|
||||
expect(await isSummaryModalOpen()).toBe(true);
|
||||
|
||||
// Close modal
|
||||
await closeSummaryModal();
|
||||
|
||||
// Verify modal is closed
|
||||
expect(await isSummaryModalOpen()).toBe(false);
|
||||
});
|
||||
|
||||
test("closes summary modal via backdrop click", async ({
|
||||
page,
|
||||
waitForHero,
|
||||
isSummaryModalOpen,
|
||||
}) => {
|
||||
// Open modal from hero
|
||||
const hero = await waitForHero();
|
||||
await hero.locator(SELECTORS.hero.readButton).click();
|
||||
|
||||
// Verify modal is open
|
||||
expect(await isSummaryModalOpen()).toBe(true);
|
||||
|
||||
// Click backdrop (outside modal content)
|
||||
const backdrop = page.locator(".fixed.inset-0.bg-black\\/70").first();
|
||||
await backdrop.click();
|
||||
|
||||
// Verify modal is closed
|
||||
await page.waitForTimeout(500);
|
||||
expect(await isSummaryModalOpen()).toBe(false);
|
||||
});
|
||||
|
||||
test("closes summary modal via escape key @smoke", async ({
|
||||
page,
|
||||
waitForHero,
|
||||
isSummaryModalOpen,
|
||||
}) => {
|
||||
// Open modal from hero
|
||||
const hero = await waitForHero();
|
||||
await hero.locator(SELECTORS.hero.readButton).click();
|
||||
|
||||
// Verify modal is open
|
||||
expect(await isSummaryModalOpen()).toBe(true);
|
||||
|
||||
// Press escape
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// Verify modal is closed
|
||||
await page.waitForTimeout(500);
|
||||
expect(await isSummaryModalOpen()).toBe(false);
|
||||
});
|
||||
|
||||
test("modal displays correct content sections", async ({
|
||||
page,
|
||||
waitForHero,
|
||||
}) => {
|
||||
// Open modal from hero
|
||||
const hero = await waitForHero();
|
||||
await hero.locator(SELECTORS.hero.readButton).click();
|
||||
|
||||
const modal = page.locator(SELECTORS.summaryModal.root);
|
||||
|
||||
// Check all required sections are present
|
||||
await expect(modal.locator(SELECTORS.summaryModal.headline)).toBeVisible();
|
||||
await expect(modal.locator(SELECTORS.summaryModal.image)).toBeVisible();
|
||||
await expect(
|
||||
modal.locator(SELECTORS.summaryModal.tldrSection),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
modal.locator(SELECTORS.summaryModal.summarySection),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
modal.locator(SELECTORS.summaryModal.sourceSection),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
modal.locator(SELECTORS.summaryModal.shareSection),
|
||||
).toBeVisible();
|
||||
await expect(modal.locator(SELECTORS.summaryModal.poweredBy)).toBeVisible();
|
||||
|
||||
// Check TL;DR list has items
|
||||
const tldrList = modal.locator(SELECTORS.summaryModal.tldrList);
|
||||
const tldrItems = await tldrList.locator("li").count();
|
||||
expect(tldrItems).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Check summary body has content
|
||||
const summaryBody = modal.locator(SELECTORS.summaryModal.summaryBody);
|
||||
await expect(summaryBody).not.toBeEmpty();
|
||||
|
||||
// Check source link is present
|
||||
const sourceLink = modal.locator(SELECTORS.summaryModal.sourceLink);
|
||||
await expect(sourceLink).toBeVisible();
|
||||
await expect(sourceLink).toHaveAttribute("target", "_blank");
|
||||
});
|
||||
|
||||
test("modal share controls are present and accessible", async ({
|
||||
page,
|
||||
waitForHero,
|
||||
}) => {
|
||||
// Open modal from hero
|
||||
const hero = await waitForHero();
|
||||
await hero.locator(SELECTORS.hero.readButton).click();
|
||||
|
||||
const modal = page.locator(SELECTORS.summaryModal.root);
|
||||
|
||||
// Check all share buttons are present
|
||||
await expect(modal.locator(SELECTORS.summaryModal.shareX)).toBeVisible();
|
||||
await expect(
|
||||
modal.locator(SELECTORS.summaryModal.shareWhatsApp),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
modal.locator(SELECTORS.summaryModal.shareLinkedIn),
|
||||
).toBeVisible();
|
||||
await expect(modal.locator(SELECTORS.summaryModal.shareCopy)).toBeVisible();
|
||||
|
||||
// Check accessible labels
|
||||
await expect(modal.locator(SELECTORS.summaryModal.shareX)).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Share on X",
|
||||
);
|
||||
await expect(
|
||||
modal.locator(SELECTORS.summaryModal.shareWhatsApp),
|
||||
).toHaveAttribute("aria-label", "Share on WhatsApp");
|
||||
await expect(
|
||||
modal.locator(SELECTORS.summaryModal.shareLinkedIn),
|
||||
).toHaveAttribute("aria-label", "Share on LinkedIn");
|
||||
await expect(
|
||||
modal.locator(SELECTORS.summaryModal.shareCopy),
|
||||
).toHaveAttribute("aria-label", "Copy article link");
|
||||
});
|
||||
|
||||
test("modal returns to feed context after closing", async ({
|
||||
page,
|
||||
waitForHero,
|
||||
waitForFeed,
|
||||
isSummaryModalOpen,
|
||||
}) => {
|
||||
// Wait for feed to be visible
|
||||
await waitForFeed();
|
||||
|
||||
// Open modal from hero
|
||||
const hero = await waitForHero();
|
||||
await hero.locator(SELECTORS.hero.readButton).click();
|
||||
|
||||
// Close modal
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify modal is closed
|
||||
expect(await isSummaryModalOpen()).toBe(false);
|
||||
|
||||
// Verify we're still on the same page (no navigation occurred)
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
|
||||
// Verify feed is still visible
|
||||
const feed = page.locator(SELECTORS.feed.root);
|
||||
await expect(feed).toBeVisible();
|
||||
});
|
||||
});
|
||||
139
e2e/tests/capabilities/microinteractions/footer.spec.ts
Normal file
139
e2e/tests/capabilities/microinteractions/footer.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { SELECTORS } from "../../fixtures/selectors";
|
||||
import { expect, test } from "../../fixtures/test";
|
||||
|
||||
test.describe("Footer Link Rendering @smoke", () => {
|
||||
test.beforeEach(async ({ gotoApp, waitForAppReady }) => {
|
||||
await gotoApp();
|
||||
await waitForAppReady();
|
||||
});
|
||||
|
||||
test("footer renders GitHub link when configured", async ({ page }) => {
|
||||
const githubLink = page.locator(SELECTORS.footer.githubLink);
|
||||
|
||||
// Check if GitHub link exists (may or may not be present based on config)
|
||||
const count = await githubLink.count();
|
||||
|
||||
if (count > 0) {
|
||||
// If present, should be visible and have correct attributes
|
||||
await expect(githubLink).toBeVisible();
|
||||
await expect(githubLink).toHaveAttribute("href");
|
||||
await expect(githubLink).toHaveAttribute("target", "_blank");
|
||||
await expect(githubLink).toHaveAttribute("rel", "noopener");
|
||||
|
||||
// Should link to GitHub
|
||||
const href = await githubLink.getAttribute("href");
|
||||
expect(href).toContain("github.com");
|
||||
}
|
||||
});
|
||||
|
||||
test("footer renders contact email link when configured", async ({
|
||||
page,
|
||||
}) => {
|
||||
const contactLink = page.locator(SELECTORS.footer.contactLink);
|
||||
|
||||
// Check if contact link exists (may or may not be present based on config)
|
||||
const count = await contactLink.count();
|
||||
|
||||
if (count > 0) {
|
||||
// If present, should be visible and have correct attributes
|
||||
await expect(contactLink).toBeVisible();
|
||||
await expect(contactLink).toHaveAttribute("href");
|
||||
|
||||
// Should be mailto link
|
||||
const href = await contactLink.getAttribute("href");
|
||||
expect(href).toMatch(/^mailto:/);
|
||||
|
||||
// Should have email text
|
||||
const text = await contactLink.textContent();
|
||||
expect(text).toContain("@");
|
||||
}
|
||||
});
|
||||
|
||||
test("footer layout is stable regardless of link configuration", async ({
|
||||
page,
|
||||
}) => {
|
||||
const footer = page.locator(SELECTORS.footer.root);
|
||||
|
||||
// Footer should always be visible
|
||||
await expect(footer).toBeVisible();
|
||||
|
||||
// Footer should have consistent structure
|
||||
const poweredBy = footer.locator(SELECTORS.footer.poweredBy);
|
||||
await expect(poweredBy).toBeVisible();
|
||||
|
||||
const termsLink = footer.locator(SELECTORS.footer.termsLink);
|
||||
await expect(termsLink).toBeVisible();
|
||||
|
||||
const attributionLink = footer.locator(SELECTORS.footer.attributionLink);
|
||||
await expect(attributionLink).toBeVisible();
|
||||
|
||||
const copyright = footer.locator("text=All rights reserved");
|
||||
await expect(copyright).toBeVisible();
|
||||
});
|
||||
|
||||
test("footer links are interactive", async ({ page }) => {
|
||||
// Terms link should open modal
|
||||
const termsLink = page.locator(SELECTORS.footer.termsLink);
|
||||
await termsLink.click();
|
||||
|
||||
const termsModal = page.locator(SELECTORS.policyModal.root);
|
||||
await expect(termsModal).toBeVisible();
|
||||
await expect(
|
||||
termsModal.locator(SELECTORS.policyModal.termsTitle),
|
||||
).toBeVisible();
|
||||
|
||||
// Close modal
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Attribution link should open modal
|
||||
const attributionLink = page.locator(SELECTORS.footer.attributionLink);
|
||||
await attributionLink.click();
|
||||
|
||||
const attributionModal = page.locator(SELECTORS.policyModal.root);
|
||||
await expect(attributionModal).toBeVisible();
|
||||
await expect(
|
||||
attributionModal.locator(SELECTORS.policyModal.attributionTitle),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("footer links have proper accessible names", async ({ page }) => {
|
||||
// Terms link
|
||||
const termsLink = page.locator(SELECTORS.footer.termsLink);
|
||||
const termsText = await termsLink.textContent();
|
||||
expect(termsText?.toLowerCase()).toContain("terms");
|
||||
|
||||
// Attribution link
|
||||
const attributionLink = page.locator(SELECTORS.footer.attributionLink);
|
||||
const attributionText = await attributionLink.textContent();
|
||||
expect(attributionText?.toLowerCase()).toContain("attribution");
|
||||
|
||||
// GitHub link (if present)
|
||||
const githubLink = page.locator(SELECTORS.footer.githubLink);
|
||||
const githubCount = await githubLink.count();
|
||||
if (githubCount > 0) {
|
||||
const githubText = await githubLink.textContent();
|
||||
expect(githubText?.toLowerCase()).toContain("github");
|
||||
}
|
||||
});
|
||||
|
||||
test("footer is responsive across viewports", async ({
|
||||
page,
|
||||
setViewport,
|
||||
}) => {
|
||||
const viewports = ["mobile", "tablet", "desktop"] as const;
|
||||
|
||||
for (const viewport of viewports) {
|
||||
await setViewport(viewport);
|
||||
|
||||
const footer = page.locator(SELECTORS.footer.root);
|
||||
await expect(footer).toBeVisible();
|
||||
|
||||
// Footer should not overflow
|
||||
const footerBox = await footer.boundingBox();
|
||||
const viewportWidth = await page.evaluate(() => window.innerWidth);
|
||||
|
||||
expect(footerBox!.width).toBeLessThanOrEqual(viewportWidth);
|
||||
}
|
||||
});
|
||||
});
|
||||
198
e2e/tests/capabilities/microinteractions/tooltip.spec.ts
Normal file
198
e2e/tests/capabilities/microinteractions/tooltip.spec.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { SELECTORS } from "../../fixtures/selectors";
|
||||
import { expect, test } from "../../fixtures/test";
|
||||
|
||||
test.describe("Contact Email Tooltip @smoke", () => {
|
||||
test.beforeEach(async ({ gotoApp, waitForAppReady }) => {
|
||||
await gotoApp();
|
||||
await waitForAppReady();
|
||||
});
|
||||
|
||||
test("tooltip appears on mouse hover", async ({ page }) => {
|
||||
const contactLink = page.locator(SELECTORS.footer.contactLink);
|
||||
|
||||
// Check if contact link exists
|
||||
const count = await contactLink.count();
|
||||
if (count === 0) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Hover over contact link
|
||||
await contactLink.hover();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Tooltip should appear
|
||||
const tooltip = page.locator(SELECTORS.footer.contactHint);
|
||||
await expect(tooltip).toBeVisible();
|
||||
|
||||
// Tooltip should have text
|
||||
const tooltipText = await tooltip.textContent();
|
||||
expect(tooltipText).toBeTruthy();
|
||||
expect(tooltipText!.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("tooltip disappears on mouse leave", async ({ page }) => {
|
||||
const contactLink = page.locator(SELECTORS.footer.contactLink);
|
||||
|
||||
// Check if contact link exists
|
||||
const count = await contactLink.count();
|
||||
if (count === 0) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Hover over contact link
|
||||
await contactLink.hover();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const tooltip = page.locator(SELECTORS.footer.contactHint);
|
||||
await expect(tooltip).toBeVisible();
|
||||
|
||||
// Move mouse away
|
||||
await page.mouse.move(0, 0);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Tooltip should disappear
|
||||
await expect(tooltip).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("tooltip follows mouse movement", async ({ page }) => {
|
||||
const contactLink = page.locator(SELECTORS.footer.contactLink);
|
||||
|
||||
// Check if contact link exists
|
||||
const count = await contactLink.count();
|
||||
if (count === 0) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Hover over contact link
|
||||
await contactLink.hover();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const tooltip = page.locator(SELECTORS.footer.contactHint);
|
||||
await expect(tooltip).toBeVisible();
|
||||
|
||||
// Get initial position
|
||||
const initialBox = await tooltip.boundingBox();
|
||||
|
||||
// Move mouse slightly
|
||||
const linkBox = await contactLink.boundingBox();
|
||||
await page.mouse.move(
|
||||
linkBox!.x + linkBox!.width / 2 + 20,
|
||||
linkBox!.y + linkBox!.height / 2,
|
||||
);
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Tooltip should still be visible
|
||||
await expect(tooltip).toBeVisible();
|
||||
});
|
||||
|
||||
test("tooltip appears on keyboard focus", async ({ page }) => {
|
||||
const contactLink = page.locator(SELECTORS.footer.contactLink);
|
||||
|
||||
// Check if contact link exists
|
||||
const count = await contactLink.count();
|
||||
if (count === 0) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Focus contact link
|
||||
await contactLink.focus();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Tooltip should appear
|
||||
const tooltip = page.locator(SELECTORS.footer.contactHint);
|
||||
await expect(tooltip).toBeVisible();
|
||||
});
|
||||
|
||||
test("tooltip disappears on keyboard blur", async ({ page }) => {
|
||||
const contactLink = page.locator(SELECTORS.footer.contactLink);
|
||||
|
||||
// Check if contact link exists
|
||||
const count = await contactLink.count();
|
||||
if (count === 0) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Focus contact link
|
||||
await contactLink.focus();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const tooltip = page.locator(SELECTORS.footer.contactHint);
|
||||
await expect(tooltip).toBeVisible();
|
||||
|
||||
// Blur contact link
|
||||
await page.evaluate(() => (document.activeElement as HTMLElement)?.blur());
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Tooltip should disappear
|
||||
await expect(tooltip).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("tooltip content is safe and appropriate", async ({ page }) => {
|
||||
const contactLink = page.locator(SELECTORS.footer.contactLink);
|
||||
|
||||
// Check if contact link exists
|
||||
const count = await contactLink.count();
|
||||
if (count === 0) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Hover to show tooltip
|
||||
await contactLink.hover();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const tooltip = page.locator(SELECTORS.footer.contactHint);
|
||||
const tooltipText = await tooltip.textContent();
|
||||
|
||||
// Should not contain inappropriate content
|
||||
const inappropriateWords = [
|
||||
"profanity",
|
||||
"offensive",
|
||||
"racist",
|
||||
"sexist",
|
||||
"misogynistic",
|
||||
];
|
||||
for (const word of inappropriateWords) {
|
||||
expect(tooltipText?.toLowerCase()).not.toContain(word);
|
||||
}
|
||||
|
||||
// Should contain helpful text
|
||||
expect(tooltipText).toBeTruthy();
|
||||
expect(tooltipText!.length).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
test("tooltip does not trap focus", async ({ page }) => {
|
||||
const contactLink = page.locator(SELECTORS.footer.contactLink);
|
||||
|
||||
// Check if contact link exists
|
||||
const count = await contactLink.count();
|
||||
if (count === 0) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Focus contact link
|
||||
await contactLink.focus();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Tooltip should be visible
|
||||
const tooltip = page.locator(SELECTORS.footer.contactHint);
|
||||
await expect(tooltip).toBeVisible();
|
||||
|
||||
// Tab away
|
||||
await page.keyboard.press("Tab");
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Tooltip should disappear
|
||||
await expect(tooltip).not.toBeVisible();
|
||||
|
||||
// Focus should have moved
|
||||
const isStillFocused = await contactLink.isFocused();
|
||||
expect(isStillFocused).toBe(false);
|
||||
});
|
||||
});
|
||||
191
e2e/tests/capabilities/modal-experience/deep-link.spec.ts
Normal file
191
e2e/tests/capabilities/modal-experience/deep-link.spec.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { SELECTORS } from "../../fixtures/selectors";
|
||||
import { expect, test } from "../../fixtures/test";
|
||||
|
||||
test.describe("Deep Link Permalink Tests @smoke", () => {
|
||||
test.beforeEach(async ({ waitForAppReady }) => {
|
||||
await waitForAppReady();
|
||||
});
|
||||
|
||||
test("valid article permalink opens modal automatically", async ({
|
||||
page,
|
||||
gotoApp,
|
||||
isSummaryModalOpen,
|
||||
}) => {
|
||||
// First get an article ID from the feed
|
||||
await gotoApp();
|
||||
await page.waitForSelector(SELECTORS.feed.articles, { timeout: 10000 });
|
||||
|
||||
const firstArticle = page.locator(SELECTORS.feed.articles).first();
|
||||
const articleId = await firstArticle
|
||||
.getAttribute("id")
|
||||
.then((id) => (id ? parseInt(id.replace("news-", "")) : null));
|
||||
|
||||
expect(articleId).not.toBeNull();
|
||||
|
||||
// Navigate to article permalink
|
||||
await gotoApp({ articleId: articleId! });
|
||||
await page.waitForTimeout(2000); // Wait for modal to open
|
||||
|
||||
// Modal should be open
|
||||
const isOpen = await isSummaryModalOpen();
|
||||
expect(isOpen).toBe(true);
|
||||
|
||||
// Modal should show correct article
|
||||
const modal = page.locator(SELECTORS.summaryModal.root);
|
||||
const modalHeadline = await modal
|
||||
.locator(SELECTORS.summaryModal.headline)
|
||||
.textContent();
|
||||
const articleHeadline = await firstArticle.locator("h3").textContent();
|
||||
expect(modalHeadline).toBe(articleHeadline);
|
||||
});
|
||||
|
||||
test("invalid article permalink shows error state", async ({
|
||||
page,
|
||||
gotoApp,
|
||||
}) => {
|
||||
// Navigate to invalid article ID
|
||||
await gotoApp({ articleId: 999999 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Should not show summary modal
|
||||
const modal = page.locator(SELECTORS.summaryModal.root);
|
||||
await expect(modal).not.toBeVisible();
|
||||
|
||||
// Should still show the page (not crash)
|
||||
const hero = page.locator(SELECTORS.hero.root);
|
||||
const feed = page.locator(SELECTORS.feed.root);
|
||||
|
||||
const heroVisible = await hero.isVisible().catch(() => false);
|
||||
const feedVisible = await feed.isVisible().catch(() => false);
|
||||
|
||||
expect(heroVisible || feedVisible).toBe(true);
|
||||
});
|
||||
|
||||
test("hero-origin modal flow via permalink", async ({
|
||||
page,
|
||||
gotoApp,
|
||||
isSummaryModalOpen,
|
||||
}) => {
|
||||
// Get hero article ID
|
||||
await gotoApp();
|
||||
await page.waitForSelector(SELECTORS.hero.root, { timeout: 10000 });
|
||||
|
||||
const hero = page.locator(SELECTORS.hero.root);
|
||||
const heroId = await hero
|
||||
.getAttribute("id")
|
||||
.then((id) => (id ? parseInt(id.replace("news-", "")) : null));
|
||||
|
||||
expect(heroId).not.toBeNull();
|
||||
|
||||
// Navigate directly to hero article
|
||||
await gotoApp({ articleId: heroId! });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Modal should open
|
||||
const isOpen = await isSummaryModalOpen();
|
||||
expect(isOpen).toBe(true);
|
||||
|
||||
// Modal should show hero article content
|
||||
const modal = page.locator(SELECTORS.summaryModal.root);
|
||||
const modalHeadline = await modal
|
||||
.locator(SELECTORS.summaryModal.headline)
|
||||
.textContent();
|
||||
const heroHeadline = await hero.locator("h1").textContent();
|
||||
expect(modalHeadline).toBe(heroHeadline);
|
||||
});
|
||||
|
||||
test("closing permalink modal updates URL", async ({
|
||||
page,
|
||||
gotoApp,
|
||||
isSummaryModalOpen,
|
||||
}) => {
|
||||
// Open via permalink
|
||||
await gotoApp({ articleId: 1 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// URL should have article parameter
|
||||
await expect(page).toHaveURL(/\?article=\d+/);
|
||||
|
||||
// Close modal
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// URL should be cleaned up (parameter removed)
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
await expect(page).not.toHaveURL(/\?article=/);
|
||||
});
|
||||
|
||||
test("modal state persists on page refresh", async ({
|
||||
page,
|
||||
gotoApp,
|
||||
isSummaryModalOpen,
|
||||
}) => {
|
||||
// Open via permalink
|
||||
await gotoApp({ articleId: 1 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Verify modal is open
|
||||
expect(await isSummaryModalOpen()).toBe(true);
|
||||
|
||||
// Refresh page
|
||||
await page.reload();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Modal should still be open
|
||||
expect(await isSummaryModalOpen()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Policy Modal Deep Links", () => {
|
||||
test("terms policy modal opens via URL parameter", async ({
|
||||
page,
|
||||
gotoApp,
|
||||
}) => {
|
||||
await gotoApp({ policy: "terms" });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Policy modal should be visible
|
||||
const modal = page.locator(SELECTORS.policyModal.root);
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// Should show terms title
|
||||
const title = modal.locator(SELECTORS.policyModal.termsTitle);
|
||||
await expect(title).toBeVisible();
|
||||
});
|
||||
|
||||
test("attribution policy modal opens via URL parameter", async ({
|
||||
page,
|
||||
gotoApp,
|
||||
}) => {
|
||||
await gotoApp({ policy: "attribution" });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Policy modal should be visible
|
||||
const modal = page.locator(SELECTORS.policyModal.root);
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// Should show attribution title
|
||||
const title = modal.locator(SELECTORS.policyModal.attributionTitle);
|
||||
await expect(title).toBeVisible();
|
||||
});
|
||||
|
||||
test("closing policy modal clears URL parameter", async ({
|
||||
page,
|
||||
gotoApp,
|
||||
}) => {
|
||||
await gotoApp({ policy: "terms" });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// URL should have policy parameter
|
||||
await expect(page).toHaveURL(/\?policy=terms/);
|
||||
|
||||
// Close modal
|
||||
const modal = page.locator(SELECTORS.policyModal.root);
|
||||
await modal.locator(SELECTORS.policyModal.closeButton).click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// URL should be cleaned up
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
await expect(page).not.toHaveURL(/\?policy=/);
|
||||
});
|
||||
});
|
||||
193
e2e/tests/capabilities/modal-experience/share.spec.ts
Normal file
193
e2e/tests/capabilities/modal-experience/share.spec.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { SELECTORS } from "../../fixtures/selectors";
|
||||
import { expect, test } from "../../fixtures/test";
|
||||
|
||||
test.describe("Source CTA and Share Interactions", () => {
|
||||
test.beforeEach(async ({ gotoApp, waitForAppReady, waitForHero }) => {
|
||||
await gotoApp();
|
||||
await waitForAppReady();
|
||||
|
||||
// Open modal from hero
|
||||
const hero = await waitForHero();
|
||||
await hero.locator(SELECTORS.hero.readButton).click();
|
||||
await expect(page.locator(SELECTORS.summaryModal.root)).toBeVisible();
|
||||
});
|
||||
|
||||
let page: any;
|
||||
|
||||
test("source link opens in new tab @smoke", async ({ page: p, context }) => {
|
||||
page = p;
|
||||
const modal = page.locator(SELECTORS.summaryModal.root);
|
||||
const sourceLink = modal.locator(SELECTORS.summaryModal.sourceLink);
|
||||
|
||||
// Check link attributes
|
||||
await expect(sourceLink).toHaveAttribute("target", "_blank");
|
||||
await expect(sourceLink).toHaveAttribute("rel", "noopener");
|
||||
await expect(sourceLink).toHaveAttribute("href");
|
||||
|
||||
// Click should open new tab
|
||||
const [newPage] = await Promise.all([
|
||||
context.waitForEvent("page"),
|
||||
sourceLink.click(),
|
||||
]);
|
||||
|
||||
// New page should have loaded
|
||||
expect(newPage).toBeDefined();
|
||||
await newPage.close();
|
||||
|
||||
// Modal should remain open
|
||||
await expect(modal).toBeVisible();
|
||||
});
|
||||
|
||||
test("share on X opens correct URL", async ({ page: p, context }) => {
|
||||
page = p;
|
||||
const modal = page.locator(SELECTORS.summaryModal.root);
|
||||
const shareX = modal.locator(SELECTORS.summaryModal.shareX);
|
||||
|
||||
// Get article headline for URL verification
|
||||
const headline = await modal
|
||||
.locator(SELECTORS.summaryModal.headline)
|
||||
.textContent();
|
||||
|
||||
// Click should open X share in new tab
|
||||
const [newPage] = await Promise.all([
|
||||
context.waitForEvent("page"),
|
||||
shareX.click(),
|
||||
]);
|
||||
|
||||
// Verify URL contains X intent
|
||||
const url = newPage.url();
|
||||
expect(url).toContain("x.com/intent/tweet");
|
||||
expect(url).toContain(encodeURIComponent(headline || ""));
|
||||
|
||||
await newPage.close();
|
||||
});
|
||||
|
||||
test("share on WhatsApp opens correct URL", async ({ page: p, context }) => {
|
||||
page = p;
|
||||
const modal = page.locator(SELECTORS.summaryModal.root);
|
||||
const shareWhatsApp = modal.locator(SELECTORS.summaryModal.shareWhatsApp);
|
||||
|
||||
// Get article headline for URL verification
|
||||
const headline = await modal
|
||||
.locator(SELECTORS.summaryModal.headline)
|
||||
.textContent();
|
||||
|
||||
// Click should open WhatsApp share in new tab
|
||||
const [newPage] = await Promise.all([
|
||||
context.waitForEvent("page"),
|
||||
shareWhatsApp.click(),
|
||||
]);
|
||||
|
||||
// Verify URL contains WhatsApp share
|
||||
const url = newPage.url();
|
||||
expect(url).toContain("wa.me");
|
||||
expect(url).toContain(encodeURIComponent(headline || ""));
|
||||
|
||||
await newPage.close();
|
||||
});
|
||||
|
||||
test("share on LinkedIn opens correct URL", async ({ page: p, context }) => {
|
||||
page = p;
|
||||
const modal = page.locator(SELECTORS.summaryModal.root);
|
||||
const shareLinkedIn = modal.locator(SELECTORS.summaryModal.shareLinkedIn);
|
||||
|
||||
// Click should open LinkedIn share in new tab
|
||||
const [newPage] = await Promise.all([
|
||||
context.waitForEvent("page"),
|
||||
shareLinkedIn.click(),
|
||||
]);
|
||||
|
||||
// Verify URL contains LinkedIn share
|
||||
const url = newPage.url();
|
||||
expect(url).toContain("linkedin.com/sharing");
|
||||
|
||||
await newPage.close();
|
||||
});
|
||||
|
||||
test("copy link button copies permalink to clipboard @smoke", async ({
|
||||
page: p,
|
||||
context,
|
||||
}) => {
|
||||
page = p;
|
||||
const modal = page.locator(SELECTORS.summaryModal.root);
|
||||
const copyButton = modal.locator(SELECTORS.summaryModal.shareCopy);
|
||||
|
||||
// Grant clipboard permissions
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
|
||||
// Click copy button
|
||||
await copyButton.click();
|
||||
|
||||
// Wait for success message
|
||||
const successMessage = modal.locator(SELECTORS.summaryModal.copySuccess);
|
||||
await expect(successMessage).toBeVisible();
|
||||
await expect(successMessage).toContainText("Permalink copied");
|
||||
|
||||
// Verify clipboard content
|
||||
const clipboardContent = await page.evaluate(() =>
|
||||
navigator.clipboard.readText(),
|
||||
);
|
||||
expect(clipboardContent).toContain("/?article=");
|
||||
expect(clipboardContent).toMatch(/http/);
|
||||
});
|
||||
|
||||
test("copy link does not navigate away", async ({ page: p }) => {
|
||||
page = p;
|
||||
const modal = page.locator(SELECTORS.summaryModal.root);
|
||||
const copyButton = modal.locator(SELECTORS.summaryModal.shareCopy);
|
||||
|
||||
// Get current URL
|
||||
const currentUrl = page.url();
|
||||
|
||||
// Click copy button
|
||||
await copyButton.click();
|
||||
|
||||
// Wait a moment
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// URL should not have changed
|
||||
await expect(page).toHaveURL(currentUrl);
|
||||
|
||||
// Modal should still be open
|
||||
await expect(modal).toBeVisible();
|
||||
});
|
||||
|
||||
test("navigation is preserved after share interactions", async ({
|
||||
page: p,
|
||||
context,
|
||||
}) => {
|
||||
page = p;
|
||||
const modal = page.locator(SELECTORS.summaryModal.root);
|
||||
|
||||
// Interact with multiple share buttons
|
||||
const shareButtons = [
|
||||
modal.locator(SELECTORS.summaryModal.shareX),
|
||||
modal.locator(SELECTORS.summaryModal.shareWhatsApp),
|
||||
modal.locator(SELECTORS.summaryModal.shareLinkedIn),
|
||||
];
|
||||
|
||||
for (const button of shareButtons) {
|
||||
if (await button.isVisible()) {
|
||||
const [newPage] = await Promise.all([
|
||||
context.waitForEvent("page"),
|
||||
button.click(),
|
||||
]);
|
||||
await newPage.close();
|
||||
|
||||
// Modal should remain open after each interaction
|
||||
await expect(modal).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
// Close modal
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Should be back on main page without navigation
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
|
||||
// Feed should be visible
|
||||
const feed = page.locator(SELECTORS.feed.root);
|
||||
await expect(feed).toBeVisible();
|
||||
});
|
||||
});
|
||||
233
e2e/tests/capabilities/responsive/breakpoints.spec.ts
Normal file
233
e2e/tests/capabilities/responsive/breakpoints.spec.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { SELECTORS } from "../../fixtures/selectors";
|
||||
import { expect, test } from "../../fixtures/test";
|
||||
import {
|
||||
hasHorizontalOverflow,
|
||||
isClipped,
|
||||
VIEWPORT_SIZES,
|
||||
type ViewportSize,
|
||||
} from "../../fixtures/viewports";
|
||||
|
||||
test.describe("Responsive Breakpoint Tests @smoke", () => {
|
||||
for (const viewport of VIEWPORT_SIZES) {
|
||||
test.describe(`${viewport} viewport`, () => {
|
||||
test.beforeEach(async ({ gotoApp, waitForAppReady, setViewport }) => {
|
||||
await setViewport(viewport);
|
||||
await gotoApp();
|
||||
await waitForAppReady();
|
||||
});
|
||||
|
||||
test("page has no horizontal overflow", async ({ page }) => {
|
||||
// Check body for overflow
|
||||
const bodyOverflow = await hasHorizontalOverflow(page, "body");
|
||||
expect(bodyOverflow).toBe(false);
|
||||
|
||||
// Check main content
|
||||
const mainOverflow = await hasHorizontalOverflow(page, "main");
|
||||
expect(mainOverflow).toBe(false);
|
||||
|
||||
// Check hero section
|
||||
const heroOverflow = await hasHorizontalOverflow(
|
||||
page,
|
||||
SELECTORS.hero.root,
|
||||
);
|
||||
expect(heroOverflow).toBe(false);
|
||||
|
||||
// Check feed section
|
||||
const feedOverflow = await hasHorizontalOverflow(
|
||||
page,
|
||||
SELECTORS.feed.root,
|
||||
);
|
||||
expect(feedOverflow).toBe(false);
|
||||
});
|
||||
|
||||
test("hero section is not clipped", async ({ page, waitForHero }) => {
|
||||
const hero = await waitForHero();
|
||||
const isHeroClipped = await isClipped(page, SELECTORS.hero.root);
|
||||
expect(isHeroClipped).toBe(false);
|
||||
});
|
||||
|
||||
test("feed articles are not clipped", async ({ page, waitForFeed }) => {
|
||||
const feed = await waitForFeed();
|
||||
const isFeedClipped = await isClipped(page, SELECTORS.feed.root);
|
||||
expect(isFeedClipped).toBe(false);
|
||||
});
|
||||
|
||||
test("modal fits within viewport", async ({
|
||||
page,
|
||||
waitForHero,
|
||||
setViewport,
|
||||
}) => {
|
||||
// Open modal
|
||||
const hero = await waitForHero();
|
||||
await hero.locator(SELECTORS.hero.readButton).click();
|
||||
|
||||
const modal = page.locator(SELECTORS.summaryModal.root);
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// Get viewport dimensions
|
||||
const viewport = await page.evaluate(() => ({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
}));
|
||||
|
||||
// Get modal dimensions
|
||||
const modalBox = await modal.boundingBox();
|
||||
expect(modalBox).not.toBeNull();
|
||||
|
||||
// Modal should fit within viewport (with some padding)
|
||||
expect(modalBox!.width).toBeLessThanOrEqual(viewport.width);
|
||||
expect(modalBox!.height).toBeLessThanOrEqual(viewport.height * 0.96); // max-h-[96vh]
|
||||
});
|
||||
|
||||
test("interactive controls are reachable", async ({
|
||||
page,
|
||||
waitForFeed,
|
||||
}) => {
|
||||
const feed = await waitForFeed();
|
||||
const firstArticle = feed.locator(SELECTORS.feed.articles).first();
|
||||
|
||||
// Check read button is visible and clickable
|
||||
const readButton = firstArticle.locator(
|
||||
SELECTORS.feed.articleReadButton,
|
||||
);
|
||||
await expect(readButton).toBeVisible();
|
||||
await expect(readButton).toBeEnabled();
|
||||
|
||||
// Check source link is visible if present
|
||||
const sourceLink = firstArticle.locator(SELECTORS.feed.articleSource);
|
||||
const hasSourceLink = (await sourceLink.count()) > 0;
|
||||
if (hasSourceLink) {
|
||||
await expect(sourceLink).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("header controls remain accessible", async ({ page }) => {
|
||||
// Check logo is visible
|
||||
const logo = page.locator(SELECTORS.header.logo);
|
||||
await expect(logo).toBeVisible();
|
||||
|
||||
// Check theme button is visible
|
||||
const themeButton = page.locator(SELECTORS.header.themeMenuButton);
|
||||
await expect(themeButton).toBeVisible();
|
||||
await expect(themeButton).toBeEnabled();
|
||||
|
||||
// Check language select is visible (may be hidden on very small screens)
|
||||
const languageSelect = page.locator(SELECTORS.header.languageSelect);
|
||||
const isVisible = await languageSelect.isVisible().catch(() => false);
|
||||
|
||||
if (isVisible) {
|
||||
await expect(languageSelect).toBeEnabled();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("Responsive Layout Adaptations", () => {
|
||||
test("mobile shows single column feed", async ({
|
||||
gotoApp,
|
||||
waitForAppReady,
|
||||
setViewport,
|
||||
waitForFeed,
|
||||
}) => {
|
||||
await setViewport("mobile");
|
||||
await gotoApp();
|
||||
await waitForAppReady();
|
||||
|
||||
const feed = await waitForFeed();
|
||||
const articles = feed.locator(SELECTORS.feed.articles);
|
||||
|
||||
// Articles should be in single column (full width)
|
||||
const firstArticle = articles.first();
|
||||
const articleBox = await firstArticle.boundingBox();
|
||||
|
||||
// Get feed container width
|
||||
const feedBox = await feed.boundingBox();
|
||||
|
||||
// Article should take most of the width (single column)
|
||||
expect(articleBox!.width).toBeGreaterThan(feedBox!.width * 0.8);
|
||||
});
|
||||
|
||||
test("tablet shows appropriate layout", async ({
|
||||
gotoApp,
|
||||
waitForAppReady,
|
||||
setViewport,
|
||||
waitForFeed,
|
||||
}) => {
|
||||
await setViewport("tablet");
|
||||
await gotoApp();
|
||||
await waitForAppReady();
|
||||
|
||||
const feed = await waitForFeed();
|
||||
const articles = feed.locator(SELECTORS.feed.articles);
|
||||
|
||||
// Should have multiple articles visible
|
||||
const count = await articles.count();
|
||||
expect(count).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Articles should be side by side (multi-column)
|
||||
const firstArticle = articles.first();
|
||||
const secondArticle = articles.nth(1);
|
||||
|
||||
const firstBox = await firstArticle.boundingBox();
|
||||
const secondBox = await secondArticle.boundingBox();
|
||||
|
||||
// Second article should be to the right of first (or below in some layouts)
|
||||
expect(secondBox!.x).not.toBe(firstBox!.x);
|
||||
});
|
||||
|
||||
test("desktop shows multi-column feed", async ({
|
||||
gotoApp,
|
||||
waitForAppReady,
|
||||
setViewport,
|
||||
waitForFeed,
|
||||
}) => {
|
||||
await setViewport("desktop");
|
||||
await gotoApp();
|
||||
await waitForAppReady();
|
||||
|
||||
const feed = await waitForFeed();
|
||||
const articles = feed.locator(SELECTORS.feed.articles);
|
||||
|
||||
// Should have multiple articles in a row
|
||||
const count = await articles.count();
|
||||
expect(count).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// First three articles should be in a row
|
||||
const articleBoxes = await articles
|
||||
.slice(0, 3)
|
||||
.evaluateAll((els) => els.map((el) => el.getBoundingClientRect()));
|
||||
|
||||
// Articles should be at different x positions (side by side)
|
||||
const xPositions = articleBoxes.map((box) => box.x);
|
||||
const uniqueXPositions = [...new Set(xPositions)];
|
||||
expect(uniqueXPositions.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("hero image maintains aspect ratio", async ({
|
||||
gotoApp,
|
||||
waitForAppReady,
|
||||
setViewport,
|
||||
waitForHero,
|
||||
}) => {
|
||||
for (const viewport of ["mobile", "tablet", "desktop"] as ViewportSize[]) {
|
||||
await setViewport(viewport);
|
||||
await gotoApp();
|
||||
await waitForAppReady();
|
||||
|
||||
const hero = await waitForHero();
|
||||
const image = hero.locator(SELECTORS.hero.image);
|
||||
|
||||
const box = await image.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
|
||||
// Image should have reasonable dimensions
|
||||
expect(box!.width).toBeGreaterThan(0);
|
||||
expect(box!.height).toBeGreaterThan(0);
|
||||
|
||||
// Aspect ratio should be roughly maintained (wider than tall)
|
||||
expect(box!.width / box!.height).toBeGreaterThan(1);
|
||||
expect(box!.width / box!.height).toBeLessThan(5);
|
||||
}
|
||||
});
|
||||
});
|
||||
250
e2e/tests/capabilities/responsive/sticky.spec.ts
Normal file
250
e2e/tests/capabilities/responsive/sticky.spec.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { SELECTORS } from "../../fixtures/selectors";
|
||||
import { expect, test } from "../../fixtures/test";
|
||||
import { getStickyPosition, ViewportSize } from "../../fixtures/viewports";
|
||||
|
||||
test.describe("Sticky Header Behavior @smoke", () => {
|
||||
test.beforeEach(async ({ gotoApp, waitForAppReady }) => {
|
||||
await gotoApp();
|
||||
await waitForAppReady();
|
||||
});
|
||||
|
||||
test("header is sticky on scroll", async ({ page }) => {
|
||||
const header = page.locator(SELECTORS.header.root);
|
||||
|
||||
// Check initial position
|
||||
const initialPosition = await getStickyPosition(
|
||||
page,
|
||||
SELECTORS.header.root,
|
||||
);
|
||||
expect(initialPosition.isSticky).toBe(true);
|
||||
|
||||
// Scroll down
|
||||
await page.evaluate(() => window.scrollTo(0, 500));
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Header should still be at top
|
||||
const scrolledPosition = await getStickyPosition(
|
||||
page,
|
||||
SELECTORS.header.root,
|
||||
);
|
||||
expect(scrolledPosition.top).toBeLessThanOrEqual(10); // Allow small offset
|
||||
expect(scrolledPosition.isSticky).toBe(true);
|
||||
});
|
||||
|
||||
test("header shrinks on scroll", async ({ page }) => {
|
||||
const header = page.locator(SELECTORS.header.root);
|
||||
const headerContainer = header.locator("> div");
|
||||
|
||||
// Get initial height
|
||||
const initialHeight = await headerContainer.evaluate(
|
||||
(el) => el.offsetHeight,
|
||||
);
|
||||
|
||||
// Scroll down
|
||||
await page.evaluate(() => window.scrollTo(0, 300));
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Get scrolled height
|
||||
const scrolledHeight = await headerContainer.evaluate(
|
||||
(el) => el.offsetHeight,
|
||||
);
|
||||
|
||||
// Header should shrink (or stay same, but not grow)
|
||||
expect(scrolledHeight).toBeLessThanOrEqual(initialHeight);
|
||||
});
|
||||
|
||||
test("header maintains glass effect on scroll", async ({ page }) => {
|
||||
// Scroll down
|
||||
await page.evaluate(() => window.scrollTo(0, 500));
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Check header has backdrop blur
|
||||
const hasBlur = await page.evaluate(() => {
|
||||
const header = document.querySelector("header");
|
||||
if (!header) return false;
|
||||
const style = window.getComputedStyle(header);
|
||||
return style.backdropFilter.includes("blur");
|
||||
});
|
||||
|
||||
expect(hasBlur).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Sticky Footer Behavior", () => {
|
||||
test.beforeEach(async ({ gotoApp, waitForAppReady }) => {
|
||||
await gotoApp();
|
||||
await waitForAppReady();
|
||||
});
|
||||
|
||||
test("footer is sticky at bottom", async ({ page }) => {
|
||||
const footer = page.locator(SELECTORS.footer.root);
|
||||
|
||||
// Check footer is visible
|
||||
await expect(footer).toBeVisible();
|
||||
|
||||
// Check footer position
|
||||
const footerBox = await footer.boundingBox();
|
||||
const viewportHeight = await page.evaluate(() => window.innerHeight);
|
||||
|
||||
// Footer should be at bottom of viewport
|
||||
expect(footerBox!.y + footerBox!.height).toBeGreaterThanOrEqual(
|
||||
viewportHeight - 10,
|
||||
);
|
||||
});
|
||||
|
||||
test("footer does not overlap main content", async ({ page }) => {
|
||||
const footer = page.locator(SELECTORS.footer.root);
|
||||
const mainContent = page.locator("main");
|
||||
|
||||
// Get bounding boxes
|
||||
const footerBox = await footer.boundingBox();
|
||||
const mainBox = await mainContent.boundingBox();
|
||||
|
||||
// Main content should have padding at bottom to account for footer
|
||||
const bodyPadding = await page.evaluate(() => {
|
||||
const body = document.body;
|
||||
const style = window.getComputedStyle(body);
|
||||
return parseInt(style.paddingBottom || "0");
|
||||
});
|
||||
|
||||
expect(bodyPadding).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Back to Top Behavior @smoke", () => {
|
||||
test.beforeEach(async ({ gotoApp, waitForAppReady }) => {
|
||||
await gotoApp();
|
||||
await waitForAppReady();
|
||||
});
|
||||
|
||||
test("back to top is hidden initially", async ({ page }) => {
|
||||
const backToTop = page.locator(SELECTORS.backToTop.root);
|
||||
|
||||
// Should not be visible at top of page
|
||||
const isVisible = await backToTop.isVisible().catch(() => false);
|
||||
expect(isVisible).toBe(false);
|
||||
});
|
||||
|
||||
test("back to top appears on scroll", async ({ page }) => {
|
||||
// Scroll down
|
||||
await page.evaluate(() => window.scrollTo(0, 800));
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const backToTop = page.locator(SELECTORS.backToTop.root);
|
||||
|
||||
// Should be visible after scroll
|
||||
await expect(backToTop).toBeVisible();
|
||||
});
|
||||
|
||||
test("back to top scrolls to top when clicked", async ({ page }) => {
|
||||
// Scroll down
|
||||
await page.evaluate(() => window.scrollTo(0, 1000));
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Click back to top
|
||||
const backToTop = page.locator(SELECTORS.backToTop.root);
|
||||
await backToTop.click();
|
||||
|
||||
// Wait for scroll animation
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Should be at top
|
||||
const scrollPosition = await page.evaluate(() => window.scrollY);
|
||||
expect(scrollPosition).toBeLessThanOrEqual(50);
|
||||
});
|
||||
|
||||
test("back to top is accessible", async ({ page }) => {
|
||||
// Scroll down to make visible
|
||||
await page.evaluate(() => window.scrollTo(0, 800));
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const backToTop = page.locator(SELECTORS.backToTop.root);
|
||||
|
||||
// Should have aria-label
|
||||
await expect(backToTop).toHaveAttribute("aria-label");
|
||||
|
||||
// Should be keyboard focusable
|
||||
await backToTop.focus();
|
||||
await expect(backToTop).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Sticky Behavior Across Breakpoints", () => {
|
||||
test("header and footer work on mobile", async ({
|
||||
gotoApp,
|
||||
waitForAppReady,
|
||||
setViewport,
|
||||
page,
|
||||
}) => {
|
||||
await setViewport("mobile");
|
||||
await gotoApp();
|
||||
await waitForAppReady();
|
||||
|
||||
// Check header is sticky
|
||||
const header = page.locator(SELECTORS.header.root);
|
||||
await expect(header).toBeVisible();
|
||||
|
||||
// Scroll down
|
||||
await page.evaluate(() => window.scrollTo(0, 500));
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Header should still be visible
|
||||
await expect(header).toBeVisible();
|
||||
|
||||
// Footer should be visible
|
||||
const footer = page.locator(SELECTORS.footer.root);
|
||||
await expect(footer).toBeVisible();
|
||||
});
|
||||
|
||||
test("header and footer work on tablet", async ({
|
||||
gotoApp,
|
||||
waitForAppReady,
|
||||
setViewport,
|
||||
page,
|
||||
}) => {
|
||||
await setViewport("tablet");
|
||||
await gotoApp();
|
||||
await waitForAppReady();
|
||||
|
||||
// Check header is sticky
|
||||
const header = page.locator(SELECTORS.header.root);
|
||||
await expect(header).toBeVisible();
|
||||
|
||||
// Scroll down
|
||||
await page.evaluate(() => window.scrollTo(0, 500));
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Header should still be visible
|
||||
await expect(header).toBeVisible();
|
||||
|
||||
// Footer should be visible
|
||||
const footer = page.locator(SELECTORS.footer.root);
|
||||
await expect(footer).toBeVisible();
|
||||
});
|
||||
|
||||
test("header and footer work on desktop", async ({
|
||||
gotoApp,
|
||||
waitForAppReady,
|
||||
setViewport,
|
||||
page,
|
||||
}) => {
|
||||
await setViewport("desktop");
|
||||
await gotoApp();
|
||||
await waitForAppReady();
|
||||
|
||||
// Check header is sticky
|
||||
const header = page.locator(SELECTORS.header.root);
|
||||
await expect(header).toBeVisible();
|
||||
|
||||
// Scroll down
|
||||
await page.evaluate(() => window.scrollTo(0, 500));
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Header should still be visible
|
||||
await expect(header).toBeVisible();
|
||||
|
||||
// Footer should be visible
|
||||
const footer = page.locator(SELECTORS.footer.root);
|
||||
await expect(footer).toBeVisible();
|
||||
});
|
||||
});
|
||||
271
e2e/tests/fixtures/accessibility.ts
vendored
Normal file
271
e2e/tests/fixtures/accessibility.ts
vendored
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Accessibility helpers for WCAG 2.2 AA testing
|
||||
*/
|
||||
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* WCAG 2.2 AA contrast ratio requirements
|
||||
*/
|
||||
export const WCAG_CONTRAST = {
|
||||
normalText: 4.5,
|
||||
largeText: 3,
|
||||
uiComponents: 3,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Check if an element has visible focus indicator
|
||||
*/
|
||||
export async function hasFocusVisible(
|
||||
page: Page,
|
||||
locator: Locator,
|
||||
): Promise<boolean> {
|
||||
return page.evaluate(
|
||||
(element: Element) => {
|
||||
const style = window.getComputedStyle(element);
|
||||
const outline = style.outlineWidth;
|
||||
const boxShadow = style.boxShadow;
|
||||
|
||||
// Check for outline or box-shadow focus indicator
|
||||
return (
|
||||
(outline && outline !== "0px" && outline !== "none") ||
|
||||
(boxShadow && boxShadow !== "none")
|
||||
);
|
||||
},
|
||||
(await locator.elementHandle()) as Element,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get computed color values for an element
|
||||
*/
|
||||
export async function getComputedColors(
|
||||
page: Page,
|
||||
locator: Locator,
|
||||
): Promise<{
|
||||
color: string;
|
||||
backgroundColor: string;
|
||||
}> {
|
||||
return page.evaluate(
|
||||
(element: Element) => {
|
||||
const style = window.getComputedStyle(element);
|
||||
return {
|
||||
color: style.color,
|
||||
backgroundColor: style.backgroundColor,
|
||||
};
|
||||
},
|
||||
(await locator.elementHandle()) as Element,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate relative luminance of a color
|
||||
* https://www.w3.org/TR/WCAG20/#relativeluminancedef
|
||||
*/
|
||||
export function getLuminance(r: number, g: number, b: number): number {
|
||||
const [rs, gs, bs] = [r, g, b].map((val) => {
|
||||
const sRGB = val / 255;
|
||||
return sRGB <= 0.03928 ? sRGB / 12.92 : ((sRGB + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
|
||||
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse RGB color string
|
||||
*/
|
||||
export function parseRGB(
|
||||
color: string,
|
||||
): { r: number; g: number; b: number } | null {
|
||||
const match = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
||||
if (!match) return null;
|
||||
|
||||
return {
|
||||
r: parseInt(match[1], 10),
|
||||
g: parseInt(match[2], 10),
|
||||
b: parseInt(match[3], 10),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse RGBA color string
|
||||
*/
|
||||
export function parseRGBA(
|
||||
color: string,
|
||||
): { r: number; g: number; b: number; a: number } | null {
|
||||
const match = color.match(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)/);
|
||||
if (!match) return null;
|
||||
|
||||
return {
|
||||
r: parseInt(match[1], 10),
|
||||
g: parseInt(match[2], 10),
|
||||
b: parseInt(match[3], 10),
|
||||
a: parseFloat(match[4]),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate contrast ratio between two luminances
|
||||
* https://www.w3.org/TR/WCAG20/#contrast-ratiodef
|
||||
*/
|
||||
export function getContrastRatio(lum1: number, lum2: number): number {
|
||||
const lighter = Math.max(lum1, lum2);
|
||||
const darker = Math.min(lum1, lum2);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check contrast ratio between two colors
|
||||
*/
|
||||
export function checkContrast(color1: string, color2: string): number | null {
|
||||
const rgb1 = parseRGB(color1) || parseRGBA(color1);
|
||||
const rgb2 = parseRGB(color2) || parseRGBA(color2);
|
||||
|
||||
if (!rgb1 || !rgb2) return null;
|
||||
|
||||
const lum1 = getLuminance(rgb1.r, rgb1.g, rgb1.b);
|
||||
const lum2 = getLuminance(rgb2.r, rgb2.g, rgb2.b);
|
||||
|
||||
return getContrastRatio(lum1, lum2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert element meets WCAG AA contrast requirements
|
||||
*/
|
||||
export async function assertContrast(
|
||||
page: Page,
|
||||
locator: Locator,
|
||||
minRatio: number = WCAG_CONTRAST.normalText,
|
||||
): Promise<void> {
|
||||
const colors = await getComputedColors(page, locator);
|
||||
const ratio = checkContrast(colors.color, colors.backgroundColor);
|
||||
|
||||
if (ratio === null) {
|
||||
throw new Error("Could not calculate contrast ratio");
|
||||
}
|
||||
|
||||
if (ratio < minRatio) {
|
||||
throw new Error(
|
||||
`Contrast ratio ${ratio.toFixed(2)} is below required ${minRatio} ` +
|
||||
`(color: ${colors.color}, background: ${colors.backgroundColor})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if element has accessible name
|
||||
*/
|
||||
export async function hasAccessibleName(locator: Locator): Promise<boolean> {
|
||||
const name = await locator.getAttribute("aria-label");
|
||||
const labelledBy = await locator.getAttribute("aria-labelledby");
|
||||
const title = await locator.getAttribute("title");
|
||||
|
||||
// Check for text content if it's a button or link
|
||||
const tagName = await locator.evaluate((el) => el.tagName.toLowerCase());
|
||||
let textContent = "";
|
||||
if (tagName === "button" || tagName === "a") {
|
||||
textContent = (await locator.textContent()) || "";
|
||||
}
|
||||
|
||||
return !!(name || labelledBy || title || textContent.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get accessible name for an element
|
||||
*/
|
||||
export async function getAccessibleName(
|
||||
page: Page,
|
||||
locator: Locator,
|
||||
): Promise<string | null> {
|
||||
return page.evaluate(
|
||||
(element: Element) => {
|
||||
// Check aria-label
|
||||
const ariaLabel = element.getAttribute("aria-label");
|
||||
if (ariaLabel) return ariaLabel;
|
||||
|
||||
// Check aria-labelledby
|
||||
const labelledBy = element.getAttribute("aria-labelledby");
|
||||
if (labelledBy) {
|
||||
const labelElement = document.getElementById(labelledBy);
|
||||
if (labelElement) return labelElement.textContent;
|
||||
}
|
||||
|
||||
// Check title attribute
|
||||
const title = element.getAttribute("title");
|
||||
if (title) return title;
|
||||
|
||||
// Check text content for interactive elements
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
if (tagName === "button" || tagName === "a") {
|
||||
return element.textContent?.trim() || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
(await locator.elementHandle()) as Element,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test keyboard navigation through a set of elements
|
||||
*/
|
||||
export async function testKeyboardNavigation(
|
||||
page: Page,
|
||||
selectors: string[],
|
||||
): Promise<void> {
|
||||
// Focus first element
|
||||
await page.focus(selectors[0]);
|
||||
|
||||
for (let i = 0; i < selectors.length; i++) {
|
||||
const activeElement = await page.evaluate(
|
||||
() =>
|
||||
document.activeElement?.getAttribute("data-testid") ||
|
||||
document.activeElement?.getAttribute("aria-label") ||
|
||||
document.activeElement?.textContent?.trim() ||
|
||||
document.activeElement?.tagName,
|
||||
);
|
||||
|
||||
// Check focus is visible
|
||||
const hasFocus = await page.evaluate(() => {
|
||||
const active = document.activeElement;
|
||||
if (!active || active === document.body) return false;
|
||||
|
||||
const style = window.getComputedStyle(active);
|
||||
return (
|
||||
(style.outlineWidth && style.outlineWidth !== "0px") ||
|
||||
(style.boxShadow && style.boxShadow !== "none")
|
||||
);
|
||||
});
|
||||
|
||||
if (!hasFocus) {
|
||||
throw new Error(`Focus not visible on element: ${activeElement}`);
|
||||
}
|
||||
|
||||
// Press Tab to move to next element
|
||||
if (i < selectors.length - 1) {
|
||||
await page.keyboard.press("Tab");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert focus is contained within a modal
|
||||
*/
|
||||
export async function assertFocusContained(
|
||||
page: Page,
|
||||
modalSelector: string,
|
||||
): Promise<void> {
|
||||
const isContained = await page.evaluate((selector: string) => {
|
||||
const modal = document.querySelector(selector);
|
||||
if (!modal) return false;
|
||||
|
||||
const activeElement = document.activeElement;
|
||||
if (!activeElement) return false;
|
||||
|
||||
return modal.contains(activeElement);
|
||||
}, modalSelector);
|
||||
|
||||
if (!isContained) {
|
||||
throw new Error("Focus is not contained within modal");
|
||||
}
|
||||
}
|
||||
164
e2e/tests/fixtures/selectors.ts
vendored
Normal file
164
e2e/tests/fixtures/selectors.ts
vendored
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Stable selector strategy for ClawFort UI elements
|
||||
*
|
||||
* Strategy (in order of preference):
|
||||
* 1. data-testid attributes (most stable)
|
||||
* 2. ARIA roles with accessible names
|
||||
* 3. Semantic HTML elements with text content
|
||||
* 4. ID selectors (for unique elements)
|
||||
* 5. Structural selectors (last resort)
|
||||
*/
|
||||
|
||||
export const SELECTORS = {
|
||||
// Header controls
|
||||
header: {
|
||||
root: "header",
|
||||
logo: 'a[href="/"]',
|
||||
languageSelect: "#language-select",
|
||||
themeMenuButton: "#theme-menu-button",
|
||||
themeMenu: "#theme-menu",
|
||||
themeOption: (theme: string) => `[data-theme-option="${theme}"]`,
|
||||
},
|
||||
|
||||
// Skip link
|
||||
skipLink: 'a[href="#main-content"]',
|
||||
|
||||
// Hero section
|
||||
hero: {
|
||||
root: 'article[itemscope][itemtype="https://schema.org/NewsArticle"]:first-of-type',
|
||||
headline: "h1",
|
||||
summary: ".hero-summary",
|
||||
meta: ".hero-meta",
|
||||
readButton: 'button:has-text("Read TL;DR")',
|
||||
sourceLink: "a.source-link",
|
||||
image: 'img[fetchpriority="high"]',
|
||||
latestPill: ".hero-latest-pill",
|
||||
timePill: ".hero-time-pill",
|
||||
},
|
||||
|
||||
// News feed
|
||||
feed: {
|
||||
root: 'section:has(h2:has-text("Recent News"))',
|
||||
articles:
|
||||
'article[itemscope][itemtype="https://schema.org/NewsArticle"]:not(:first-of-type)',
|
||||
article: (id: number) => `#news-${id}`,
|
||||
articleTitle: "h3",
|
||||
articleSummary: ".news-card-summary",
|
||||
articleReadButton: 'button:has-text("Read TL;DR")',
|
||||
articleSource: "a.source-link",
|
||||
},
|
||||
|
||||
// Summary modal
|
||||
summaryModal: {
|
||||
root: '[role="dialog"][aria-modal="true"]:has-text("TL;DR")',
|
||||
closeButton: 'button:has-text("Close")',
|
||||
headline: "h2",
|
||||
image: "img",
|
||||
tldrSection: 'h3:has-text("TL;DR")',
|
||||
tldrList: "ul",
|
||||
summarySection: 'h3:has-text("Summary")',
|
||||
summaryBody: ".modal-body-text",
|
||||
sourceSection: 'h3:has-text("Source and Citation")',
|
||||
sourceLink: 'a:has-text("Read Full Article")',
|
||||
shareSection: 'h3:has-text("Share")',
|
||||
shareX: '[aria-label="Share on X"]',
|
||||
shareWhatsApp: '[aria-label="Share on WhatsApp"]',
|
||||
shareLinkedIn: '[aria-label="Share on LinkedIn"]',
|
||||
shareCopy: '[aria-label="Copy article link"]',
|
||||
copySuccess: "text=Permalink copied.",
|
||||
poweredBy: "text=Powered by Perplexity",
|
||||
},
|
||||
|
||||
// Policy modals
|
||||
policyModal: {
|
||||
root: '[role="dialog"][aria-modal="true"]:has(h2)',
|
||||
closeButton: 'button:has-text("Close")',
|
||||
termsTitle: 'h2:has-text("Terms of Use")',
|
||||
attributionTitle: 'h2:has-text("Attribution and Ownership Disclaimer")',
|
||||
},
|
||||
|
||||
// Footer
|
||||
footer: {
|
||||
root: "footer",
|
||||
poweredBy: 'a[href*="perplexity"]',
|
||||
termsLink: 'button:has-text("Terms of Use")',
|
||||
attributionLink: 'button:has-text("Attribution")',
|
||||
githubLink: 'a:has-text("GitHub")',
|
||||
contactLink: 'a[href^="mailto:"]',
|
||||
contactHint: "#contact-hint",
|
||||
copyright: "text=All rights reserved",
|
||||
},
|
||||
|
||||
// Back to top
|
||||
backToTop: {
|
||||
root: '[aria-label="Back to top"]',
|
||||
icon: "svg",
|
||||
},
|
||||
|
||||
// Theme menu
|
||||
themeMenu: {
|
||||
root: "#theme-menu",
|
||||
options: '[role="menuitem"]',
|
||||
option: (theme: string) => `[data-theme-option="${theme}"]`,
|
||||
},
|
||||
|
||||
// Empty state
|
||||
emptyState: {
|
||||
root: '.text-6xl:has-text("🤖")',
|
||||
heading: 'h2:has-text("No News Yet")',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get a stable locator string for a control
|
||||
*/
|
||||
export function getSelector(path: string): string {
|
||||
const parts = path.split(".");
|
||||
let current: any = SELECTORS;
|
||||
|
||||
for (const part of parts) {
|
||||
if (current[part] === undefined) {
|
||||
throw new Error(`Invalid selector path: ${path}`);
|
||||
}
|
||||
current = current[part];
|
||||
}
|
||||
|
||||
if (typeof current === "function") {
|
||||
throw new Error(`Selector path requires parameter: ${path}`);
|
||||
}
|
||||
|
||||
return current as string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test ID attributes that should be added to the frontend for better test stability
|
||||
*/
|
||||
export const RECOMMENDED_TEST_IDS = {
|
||||
// Header
|
||||
"header-root": "header",
|
||||
"header-logo": "header-logo",
|
||||
"language-select": "language-select",
|
||||
"theme-menu-button": "theme-menu-button",
|
||||
|
||||
// Hero
|
||||
"hero-article": "hero-article",
|
||||
"hero-headline": "hero-headline",
|
||||
"hero-read-button": "hero-read-button",
|
||||
|
||||
// Feed
|
||||
"feed-section": "feed-section",
|
||||
"feed-article": (id: number) => `feed-article-${id}`,
|
||||
"feed-read-button": (id: number) => `feed-read-button-${id}`,
|
||||
|
||||
// Modal
|
||||
"summary-modal": "summary-modal",
|
||||
"summary-modal-close": "summary-modal-close",
|
||||
"summary-modal-headline": "summary-modal-headline",
|
||||
|
||||
// Footer
|
||||
"footer-root": "footer",
|
||||
"footer-contact": "footer-contact",
|
||||
|
||||
// Back to top
|
||||
"back-to-top": "back-to-top",
|
||||
};
|
||||
347
e2e/tests/fixtures/test.ts
vendored
Normal file
347
e2e/tests/fixtures/test.ts
vendored
Normal file
@@ -0,0 +1,347 @@
|
||||
import { test as base, expect, type Locator, Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Viewport profiles for responsive testing
|
||||
*/
|
||||
export const VIEWPORT_PROFILES = {
|
||||
mobile: { width: 375, height: 667 },
|
||||
tablet: { width: 768, height: 1024 },
|
||||
desktop: { width: 1280, height: 720 },
|
||||
widescreen: { width: 1920, height: 1080 },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Theme profiles for accessibility testing
|
||||
*/
|
||||
export const THEME_PROFILES = {
|
||||
light: "light",
|
||||
dark: "dark",
|
||||
contrast: "contrast",
|
||||
} as const;
|
||||
|
||||
export type ThemeProfile = keyof typeof THEME_PROFILES;
|
||||
export type ViewportProfile = keyof typeof VIEWPORT_PROFILES;
|
||||
|
||||
/**
|
||||
* Article data shape for deterministic testing
|
||||
*/
|
||||
export interface TestArticle {
|
||||
id: number;
|
||||
headline: string;
|
||||
summary: string;
|
||||
source_url: string;
|
||||
source_citation: string;
|
||||
published_at: string;
|
||||
image_url: string;
|
||||
summary_image_url?: string;
|
||||
tldr_points?: string[];
|
||||
summary_body?: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test fixture interface
|
||||
*/
|
||||
export interface ClawFortFixtures {
|
||||
/**
|
||||
* Navigate to application with optional article permalink
|
||||
*/
|
||||
gotoApp: (options?: {
|
||||
articleId?: number;
|
||||
policy?: "terms" | "attribution";
|
||||
}) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Set theme preference on the page
|
||||
*/
|
||||
setTheme: (theme: ThemeProfile) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Set viewport to a named profile
|
||||
*/
|
||||
setViewport: (profile: ViewportProfile) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Wait for hero section to be loaded
|
||||
*/
|
||||
waitForHero: () => Promise<Locator>;
|
||||
|
||||
/**
|
||||
* Wait for news feed to be loaded
|
||||
*/
|
||||
waitForFeed: () => Promise<Locator>;
|
||||
|
||||
/**
|
||||
* Get hero article data from page
|
||||
*/
|
||||
getHeroArticle: () => Promise<TestArticle | null>;
|
||||
|
||||
/**
|
||||
* Get feed articles data from page
|
||||
*/
|
||||
getFeedArticles: () => Promise<TestArticle[]>;
|
||||
|
||||
/**
|
||||
* Open summary modal for an article
|
||||
*/
|
||||
openSummaryModal: (articleId: number) => Promise<Locator>;
|
||||
|
||||
/**
|
||||
* Close summary modal
|
||||
*/
|
||||
closeSummaryModal: () => Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if summary modal is open
|
||||
*/
|
||||
isSummaryModalOpen: () => Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Get stable selector for critical controls
|
||||
*/
|
||||
getControl: (name: string) => Locator;
|
||||
|
||||
/**
|
||||
* Wait for app to be fully initialized
|
||||
*/
|
||||
waitForAppReady: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended test with ClawFort fixtures
|
||||
*/
|
||||
export const test = base.extend<ClawFortFixtures>({
|
||||
gotoApp: async ({ page }, use) => {
|
||||
await use(async (options = {}) => {
|
||||
let url = "/";
|
||||
|
||||
if (options.articleId) {
|
||||
url = `/?article=${options.articleId}`;
|
||||
} else if (options.policy) {
|
||||
url = `/?policy=${options.policy}`;
|
||||
}
|
||||
|
||||
await page.goto(url);
|
||||
await page.waitForLoadState("networkidle");
|
||||
});
|
||||
},
|
||||
|
||||
setTheme: async ({ page }, use) => {
|
||||
await use(async (theme: ThemeProfile) => {
|
||||
// Open theme menu
|
||||
const themeButton = page.locator("#theme-menu-button");
|
||||
await themeButton.click();
|
||||
|
||||
// Select theme option
|
||||
const themeOption = page.locator(`[data-theme-option="${theme}"]`);
|
||||
await themeOption.click();
|
||||
|
||||
// Wait for theme to apply
|
||||
await page.waitForSelector(`html[data-theme="${theme}"]`, {
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
setViewport: async ({ page }, use) => {
|
||||
await use(async (profile: ViewportProfile) => {
|
||||
const viewport = VIEWPORT_PROFILES[profile];
|
||||
await page.setViewportSize(viewport);
|
||||
});
|
||||
},
|
||||
|
||||
waitForHero: async ({ page }, use) => {
|
||||
await use(async () => {
|
||||
const hero = page
|
||||
.locator(
|
||||
'article[itemscope][itemtype="https://schema.org/NewsArticle"]',
|
||||
)
|
||||
.first();
|
||||
await hero.waitFor({ state: "visible", timeout: 15000 });
|
||||
return hero;
|
||||
});
|
||||
},
|
||||
|
||||
waitForFeed: async ({ page }, use) => {
|
||||
await use(async () => {
|
||||
const feed = page.locator('section:has(h2:has-text("Recent News"))');
|
||||
await feed.waitFor({ state: "visible", timeout: 15000 });
|
||||
return feed;
|
||||
});
|
||||
},
|
||||
|
||||
getHeroArticle: async ({ page }, use) => {
|
||||
await use(async () => {
|
||||
const hero = await page
|
||||
.locator(
|
||||
'article[itemscope][itemtype="https://schema.org/NewsArticle"]',
|
||||
)
|
||||
.first();
|
||||
|
||||
// Check if hero exists and has content
|
||||
const count = await hero.count();
|
||||
if (count === 0) return null;
|
||||
|
||||
// Extract hero article data
|
||||
const headline = await hero
|
||||
.locator("h1")
|
||||
.textContent()
|
||||
.catch(() => null);
|
||||
const summary = await hero
|
||||
.locator(".hero-summary")
|
||||
.textContent()
|
||||
.catch(() => null);
|
||||
const id = await hero
|
||||
.getAttribute("id")
|
||||
.then((id) => (id ? parseInt(id.replace("news-", "")) : null));
|
||||
|
||||
if (!headline || !id) return null;
|
||||
|
||||
return {
|
||||
id,
|
||||
headline,
|
||||
summary: summary || "",
|
||||
source_url: "",
|
||||
source_citation: "",
|
||||
published_at: new Date().toISOString(),
|
||||
image_url: "",
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
getFeedArticles: async ({ page }, use) => {
|
||||
await use(async () => {
|
||||
const articles = await page
|
||||
.locator(
|
||||
'article[itemscope][itemtype="https://schema.org/NewsArticle"]',
|
||||
)
|
||||
.all();
|
||||
const feedArticles: TestArticle[] = [];
|
||||
|
||||
for (const article of articles.slice(1)) {
|
||||
// Skip hero
|
||||
const id = await article
|
||||
.getAttribute("id")
|
||||
.then((id) => (id ? parseInt(id.replace("news-", "")) : null));
|
||||
const headline = await article
|
||||
.locator("h3")
|
||||
.textContent()
|
||||
.catch(() => null);
|
||||
const summary = await article
|
||||
.locator(".news-card-summary")
|
||||
.textContent()
|
||||
.catch(() => null);
|
||||
|
||||
if (id && headline) {
|
||||
feedArticles.push({
|
||||
id,
|
||||
headline,
|
||||
summary: summary || "",
|
||||
source_url: "",
|
||||
source_citation: "",
|
||||
published_at: new Date().toISOString(),
|
||||
image_url: "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return feedArticles;
|
||||
});
|
||||
},
|
||||
|
||||
openSummaryModal: async ({ page }, use) => {
|
||||
await use(async (articleId: number) => {
|
||||
// Find article and click its "Read TL;DR" button
|
||||
const article = page.locator(`#news-${articleId}`);
|
||||
const readButton = article.locator('button:has-text("Read TL;DR")');
|
||||
await readButton.click();
|
||||
|
||||
// Wait for modal to appear
|
||||
const modal = page
|
||||
.locator('[role="dialog"][aria-modal="true"]')
|
||||
.filter({ hasText: "TL;DR" });
|
||||
await modal.waitFor({ state: "visible", timeout: 5000 });
|
||||
|
||||
return modal;
|
||||
});
|
||||
},
|
||||
|
||||
closeSummaryModal: async ({ page }, use) => {
|
||||
await use(async () => {
|
||||
const closeButton = page.locator(
|
||||
'[role="dialog"] button:has-text("Close")',
|
||||
);
|
||||
await closeButton.click();
|
||||
|
||||
// Wait for modal to disappear
|
||||
const modal = page
|
||||
.locator('[role="dialog"][aria-modal="true"]')
|
||||
.filter({ hasText: "TL;DR" });
|
||||
await modal.waitFor({ state: "hidden", timeout: 5000 });
|
||||
});
|
||||
},
|
||||
|
||||
isSummaryModalOpen: async ({ page }, use) => {
|
||||
await use(async () => {
|
||||
const modal = page
|
||||
.locator('[role="dialog"][aria-modal="true"]')
|
||||
.filter({ hasText: "TL;DR" });
|
||||
return await modal.isVisible().catch(() => false);
|
||||
});
|
||||
},
|
||||
|
||||
getControl: async ({ page }, use) => {
|
||||
await use((name: string) => {
|
||||
// Stable selector strategy: prefer test-id, then role, then accessible name
|
||||
const selectors: Record<string, string> = {
|
||||
"theme-menu": "#theme-menu-button",
|
||||
"language-select": "#language-select",
|
||||
"back-to-top": '[aria-label="Back to top"]',
|
||||
"share-x": '[aria-label="Share on X"]',
|
||||
"share-whatsapp": '[aria-label="Share on WhatsApp"]',
|
||||
"share-linkedin": '[aria-label="Share on LinkedIn"]',
|
||||
"share-copy": '[aria-label="Copy article link"]',
|
||||
"modal-close": '[role="dialog"] button:has-text("Close")',
|
||||
"terms-link": 'button:has-text("Terms of Use")',
|
||||
"attribution-link": 'button:has-text("Attribution")',
|
||||
"hero-read-more": 'article:first-of-type button:has-text("Read TL;DR")',
|
||||
"skip-link": 'a[href="#main-content"]',
|
||||
};
|
||||
|
||||
const selector = selectors[name];
|
||||
if (!selector) {
|
||||
throw new Error(
|
||||
`Unknown control: ${name}. Available controls: ${Object.keys(selectors).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
return page.locator(selector);
|
||||
});
|
||||
},
|
||||
|
||||
waitForAppReady: async ({ page }, use) => {
|
||||
await use(async () => {
|
||||
// Wait for page to be fully loaded
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Wait for Alpine.js to initialize
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
return (
|
||||
document.querySelector("html")?.hasAttribute("data-theme") ||
|
||||
document.readyState === "complete"
|
||||
);
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
// Wait for either hero or feed to be visible
|
||||
await Promise.race([
|
||||
page.waitForSelector("article[itemscope]", { timeout: 15000 }),
|
||||
page.waitForSelector('.text-6xl:has-text("🤖")', { timeout: 15000 }), // No news state
|
||||
]);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
107
e2e/tests/fixtures/themes.ts
vendored
Normal file
107
e2e/tests/fixtures/themes.ts
vendored
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Theme profile helpers for testing across light/dark/contrast themes
|
||||
*/
|
||||
|
||||
export type Theme = "light" | "dark" | "contrast";
|
||||
|
||||
export const THEMES: Theme[] = ["light", "dark", "contrast"];
|
||||
|
||||
/**
|
||||
* Theme-specific CSS custom property values
|
||||
*/
|
||||
export const THEME_VALUES = {
|
||||
light: {
|
||||
"--cf-bg": "#f8fafc",
|
||||
"--cf-text": "#0f172a",
|
||||
"--cf-text-strong": "#0f172a",
|
||||
"--cf-text-muted": "#475569",
|
||||
"--cf-link": "#1d4ed8",
|
||||
"--cf-link-hover": "#1e40af",
|
||||
"--cf-link-visited": "#6d28d9",
|
||||
"--cf-card-bg": "#ffffff",
|
||||
},
|
||||
dark: {
|
||||
"--cf-bg": "#0f172a",
|
||||
"--cf-text": "#f1f5f9",
|
||||
"--cf-text-strong": "#e2e8f0",
|
||||
"--cf-text-muted": "#94a3b8",
|
||||
"--cf-link": "#93c5fd",
|
||||
"--cf-link-hover": "#bfdbfe",
|
||||
"--cf-link-visited": "#c4b5fd",
|
||||
"--cf-card-bg": "#1e293b",
|
||||
},
|
||||
contrast: {
|
||||
"--cf-bg": "#000000",
|
||||
"--cf-text": "#ffffff",
|
||||
"--cf-text-strong": "#ffffff",
|
||||
"--cf-text-muted": "#f8fafc",
|
||||
"--cf-link": "#ffff80",
|
||||
"--cf-link-hover": "#ffff00",
|
||||
"--cf-link-visited": "#ffb3ff",
|
||||
"--cf-card-bg": "#000000",
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* WCAG 2.2 AA contrast ratio requirements
|
||||
*/
|
||||
export const WCAG_CONTRAST = {
|
||||
normalText: 4.5,
|
||||
largeText: 3,
|
||||
uiComponents: 3,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Set theme on the page
|
||||
*/
|
||||
export async function setTheme(page: any, theme: Theme): Promise<void> {
|
||||
// Open theme menu
|
||||
await page.click("#theme-menu-button");
|
||||
|
||||
// Click theme option
|
||||
await page.click(`[data-theme-option="${theme}"]`);
|
||||
|
||||
// Wait for theme to apply
|
||||
await page.waitForSelector(`html[data-theme="${theme}"]`, { timeout: 5000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get computed CSS custom property value
|
||||
*/
|
||||
export async function getCssVariable(
|
||||
page: any,
|
||||
variable: string,
|
||||
): Promise<string> {
|
||||
return page.evaluate((varName: string) => {
|
||||
return getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(varName)
|
||||
.trim();
|
||||
}, variable);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that a theme is active
|
||||
*/
|
||||
export async function assertThemeActive(
|
||||
page: any,
|
||||
theme: Theme,
|
||||
): Promise<void> {
|
||||
const themeAttr = await page.getAttribute("html", "data-theme");
|
||||
if (themeAttr !== theme) {
|
||||
throw new Error(`Expected theme "${theme}" but got "${themeAttr}"`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a test across all themes
|
||||
*/
|
||||
export function testAllThemes(
|
||||
name: string,
|
||||
testFn: (theme: Theme) => Promise<void> | void,
|
||||
): void {
|
||||
for (const theme of THEMES) {
|
||||
test(`${name} - ${theme} theme`, async () => {
|
||||
await testFn(theme);
|
||||
});
|
||||
}
|
||||
}
|
||||
151
e2e/tests/fixtures/viewports.ts
vendored
Normal file
151
e2e/tests/fixtures/viewports.ts
vendored
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Viewport profile helpers for responsive testing
|
||||
*/
|
||||
|
||||
export type ViewportSize = "mobile" | "tablet" | "desktop" | "widescreen";
|
||||
|
||||
export interface ViewportDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export const VIEWPORTS: Record<ViewportSize, ViewportDimensions> = {
|
||||
mobile: { width: 375, height: 667 }, // iPhone SE / similar
|
||||
tablet: { width: 768, height: 1024 }, // iPad / similar
|
||||
desktop: { width: 1280, height: 720 }, // Standard desktop
|
||||
widescreen: { width: 1920, height: 1080 }, // Large desktop
|
||||
} as const;
|
||||
|
||||
export const VIEWPORT_SIZES: ViewportSize[] = [
|
||||
"mobile",
|
||||
"tablet",
|
||||
"desktop",
|
||||
"widescreen",
|
||||
];
|
||||
|
||||
/**
|
||||
* Breakpoint definitions matching CSS media queries
|
||||
*/
|
||||
export const BREAKPOINTS = {
|
||||
sm: 640, // Small devices
|
||||
md: 768, // Medium devices (tablet)
|
||||
lg: 1024, // Large devices (desktop)
|
||||
xl: 1280, // Extra large
|
||||
"2xl": 1536, // 2X large
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Set viewport size
|
||||
*/
|
||||
export async function setViewport(
|
||||
page: any,
|
||||
size: ViewportSize,
|
||||
): Promise<void> {
|
||||
const dimensions = VIEWPORTS[size];
|
||||
await page.setViewportSize(dimensions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current viewport size
|
||||
*/
|
||||
export async function getViewport(page: any): Promise<ViewportDimensions> {
|
||||
return page.evaluate(() => ({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert viewport is at expected size
|
||||
*/
|
||||
export async function assertViewport(
|
||||
page: any,
|
||||
size: ViewportSize,
|
||||
): Promise<void> {
|
||||
const expected = VIEWPORTS[size];
|
||||
const actual = await getViewport(page);
|
||||
|
||||
if (actual.width !== expected.width || actual.height !== expected.height) {
|
||||
throw new Error(
|
||||
`Expected viewport ${size} (${expected.width}x${expected.height}) ` +
|
||||
`but got ${actual.width}x${actual.height}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if element has horizontal overflow
|
||||
*/
|
||||
export async function hasHorizontalOverflow(
|
||||
page: any,
|
||||
selector: string,
|
||||
): Promise<boolean> {
|
||||
return page.evaluate((sel: string) => {
|
||||
const element = document.querySelector(sel);
|
||||
if (!element) return false;
|
||||
return element.scrollWidth > element.clientWidth;
|
||||
}, selector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if element is clipped (overflow hidden with content exceeding bounds)
|
||||
*/
|
||||
export async function isClipped(page: any, selector: string): Promise<boolean> {
|
||||
return page.evaluate((sel: string) => {
|
||||
const element = document.querySelector(sel);
|
||||
if (!element) return false;
|
||||
|
||||
const style = window.getComputedStyle(element);
|
||||
const rect = element.getBoundingClientRect();
|
||||
|
||||
// Check if element has overflow hidden and children exceed bounds
|
||||
const children = element.children;
|
||||
for (const child of children) {
|
||||
const childRect = child.getBoundingClientRect();
|
||||
if (childRect.right > rect.right || childRect.bottom > rect.bottom) {
|
||||
return (
|
||||
style.overflow === "hidden" ||
|
||||
style.overflowX === "hidden" ||
|
||||
style.overflowY === "hidden"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}, selector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a test across all viewport sizes
|
||||
*/
|
||||
export function testAllViewports(
|
||||
name: string,
|
||||
testFn: (size: ViewportSize) => Promise<void> | void,
|
||||
): void {
|
||||
for (const size of VIEWPORT_SIZES) {
|
||||
test(`${name} - ${size}`, async () => {
|
||||
await testFn(size);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check sticky element position
|
||||
*/
|
||||
export async function getStickyPosition(
|
||||
page: any,
|
||||
selector: string,
|
||||
): Promise<{ top: number; isSticky: boolean }> {
|
||||
return page.evaluate((sel: string) => {
|
||||
const element = document.querySelector(sel);
|
||||
if (!element) return { top: 0, isSticky: false };
|
||||
|
||||
const style = window.getComputedStyle(element);
|
||||
const rect = element.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
top: rect.top,
|
||||
isSticky: style.position === "sticky" || style.position === "fixed",
|
||||
};
|
||||
}, selector);
|
||||
}
|
||||
Reference in New Issue
Block a user